From 58ca9a3b212d112fe9f236384bfabfc983870ee2 Mon Sep 17 00:00:00 2001 From: yavon007 Date: Sun, 3 May 2026 04:39:03 +0800 Subject: [PATCH] feat: add SSH key passphrase field and file picker for key path - Add ssh_key_passphrase field to ConnectionConfig model with full keyring lifecycle - Pass passphrase to russh load_secret_key for encrypted SSH keys - Add native file picker (FolderOpen icon) next to SSH key path input - Add password-type input for key passphrase in connection dialog - Make connection dialog form scrollable for short windows - Fix alignment of key path input and browse button - Add i18n keys (en + zh-CN) --- src-tauri/src/commands/connection.rs | 1 + src-tauri/src/commands/connection_secrets.rs | 14 ++++++++ src-tauri/src/db/ssh_tunnel.rs | 7 ++-- src-tauri/src/models/connection.rs | 2 ++ .../connection/ConnectionDialog.vue | 33 +++++++++++++++++-- src/i18n/locales/en.ts | 3 ++ src/i18n/locales/zh-CN.ts | 3 ++ src/types/database.ts | 1 + 8 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 823e326c7..60669e65e 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -171,6 +171,7 @@ impl AppState { &config.ssh_user, &config.ssh_password, &config.ssh_key_path, + &config.ssh_key_passphrase, &config.host, config.port, config.ssh_expose_lan, diff --git a/src-tauri/src/commands/connection_secrets.rs b/src-tauri/src/commands/connection_secrets.rs index 4dec9232d..2b2154ef8 100644 --- a/src-tauri/src/commands/connection_secrets.rs +++ b/src-tauri/src/commands/connection_secrets.rs @@ -6,6 +6,7 @@ use tauri::{AppHandle, Manager}; pub(super) const MAIN_PASSWORD_KEY: &str = "password"; pub(super) const SSH_PASSWORD_KEY: &str = "ssh_password"; +pub(super) const SSH_KEY_PASSPHRASE_KEY: &str = "ssh_key_passphrase"; pub(super) const CONNECTION_STRING_KEY: &str = "connection_string"; const KEYRING_SERVICE: &str = "dev.dbx.connections"; @@ -103,6 +104,7 @@ pub(super) fn save_connections_to_file( for config in configs { 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)?; persist_optional_secret( store, &config.id, @@ -143,6 +145,15 @@ pub(super) fn load_connections_from_file( needs_rewrite = true; } + if config.ssh_key_passphrase.is_empty() { + if let Some(secret) = store.get_secret(&config.id, SSH_KEY_PASSPHRASE_KEY)? { + config.ssh_key_passphrase = secret; + } + } else { + store.set_secret(&config.id, SSH_KEY_PASSPHRASE_KEY, &config.ssh_key_passphrase)?; + needs_rewrite = true; + } + match config .connection_string .as_deref() @@ -187,6 +198,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)?; store.delete_secret(&config.id, CONNECTION_STRING_KEY)?; } Ok(()) @@ -235,6 +247,7 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec { .map(|mut config| { config.password.clear(); config.ssh_password.clear(); + config.ssh_key_passphrase.clear(); config.connection_string = None; config }) @@ -352,6 +365,7 @@ mod tests { ssh_user: String::new(), ssh_password: ssh_password.to_string(), ssh_key_path: String::new(), + ssh_key_passphrase: String::new(), ssh_expose_lan: false, ssl: false, connection_string: None, diff --git a/src-tauri/src/db/ssh_tunnel.rs b/src-tauri/src/db/ssh_tunnel.rs index bd6e9fa8b..2bd9f55fd 100644 --- a/src-tauri/src/db/ssh_tunnel.rs +++ b/src-tauri/src/db/ssh_tunnel.rs @@ -28,6 +28,7 @@ async fn connect_and_authenticate( ssh_user: &str, ssh_password: &str, ssh_key_path: &str, + ssh_key_passphrase: &str, ) -> Result, String> { let config = Arc::new(Config { nodelay: true, @@ -39,7 +40,8 @@ async fn connect_and_authenticate( .map_err(|e| format!("SSH connection failed: {e}"))?; if !ssh_key_path.is_empty() { - let key_pair = load_secret_key(ssh_key_path, None) + let passphrase = if ssh_key_passphrase.is_empty() { None } else { Some(ssh_key_passphrase) }; + let key_pair = load_secret_key(ssh_key_path, passphrase) .map_err(|e| format!("Failed to load SSH key: {e}"))?; let auth_res = session .authenticate_publickey( @@ -158,6 +160,7 @@ impl TunnelManager { ssh_user: &str, ssh_password: &str, ssh_key_path: &str, + ssh_key_passphrase: &str, remote_host: &str, remote_port: u16, expose_to_lan: bool, @@ -165,7 +168,7 @@ impl TunnelManager { let local_port = portpicker::pick_unused_port().ok_or("No available port")?; let session = - connect_and_authenticate(ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path) + connect_and_authenticate(ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path, ssh_key_passphrase) .await?; let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" }; diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index 2d96ff3eb..eefec7764 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -32,6 +32,8 @@ pub struct ConnectionConfig { #[serde(default)] pub ssh_key_path: String, #[serde(default)] + pub ssh_key_passphrase: String, + #[serde(default)] pub ssh_expose_lan: bool, #[serde(default)] pub ssl: bool, diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 4ddae6cd2..acbe2b77f 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -12,10 +12,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/tauri"; +import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; +import { FolderOpen } from "lucide-vue-next"; const { t } = useI18n(); const open = defineModel("open", { default: false }); @@ -54,6 +57,7 @@ const defaultForm = (): Omit => ({ ssh_user: "", ssh_password: "", ssh_key_path: "", + ssh_key_passphrase: "", ssh_expose_lan: false, ssl: false, connection_string: undefined, @@ -156,6 +160,7 @@ watch(() => props.editConfig, (config) => { ssh_user: config.ssh_user || "", ssh_password: config.ssh_password || "", ssh_key_path: config.ssh_key_path || "", + ssh_key_passphrase: config.ssh_key_passphrase || "", ssh_expose_lan: config.ssh_expose_lan || false, ssl: config.ssl || false, connection_string: config.connection_string, @@ -298,6 +303,16 @@ const dialogTitle = ref(""); watch([() => editingId.value, () => open.value], () => { dialogTitle.value = editingId.value ? t('connection.editTitle') : t('connection.title'); }); + +async function browseSshKeyPath() { + const selected = await openFileDialog({ + title: "Select SSH Private Key", + multiple: false, + }); + if (selected && typeof selected === "string") { + form.value.ssh_key_path = selected; + } +}