From c7991b316e37b4b4a013b99cb64fdede11857652 Mon Sep 17 00:00:00 2001 From: rainhan Date: Mon, 13 Jul 2026 17:51:51 +0800 Subject: [PATCH] feat(connection): test SSH tunnel profiles --- .../connection/TunnelProfileManager.vue | 51 ++++++++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../connection/tunnelProfiles.spec.ts | 33 ++++++- apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 4 + apps/desktop/src/lib/backend/tauri.ts | 4 + .../src/lib/connection/tunnelProfiles.ts | 23 +++++ apps/desktop/src/stores/tunnelProfileStore.ts | 11 ++- crates/dbx-core/src/connection.rs | 88 +++++++++++++++++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/tunnel_profiles.rs | 7 ++ src-tauri/src/commands/tunnel_profiles.rs | 8 ++ src-tauri/src/lib.rs | 1 + 19 files changed, 256 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/connection/TunnelProfileManager.vue b/apps/desktop/src/components/connection/TunnelProfileManager.vue index eeb9260e93..ec439a82ed 100644 --- a/apps/desktop/src/components/connection/TunnelProfileManager.vue +++ b/apps/desktop/src/components/connection/TunnelProfileManager.vue @@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Loader2, Plus, Trash2 } from "@lucide/vue"; import { useToast } from "@/composables/useToast"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; -import { createTunnelProfile, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles"; +import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles"; import type { TunnelProfile } from "@/types/database"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -21,6 +21,9 @@ const draft = ref([]); const selectedId = ref(null); const isSaving = ref(false); const hasInitializedDraft = ref(false); +const isTesting = ref(false); +const testResult = ref<{ ok: boolean; message: string } | null>(null); +const testGuard = createTunnelProfileTestGuard(); function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] { return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[]; @@ -54,6 +57,22 @@ const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.va const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null)); const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null)); +function invalidateProfileTest() { + testGuard.invalidate(); + isTesting.value = false; + testResult.value = null; +} + +// Profile tests are asynchronous, so any selection or configuration change +// must invalidate the request before it can publish a stale result. +watch( + [selectedId, selectedSsh], + () => { + invalidateProfileTest(); + }, + { deep: true }, +); + function profileTypeLabel(profile: TunnelProfile): string { if (profile.type === "proxy") return "Proxy"; if (profile.type === "http_tunnel") return t("connection.httpTunnel"); @@ -96,6 +115,7 @@ function updateProxyType(value: unknown) { async function save() { if (isSaving.value) return; + invalidateProfileTest(); isSaving.value = true; try { await store.saveProfiles(cloneProfiles(draft.value)); @@ -106,6 +126,25 @@ async function save() { isSaving.value = false; } } + +async function testSelected() { + const profile = selectedSsh.value; + if (!profile || isTesting.value) return; + const profileSnapshot = cloneProfiles([profile])[0]; + const requestId = testGuard.start(profileSnapshot); + isTesting.value = true; + testResult.value = null; + try { + const message = await store.testProfile(profileSnapshot); + if (!testGuard.isCurrent(requestId, selectedSsh.value)) return; + testResult.value = { ok: true, message: message || t("settings.tunnelsTestSuccess") }; + } catch (error) { + if (!testGuard.isCurrent(requestId, selectedSsh.value)) return; + testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) }; + } finally { + if (testGuard.isCurrent(requestId, selectedSsh.value)) isTesting.value = false; + } +} -
+
+

{{ t("settings.tunnelsUnsavedHint") }}

+ +

+ {{ testResult.message }} +

diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e55064a167..2cc33dac25 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3116,6 +3116,10 @@ export default { tunnelsSaved: "Tunnel profiles saved", tunnelsSaveFailed: "Failed to save tunnel profiles: {message}", tunnelsUnsavedHint: "Unsaved changes", + tunnelsTest: "Test", + tunnelsTesting: "Testing...", + tunnelsTestSuccess: "SSH tunnel connection successful", + tunnelsTestFailed: "Tunnel test failed: {message}", redisTab: "Redis", shortcutsTab: "Shortcuts", snippetsTab: "Snippets", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 913741a174..e3d8a96fd2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2965,6 +2965,10 @@ export default withEnglishFallback({ tunnelsSaved: "Perfiles de túnel guardados", tunnelsSaveFailed: "Error al guardar los perfiles de túnel: {message}", tunnelsUnsavedHint: "Cambios sin guardar", + tunnelsTest: "Probar", + tunnelsTesting: "Probando...", + tunnelsTestSuccess: "Conexión del túnel SSH correcta", + tunnelsTestFailed: "Error al probar el túnel: {message}", redisTab: "Redis", shortcutsTab: "Atajos", snippetsTab: "Fragmentos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 61aeb01bdb..4deac3b866 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2963,6 +2963,10 @@ export default withEnglishFallback({ tunnelsSaved: "Profili tunnel salvati", tunnelsSaveFailed: "Impossibile salvare i profili tunnel: {message}", tunnelsUnsavedHint: "Modifiche non salvate", + tunnelsTest: "Prova", + tunnelsTesting: "Prova in corso...", + tunnelsTestSuccess: "Connessione tunnel SSH riuscita", + tunnelsTestFailed: "Prova del tunnel non riuscita: {message}", redisTab: "Redis", shortcutsTab: "Scorciatoie", snippetsTab: "Snippet", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index fe66d88333..48a9983e2b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2964,6 +2964,10 @@ export default withEnglishFallback({ tunnelsSaved: "トンネルプロファイルを保存しました", tunnelsSaveFailed: "トンネルプロファイルの保存に失敗しました: {message}", tunnelsUnsavedHint: "未保存の変更があります", + tunnelsTest: "テスト", + tunnelsTesting: "テスト中...", + tunnelsTestSuccess: "SSH トンネル接続に成功しました", + tunnelsTestFailed: "トンネルのテストに失敗しました: {message}", redisTab: "Redis", shortcutsTab: "ショートカット", snippetsTab: "スニペット", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 258e51eb3d..031a5f7434 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2965,6 +2965,10 @@ export default withEnglishFallback({ tunnelsSaved: "Perfis de túnel salvos", tunnelsSaveFailed: "Falha ao salvar os perfis de túnel: {message}", tunnelsUnsavedHint: "Alterações não salvas", + tunnelsTest: "Testar", + tunnelsTesting: "Testando...", + tunnelsTestSuccess: "Conexão do túnel SSH bem-sucedida", + tunnelsTestFailed: "Falha no teste do túnel: {message}", redisTab: "Redis", shortcutsTab: "Atalhos", snippetsTab: "Snippets", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e11a8251f0..5b6a735896 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3115,6 +3115,10 @@ export default withEnglishFallback({ tunnelsSaved: "隧道档案已保存", tunnelsSaveFailed: "保存隧道档案失败:{message}", tunnelsUnsavedHint: "有未保存的修改", + tunnelsTest: "测试", + tunnelsTesting: "测试中...", + tunnelsTestSuccess: "SSH 隧道连接成功", + tunnelsTestFailed: "隧道测试失败:{message}", redisTab: "Redis", shortcutsTab: "快捷键", snippetsTab: "代码片段", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b1cf13c25e..1065815c5b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2828,6 +2828,10 @@ export default withEnglishFallback({ tunnelsSaved: "隧道設定檔已儲存", tunnelsSaveFailed: "儲存隧道設定檔失敗:{message}", tunnelsUnsavedHint: "有未儲存的變更", + tunnelsTest: "測試", + tunnelsTesting: "測試中...", + tunnelsTestSuccess: "SSH 通道連線成功", + tunnelsTestFailed: "通道測試失敗:{message}", fontFamily: "字型", uiFontFamily: "介面字型", uiFontAppDefault: "DBX 預設", diff --git a/apps/desktop/src/lib/__tests__/connection/tunnelProfiles.spec.ts b/apps/desktop/src/lib/__tests__/connection/tunnelProfiles.spec.ts index daf5f7aabd..87a973fa8d 100644 --- a/apps/desktop/src/lib/__tests__/connection/tunnelProfiles.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/tunnelProfiles.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createTunnelProfile, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; +import { createTunnelProfile, createTunnelProfileTestGuard, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; import type { TunnelProfile } from "@/types/database"; function sshProfile(overrides: Partial = {}): TunnelProfile { @@ -34,6 +34,37 @@ describe("tunnelProfileSummary", () => { }); }); +describe("createTunnelProfileTestGuard", () => { + it("invalidates a request when the tested profile changes", () => { + const guard = createTunnelProfileTestGuard(); + const profile = sshProfile(); + const requestId = guard.start(profile); + + expect(guard.isCurrent(requestId, profile)).toBe(true); + expect(guard.isCurrent(requestId, { ...profile, password: "changed" } as TunnelProfile)).toBe(false); + }); + + it("invalidates a request after switching or removing the profile", () => { + const guard = createTunnelProfileTestGuard(); + const requestId = guard.start(sshProfile()); + + guard.invalidate(); + + expect(guard.isCurrent(requestId, sshProfile())).toBe(false); + expect(guard.isCurrent(requestId, null)).toBe(false); + }); + + it("ignores an older response after a newer request starts", () => { + const guard = createTunnelProfileTestGuard(); + const profile = sshProfile(); + const olderRequestId = guard.start(profile); + const newerRequestId = guard.start(profile); + + expect(guard.isCurrent(olderRequestId, profile)).toBe(false); + expect(guard.isCurrent(newerRequestId, profile)).toBe(true); + }); +}); + describe("tunnelProfileReferenceLayer", () => { it("keeps only identity, enabled state, and the reference", () => { const profile = sshProfile(); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index f58d3215ae..de8735d9a4 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -61,6 +61,7 @@ export const saveConnections = forward("saveConnections"); export const loadConnections = forward("loadConnections"); export const loadTunnelProfiles = forward("loadTunnelProfiles"); export const saveTunnelProfiles = forward("saveTunnelProfiles"); +export const testTunnelProfile = forward("testTunnelProfile"); export const readKeychainPassword = forward("readKeychainPassword"); export const readKeychainPasswords = forward("readKeychainPasswords"); export const decryptConfig = forward("decryptConfig"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index f4013b5ff4..b62bfd1837 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -245,6 +245,10 @@ export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise { + return post("/api/tunnel-profiles/test", profile); +} + export async function readKeychainPassword(_service: string): Promise { return ""; // Not available in web backend } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index bc218ca746..7a1d2827aa 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1120,6 +1120,10 @@ export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise { + return invoke("test_tunnel_profile", { profile }); +} + export async function readKeychainPassword(service: string): Promise { return invoke("read_keychain_password", { service, account: null }); } diff --git a/apps/desktop/src/lib/connection/tunnelProfiles.ts b/apps/desktop/src/lib/connection/tunnelProfiles.ts index 4891f70a41..fd5f00f8e7 100644 --- a/apps/desktop/src/lib/connection/tunnelProfiles.ts +++ b/apps/desktop/src/lib/connection/tunnelProfiles.ts @@ -3,6 +3,29 @@ import type { TransportLayerConfig, TunnelProfile } from "@/types/database"; export type TunnelProfileType = TunnelProfile["type"]; +export interface TunnelProfileTestGuard { + invalidate: () => void; + start: (profile: TunnelProfile) => number; + isCurrent: (requestId: number, profile: TunnelProfile | null) => boolean; +} + +export function createTunnelProfileTestGuard(): TunnelProfileTestGuard { + let requestId = 0; + let testedProfile = ""; + + return { + invalidate: () => { + requestId++; + testedProfile = ""; + }, + start: (profile) => { + testedProfile = JSON.stringify(profile); + return ++requestId; + }, + isCurrent: (candidateRequestId, profile) => candidateRequestId === requestId && profile !== null && JSON.stringify(profile) === testedProfile, + }; +} + export function createTunnelProfile(type: TunnelProfileType): TunnelProfile { if (type === "proxy") { return { diff --git a/apps/desktop/src/stores/tunnelProfileStore.ts b/apps/desktop/src/stores/tunnelProfileStore.ts index 6de718e561..950f5fb701 100644 --- a/apps/desktop/src/stores/tunnelProfileStore.ts +++ b/apps/desktop/src/stores/tunnelProfileStore.ts @@ -44,5 +44,14 @@ export const useTunnelProfileStore = defineStore("tunnelProfiles", () => { } } - return { profiles, isLoaded, init, refresh, profileById, saveProfiles }; + /** + * Tests a profile's transport layer in isolation (no downstream database). + * The draft profile is passed straight through, so it validates the values + * currently in the editor rather than the last-saved copy. + */ + async function testProfile(profile: TunnelProfile): Promise { + return api.testTunnelProfile(profile); + } + + return { profiles, isLoaded, init, refresh, profileById, saveProfiles, testProfile }; }); diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 8331443f17..0b83da55cd 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1536,6 +1536,52 @@ impl AppState { .collect() } + /// Tests a shared tunnel profile in isolation (no downstream database), for + /// the Test button in Settings > Tunnels. Only SSH profiles are checked: + /// starting an SSH tunnel connects and authenticates eagerly, so a + /// successful start verifies host reachability and credentials. Proxy and + /// HTTP-tunnel layers connect lazily (nothing happens until traffic flows), + /// so there is nothing to verify here without a target to probe. + pub async fn test_tunnel_profile(&self, profile: &TransportLayerConfig) -> Result { + let TransportLayerConfig::Ssh(ssh) = profile else { + return Err("Tunnel test is currently only supported for SSH profiles.".to_string()); + }; + let ssh = crate::ssh_config::resolve_ssh_tunnel_config(ssh); + if ssh.host.trim().is_empty() { + return Err("SSH host is required.".to_string()); + } + let timeout = if ssh.connect_timeout_secs == 0 { + crate::models::connection::default_ssh_connect_timeout_secs() + } else { + ssh.connect_timeout_secs + }; + // A throwaway id so the probe never reuses or evicts a live tunnel, and + // a sentinel forward target: SSH auth completes on connect, before any + // channel to this target is opened, so it need not be reachable. + let probe_id = format!("__tunnel_profile_test__:{}", uuid::Uuid::new_v4()); + let result = self + .tunnels + .start_tunnel( + &probe_id, + &ssh.host, + ssh.port, + &ssh.user, + &ssh.password, + &ssh.key_path, + &ssh.key_passphrase, + ssh.use_ssh_agent, + &ssh.ssh_agent_sock_path, + &ssh.auth_method, + timeout, + "127.0.0.1", + 1, + false, + ) + .await; + self.tunnels.stop_tunnel(&probe_id).await; + result.map(|_| "SSH tunnel connection successful".to_string()) + } + pub async fn connection_host_port( &self, connection_id: &str, @@ -3713,6 +3759,48 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn test_tunnel_profile_rejects_non_ssh_and_missing_host() { + let (state, dir) = test_app_state().await; + + // Non-SSH profiles cannot be tested in isolation (they connect lazily). + let proxy = TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "p1".to_string(), + name: String::new(), + enabled: true, + proxy_type: ProxyType::Socks5, + host: "127.0.0.1".to_string(), + port: 1080, + username: String::new(), + password: String::new(), + profile_id: String::new(), + }); + let err = state.test_tunnel_profile(&proxy).await.unwrap_err(); + assert!(err.contains("SSH"), "unexpected error: {err}"); + + // An SSH profile with no host fails fast rather than dialing an empty host. + let ssh = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "s1".to_string(), + name: String::new(), + enabled: true, + host: String::new(), + port: 22, + user: "root".to_string(), + password: String::new(), + key_path: String::new(), + key_passphrase: String::new(), + connect_timeout_secs: 5, + expose_lan: false, + use_ssh_agent: false, + ssh_agent_sock_path: String::new(), + auth_method: "password".to_string(), + profile_id: String::new(), + }); + assert!(state.test_tunnel_profile(&ssh).await.is_err()); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn stale_connection_attempt_cannot_replace_newer_pool() { let (state, dir) = test_app_state().await; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index ee9a491b30..4e327a6dfc 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -247,6 +247,7 @@ async fn main() { // Tunnel profiles .route("/tunnel-profiles/list", get(routes::tunnel_profiles::load_tunnel_profiles)) .route("/tunnel-profiles/save", post(routes::tunnel_profiles::save_tunnel_profiles)) + .route("/tunnel-profiles/test", post(routes::tunnel_profiles::test_tunnel_profile)) // Agent drivers .route("/agents/installed-local", get(routes::agents::list_installed_agents_local)) .route("/agents/installed", get(routes::agents::list_installed_agents)) diff --git a/crates/dbx-web/src/routes/tunnel_profiles.rs b/crates/dbx-web/src/routes/tunnel_profiles.rs index 90fc283d7c..7f7ae031fe 100644 --- a/crates/dbx-web/src/routes/tunnel_profiles.rs +++ b/crates/dbx-web/src/routes/tunnel_profiles.rs @@ -26,3 +26,10 @@ pub async fn save_tunnel_profiles( ) -> Result, AppError> { state.app.storage.save_tunnel_profiles(&body.profiles).await.map(Json).map_err(AppError) } + +pub async fn test_tunnel_profile( + State(state): State>, + Json(profile): Json, +) -> Result, AppError> { + state.app.test_tunnel_profile(&profile).await.map(Json).map_err(AppError) +} diff --git a/src-tauri/src/commands/tunnel_profiles.rs b/src-tauri/src/commands/tunnel_profiles.rs index 0213cc596c..20bbe84f4e 100644 --- a/src-tauri/src/commands/tunnel_profiles.rs +++ b/src-tauri/src/commands/tunnel_profiles.rs @@ -17,3 +17,11 @@ pub async fn save_tunnel_profiles( ) -> Result<(), String> { state.storage.save_tunnel_profiles(&profiles).await } + +#[tauri::command] +pub async fn test_tunnel_profile( + state: State<'_, Arc>, + profile: TransportLayerConfig, +) -> Result { + state.test_tunnel_profile(&profile).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bad079ba03..78daad4259 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1275,6 +1275,7 @@ pub fn run() { commands::ssh_config::list_ssh_config_hosts, commands::tunnel_profiles::load_tunnel_profiles, commands::tunnel_profiles::save_tunnel_profiles, + commands::tunnel_profiles::test_tunnel_profile, ]) .build(tauri::generate_context!()) .expect("error while building tauri application")