From 46c05b811fda4891fddeefa1451259e6d51b7e6e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 1 Jun 2026 14:48:33 +0800 Subject: [PATCH] feat(connection): support multi-hop ssh tunnels --- .../connection/ConnectionDialog.vue | 483 ++++++++++++++---- apps/desktop/src/i18n/locales/en.ts | 14 + apps/desktop/src/i18n/locales/es.ts | 14 + apps/desktop/src/i18n/locales/zh-CN.ts | 14 + apps/desktop/src/stores/connectionStore.ts | 1 + apps/desktop/src/types/database.ts | 15 + crates/dbx-core/src/agent_connection.rs | 1 + crates/dbx-core/src/cloud_sync.rs | 71 ++- crates/dbx-core/src/connection.rs | 26 +- crates/dbx-core/src/connection_secrets.rs | 103 +++- crates/dbx-core/src/db/ssh_tunnel.rs | 283 ++++++++-- crates/dbx-core/src/models/connection.rs | 184 +++++++ crates/dbx-core/src/query.rs | 1 + crates/dbx-core/src/storage.rs | 53 ++ crates/dbx-core/src/transfer.rs | 1 + .../dbx-core/tests/live_postgres_transfer.rs | 1 + crates/dbx-web/src/routes/connection.rs | 1 + packages/node-core/src/connections.ts | 16 + src-tauri/src/commands/connection.rs | 1 + 19 files changed, 1131 insertions(+), 152 deletions(-) diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c5bf462d0..f88034661 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import type { ConnectionConfig, DatabaseType, JdbcDriverInfo } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, SshTunnelConfig } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -23,17 +23,22 @@ import { copyToClipboard } from "@/lib/clipboard"; import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint"; import { ArrowLeft, + ArrowDown, + ArrowUp, ChevronRight, Copy, ExternalLink, FilePlus2, FolderOpen, + GripVertical, Grid3X3, KeyRound, Link2, List, + Plus, Search, ShieldCheck, + Trash2, } from "lucide-vue-next"; type DbOption = { value: string; label: string }; @@ -87,6 +92,7 @@ const defaultForm = (): Omit => ({ ssh_key_passphrase: "", ssh_expose_lan: false, ssh_connect_timeout_secs: 5, + ssh_tunnels: [], connect_timeout_secs: 5, query_timeout_secs: 30, proxy_enabled: false, @@ -110,7 +116,71 @@ const defaultForm = (): Omit => ({ redis_cluster_nodes: "", }); +function defaultSshTunnel(): SshTunnelConfig { + return { + id: uuid(), + name: "", + enabled: true, + host: "", + port: 22, + user: "", + password: "", + key_path: "", + key_passphrase: "", + connect_timeout_secs: 5, + expose_lan: false, + }; +} + +function normalizeSshTunnel(hop: Partial): SshTunnelConfig { + return { + id: hop.id || uuid(), + name: hop.name || "", + enabled: hop.enabled !== false, + host: hop.host || "", + port: Number(hop.port) || 22, + user: hop.user || "", + password: hop.password || "", + key_path: hop.key_path || "", + key_passphrase: hop.key_passphrase || "", + connect_timeout_secs: Number(hop.connect_timeout_secs) || 5, + expose_lan: !!hop.expose_lan, + }; +} + +function sshTunnelsForConfig(config: ConnectionConfig): SshTunnelConfig[] { + if (config.ssh_tunnels?.length) { + return config.ssh_tunnels.map(normalizeSshTunnel); + } + if ( + config.ssh_enabled || + config.ssh_host || + config.ssh_user || + config.ssh_password || + config.ssh_key_path || + config.ssh_key_passphrase + ) { + return [ + normalizeSshTunnel({ + id: "legacy", + enabled: true, + host: config.ssh_host || "", + port: config.ssh_port || 22, + user: config.ssh_user || "", + password: config.ssh_password || "", + key_path: config.ssh_key_path || "", + key_passphrase: config.ssh_key_passphrase || "", + connect_timeout_secs: config.ssh_connect_timeout_secs || 5, + expose_lan: config.ssh_expose_lan || false, + }), + ]; + } + return []; +} + const form = ref(defaultForm()); +const selectedSshTunnelId = ref(null); +const draggedSshTunnelId = ref(null); const selectedType = ref("mysql"); const customDriverName = ref(""); const mongoUseUrl = ref(false); @@ -366,6 +436,7 @@ watch( ssh_key_passphrase: config.ssh_key_passphrase || "", ssh_expose_lan: config.ssh_expose_lan || false, ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, + ssh_tunnels: sshTunnelsForConfig(config), connect_timeout_secs: config.connect_timeout_secs || 5, query_timeout_secs: config.query_timeout_secs ?? 30, proxy_enabled: config.proxy_enabled || false, @@ -388,6 +459,7 @@ watch( redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", }; + selectedSshTunnelId.value = form.value.ssh_tunnels?.[0]?.id || null; selectedType.value = profile; if (profile === "oceanbase") { oceanbaseSubMode.value = config.driver_profile === "oceanbase-oracle" ? "oracle" : "mysql"; @@ -400,6 +472,7 @@ watch( } else { editingId.value = null; form.value = defaultForm(); + selectedSshTunnelId.value = null; selectedType.value = "mysql"; customDriverName.value = ""; oceanbaseSubMode.value = "mysql"; @@ -429,6 +502,20 @@ const databasePlaceholder = computed(() => { return t("connection.databasePlaceholderWithDefault", { database: fallback }); }); +const sshTunnels = computed(() => form.value.ssh_tunnels || []); +const selectedSshTunnel = computed(() => { + const tunnels = sshTunnels.value; + return tunnels.find((hop) => hop.id === selectedSshTunnelId.value) || tunnels[0] || null; +}); +const sshPathSegments = computed(() => { + const hops = sshTunnels.value.filter((hop) => hop.enabled !== false); + return [ + "DBX", + ...hops.map((hop, index) => hop.name?.trim() || hop.host?.trim() || `SSH ${index + 1}`), + form.value.host || "Database", + ]; +}); + function defaultDatabaseForProfile() { if (form.value.db_type === "redshift") return "dev"; if (form.value.db_type === "gaussdb") return "postgres"; @@ -774,6 +861,24 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { } const sshTimeout = Number(config.ssh_connect_timeout_secs); config.ssh_connect_timeout_secs = Number.isFinite(sshTimeout) && sshTimeout > 0 ? sshTimeout : 5; + config.ssh_tunnels = (config.ssh_tunnels || []).map((hop) => { + const normalized = normalizeSshTunnel(hop); + const timeout = Number(normalized.connect_timeout_secs); + normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5; + return normalized; + }); + const firstSshTunnel = config.ssh_tunnels[0]; + if (firstSshTunnel) { + config.ssh_host = firstSshTunnel.host; + config.ssh_port = firstSshTunnel.port; + config.ssh_user = firstSshTunnel.user; + config.ssh_password = firstSshTunnel.password || ""; + config.ssh_key_path = firstSshTunnel.key_path || ""; + config.ssh_key_passphrase = firstSshTunnel.key_passphrase || ""; + config.ssh_expose_lan = !!firstSshTunnel.expose_lan; + config.ssh_connect_timeout_secs = firstSshTunnel.connect_timeout_secs || config.ssh_connect_timeout_secs; + } + validateSshTunnels(config); const connectTimeout = Number(config.connect_timeout_secs); config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 5; const queryTimeout = Number(config.query_timeout_secs); @@ -1012,6 +1117,8 @@ async function copyTestResult() { function resetForm() { editingId.value = null; form.value = defaultForm(); + selectedSshTunnelId.value = null; + draggedSshTunnelId.value = null; selectedType.value = "mysql"; customDriverName.value = ""; mongoUseUrl.value = false; @@ -1137,6 +1244,80 @@ watch(supportsTlsToggle, (value) => { } }); +function ensureSelectedSshTunnel() { + if (!selectedSshTunnelId.value || !sshTunnels.value.some((hop) => hop.id === selectedSshTunnelId.value)) { + selectedSshTunnelId.value = sshTunnels.value[0]?.id || null; + } +} + +function addSshTunnel() { + const next = defaultSshTunnel(); + next.name = t("connection.sshHopDefaultName", { index: sshTunnels.value.length + 1 }); + form.value.ssh_tunnels = [...sshTunnels.value, next]; + form.value.ssh_enabled = true; + selectedSshTunnelId.value = next.id; + resetTestState(); +} + +function duplicateSshTunnel(hop: SshTunnelConfig) { + const next = { ...normalizeSshTunnel(hop), id: uuid(), name: hop.name ? `${hop.name} copy` : "" }; + form.value.ssh_tunnels = [...sshTunnels.value, next]; + selectedSshTunnelId.value = next.id; + resetTestState(); +} + +function removeSshTunnel(id: string) { + form.value.ssh_tunnels = sshTunnels.value.filter((hop) => hop.id !== id); + ensureSelectedSshTunnel(); + resetTestState(); +} + +function moveSshTunnel(id: string, direction: -1 | 1) { + const tunnels = [...sshTunnels.value]; + const index = tunnels.findIndex((hop) => hop.id === id); + const target = index + direction; + if (index < 0 || target < 0 || target >= tunnels.length) return; + [tunnels[index], tunnels[target]] = [tunnels[target], tunnels[index]]; + form.value.ssh_tunnels = tunnels; + resetTestState(); +} + +function dropSshTunnel(targetId: string) { + const sourceId = draggedSshTunnelId.value; + draggedSshTunnelId.value = null; + if (!sourceId || sourceId === targetId) return; + const tunnels = [...sshTunnels.value]; + const sourceIndex = tunnels.findIndex((hop) => hop.id === sourceId); + const targetIndex = tunnels.findIndex((hop) => hop.id === targetId); + if (sourceIndex < 0 || targetIndex < 0) return; + const [source] = tunnels.splice(sourceIndex, 1); + tunnels.splice(targetIndex, 0, source); + form.value.ssh_tunnels = tunnels; + resetTestState(); +} + +function validateSshTunnels(config: ConnectionConfig) { + if (!config.ssh_enabled) return; + const tunnels = config.ssh_tunnels || []; + tunnels.forEach((hop, index) => { + if (hop.enabled === false) return; + const label = hop.name?.trim() || t("connection.sshHopDefaultName", { index: index + 1 }); + if (!hop.host?.trim()) throw new Error(t("connection.sshHopInvalidHost", { hop: label })); + if (!hop.user?.trim()) throw new Error(t("connection.sshHopInvalidUser", { hop: label })); + const port = Number(hop.port); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + throw new Error(t("connection.sshHopInvalidPort", { hop: label })); + } + if (!hop.password?.trim() && !hop.key_path?.trim()) { + throw new Error(t("connection.sshHopInvalidAuth", { hop: label })); + } + const timeout = Number(hop.connect_timeout_secs); + if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) { + throw new Error(t("connection.sshHopInvalidTimeout", { hop: label })); + } + }); +} + async function save() { if (!ensureConnectionHostResolvedFromUrl()) return; if (isSaving.value) return; @@ -1181,7 +1362,7 @@ watch([() => editingId.value, () => open.value], () => { dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title"); }); -async function browseSshKeyPath() { +async function browseSshKeyPath(target?: SshTunnelConfig | null) { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); const selected = await open({ @@ -1189,7 +1370,11 @@ async function browseSshKeyPath() { multiple: false, }); if (selected && typeof selected === "string") { - form.value.ssh_key_path = selected; + if (target) { + target.key_path = selected; + } else { + form.value.ssh_key_path = selected; + } } } } @@ -2318,92 +2503,218 @@ function openExternalUrl(url: string) { {{ t("connection.sshEnable") }} -
- - - -
-
- - -
-
- - -
-
- -
- - - - - - {{ t("connection.sshKeyPathBrowse") }} - + @click.stop + /> + + {{ hop.name || hop.host || t("connection.sshHopDefaultName", { index: index + 1 }) }} + + + + + + {{ t("connection.sshHopMoveUp") }} + + + + + + {{ t("connection.sshHopMoveDown") }} + + +
+
+ + + +
-
- - -
-
- - -
-
- - -
+ + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index bce00a582..9824e662b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -235,6 +235,20 @@ export default { sshKeyPathBrowse: "Browse", sshExposeLan: "Expose tunnel to LAN", sshConnectTimeout: "SSH Timeout (seconds)", + sshHops: "SSH Hops", + sshHopAdd: "Add hop", + sshHopDuplicate: "Duplicate", + sshHopDelete: "Delete", + sshHopMoveUp: "Move up", + sshHopMoveDown: "Move down", + sshHopName: "Hop Name", + sshHopNamePlaceholder: "Bastion, jump host, production gateway", + sshHopDefaultName: "Hop {index}", + sshHopInvalidHost: "{hop}: SSH host is required", + sshHopInvalidUser: "{hop}: SSH user is required", + sshHopInvalidPort: "{hop}: SSH port must be between 1 and 65535", + sshHopInvalidAuth: "{hop}: password or key path is required", + sshHopInvalidTimeout: "{hop}: SSH timeout must be between 1 and 300 seconds", connectTimeout: "Connection Timeout (seconds)", queryTimeout: "Query Timeout (seconds)", proxy: "Proxy", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b6a592cf6..4d6b37a99 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -232,6 +232,20 @@ export default { sshKeyPathBrowse: "Examinar", sshExposeLan: "Exponer túnel a la red local", sshConnectTimeout: "Tiempo de espera SSH (segundos)", + sshHops: "Saltos SSH", + sshHopAdd: "Agregar salto", + sshHopDuplicate: "Duplicar", + sshHopDelete: "Eliminar", + sshHopMoveUp: "Subir", + sshHopMoveDown: "Bajar", + sshHopName: "Nombre del salto", + sshHopNamePlaceholder: "Bastión, jump host, puerta de producción", + sshHopDefaultName: "Salto {index}", + sshHopInvalidHost: "{hop}: el host SSH es obligatorio", + sshHopInvalidUser: "{hop}: el usuario SSH es obligatorio", + sshHopInvalidPort: "{hop}: el puerto SSH debe estar entre 1 y 65535", + sshHopInvalidAuth: "{hop}: se requiere contraseña o ruta de clave", + sshHopInvalidTimeout: "{hop}: el tiempo de espera SSH debe estar entre 1 y 300 segundos", connectTimeout: "Tiempo de espera de conexión (segundos)", queryTimeout: "Tiempo de espera de consulta (segundos)", proxy: "Proxy", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index addc4a64e..8a9721802 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -231,6 +231,20 @@ export default { sshKeyPathBrowse: "浏览", sshExposeLan: "允许局域网访问隧道", sshConnectTimeout: "SSH 超时时间(秒)", + sshHops: "SSH 层级", + sshHopAdd: "添加层级", + sshHopDuplicate: "复制", + sshHopDelete: "删除", + sshHopMoveUp: "上移", + sshHopMoveDown: "下移", + sshHopName: "层级名称", + sshHopNamePlaceholder: "堡垒机、跳板机、生产网关", + sshHopDefaultName: "第 {index} 跳", + sshHopInvalidHost: "{hop}:SSH 主机不能为空", + sshHopInvalidUser: "{hop}:SSH 用户不能为空", + sshHopInvalidPort: "{hop}:SSH 端口必须在 1 到 65535 之间", + sshHopInvalidAuth: "{hop}:需要填写密码或密钥路径", + sshHopInvalidTimeout: "{hop}:SSH 超时时间必须在 1 到 300 秒之间", connectTimeout: "连接超时(秒)", queryTimeout: "查询超时(秒)", proxy: "代理", diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index ec58fbc26..05379eede 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -246,6 +246,7 @@ export const useConnectionStore = defineStore("connection", () => { ? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim()) : [], ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, + ssh_tunnels: Array.isArray(config.ssh_tunnels) ? config.ssh_tunnels : [], connect_timeout_secs: config.connect_timeout_secs || 5, query_timeout_secs: config.query_timeout_secs ?? 30, proxy_type: config.proxy_type || "socks5", diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 2126797f7..726b11de3 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -76,6 +76,7 @@ export interface ConnectionConfig { ssh_key_passphrase?: string; ssh_expose_lan?: boolean; ssh_connect_timeout_secs?: number; + ssh_tunnels?: SshTunnelConfig[]; connect_timeout_secs?: number; query_timeout_secs?: number; proxy_enabled?: boolean; @@ -101,6 +102,20 @@ export interface ConnectionConfig { one_time?: boolean; } +export interface SshTunnelConfig { + id: string; + name?: string; + enabled?: boolean; + host: string; + port: number; + user: string; + password?: string; + key_path?: string; + key_passphrase?: string; + connect_timeout_secs?: number; + expose_lan?: boolean; +} + export interface AttachedDatabaseConfig { name: string; path: string; diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index ce957383f..ca8767e0c 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -212,6 +212,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), proxy_enabled: false, diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 2d672c49f..88c737175 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -24,6 +24,7 @@ const SECRET_KEYS: &[&str] = &[ "redis_sentinel_password", "connection_string", ]; +const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -302,6 +303,10 @@ fn scrub_connection_secrets(config: &mut ConnectionConfig) { config.password.clear(); config.ssh_password.clear(); config.ssh_key_passphrase.clear(); + for hop in &mut config.ssh_tunnels { + hop.password.clear(); + hop.key_passphrase.clear(); + } config.proxy_password.clear(); config.redis_sentinel_password.clear(); config.connection_string = None; @@ -324,6 +329,15 @@ async fn build_sensitive_payload( push_secret(&mut connection_secrets, &config.id, "password", &config.password); push_secret(&mut connection_secrets, &config.id, "ssh_password", &config.ssh_password); push_secret(&mut connection_secrets, &config.id, "ssh_key_passphrase", &config.ssh_key_passphrase); + for (index, hop) in config.ssh_tunnels.iter().enumerate() { + push_secret(&mut connection_secrets, &config.id, &ssh_tunnel_password_key(index, hop), &hop.password); + push_secret( + &mut connection_secrets, + &config.id, + &ssh_tunnel_key_passphrase_key(index, hop), + &hop.key_passphrase, + ); + } push_secret(&mut connection_secrets, &config.id, "proxy_password", &config.proxy_password); push_secret(&mut connection_secrets, &config.id, "redis_sentinel_password", &config.redis_sentinel_password); if let Some(connection_string) = &config.connection_string { @@ -347,7 +361,7 @@ fn push_secret(secrets: &mut Vec, connection_id: &str, async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPayload) -> Result<(), String> { for secret in &payload.connection_secrets { - if !SECRET_KEYS.contains(&secret.key.as_str()) { + if !SECRET_KEYS.contains(&secret.key.as_str()) && !secret.key.starts_with(SSH_TUNNEL_SECRET_PREFIX) { continue; } storage.set_secret(&secret.connection_id, &secret.key, &secret.secret).await?; @@ -363,10 +377,30 @@ async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionCo for key in SECRET_KEYS { storage.delete_secret(&config.id, key).await?; } + for (index, hop) in config.ssh_tunnels.iter().enumerate() { + storage.delete_secret(&config.id, &ssh_tunnel_password_key(index, hop)).await?; + storage.delete_secret(&config.id, &ssh_tunnel_key_passphrase_key(index, hop)).await?; + } } Ok(()) } +fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + if hop.id.trim().is_empty() { + index.to_string() + } else { + hop.id.clone() + } +} + +fn ssh_tunnel_password_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.password", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + +fn ssh_tunnel_key_passphrase_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.key_passphrase", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + fn encrypt_sensitive_payload(payload: &SensitiveSyncPayload, passphrase: &str) -> Result { let plaintext = serde_json::to_vec(payload).map_err(|e| e.to_string())?; encrypt_bytes_with_secret(&plaintext, passphrase) @@ -462,7 +496,7 @@ mod tests { decrypt_sensitive_payload, encrypt_sensitive_payload, normalized_remote_path, parent_collection_paths, scrub_connection_secrets, ConnectionSecretSnapshot, SensitiveSyncPayload, }; - use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType}; + use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType, SshTunnelConfig}; #[test] fn normalizes_empty_remote_path_to_default() { @@ -502,6 +536,19 @@ mod tests { ssh_key_passphrase: "key".to_string(), ssh_expose_lan: false, ssh_connect_timeout_secs: 5, + ssh_tunnels: vec![SshTunnelConfig { + id: "hop-1".to_string(), + name: String::new(), + enabled: true, + host: "bastion".to_string(), + port: 22, + user: "user".to_string(), + password: "hop-password".to_string(), + key_path: String::new(), + key_passphrase: "hop-passphrase".to_string(), + connect_timeout_secs: 5, + expose_lan: false, + }], connect_timeout_secs: 5, query_timeout_secs: 30, proxy_enabled: false, @@ -531,6 +578,8 @@ mod tests { assert!(config.password.is_empty()); assert!(config.ssh_password.is_empty()); assert!(config.ssh_key_passphrase.is_empty()); + assert!(config.ssh_tunnels[0].password.is_empty()); + assert!(config.ssh_tunnels[0].key_passphrase.is_empty()); assert!(config.proxy_password.is_empty()); assert!(config.redis_sentinel_password.is_empty()); assert!(config.connection_string.is_none()); @@ -539,17 +588,25 @@ mod tests { #[test] fn encrypted_sensitive_payload_round_trips() { let payload = SensitiveSyncPayload { - connection_secrets: vec![ConnectionSecretSnapshot { - connection_id: "c1".to_string(), - key: "password".to_string(), - secret: "secret".to_string(), - }], + connection_secrets: vec![ + ConnectionSecretSnapshot { + connection_id: "c1".to_string(), + key: "password".to_string(), + secret: "secret".to_string(), + }, + ConnectionSecretSnapshot { + connection_id: "c1".to_string(), + key: "ssh_tunnels.hop-1.password".to_string(), + secret: "hop-secret".to_string(), + }, + ], ai_config: None, }; let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap(); assert_ne!(encrypted.ciphertext, "secret"); let decrypted = decrypt_sensitive_payload(&encrypted, "sync-pass").unwrap(); assert_eq!(decrypted.connection_secrets[0].secret, "secret"); + assert_eq!(decrypted.connection_secrets[1].secret, "hop-secret"); } #[test] diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index cc38030ee..b662d6b94 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -401,7 +401,8 @@ impl AppState { connection_id: &str, config: &ConnectionConfig, ) -> Result<(String, u16), String> { - if !config.ssh_enabled || config.ssh_host.is_empty() { + let ssh_hops = config.effective_ssh_tunnels(); + if ssh_hops.is_empty() { if config.proxy_enabled && !config.proxy_host.is_empty() { if let Some(local_port) = self.proxy_tunnels.local_port(connection_id).await { return Ok(("127.0.0.1".to_string(), local_port)); @@ -465,22 +466,7 @@ impl AppState { (config.host.clone(), config.port) }; - let local_port = self - .tunnels - .start_tunnel( - connection_id, - &config.ssh_host, - config.ssh_port, - &config.ssh_user, - &config.ssh_password, - &config.ssh_key_path, - &config.ssh_key_passphrase, - config.effective_ssh_connect_timeout_secs(), - &remote_host, - remote_port, - config.ssh_expose_lan, - ) - .await?; + let local_port = self.tunnels.start_chain(connection_id, &ssh_hops, &remote_host, remote_port).await?; Ok(("127.0.0.1".to_string(), local_port)) } @@ -657,7 +643,7 @@ impl AppState { // Re-establish SSH tunnels that have died let tunnel_connection_ids: Vec = { let configs = self.configs.read().await; - configs.iter().filter(|(_, c)| c.ssh_enabled && !c.ssh_host.is_empty()).map(|(id, _)| id.clone()).collect() + configs.iter().filter(|(_, c)| c.has_effective_ssh_tunnels()).map(|(id, _)| id.clone()).collect() }; for connection_id in tunnel_connection_ids { self.tunnels.stop_tunnel(&connection_id).await; @@ -700,8 +686,7 @@ impl AppState { async fn uses_forwarded_transport(&self, connection_id: &str) -> bool { let configs = self.configs.read().await; configs.get(connection_id).is_some_and(|config| { - (config.ssh_enabled && !config.ssh_host.is_empty()) - || (config.proxy_enabled && !config.proxy_host.is_empty()) + config.has_effective_ssh_tunnels() || (config.proxy_enabled && !config.proxy_host.is_empty()) }) } } @@ -956,6 +941,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: crate::models::connection::default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: crate::models::connection::default_query_timeout_secs(), proxy_enabled: false, diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 3c7d855c0..f977deb83 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; pub const MAIN_PASSWORD_KEY: &str = "password"; pub const SSH_PASSWORD_KEY: &str = "ssh_password"; pub const SSH_KEY_PASSPHRASE_KEY: &str = "ssh_key_passphrase"; +pub const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; pub const PROXY_PASSWORD_KEY: &str = "proxy_password"; pub const REDIS_SENTINEL_PASSWORD_KEY: &str = "redis_sentinel_password"; pub const CONNECTION_STRING_KEY: &str = "connection_string"; @@ -14,6 +15,9 @@ pub trait ConnectionSecretStore { fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>; fn get_secret(&self, connection_id: &str, key: &str) -> Result, String>; fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String>; + fn delete_secret_prefix(&self, _connection_id: &str, _key_prefix: &str) -> Result<(), String> { + Ok(()) + } } pub struct FileSecretStore { @@ -51,6 +55,13 @@ impl ConnectionSecretStore for FileSecretStore { map.remove(&secret_account(connection_id, key)); self.write_store(&map) } + + fn delete_secret_prefix(&self, connection_id: &str, key_prefix: &str) -> Result<(), String> { + let mut map = self.read_store(); + let account_prefix = secret_account(connection_id, key_prefix); + map.retain(|key, _| !key.starts_with(&account_prefix)); + self.write_store(&map) + } } pub fn save_connections_to_file( @@ -63,6 +74,11 @@ pub fn save_connections_to_file( persist_secret(store, &config.id, MAIN_PASSWORD_KEY, &config.password)?; persist_secret(store, &config.id, SSH_PASSWORD_KEY, &config.ssh_password)?; persist_secret(store, &config.id, SSH_KEY_PASSPHRASE_KEY, &config.ssh_key_passphrase)?; + delete_secret_prefix(store, &config.id, SSH_TUNNEL_SECRET_PREFIX)?; + for (index, hop) in config.ssh_tunnels.iter().enumerate() { + persist_secret(store, &config.id, &ssh_tunnel_password_key(index, hop), &hop.password)?; + persist_secret(store, &config.id, &ssh_tunnel_key_passphrase_key(index, hop), &hop.key_passphrase)?; + } persist_secret(store, &config.id, PROXY_PASSWORD_KEY, &config.proxy_password)?; persist_secret(store, &config.id, REDIS_SENTINEL_PASSWORD_KEY, &config.redis_sentinel_password)?; persist_optional_secret(store, &config.id, CONNECTION_STRING_KEY, config.connection_string.as_deref())?; @@ -109,6 +125,25 @@ pub fn load_connections_from_file( needs_rewrite = true; } + for (index, hop) in config.ssh_tunnels.iter_mut().enumerate() { + if hop.password.is_empty() { + if let Some(secret) = store.get_secret(&config.id, &ssh_tunnel_password_key(index, hop))? { + hop.password = secret; + } + } else { + store.set_secret(&config.id, &ssh_tunnel_password_key(index, hop), &hop.password)?; + needs_rewrite = true; + } + if hop.key_passphrase.is_empty() { + if let Some(secret) = store.get_secret(&config.id, &ssh_tunnel_key_passphrase_key(index, hop))? { + hop.key_passphrase = secret; + } + } else { + store.set_secret(&config.id, &ssh_tunnel_key_passphrase_key(index, hop), &hop.key_passphrase)?; + needs_rewrite = true; + } + } + if config.proxy_password.is_empty() { if let Some(secret) = store.get_secret(&config.id, PROXY_PASSWORD_KEY)? { config.proxy_password = secret; @@ -168,6 +203,7 @@ fn delete_removed_connection_secrets( store.delete_secret(&config.id, MAIN_PASSWORD_KEY)?; store.delete_secret(&config.id, SSH_PASSWORD_KEY)?; store.delete_secret(&config.id, SSH_KEY_PASSPHRASE_KEY)?; + delete_secret_prefix(store, &config.id, SSH_TUNNEL_SECRET_PREFIX)?; store.delete_secret(&config.id, CONNECTION_STRING_KEY)?; } Ok(()) @@ -198,6 +234,30 @@ fn persist_optional_secret( } } +fn delete_secret_prefix( + store: &dyn ConnectionSecretStore, + connection_id: &str, + key_prefix: &str, +) -> Result<(), String> { + store.delete_secret_prefix(connection_id, key_prefix) +} + +fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + if hop.id.trim().is_empty() { + index.to_string() + } else { + hop.id.clone() + } +} + +fn ssh_tunnel_password_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.password", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + +fn ssh_tunnel_key_passphrase_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.key_passphrase", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + fn read_connections(path: &Path) -> Result, String> { let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?; serde_json::from_str(&json).map_err(|e| e.to_string()) @@ -217,6 +277,10 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec { config.password.clear(); config.ssh_password.clear(); config.ssh_key_passphrase.clear(); + for hop in &mut config.ssh_tunnels { + hop.password.clear(); + hop.key_passphrase.clear(); + } config.proxy_password.clear(); config.redis_sentinel_password.clear(); config.connection_string = None; @@ -235,7 +299,7 @@ mod tests { load_connections_from_file, save_connections_to_file, ConnectionSecretStore, CONNECTION_STRING_KEY, MAIN_PASSWORD_KEY, REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY, }; - use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType}; + use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType, SshTunnelConfig}; use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; @@ -275,6 +339,13 @@ mod tests { self.deleted.borrow_mut().push(secret_key(connection_id, key)); Ok(()) } + + fn delete_secret_prefix(&self, connection_id: &str, key_prefix: &str) -> Result<(), String> { + let prefix = secret_key(connection_id, key_prefix); + self.values.borrow_mut().retain(|key, _| !key.starts_with(&prefix)); + self.deleted.borrow_mut().push(prefix); + Ok(()) + } } fn secret_key(connection_id: &str, key: &str) -> String { @@ -312,6 +383,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: crate::models::connection::default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: crate::models::connection::default_connect_timeout_secs(), query_timeout_secs: crate::models::connection::default_query_timeout_secs(), proxy_enabled: false, @@ -339,6 +411,22 @@ mod tests { } } + fn ssh_hop(id: &str, password: &str, passphrase: &str) -> SshTunnelConfig { + SshTunnelConfig { + id: id.to_string(), + name: String::new(), + enabled: true, + host: "bastion".to_string(), + port: 22, + user: "user".to_string(), + password: password.to_string(), + key_path: "~/.ssh/id_ed25519".to_string(), + key_passphrase: passphrase.to_string(), + connect_timeout_secs: 5, + expose_lan: false, + } + } + fn read_configs(path: &Path) -> Vec { let json = std::fs::read_to_string(path).unwrap(); serde_json::from_str(&json).unwrap() @@ -349,6 +437,7 @@ mod tests { let path = temp_connections_file("save-redacts"); let store = MemorySecretStore::default(); let mut config = connection("main", "db-secret", "ssh-secret"); + config.ssh_tunnels = vec![ssh_hop("hop-1", "hop-secret", "hop-key")]; config.redis_sentinel_password = "sentinel-secret".to_string(); let configs = vec![config]; @@ -356,10 +445,14 @@ mod tests { assert_eq!(store.get_existing("main", MAIN_PASSWORD_KEY).as_deref(), Some("db-secret")); assert_eq!(store.get_existing("main", SSH_PASSWORD_KEY).as_deref(), Some("ssh-secret")); + assert_eq!(store.get_existing("main", "ssh_tunnels.hop-1.password").as_deref(), Some("hop-secret")); + assert_eq!(store.get_existing("main", "ssh_tunnels.hop-1.key_passphrase").as_deref(), Some("hop-key")); assert_eq!(store.get_existing("main", REDIS_SENTINEL_PASSWORD_KEY).as_deref(), Some("sentinel-secret")); let persisted = read_configs(&path); assert_eq!(persisted[0].password, ""); assert_eq!(persisted[0].ssh_password, ""); + assert_eq!(persisted[0].ssh_tunnels[0].password, ""); + assert_eq!(persisted[0].ssh_tunnels[0].key_passphrase, ""); assert_eq!(persisted[0].redis_sentinel_password, ""); } @@ -369,14 +462,20 @@ mod tests { let store = MemorySecretStore::default(); store.set_existing("main", MAIN_PASSWORD_KEY, "db-secret"); store.set_existing("main", SSH_PASSWORD_KEY, "ssh-secret"); + store.set_existing("main", "ssh_tunnels.hop-1.password", "hop-secret"); + store.set_existing("main", "ssh_tunnels.hop-1.key_passphrase", "hop-key"); store.set_existing("main", REDIS_SENTINEL_PASSWORD_KEY, "sentinel-secret"); - let sanitized = vec![connection("main", "", "")]; + let mut sanitized_config = connection("main", "", ""); + sanitized_config.ssh_tunnels = vec![ssh_hop("hop-1", "", "")]; + let sanitized = vec![sanitized_config]; std::fs::write(&path, serde_json::to_string_pretty(&sanitized).unwrap()).unwrap(); let loaded = load_connections_from_file(&path, &store).unwrap(); assert_eq!(loaded[0].password, "db-secret"); assert_eq!(loaded[0].ssh_password, "ssh-secret"); + assert_eq!(loaded[0].ssh_tunnels[0].password, "hop-secret"); + assert_eq!(loaded[0].ssh_tunnels[0].key_passphrase, "hop-key"); assert_eq!(loaded[0].redis_sentinel_password, "sentinel-secret"); } diff --git a/crates/dbx-core/src/db/ssh_tunnel.rs b/crates/dbx-core/src/db/ssh_tunnel.rs index 4e5b970f4..1309bd387 100644 --- a/crates/dbx-core/src/db/ssh_tunnel.rs +++ b/crates/dbx-core/src/db/ssh_tunnel.rs @@ -10,6 +10,8 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::Duration; +use crate::models::connection::SshTunnelConfig; + use super::file_validator::validate_file_path; /// Initial delay between SSH reconnect attempts. @@ -171,8 +173,8 @@ async fn forward_loop(session: &Handle, listener: &TcpListener, remot #[allow(clippy::too_many_arguments)] async fn tunnel_reconnect_loop( mut session: Handle, - ssh_host: String, - ssh_port: u16, + connect_host: String, + connect_port: u16, ssh_user: String, ssh_password: String, ssh_key_path: String, @@ -183,11 +185,11 @@ async fn tunnel_reconnect_loop( remote_port: u16, ) { loop { - log::info!("SSH tunnel active: {}:{} -> {}:{}", ssh_host, ssh_port, remote_host, remote_port); + log::info!("SSH tunnel active: {}:{} -> {}:{}", connect_host, connect_port, remote_host, remote_port); forward_loop(&session, &listener, &remote_host, remote_port).await; - log::warn!("SSH tunnel connection lost ({}:{}), reconnecting...", ssh_host, ssh_port); + log::warn!("SSH tunnel connection lost ({}:{}), reconnecting...", connect_host, connect_port); // Reconnect with exponential backoff let mut delay = INITIAL_RECONNECT_DELAY; @@ -196,7 +198,7 @@ async fn tunnel_reconnect_loop( loop { if attempts >= MAX_RECONNECT_ATTEMPTS { log::error!( - "SSH tunnel ({ssh_host}:{ssh_port}): max reconnect attempts ({MAX_RECONNECT_ATTEMPTS}) exhausted, giving up" + "SSH tunnel ({connect_host}:{connect_port}): max reconnect attempts ({MAX_RECONNECT_ATTEMPTS}) exhausted, giving up" ); return; } @@ -204,8 +206,8 @@ async fn tunnel_reconnect_loop( tokio::time::sleep(delay).await; match connect_and_authenticate( - &ssh_host, - ssh_port, + &connect_host, + connect_port, &ssh_user, &ssh_password, &ssh_key_path, @@ -216,15 +218,20 @@ async fn tunnel_reconnect_loop( { Ok(new_session) => { session = new_session; - log::info!("SSH tunnel reconnected to {}:{} (attempt {})", ssh_host, ssh_port, attempts + 1); + log::info!( + "SSH tunnel reconnected to {}:{} (attempt {})", + connect_host, + connect_port, + attempts + 1 + ); break; } Err(e) => { attempts += 1; log::error!( "SSH reconnect failed ({}:{}, attempt {attempts}/{MAX_RECONNECT_ATTEMPTS}): {e}", - ssh_host, - ssh_port, + connect_host, + connect_port, ); // Exponential backoff: double the delay, cap at MAX_RECONNECT_DELAY delay = std::cmp::min(delay * 2, MAX_RECONNECT_DELAY); @@ -234,8 +241,22 @@ async fn tunnel_reconnect_loop( } } +struct TunnelEntry { + handles: Vec>, + local_port: u16, +} + +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlannedTunnel { + connect_host: String, + connect_port: u16, + remote_host: String, + remote_port: u16, +} + pub struct TunnelManager { - tunnels: Mutex, u16)>>, + tunnels: Mutex>, } impl Default for TunnelManager { @@ -264,14 +285,10 @@ impl TunnelManager { remote_port: u16, expose_to_lan: bool, ) -> Result { - let local_port = portpicker::pick_unused_port().ok_or("No available port")?; - - let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" }; - let listener = - TcpListener::bind((bind_addr, local_port)).await.map_err(|e| format!("Failed to bind local port: {e}"))?; - - // Initial connection: fail fast on bad credentials - let session = connect_and_authenticate( + if let Some(local_port) = self.local_port(connection_id).await { + return Ok(local_port); + } + let (handle, local_port) = spawn_tunnel( ssh_host, ssh_port, ssh_user, @@ -279,35 +296,227 @@ impl TunnelManager { ssh_key_path, ssh_key_passphrase, connect_timeout_secs, + remote_host, + remote_port, + expose_to_lan, ) .await?; - let handle = tokio::spawn(tunnel_reconnect_loop( - session, - ssh_host.to_string(), - ssh_port, - ssh_user.to_string(), - ssh_password.to_string(), - ssh_key_path.to_string(), - ssh_key_passphrase.to_string(), - connect_timeout_secs, - listener, - remote_host.to_string(), - remote_port, - )); - - self.tunnels.lock().await.insert(connection_id.to_string(), (handle, local_port)); + self.tunnels.lock().await.insert(connection_id.to_string(), TunnelEntry { handles: vec![handle], local_port }); Ok(local_port) } + pub async fn start_chain( + &self, + connection_id: &str, + hops: &[SshTunnelConfig], + remote_host: &str, + remote_port: u16, + ) -> Result { + if hops.is_empty() { + return Err("No SSH tunnel hops configured".to_string()); + } + if let Some(local_port) = self.local_port(connection_id).await { + return Ok(local_port); + } + + let mut handles = Vec::new(); + let mut next_connect_endpoint: Option<(String, u16)> = None; + let mut final_local_port = 0; + + for (index, hop) in hops.iter().enumerate() { + let is_last = index + 1 == hops.len(); + let (connect_host, connect_port) = + next_connect_endpoint.clone().unwrap_or_else(|| (hop.host.clone(), hop.port)); + let (target_host, target_port) = if is_last { + (remote_host.to_string(), remote_port) + } else { + (hops[index + 1].host.clone(), hops[index + 1].port) + }; + + let (handle, local_port) = spawn_tunnel( + &connect_host, + connect_port, + &hop.user, + &hop.password, + &hop.key_path, + &hop.key_passphrase, + effective_hop_timeout(hop), + &target_host, + target_port, + is_last && hop.expose_lan, + ) + .await + .map_err(|err| format!("SSH hop {} failed: {err}", index + 1))?; + + handles.push(handle); + final_local_port = local_port; + next_connect_endpoint = Some(("127.0.0.1".to_string(), local_port)); + } + + self.tunnels + .lock() + .await + .insert(connection_id.to_string(), TunnelEntry { handles, local_port: final_local_port }); + + Ok(final_local_port) + } + pub async fn local_port(&self, connection_id: &str) -> Option { - self.tunnels.lock().await.get(connection_id).map(|(_, port)| *port) + self.tunnels.lock().await.get(connection_id).map(|entry| entry.local_port) } pub async fn stop_tunnel(&self, connection_id: &str) { - if let Some((handle, _)) = self.tunnels.lock().await.remove(connection_id) { - handle.abort(); + if let Some(entry) = self.tunnels.lock().await.remove(connection_id) { + for handle in entry.handles { + handle.abort(); + } } } } + +#[allow(clippy::too_many_arguments)] +async fn spawn_tunnel( + connect_host: &str, + connect_port: u16, + ssh_user: &str, + ssh_password: &str, + ssh_key_path: &str, + ssh_key_passphrase: &str, + connect_timeout_secs: u64, + remote_host: &str, + remote_port: u16, + expose_to_lan: bool, +) -> Result<(JoinHandle<()>, u16), String> { + let local_port = portpicker::pick_unused_port().ok_or("No available port")?; + + let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" }; + let listener = + TcpListener::bind((bind_addr, local_port)).await.map_err(|e| format!("Failed to bind local port: {e}"))?; + + // Initial connection: fail fast on bad credentials + let session = connect_and_authenticate( + connect_host, + connect_port, + ssh_user, + ssh_password, + ssh_key_path, + ssh_key_passphrase, + connect_timeout_secs, + ) + .await?; + + let handle = tokio::spawn(tunnel_reconnect_loop( + session, + connect_host.to_string(), + connect_port, + ssh_user.to_string(), + ssh_password.to_string(), + ssh_key_path.to_string(), + ssh_key_passphrase.to_string(), + connect_timeout_secs, + listener, + remote_host.to_string(), + remote_port, + )); + + Ok((handle, local_port)) +} + +fn effective_hop_timeout(hop: &SshTunnelConfig) -> u64 { + if hop.connect_timeout_secs == 0 { + crate::models::connection::default_ssh_connect_timeout_secs() + } else { + hop.connect_timeout_secs + } +} + +#[cfg(test)] +fn plan_chain( + hops: &[SshTunnelConfig], + remote_host: &str, + remote_port: u16, + local_ports: &[u16], +) -> Vec { + let mut planned = Vec::new(); + let mut next_connect_endpoint: Option<(String, u16)> = None; + for (index, hop) in hops.iter().enumerate() { + let is_last = index + 1 == hops.len(); + let (connect_host, connect_port) = + next_connect_endpoint.clone().unwrap_or_else(|| (hop.host.clone(), hop.port)); + let (target_host, target_port) = if is_last { + (remote_host.to_string(), remote_port) + } else { + (hops[index + 1].host.clone(), hops[index + 1].port) + }; + planned.push(PlannedTunnel { connect_host, connect_port, remote_host: target_host, remote_port: target_port }); + if let Some(local_port) = local_ports.get(index) { + next_connect_endpoint = Some(("127.0.0.1".to_string(), *local_port)); + } + } + planned +} + +#[cfg(test)] +mod tests { + use super::{effective_hop_timeout, plan_chain, PlannedTunnel, TunnelManager}; + use crate::models::connection::{default_ssh_connect_timeout_secs, SshTunnelConfig}; + + fn hop(id: &str, host: &str, port: u16) -> SshTunnelConfig { + SshTunnelConfig { + id: id.to_string(), + name: String::new(), + enabled: true, + host: host.to_string(), + port, + user: "user".to_string(), + password: "secret".to_string(), + key_path: String::new(), + key_passphrase: String::new(), + connect_timeout_secs: 5, + expose_lan: false, + } + } + + #[test] + fn chain_plan_routes_each_hop_to_next_endpoint() { + let hops = vec![hop("a", "bastion-a", 22), hop("b", "bastion-b", 2200)]; + + let planned = plan_chain(&hops, "db.internal", 5432, &[41001, 41002]); + + assert_eq!( + planned, + vec![ + PlannedTunnel { + connect_host: "bastion-a".to_string(), + connect_port: 22, + remote_host: "bastion-b".to_string(), + remote_port: 2200, + }, + PlannedTunnel { + connect_host: "127.0.0.1".to_string(), + connect_port: 41001, + remote_host: "db.internal".to_string(), + remote_port: 5432, + }, + ] + ); + } + + #[test] + fn zero_hop_timeout_uses_default() { + let mut tunnel = hop("a", "bastion-a", 22); + tunnel.connect_timeout_secs = 0; + + assert_eq!(effective_hop_timeout(&tunnel), default_ssh_connect_timeout_secs()); + } + + #[tokio::test] + async fn local_port_reuses_existing_chain_entry() { + let manager = TunnelManager::new(); + + assert_eq!(manager.local_port("missing").await, None); + manager.stop_tunnel("missing").await; + } +} diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index e90ae211e..5a422c311 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -41,6 +41,8 @@ pub struct ConnectionConfig { pub ssh_expose_lan: bool, #[serde(default = "default_ssh_connect_timeout_secs")] pub ssh_connect_timeout_secs: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ssh_tunnels: Vec, #[serde(default = "default_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default = "default_query_timeout_secs")] @@ -92,12 +94,42 @@ pub struct ConnectionConfig { pub one_time: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SshTunnelConfig { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub host: String, + #[serde(default = "default_ssh_port")] + pub port: u16, + #[serde(default)] + pub user: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub key_path: String, + #[serde(default)] + pub key_passphrase: String, + #[serde(default = "default_ssh_connect_timeout_secs")] + pub connect_timeout_secs: u64, + #[serde(default)] + pub expose_lan: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AttachedDatabaseConfig { pub name: String, pub path: String, } +fn default_true() -> bool { + true +} + fn default_ssh_port() -> u16 { 22 } @@ -205,6 +237,38 @@ impl ConnectionConfig { } } + pub fn effective_ssh_tunnels(&self) -> Vec { + if !self.ssh_enabled { + return Vec::new(); + } + + if !self.ssh_tunnels.is_empty() { + return self.ssh_tunnels.iter().filter(|hop| hop.enabled).cloned().collect(); + } + + if self.ssh_host.trim().is_empty() { + return Vec::new(); + } + + vec![SshTunnelConfig { + id: "legacy".to_string(), + name: String::new(), + enabled: true, + host: self.ssh_host.clone(), + port: self.ssh_port, + user: self.ssh_user.clone(), + password: self.ssh_password.clone(), + key_path: self.ssh_key_path.clone(), + key_passphrase: self.ssh_key_passphrase.clone(), + connect_timeout_secs: self.effective_ssh_connect_timeout_secs(), + expose_lan: self.ssh_expose_lan, + }] + } + + pub fn has_effective_ssh_tunnels(&self) -> bool { + !self.effective_ssh_tunnels().is_empty() + } + pub fn effective_connect_timeout_secs(&self) -> u64 { if self.connect_timeout_secs == 0 { default_connect_timeout_secs() @@ -914,6 +978,7 @@ fn bracket_ipv6(host: &str) -> String { mod tests { use super::{ default_query_timeout_secs, default_ssh_connect_timeout_secs, ConnectionConfig, DatabaseType, ProxyType, + SshTunnelConfig, }; use std::str::FromStr; @@ -942,6 +1007,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: super::default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), proxy_enabled: false, @@ -1069,6 +1135,124 @@ mod tests { assert_eq!(config.effective_ssh_connect_timeout_secs(), default_ssh_connect_timeout_secs()); } + #[test] + fn effective_ssh_tunnels_adapts_legacy_single_hop() { + let mut config = mysql_config("root", "", None); + config.ssh_enabled = true; + config.ssh_host = "bastion.example.com".to_string(); + config.ssh_port = 2200; + config.ssh_user = "deploy".to_string(); + config.ssh_password = "secret".to_string(); + config.ssh_connect_timeout_secs = 0; + config.ssh_expose_lan = true; + + let hops = config.effective_ssh_tunnels(); + + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].id, "legacy"); + assert_eq!(hops[0].host, "bastion.example.com"); + assert_eq!(hops[0].port, 2200); + assert_eq!(hops[0].user, "deploy"); + assert_eq!(hops[0].password, "secret"); + assert_eq!(hops[0].connect_timeout_secs, default_ssh_connect_timeout_secs()); + assert!(hops[0].expose_lan); + } + + #[test] + fn effective_ssh_tunnels_prefers_explicit_hops() { + let mut config = mysql_config("root", "", None); + config.ssh_enabled = true; + config.ssh_host = "legacy.example.com".to_string(); + config.ssh_tunnels = vec![SshTunnelConfig { + id: "hop-1".to_string(), + name: "Bastion".to_string(), + enabled: true, + host: "new.example.com".to_string(), + port: 22, + user: "alice".to_string(), + password: String::new(), + key_path: "~/.ssh/id_ed25519".to_string(), + key_passphrase: String::new(), + connect_timeout_secs: 7, + expose_lan: false, + }]; + + let hops = config.effective_ssh_tunnels(); + + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].id, "hop-1"); + assert_eq!(hops[0].host, "new.example.com"); + } + + #[test] + fn effective_ssh_tunnels_filters_disabled_explicit_hops() { + let mut config = mysql_config("root", "", None); + config.ssh_enabled = true; + config.ssh_tunnels = vec![ + SshTunnelConfig { + id: "disabled".to_string(), + name: String::new(), + enabled: false, + host: "disabled.example.com".to_string(), + port: 22, + user: "alice".to_string(), + password: "secret".to_string(), + key_path: String::new(), + key_passphrase: String::new(), + connect_timeout_secs: 5, + expose_lan: false, + }, + SshTunnelConfig { + id: "enabled".to_string(), + name: String::new(), + enabled: true, + host: "enabled.example.com".to_string(), + port: 22, + user: "alice".to_string(), + password: "secret".to_string(), + key_path: String::new(), + key_passphrase: String::new(), + connect_timeout_secs: 5, + expose_lan: false, + }, + ]; + + let hops = config.effective_ssh_tunnels(); + + assert_eq!(hops.len(), 1); + assert_eq!(hops[0].id, "enabled"); + } + + #[test] + fn effective_ssh_tunnels_empty_without_explicit_or_legacy_ssh() { + let config = mysql_config("root", "", None); + + assert!(config.effective_ssh_tunnels().is_empty()); + assert!(!config.has_effective_ssh_tunnels()); + } + + #[test] + fn effective_ssh_tunnels_does_not_fall_back_when_explicit_list_is_disabled() { + let mut config = mysql_config("root", "", None); + config.ssh_enabled = true; + config.ssh_host = "legacy.example.com".to_string(); + config.ssh_tunnels = vec![SshTunnelConfig { + id: "disabled".to_string(), + name: String::new(), + enabled: false, + host: "disabled.example.com".to_string(), + port: 22, + user: "alice".to_string(), + password: "secret".to_string(), + key_path: String::new(), + key_passphrase: String::new(), + connect_timeout_secs: 5, + expose_lan: false, + }]; + + assert!(config.effective_ssh_tunnels().is_empty()); + } + #[test] fn query_timeout_zero_disables_timeout() { let mut config = mysql_config("root", "", None); diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index edd46bfb5..2a7ca4201 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1452,6 +1452,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: 5, + ssh_tunnels: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, proxy_enabled: false, diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a0d040a0b..eeab97dcd 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -11,6 +11,8 @@ use crate::history::{HistoryEntry, MAX_HISTORY}; use crate::models::connection::ConnectionConfig; use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary}; +const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; + pub struct Storage { db: SqliteHandle, } @@ -173,6 +175,40 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> { Ok(()) } +fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + if hop.id.trim().is_empty() { + index.to_string() + } else { + hop.id.clone() + } +} + +fn ssh_tunnel_password_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.password", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + +fn ssh_tunnel_key_passphrase_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { + format!("{}{}.key_passphrase", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) +} + +fn scrub_ssh_tunnel_secrets(config: &mut ConnectionConfig) { + for hop in &mut config.ssh_tunnels { + hop.password.clear(); + hop.key_passphrase.clear(); + } +} + +fn delete_secret_prefix_in_tx( + tx: &rusqlite::Transaction<'_>, + connection_id: &str, + key_prefix: &str, +) -> Result<(), String> { + let like = format!("{key_prefix}%"); + tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2", params![connection_id, like]) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + // History impl Storage { @@ -514,6 +550,7 @@ impl Storage { sanitized.password = String::new(); sanitized.ssh_password = String::new(); sanitized.ssh_key_passphrase = String::new(); + scrub_ssh_tunnel_secrets(&mut sanitized); sanitized.proxy_password = String::new(); sanitized.redis_sentinel_password = String::new(); sanitized.connection_string = None; @@ -550,6 +587,7 @@ impl Storage { sanitized.password = String::new(); sanitized.ssh_password = String::new(); sanitized.ssh_key_passphrase = String::new(); + scrub_ssh_tunnel_secrets(&mut sanitized); sanitized.proxy_password = String::new(); sanitized.redis_sentinel_password = String::new(); sanitized.connection_string = None; @@ -561,6 +599,16 @@ impl Storage { persist_secret_in_tx(&tx, &config.id, "password", &config.password)?; persist_secret_in_tx(&tx, &config.id, "ssh_password", &config.ssh_password)?; persist_secret_in_tx(&tx, &config.id, "ssh_key_passphrase", &config.ssh_key_passphrase)?; + delete_secret_prefix_in_tx(&tx, &config.id, SSH_TUNNEL_SECRET_PREFIX)?; + for (index, hop) in config.ssh_tunnels.iter().enumerate() { + persist_secret_in_tx(&tx, &config.id, &ssh_tunnel_password_key(index, hop), &hop.password)?; + persist_secret_in_tx( + &tx, + &config.id, + &ssh_tunnel_key_passphrase_key(index, hop), + &hop.key_passphrase, + )?; + } persist_secret_in_tx(&tx, &config.id, "proxy_password", &config.proxy_password)?; persist_secret_in_tx(&tx, &config.id, "redis_sentinel_password", &config.redis_sentinel_password)?; if let Some(cs) = &config.connection_string { @@ -605,6 +653,11 @@ impl Storage { config.password = self.get_secret(&id, "password").await?.unwrap_or_default(); config.ssh_password = self.get_secret(&id, "ssh_password").await?.unwrap_or_default(); config.ssh_key_passphrase = self.get_secret(&id, "ssh_key_passphrase").await?.unwrap_or_default(); + for (index, hop) in config.ssh_tunnels.iter_mut().enumerate() { + hop.password = self.get_secret(&id, &ssh_tunnel_password_key(index, hop)).await?.unwrap_or_default(); + hop.key_passphrase = + self.get_secret(&id, &ssh_tunnel_key_passphrase_key(index, hop)).await?.unwrap_or_default(); + } config.proxy_password = self.get_secret(&id, "proxy_password").await?.unwrap_or_default(); config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default(); config.connection_string = self.get_secret(&id, "connection_string").await?; diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index b0743dfbf..1758362d1 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -2437,6 +2437,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: 5, + ssh_tunnels: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, proxy_enabled: false, diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 5e4908a36..18d5aa205 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -33,6 +33,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: 5, + ssh_tunnels: Vec::new(), connect_timeout_secs: 5, query_timeout_secs: 30, proxy_enabled: false, diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 3f4d2ec2c..4e07f88a0 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -168,6 +168,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: dbx_core::models::connection::default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: dbx_core::models::connection::default_connect_timeout_secs(), query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(), proxy_enabled: false, diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts index 5c2930a34..192b2f646 100644 --- a/packages/node-core/src/connections.ts +++ b/packages/node-core/src/connections.ts @@ -16,6 +16,7 @@ export interface ConnectionConfig { database?: string; url_params?: string; ssh_enabled: boolean; + ssh_tunnels?: SshTunnelConfig[]; proxy_enabled?: boolean; proxy_type?: "socks5" | "http"; proxy_host?: string; @@ -33,6 +34,20 @@ export interface ConnectionConfig { redis_sentinel_tls?: boolean; } +export interface SshTunnelConfig { + id: string; + name?: string; + enabled?: boolean; + host: string; + port: number; + user: string; + password?: string; + key_path?: string; + key_passphrase?: string; + connect_timeout_secs?: number; + expose_lan?: boolean; +} + export interface ConnectionStoreOptions { path?: string; } @@ -197,6 +212,7 @@ export async function addConnection(config: Omit): Promi ssh_key_path: "", ssh_key_passphrase: "", ssh_expose_lan: false, + ssh_tunnels: normalized.ssh_tunnels ?? [], proxy_enabled: normalized.proxy_enabled ?? false, proxy_type: normalized.proxy_type ?? "socks5", proxy_host: normalized.proxy_host ?? "", diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 2dcfe2c51..b14b44660 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -178,6 +178,7 @@ mod tests { ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssh_connect_timeout_secs: dbx_core::models::connection::default_ssh_connect_timeout_secs(), + ssh_tunnels: Vec::new(), connect_timeout_secs: dbx_core::models::connection::default_connect_timeout_secs(), query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(), proxy_enabled: false,