diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e6fe2901c9..8b03a1c930 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -230,7 +230,7 @@ type LegacyTransportFields = { }; type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields; type ConnectionForm = Omit; -type ConnectionTestState = ConnectionTestResult & { ok: boolean }; +type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" }; const { t } = useI18n(); const { toast } = useToast(); @@ -276,6 +276,7 @@ function initialConnectionGroupId(): string | null { } const tunnelProfileStore = useTunnelProfileStore(); const isTesting = ref(false); +const isTestingSshTunnel = ref(false); const isSaving = ref(false); const testResult = ref(null); const testedConfigFingerprint = ref(""); @@ -2651,6 +2652,7 @@ const databasePlaceholder = computed(() => { }); const transportLayers = computed(() => form.value.transport_layers || []); +const hasEnabledSshLayer = computed(() => transportLayers.value.some((layer) => layer.enabled !== false && layer.type === "ssh")); const selectedTransportLayer = computed(() => { const layers = transportLayers.value; return layers.find((layer) => layer.id === selectedTransportLayerId.value) || layers[0] || null; @@ -3361,7 +3363,8 @@ const databaseInfoCompactLabel = computed(() => ); const testResultMessage = computed(() => { if (!testResult.value) return ""; - return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message); + if (!testResult.value.ok) return translateBackendError(t, testResult.value.message); + return testResult.value.scope === "ssh" ? t("connection.sshTunnelTestSuccess") : t("connection.testSuccess"); }); const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value)); const agentInstallProgressLabel = computed(() => { @@ -3483,6 +3486,7 @@ watch(customDriverName, (value) => { }); async function testConnection() { + if (isTestingSshTunnel.value) return; if (!ensureConnectionHostResolvedFromUrl()) return; const runId = ++testRunId; @@ -3525,6 +3529,28 @@ async function testConnection() { } } +async function testSshTunnel() { + if (isTesting.value || isTestingSshTunnel.value) return; + + const runId = ++testRunId; + isTestingSshTunnel.value = true; + testResult.value = null; + testResultCopied.value = false; + try { + const config = connectionConfigForSshTunnelTest(editingId.value || draftTestConnectionId.value); + const message = await api.testSshTunnel(config); + if (runId !== testRunId) return; + testResult.value = { ok: true, message, scope: "ssh" }; + } catch (error) { + if (runId !== testRunId) return; + const message = errorMessage(error); + testResult.value = { ok: false, message, scope: "ssh" }; + showConnectionError(message); + } finally { + if (runId === testRunId) isTestingSshTunnel.value = false; + } +} + function clearEditedConnectionErrorAfterSuccessfulTest() { if (editingId.value) store.clearConnectionError(editingId.value); } @@ -3708,6 +3734,24 @@ function generateConnectionName(): string { return `${label}_${rand}`; } +function normalizeTransportLayersForSubmit(config: LegacyConnectionConfig) { + config.transport_layers = (config.transport_layers || []).map(normalizeTransportLayer); + config.transport_layers = config.transport_layers.map((layer) => { + if (layer.type !== "ssh") return layer; + const normalized = normalizeSshTunnel(layer); + const timeout = Number(normalized.connect_timeout_secs); + normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5; + return { type: "ssh", ...normalized }; + }); + validateTransportLayers(config); +} + +function connectionConfigForSshTunnelTest(id: string): ConnectionConfig { + const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig; + normalizeTransportLayersForSubmit(config); + return config; +} + function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig { const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig; config.database_info = undefined; @@ -3751,18 +3795,10 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo throw new Error(t("connection.spannerFieldsRequired")); } } - config.transport_layers = (config.transport_layers || []).map(normalizeTransportLayer); - config.transport_layers = config.transport_layers.map((layer) => { - if (layer.type !== "ssh") return layer; - const normalized = normalizeSshTunnel(layer); - const timeout = Number(normalized.connect_timeout_secs); - normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5; - return { type: "ssh", ...normalized }; - }); - if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers.some((layer) => layer.enabled !== false)) { + normalizeTransportLayersForSubmit(config); + if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers?.some((layer) => layer.enabled !== false)) { throw new Error(t("connection.oracleTnsTransportUnsupported")); } - validateTransportLayers(config); if (config.db_type === "oracle" && config.oracle_connection_type === "tns") { const alias = config.database?.trim() || ""; const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value); @@ -4364,6 +4400,7 @@ function isOracleSysUser(config: Pick) function resetTestState() { testRunId += 1; isTesting.value = false; + isTestingSshTunnel.value = false; testResult.value = null; clearTestedConnectionInfo(); showConnectionErrorDialog.value = false; @@ -8708,6 +8745,16 @@ function openExternalUrl(url: string) { +
+ +
+ +
+
@@ -8715,7 +8762,7 @@ function openExternalUrl(url: string) {
- @@ -8729,25 +8776,25 @@ function openExternalUrl(url: string) {
- - - - -
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1cc621f779..dd89247310 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1042,6 +1042,9 @@ export default { groupsDeleted: "{count} groups deleted", loadFailed: "Failed to load saved connections: {message}", sshTunnel: "Tunnel / Proxy", + sshTunnelTest: "Test SSH tunnel", + sshTunnelTesting: "Testing SSH tunnel...", + sshTunnelTestSuccess: "SSH tunnel connection successful", advancedTab: "Advanced", sshEnable: "Use tunnel / proxy", sshHost: "SSH Host", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 3359829d8d..16b0f58b09 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -830,6 +830,9 @@ export default withEnglishFallback({ groupsDeleted: "Se eliminaron {count} grupos", loadFailed: "Error al cargar las conexiones guardadas: {message}", sshTunnel: "Túnel SSH / Proxy", + sshTunnelTest: "Probar túnel SSH", + sshTunnelTesting: "Probando túnel SSH...", + sshTunnelTestSuccess: "Conexión del túnel SSH correcta", advancedTab: "Avanzado", sshEnable: "Usar túnel SSH / proxy", sshHost: "Host SSH", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e238ef09d5..80d371f979 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -828,6 +828,9 @@ export default withEnglishFallback({ groupsDeleted: "Eliminati {count} gruppi", loadFailed: "Impossibile caricare le connessioni salvate: {message}", sshTunnel: "Tunnel SSH / Proxy", + sshTunnelTest: "Testa tunnel SSH", + sshTunnelTesting: "Test tunnel SSH in corso...", + sshTunnelTestSuccess: "Connessione al tunnel SSH riuscita", advancedTab: "Avanzate", sshEnable: "Usa tunnel SSH / proxy", sshHost: "Host SSH", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6731ab1319..85ce619382 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -794,6 +794,9 @@ export default withEnglishFallback({ groupsDeleted: "{count} 個のグループを削除しました", loadFailed: "保存された接続の読み込みに失敗しました: {message}", sshTunnel: "SSHトンネル / プロキシ", + sshTunnelTest: "SSH トンネルをテスト", + sshTunnelTesting: "SSH トンネルをテスト中...", + sshTunnelTestSuccess: "SSH トンネル接続に成功しました", advancedTab: "詳細", sshEnable: "SSHトンネル/プロキシを使用する", sshHost: "SSHホスト", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e9807b5692..853b1d96db 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -934,6 +934,9 @@ export default withEnglishFallback({ groupsDeleted: "그룹 {count}개를 삭제했습니다", loadFailed: "저장된 연결을 불러오는 데 실패했습니다: {message}", sshTunnel: "터널 / 프록시", + sshTunnelTest: "SSH 터널 테스트", + sshTunnelTesting: "SSH 터널 테스트 중...", + sshTunnelTestSuccess: "SSH 터널 연결 성공", advancedTab: "고급", sshEnable: "터널 / 프록시 사용", sshHost: "SSH 호스트", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 692391dd6c..4b726238a2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -829,6 +829,9 @@ export default withEnglishFallback({ groupsDeleted: "{count} grupos excluídos", loadFailed: "Falha ao carregar as conexões salvas: {message}", sshTunnel: "Túnel SSH / Proxy", + sshTunnelTest: "Testar túnel SSH", + sshTunnelTesting: "Testando túnel SSH...", + sshTunnelTestSuccess: "Conexão do túnel SSH bem-sucedida", advancedTab: "Avançado", sshEnable: "Usar túnel SSH / proxy", sshHost: "Host SSH", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1fc12c113e..43138519cc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -969,6 +969,9 @@ export default withEnglishFallback({ groupsDeleted: "已删除 {count} 个分组", loadFailed: "加载已保存连接失败:{message}", sshTunnel: "隧道/代理", + sshTunnelTest: "测试 SSH 隧道", + sshTunnelTesting: "正在测试 SSH 隧道...", + sshTunnelTestSuccess: "SSH 隧道连接成功", advancedTab: "高级", sshEnable: "使用隧道/代理连接", sshHost: "SSH 主机", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index fd7903ee2e..6d8136c21e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -828,6 +828,9 @@ export default withEnglishFallback({ groupsDeleted: "已刪除 {count} 個群組", loadFailed: "載入已儲存連線失敗:{message}", sshTunnel: "SSH 隧道/代理", + sshTunnelTest: "測試 SSH 隧道", + sshTunnelTesting: "正在測試 SSH 隧道...", + sshTunnelTestSuccess: "SSH 隧道連線成功", advancedTab: "進階", sshEnable: "使用 SSH 隧道/代理連線", sshHost: "SSH 主機", diff --git a/apps/desktop/src/lib/__tests__/connection/sshTunnelConnectionDialog.spec.ts b/apps/desktop/src/lib/__tests__/connection/sshTunnelConnectionDialog.spec.ts new file mode 100644 index 0000000000..b36c2ce88f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/sshTunnelConnectionDialog.spec.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); + +describe("ConnectionDialog SSH tunnel test", () => { + it("exposes a dedicated SSH tunnel test without running the database test", () => { + expect(dialogSource).toContain("async function testSshTunnel()"); + expect(dialogSource).toContain("await api.testSshTunnel(config)"); + expect(dialogSource).toContain('@click="testSshTunnel"'); + expect(dialogSource).toContain('t("connection.sshTunnelTest")'); + }); + + it("builds the tunnel probe config without database-specific validation", () => { + const start = dialogSource.indexOf("function connectionConfigForSshTunnelTest"); + const end = dialogSource.indexOf("function connectionConfigForSubmit", start); + const helperSource = dialogSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(helperSource).toContain("normalizeTransportLayersForSubmit(config)"); + expect(helperSource).not.toContain("kingbaseDatabaseRequired"); + expect(helperSource).not.toContain("dynamodbCredentialsRequired"); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index cc01725ce9..de88e44c14 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -52,6 +52,7 @@ function forward(name: K): Backend[K] { // Connection export const testConnection = forward("testConnection"); +export const testSshTunnel = forward("testSshTunnel"); export const testConnectionWithInfo = forward("testConnectionWithInfo"); export const connectDb = forward("connectDb"); export const connectionDatabaseInfo = forward("connectionDatabaseInfo"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 23da3cea22..7ace725fe2 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -374,6 +374,10 @@ export async function testConnection(config: ConnectionConfig): Promise return post("/api/connection/test", { config }); } +export async function testSshTunnel(config: ConnectionConfig): Promise { + return post("/api/connection/test-ssh-tunnel", { config }); +} + export async function testConnectionWithInfo(config: ConnectionConfig): Promise { const response = await fetch(apiUrl("/api/connection/test-info"), { method: "POST", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index dd0767ba59..7a1ca9fc0e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1068,6 +1068,10 @@ export async function testConnection(config: ConnectionConfig): Promise return invokeBackend("test_connection", { config }); } +export async function testSshTunnel(config: ConnectionConfig): Promise { + return invokeBackend("test_ssh_tunnel", { config }); +} + export async function testConnectionWithInfo(config: ConnectionConfig): Promise { try { const result = await invoke("test_connection_with_info", { diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 18b7350fa0..0a1fa1a13f 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -306,6 +306,13 @@ pub struct AppState { pub mq_registry: crate::mq::MqAdminRegistry, } +fn transport_layers_through_last_ssh(layers: &[TransportLayerConfig]) -> Result<&[TransportLayerConfig], String> { + let Some(last_ssh_index) = layers.iter().rposition(|layer| matches!(layer, TransportLayerConfig::Ssh(_))) else { + return Err("Connection has no enabled SSH tunnel layer".to_string()); + }; + Ok(&layers[..=last_ssh_index]) +} + /// 活跃时间以进程内单调时钟的相对毫秒存储(AtomicU64):热路径每条查询都要 /// 更新它,读锁 + 原子写让并发查询不再在全局写锁上串行化。 /// 基准偏移让测试能构造"过去"的时间点(否则进程刚启动时相对毫秒接近 0)。 @@ -2829,6 +2836,34 @@ impl AppState { } } + /// Tests the enabled SSH chain without opening a database connection. + /// Layers before the final SSH hop are included because they may be + /// required to reach that hop; layers after it are unrelated to SSH auth. + pub async fn test_connection_ssh_tunnel(&self, config: &ConnectionConfig) -> Result { + let resolved_layers = self.resolved_transport_layers(config).await?; + let test_layers = transport_layers_through_last_ssh(&resolved_layers)?; + let probe_id = format!("__connection_ssh_test__:{}", uuid::Uuid::new_v4()); + let result = db::transport_layer_tunnel::start_transport_layers( + &probe_id, + test_layers, + "127.0.0.1", + 1, + &self.tunnels, + &self.proxy_tunnels, + &self.http_tunnels, + ) + .await; + db::transport_layer_tunnel::stop_transport_layers( + &probe_id, + test_layers.len(), + &self.tunnels, + &self.proxy_tunnels, + &self.http_tunnels, + ) + .await; + result.map(|_| "SSH tunnel connection successful".to_string()) + } + pub async fn connection_host_port( &self, connection_id: &str, @@ -5890,9 +5925,9 @@ mod tests { mysql_pool_setup_queries, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, - task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, - validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, TxnConnection, - GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, + task_client_session_id, transport_layers_through_last_ssh, upsert_connection_url_param, uses_bare_mysql_pool, + uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, + TxnConnection, GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, @@ -6928,6 +6963,59 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn ssh_tunnel_test_includes_prerequisites_through_final_ssh_hop() { + let before = TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "before".to_string(), + name: String::new(), + enabled: true, + proxy_type: ProxyType::Socks5, + host: "proxy.internal".to_string(), + port: 1080, + username: String::new(), + password: String::new(), + test_target: None, + profile_id: String::new(), + }); + let ssh = TransportLayerConfig::Ssh(ssh_layer("ssh", "")); + let after = TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "after".to_string(), + name: String::new(), + enabled: true, + proxy_type: ProxyType::Http, + host: "downstream.internal".to_string(), + port: 8080, + username: String::new(), + password: String::new(), + test_target: None, + profile_id: String::new(), + }); + let layers = vec![before.clone(), ssh.clone(), after]; + + assert_eq!(transport_layers_through_last_ssh(&layers).unwrap(), &[before, ssh]); + } + + #[test] + fn ssh_tunnel_test_rejects_chain_without_ssh() { + let layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "proxy".to_string(), + name: String::new(), + enabled: true, + proxy_type: ProxyType::Socks5, + host: "proxy.internal".to_string(), + port: 1080, + username: String::new(), + password: String::new(), + test_target: None, + profile_id: String::new(), + })]; + + assert_eq!( + transport_layers_through_last_ssh(&layers).unwrap_err(), + "Connection has no enabled SSH tunnel layer" + ); + } + #[tokio::test] async fn test_tunnel_profile_rejects_non_ssh_and_missing_host() { let (state, dir) = test_app_state().await; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index f69359c094..b608947e3a 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -379,6 +379,7 @@ async fn main() { // Connection .route("/connection/test", post(routes::connection::test_connection)) .route("/connection/test-info", post(routes::connection::test_connection_with_info)) + .route("/connection/test-ssh-tunnel", post(routes::connection::test_ssh_tunnel)) .route("/connection/connect", post(routes::connection::connect_db)) .route("/connection/database-info", post(routes::connection::connected_database_info)) .route("/connection/database-info/save", post(routes::connection::save_connection_database_info)) diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index a879c23fe0..c9851dd642 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -373,6 +373,13 @@ pub async fn test_connection_with_info( run_temporary_connection_test(&state.app, body.config, true).await.map(Json).map_err(AppError::from) } +pub async fn test_ssh_tunnel( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.test_connection_ssh_tunnel(&body.config.canonicalized()).await.map(Json).map_err(AppError::from) +} + pub async fn connect_db( State(state): State>, headers: HeaderMap, diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index c3175ae452..98089a094a 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1112,6 +1112,11 @@ pub async fn test_connection_with_info( test_connection_with_info_inner(state.inner(), config).await } +#[tauri::command] +pub async fn test_ssh_tunnel(state: State<'_, Arc>, config: ConnectionConfig) -> Result { + state.test_connection_ssh_tunnel(&config.canonicalized()).await +} + async fn test_connection_with_info_inner( state: &Arc, config: ConnectionConfig, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4972ea0eb..37e35def1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1794,6 +1794,7 @@ pub fn run() { commands::cloud_sync::snippet_sync_download, commands::connection::test_connection, commands::connection::test_connection_with_info, + commands::connection::test_ssh_tunnel, commands::connection::connect_db, commands::connection::connection_final_proxy_port, commands::connection::disconnect_db,