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)
This commit is contained in:
yavon007 2026-05-03 04:39:03 +08:00
parent 0fdd0c1f9b
commit 58ca9a3b21
8 changed files with 60 additions and 4 deletions

View File

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

View File

@ -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<ConnectionConfig> {
.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,

View File

@ -28,6 +28,7 @@ async fn connect_and_authenticate(
ssh_user: &str,
ssh_password: &str,
ssh_key_path: &str,
ssh_key_passphrase: &str,
) -> Result<Handle<SshClient>, 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" };

View File

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

View File

@ -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<boolean>("open", { default: false });
@ -54,6 +57,7 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
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;
}
}
</script>
<template>
@ -307,7 +322,7 @@ watch([() => editingId.value, () => open.value], () => {
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('connection.name') }}</Label>
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
@ -514,7 +529,21 @@ watch([() => editingId.value, () => open.value], () => {
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
<Input v-model="form.ssh_key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
<div class="col-span-3 flex items-center gap-1">
<Input v-model="form.ssh_key_path" class="flex-1" placeholder="~/.ssh/id_rsa" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseSshKeyPath">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('connection.sshKeyPathBrowse') }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t('connection.sshKeyPassphrase') }}</Label>
<Input v-model="form.ssh_key_passphrase" type="password" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />

View File

@ -62,6 +62,9 @@ export default {
sshPassword: "SSH Password",
sshPasswordPlaceholder: "Leave empty to use key",
sshKeyPath: "Key Path",
sshKeyPassphrase: "Key Passphrase",
sshKeyPassphrasePlaceholder: "Leave empty if key is not encrypted",
sshKeyPathBrowse: "Browse",
sshExposeLan: "Expose tunnel to LAN",
compatible: "Compatible",
mainstream: "Popular",

View File

@ -64,6 +64,9 @@ export default {
sshPassword: "SSH 密码",
sshPasswordPlaceholder: "留空则使用密钥",
sshKeyPath: "密钥路径",
sshKeyPassphrase: "密钥密码",
sshKeyPassphrasePlaceholder: "密钥未加密则留空",
sshKeyPathBrowse: "浏览",
sshExposeLan: "允许局域网访问隧道",
compatible: "兼容",
mainstream: "主流",

View File

@ -19,6 +19,7 @@ export interface ConnectionConfig {
ssh_user?: string;
ssh_password?: string;
ssh_key_path?: string;
ssh_key_passphrase?: string;
ssh_expose_lan?: boolean;
ssl?: boolean;
connection_string?: string;