feat(connection): support multi-hop ssh tunnels
This commit is contained in:
parent
7f5de70687
commit
46c05b811f
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "代理",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue