From f800d80f900802ba487eb051a20bebbdd69b99f0 Mon Sep 17 00:00:00 2001 From: zergmk2 Date: Sun, 5 Jul 2026 20:29:49 +0800 Subject: [PATCH] feat: add SSH config aliases and PostgreSQL extensions --- .../connection/ConnectionDialog.vue | 128 +++++++- .../objects/InstallExtensionDialog.vue | 155 ++++++++++ .../src/components/sidebar/TreeItem.vue | 30 +- apps/desktop/src/i18n/locales/en.ts | 22 ++ apps/desktop/src/i18n/locales/es.ts | 22 ++ apps/desktop/src/i18n/locales/it.ts | 22 ++ apps/desktop/src/i18n/locales/ja.ts | 22 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 22 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 22 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 22 ++ apps/desktop/src/lib/backend/api.ts | 3 + apps/desktop/src/lib/backend/http.ts | 14 + apps/desktop/src/lib/backend/tauri.ts | 14 + apps/desktop/src/lib/database/dbAdminSql.ts | 21 ++ apps/desktop/src/stores/connectionStore.ts | 47 +++ apps/desktop/src/stores/queryStore.ts | 7 + apps/desktop/src/types/database.ts | 30 +- crates/dbx-core/src/cloud_sync.rs | 1 + crates/dbx-core/src/connection_secrets.rs | 1 + crates/dbx-core/src/db/postgres.rs | 54 +++- crates/dbx-core/src/db/ssh_tunnel.rs | 32 +- .../dbx-core/src/db/transport_layer_tunnel.rs | 128 +++++++- crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/models/connection.rs | 7 + crates/dbx-core/src/schema.rs | 37 +++ crates/dbx-core/src/ssh_config.rs | 288 ++++++++++++++++++ crates/dbx-core/src/types.rs | 8 + crates/dbx-web/src/main.rs | 3 + crates/dbx-web/src/routes/mod.rs | 1 + crates/dbx-web/src/routes/schema.rs | 23 ++ crates/dbx-web/src/routes/ssh_config.rs | 8 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/schema.rs | 19 ++ src-tauri/src/commands/ssh_config.rs | 6 + src-tauri/src/lib.rs | 3 + 35 files changed, 1191 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/components/objects/InstallExtensionDialog.vue create mode 100644 crates/dbx-core/src/ssh_config.rs create mode 100644 crates/dbx-web/src/routes/ssh_config.rs create mode 100644 src-tauri/src/commands/ssh_config.rs diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index fa51fcb87..15902ab36 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; @@ -200,9 +200,24 @@ function defaultSshTunnel(): SshTunnelConfig { expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: "", + auth_method: "password", }; } +/** + * Infers a login method for connections saved before `auth_method` existed + * (or imported from a source that never set it), so the dropdown shows a + * sensible current state instead of defaulting blindly to "password". + * Mirrors the priority `connect_and_authenticate` actually uses at connect + * time (key > password > agent > none) — see `db/ssh_tunnel.rs`. + */ +function inferSshAuthMethod(hop: Partial): "password" | "key" | "agent" | "none" { + if (hop.key_path?.trim()) return "key"; + if (hop.password) return "password"; + if (hop.use_ssh_agent) return "agent"; + return "none"; +} + function normalizeSshTunnel(hop: Partial): SshTunnelConfig { return { id: hop.id || uuid(), @@ -218,6 +233,7 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { expose_lan: !!hop.expose_lan, use_ssh_agent: !!hop.use_ssh_agent, ssh_agent_sock_path: hop.ssh_agent_sock_path || "", + auth_method: hop.auth_method || inferSshAuthMethod(hop), }; } @@ -346,6 +362,7 @@ const mongoUseUrl = ref(false); const jdbcDriverPathsInput = ref(""); const jdbcDrivers = ref([]); const jdbcMavenBundles = ref([]); +const sshConfigHosts = ref([]); const agentDrivers = ref([]); const selectedJdbcDriverPath = ref(""); const jdbcManualClasspathOpen = ref(false); @@ -2796,6 +2813,7 @@ watch( if (!props.prefillConfig?.oneTime) { void loadJdbcDrivers(); void loadAgentDrivers(); + void loadSshConfigHosts(); } // Preload database names so the summary count is accurate right away. void nextTick(() => { @@ -2930,6 +2948,33 @@ function updateSelectedProxyType(value: unknown) { resetTestState(); } +/** + * "agent" is legacy-only: it's never chosen from this dropdown, only ever + * inherited from a connection saved before this selector existed. Once the + * user picks something else, the option (and its underlying checkbox) is + * gone from the form for good. + */ +function isLegacySshAgentMethod(hop: Partial | null | undefined) { + return hop?.auth_method === "agent"; +} + +function updateSelectedSshAuthMethod(value: unknown) { + const layer = selectedSshLayer.value; + if (!layer) return; + layer.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password"; + // Scrub credential fields that do not apply to the selected method so + // they are not accidentally submitted or used by the backend fallback. + if (layer.auth_method !== "password") layer.password = ""; + if (layer.auth_method !== "key") { + layer.key_path = ""; + layer.key_passphrase = ""; + } + if (layer.auth_method !== "key") { + layer.use_ssh_agent = false; + } + resetTestState(); +} + function validateTransportLayers(config: LegacyConnectionConfig) { const layers = config.transport_layers || []; layers.forEach((layer, index) => { @@ -3013,6 +3058,28 @@ watch([() => editingId.value, () => open.value], () => { dialogTitle.value = editingId.value ? t("connection.editTitle") : t("connection.title"); }); +const sshConfigHostAliases = computed(() => sshConfigHosts.value.map((entry) => entry.alias)); + +/** + * Prefills user/port/key_path from a matching ~/.ssh/config alias, without + * overwriting values the user already changed away from the form defaults. + * This is a UX preview only — the authoritative resolution happens in the + * Rust backend at connect time (see resolve_ssh_tunnel_config), so imported + * configs that never touched this UI still resolve correctly. + */ +function applySshConfigHostAliasPrefill(target: SshTunnelConfig) { + const entry = sshConfigHosts.value.find((candidate) => candidate.alias === target.host); + if (!entry) return; + if (target.user === DEFAULT_SSH_USER && entry.user) target.user = entry.user; + if (target.port === 22 && entry.port) target.port = entry.port; + if (!target.key_path && entry.identity_file) { + target.key_path = entry.identity_file; + if ((!target.auth_method || target.auth_method === "password") && !target.password?.trim()) { + target.auth_method = "key"; + } + } +} + async function browseSshKeyPath(target?: SshTunnelConfig | null) { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -3229,6 +3296,14 @@ async function loadJdbcDrivers() { } } +async function loadSshConfigHosts() { + try { + sshConfigHosts.value = await api.listSshConfigHosts(); + } catch { + sshConfigHosts.value = []; + } +} + async function loadAgentDrivers() { try { agentDrivers.value = await api.listInstalledAgentsLocal(); @@ -4733,7 +4808,10 @@ function openExternalUrl(url: string) {