feat(connection): support multi-hop ssh tunnels

This commit is contained in:
t8y2 2026-06-01 14:48:33 +08:00
parent 7f5de70687
commit 46c05b811f
19 changed files with 1131 additions and 152 deletions

View File

@ -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<ConnectionConfig, "id"> => ({
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<ConnectionConfig, "id"> => ({
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>): 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<string | null>(null);
const draggedSshTunnelId = ref<string | null>(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) {
<span class="text-xs text-muted-foreground">{{ t("connection.sshEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
<Input
v-model="form.ssh_host"
class="col-span-2"
placeholder="ssh.example.com"
:disabled="!form.ssh_enabled"
/>
<Input
v-model.number="form.ssh_port"
type="number"
class="col-span-1"
:disabled="!form.ssh_enabled"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" :disabled="!form.ssh_enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
<Input
v-model="form.ssh_password"
type="password"
class="col-span-3"
:placeholder="t('connection.sshPasswordPlaceholder')"
:disabled="!form.ssh_enabled"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshKeyPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input
v-model="form.ssh_key_path"
class="flex-1"
placeholder="~/.ssh/id_rsa"
:disabled="!form.ssh_enabled"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
<div class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right text-xs">{{ t("connection.sshHops") }}</Label>
<div class="col-span-3 grid gap-3">
<div class="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
<template v-for="(segment, index) in sshPathSegments" :key="`${segment}-${index}`">
<span class="rounded border bg-muted/40 px-2 py-1">{{ segment }}</span>
<ChevronRight v-if="index < sshPathSegments.length - 1" class="h-3 w-3" />
</template>
</div>
<div class="grid gap-2">
<button
v-for="(hop, index) in sshTunnels"
:key="hop.id"
type="button"
draggable="true"
class="flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
:class="hop.id === selectedSshTunnel?.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'"
:disabled="!form.ssh_enabled"
@click="selectedSshTunnelId = hop.id"
@dragstart="draggedSshTunnelId = hop.id"
@dragover.prevent
@drop="dropSshTunnel(hop.id)"
>
<GripVertical class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="w-5 shrink-0 text-muted-foreground">{{ index + 1 }}</span>
<input
v-model="hop.enabled"
type="checkbox"
class="mr-0"
:disabled="!form.ssh_enabled"
@click="browseSshKeyPath"
>
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshKeyPathBrowse") }}</TooltipContent>
</Tooltip>
@click.stop
/>
<span class="min-w-0 flex-1 truncate">
{{ hop.name || hop.host || t("connection.sshHopDefaultName", { index: index + 1 }) }}
</span>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="index === 0 || !form.ssh_enabled"
@click.stop="moveSshTunnel(hop.id, -1)"
>
<ArrowUp class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveUp") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="index === sshTunnels.length - 1 || !form.ssh_enabled"
@click.stop="moveSshTunnel(hop.id, 1)"
>
<ArrowDown class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.sshHopMoveDown") }}</TooltipContent>
</Tooltip>
</button>
</div>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="addSshTunnel"
>
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopAdd") }}
</Button>
<Button
v-if="selectedSshTunnel"
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="duplicateSshTunnel(selectedSshTunnel)"
>
<Copy class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDuplicate") }}
</Button>
<Button
v-if="selectedSshTunnel"
type="button"
variant="outline"
size="sm"
:disabled="!form.ssh_enabled"
@click="removeSshTunnel(selectedSshTunnel.id)"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.sshHopDelete") }}
</Button>
</div>
</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')"
:disabled="!form.ssh_enabled"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label
class="col-span-3 flex items-center gap-2"
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<input type="checkbox" v-model="form.ssh_expose_lan" class="mr-0" :disabled="!form.ssh_enabled" />
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
<Input
v-model.number="form.ssh_connect_timeout_secs"
type="number"
min="5"
max="300"
step="1"
class="col-span-3"
:disabled="!form.ssh_enabled"
/>
</div>
<template v-if="selectedSshTunnel">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshHopName") }}</Label>
<Input
v-model="selectedSshTunnel.name"
class="col-span-3"
:placeholder="t('connection.sshHopNamePlaceholder')"
:disabled="!form.ssh_enabled"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshHost") }}</Label>
<Input
v-model="selectedSshTunnel.host"
class="col-span-2"
placeholder="ssh.example.com"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
<Input
v-model.number="selectedSshTunnel.port"
type="number"
min="1"
max="65535"
class="col-span-1"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshUser") }}</Label>
<Input
v-model="selectedSshTunnel.user"
class="col-span-3"
placeholder="root"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshPassword") }}</Label>
<Input
v-model="selectedSshTunnel.password"
type="password"
class="col-span-3"
:placeholder="t('connection.sshPasswordPlaceholder')"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshKeyPath") }}</Label>
<div class="col-span-3 flex items-center gap-1">
<Input
v-model="selectedSshTunnel.key_path"
class="flex-1"
placeholder="~/.ssh/id_rsa"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
@click="browseSshKeyPath(selectedSshTunnel)"
>
<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="selectedSshTunnel.key_passphrase"
type="password"
class="col-span-3"
:placeholder="t('connection.sshKeyPassphrasePlaceholder')"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label
class="col-span-3 flex items-center gap-2"
:class="form.ssh_enabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
>
<input
type="checkbox"
v-model="selectedSshTunnel.expose_lan"
class="mr-0"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.sshConnectTimeout") }}</Label>
<Input
v-model.number="selectedSshTunnel.connect_timeout_secs"
type="number"
min="1"
max="300"
step="1"
class="col-span-3"
:disabled="!form.ssh_enabled || selectedSshTunnel.enabled === false"
/>
</div>
</template>
</div>
</TabsContent>

View File

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

View File

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

View File

@ -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: "代理",

View File

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

View File

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

View File

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

View File

@ -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<ConnectionSecretSnapshot>, 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<EncryptedSecretsBlob, String> {
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]

View File

@ -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<String> = {
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,

View File

@ -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<Option<String>, 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<Vec<ConnectionConfig>, 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<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;
@ -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<ConnectionConfig> {
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");
}

View File

@ -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<SshClient>, listener: &TcpListener, remot
#[allow(clippy::too_many_arguments)]
async fn tunnel_reconnect_loop(
mut session: Handle<SshClient>,
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<JoinHandle<()>>,
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<HashMap<String, (JoinHandle<()>, u16)>>,
tunnels: Mutex<HashMap<String, TunnelEntry>>,
}
impl Default for TunnelManager {
@ -264,14 +285,10 @@ impl TunnelManager {
remote_port: u16,
expose_to_lan: bool,
) -> Result<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(
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<u16, String> {
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<u16> {
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<PlannedTunnel> {
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;
}
}

View File

@ -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<SshTunnelConfig>,
#[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<SshTunnelConfig> {
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);

View File

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

View File

@ -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?;

View File

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

View File

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

View File

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

View File

@ -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<ConnectionConfig, "id">): 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 ?? "",

View File

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