feat(connection): add reusable tunnel profiles

This commit is contained in:
rainhan 2026-07-12 22:03:46 +08:00 committed by GitHub
parent 4dcee1aca0
commit 977e7f78ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1335 additions and 22 deletions

View File

@ -2074,6 +2074,10 @@ onUnmounted(() => {
setConnectionDialogOpen(false);
openDriverStorePage();
"
@open-tunnel-profile-settings="
setConnectionDialogOpen(false);
openSettings('tunnels');
"
@open-lineage-target="openLineageTarget"
@open-database-search-target="openDatabaseSearchTarget"
@open-diagram-target="openDiagramTarget"

View File

@ -18,6 +18,8 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
@ -114,9 +116,11 @@ const emit = defineEmits<{
connectSucceeded: [name: string];
connectFailed: [message: string];
openDriverStore: [];
openTunnelProfileSettings: [];
}>();
const store = useConnectionStore();
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isSaving = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
@ -251,6 +255,7 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
use_ssh_agent: !!hop.use_ssh_agent,
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
auth_method: hop.auth_method || inferSshAuthMethod(hop),
profile_id: hop.profile_id || undefined,
};
}
@ -288,6 +293,7 @@ function normalizeProxyTunnel(layer: Partial<ProxyTunnelConfig>): ProxyTunnelCon
port: Number(layer.port) || 1080,
username: layer.username || "",
password: layer.password || "",
profile_id: layer.profile_id || undefined,
};
}
@ -299,6 +305,7 @@ function normalizeHttpTunnel(layer: Partial<HttpTunnelConfig>): HttpTunnelConfig
url: layer.url || "",
token: layer.token || "",
connect_timeout_secs: Number(layer.connect_timeout_secs) || 10,
profile_id: layer.profile_id || undefined,
};
}
@ -1534,6 +1541,32 @@ const selectedSshLayer = computed(() => (selectedTransportLayer.value?.type ===
const selectedProxyLayer = computed(() => (selectedTransportLayer.value?.type === "proxy" ? selectedTransportLayer.value : null));
const selectedHttpTunnelLayer = computed(() => (selectedTransportLayer.value?.type === "http_tunnel" ? selectedTransportLayer.value : null));
const tunnelProfiles = computed(() => tunnelProfileStore.profiles);
const selectedLayerProfileId = computed(() => selectedTransportLayer.value?.profile_id || "");
const selectedLayerProfile = computed(() => tunnelProfileStore.profileById(selectedLayerProfileId.value));
function tunnelProfileOptionLabel(profile: (typeof tunnelProfiles.value)[number]): string {
const summary = tunnelProfileSummary(profile);
if (!profile.name?.trim()) return summary || profile.id;
return summary ? `${profile.name} (${summary})` : profile.name;
}
function applyTunnelProfileSelection(value: unknown) {
const selected = selectedTransportLayer.value;
if (!selected) return;
if (!value || value === "custom") {
if (!selected.profile_id) return;
const detached = detachTunnelProfileLayer(selected, tunnelProfileStore.profileById(selected.profile_id));
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? detached : layer));
} else {
const profile = tunnelProfileStore.profileById(String(value));
if (!profile) return;
const stub = tunnelProfileReferenceLayer(profile, selected);
form.value.transport_layers = transportLayers.value.map((layer) => (layer.id === selected.id ? stub : layer));
}
resetTestState();
}
function transportLayerDefaultName(layer: TransportLayerConfig, index: number): string {
if (layer.type === "proxy") return `Proxy ${index + 1}`;
if (layer.type === "http_tunnel") return t("connection.httpTunnelDefaultName", { index: index + 1 });
@ -1541,6 +1574,11 @@ function transportLayerDefaultName(layer: TransportLayerConfig, index: number):
}
function transportLayerDisplayName(layer: TransportLayerConfig, index: number): string {
if (layer.profile_id) {
const profile = tunnelProfileStore.profileById(layer.profile_id);
if (profile) return profile.name?.trim() || tunnelProfileSummary(profile) || transportLayerDefaultName(layer, index);
return layer.name?.trim() || t("connection.tunnelProfileMissingName");
}
const target = layer.type === "http_tunnel" ? layer.url?.trim() : layer.host?.trim();
return layer.name?.trim() || target || transportLayerDefaultName(layer, index);
}
@ -3306,6 +3344,9 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
const layers = config.transport_layers || [];
layers.forEach((layer, index) => {
if (layer.enabled === false) return;
// Profile-referencing layers are stubs: the shared profile supplies the
// whole configuration at connect time, so there is nothing to validate.
if (layer.profile_id) return;
const label = layer.name?.trim() || transportLayerDefaultName(layer, index);
if (layer.type === "http_tunnel") {
if (index !== 0) throw new Error(t("connection.httpTunnelInvalidOrder", { hop: label }));
@ -3713,6 +3754,7 @@ function onJdbcDriverSelect(id: any) {
}
onMounted(async () => {
void tunnelProfileStore.init();
unlistenAgentInstallProgress = await api.listenAgentInstallProgress(handleAgentInstallProgress);
});
@ -5372,7 +5414,35 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHopName") }}</Label>
<Input v-model="selectedTransportLayer.name" class="col-span-3" :placeholder="t('connection.sshHopNamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="tunnelProfiles.length || selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.tunnelProfile") }}</Label>
<div class="col-span-3 flex min-w-0 items-center gap-2">
<Select :model-value="selectedLayerProfileId || 'custom'" @update:model-value="applyTunnelProfileSelection">
<SelectTrigger class="h-9 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">{{ t("connection.tunnelProfileCustom") }}</SelectItem>
<SelectItem v-for="profile in tunnelProfiles" :key="profile.id" :value="profile.id">{{ tunnelProfileOptionLabel(profile) }}</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" class="shrink-0" @click="emit('openTunnelProfileSettings')">
{{ t("connection.tunnelProfileManage") }}
</Button>
</div>
</div>
<div v-if="selectedLayerProfileId" class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 grid min-w-0 gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<template v-if="selectedLayerProfile">
<span class="truncate font-medium text-foreground">{{ selectedLayerProfile.name || tunnelProfileSummary(selectedLayerProfile) }}</span>
<span v-if="selectedLayerProfile.name && tunnelProfileSummary(selectedLayerProfile)" class="truncate">{{ tunnelProfileSummary(selectedLayerProfile) }}</span>
<span>{{ t("connection.tunnelProfileManaged") }}</span>
</template>
<span v-else class="text-red-500">{{ t("connection.tunnelProfileMissing") }}</span>
</div>
</div>
<div v-if="!selectedLayerProfileId" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">Type</Label>
<Select :model-value="selectedTransportLayer.type" @update:model-value="(value: any) => changeSelectedTransportLayerType(value)">
<SelectTrigger class="col-span-3 h-9">
@ -5385,7 +5455,7 @@ function openExternalUrl(url: string) {
</SelectContent>
</Select>
</div>
<template v-if="selectedSshLayer">
<template v-if="selectedSshLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshHost") }}</Label>
<Input v-model="selectedSshLayer.host" class="col-span-2" list="ssh-config-host-aliases" :placeholder="t('connection.sshHostPlaceholder')" :disabled="selectedSshLayer.enabled === false" @change="applySshConfigHostAliasPrefill(selectedSshLayer!)" />
@ -5463,7 +5533,7 @@ function openExternalUrl(url: string) {
<Input v-model.number="selectedSshLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedSshLayer.enabled === false" />
</div>
</template>
<template v-else-if="selectedProxyLayer">
<template v-else-if="selectedProxyLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxyLayer.proxy_type || 'socks5'" :disabled="selectedProxyLayer.enabled === false" @update:model-value="updateSelectedProxyType">
@ -5490,7 +5560,7 @@ function openExternalUrl(url: string) {
<PasswordInput v-model="selectedProxyLayer.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" :disabled="selectedProxyLayer.enabled === false" />
</div>
</template>
<template v-else-if="selectedHttpTunnelLayer">
<template v-else-if="selectedHttpTunnelLayer && !selectedLayerProfileId">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttpTunnelLayer.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" :disabled="selectedHttpTunnelLayer.enabled === false" />

View File

@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Plus, Trash2 } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { createTunnelProfile, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
import type { TunnelProfile } from "@/types/database";
import { translateBackendError } from "@/i18n/backend-errors";
const { t } = useI18n();
const { toast } = useToast();
const store = useTunnelProfileStore();
const draft = ref<TunnelProfile[]>([]);
const selectedId = ref<string | null>(null);
const isSaving = ref(false);
function cloneProfiles(profiles: TunnelProfile[]): TunnelProfile[] {
return JSON.parse(JSON.stringify(profiles)) as TunnelProfile[];
}
function resetDraft() {
draft.value = cloneProfiles(store.profiles);
if (!draft.value.some((profile) => profile.id === selectedId.value)) {
selectedId.value = draft.value[0]?.id || null;
}
}
const isDirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(store.profiles));
void store.init();
watch(
() => store.isLoaded,
(loaded) => {
if (loaded && !isDirty.value) resetDraft();
},
{ immediate: true },
);
const selected = computed(() => draft.value.find((profile) => profile.id === selectedId.value) || null);
const selectedSsh = computed(() => (selected.value?.type === "ssh" ? selected.value : null));
const selectedProxy = computed(() => (selected.value?.type === "proxy" ? selected.value : null));
const selectedHttp = computed(() => (selected.value?.type === "http_tunnel" ? selected.value : null));
function profileTypeLabel(profile: TunnelProfile): string {
if (profile.type === "proxy") return "Proxy";
if (profile.type === "http_tunnel") return t("connection.httpTunnel");
return "SSH";
}
function profileDisplayName(profile: TunnelProfile): string {
return profile.name?.trim() || tunnelProfileSummary(profile) || t("settings.tunnelsUnnamedProfile");
}
function addProfile(type: TunnelProfileType) {
const profile = createTunnelProfile(type);
draft.value = [...draft.value, profile];
selectedId.value = profile.id;
}
function removeSelected() {
const current = selected.value;
if (!current) return;
draft.value = draft.value.filter((profile) => profile.id !== current.id);
selectedId.value = draft.value[0]?.id || null;
}
function updateSshAuthMethod(value: unknown) {
const profile = selectedSsh.value;
if (!profile) return;
profile.auth_method = value === "key" ? "key" : value === "none" ? "none" : "password";
if (profile.auth_method !== "password") profile.password = "";
if (profile.auth_method !== "key") {
profile.key_path = "";
profile.key_passphrase = "";
}
}
function updateProxyType(value: unknown) {
const profile = selectedProxy.value;
if (!profile) return;
profile.proxy_type = value === "http" ? "http" : "socks5";
}
async function save() {
if (isSaving.value) return;
isSaving.value = true;
try {
await store.saveProfiles(cloneProfiles(draft.value));
toast(t("settings.tunnelsSaved"));
} catch (error) {
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, String(error)) }), 5000);
} finally {
isSaving.value = false;
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<p class="text-xs text-muted-foreground">{{ t("settings.tunnelsDescription") }}</p>
<div class="grid min-w-0 gap-2">
<p v-if="!draft.length" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("settings.tunnelsEmpty") }}
</p>
<button v-for="profile in draft" :key="profile.id" type="button" class="flex min-h-10 items-center gap-2 rounded-md border px-3 text-left text-xs transition-colors" :class="profile.id === selectedId ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'" @click="selectedId = profile.id">
<span class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{{ profileTypeLabel(profile) }}</span>
<span class="min-w-0 flex-1 truncate">{{ profileDisplayName(profile) }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{ tunnelProfileSummary(profile) }}</span>
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" @click="addProfile('ssh')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddSsh") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="addProfile('proxy')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddProxy") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="addProfile('http_tunnel')">
<Plus class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsAddHttp") }}
</Button>
<Button v-if="selected" type="button" variant="outline" size="sm" @click="removeSelected">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("settings.tunnelsDelete") }}
</Button>
</div>
<template v-if="selected">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("settings.tunnelsProfileName") }}</Label>
<Input v-model="selected.name" class="col-span-3" :placeholder="t('settings.tunnelsProfileNamePlaceholder')" />
</div>
<template v-if="selectedSsh">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshHost") }}</Label>
<Input v-model="selectedSsh.host" class="col-span-2" :placeholder="t('connection.sshHostPlaceholder')" />
<Input v-model.number="selectedSsh.port" type="number" min="1" max="65535" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshUser") }}</Label>
<Input v-model="selectedSsh.user" class="col-span-3" placeholder="root" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshAuthMethod") }}</Label>
<Select :model-value="selectedSsh.auth_method || 'password'" @update:model-value="updateSshAuthMethod">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">{{ t("connection.sshAuthMethodPassword") }}</SelectItem>
<SelectItem value="key">{{ t("connection.sshAuthMethodKey") }}</SelectItem>
<SelectItem value="none">{{ t("connection.sshAuthMethodNone") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="!selectedSsh.auth_method || selectedSsh.auth_method === 'password'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshPassword") }}</Label>
<PasswordInput v-model="selectedSsh.password" class="col-span-3" :placeholder="t('connection.sshPasswordPlaceholder')" />
</div>
<div v-if="selectedSsh.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshKeyPath") }}</Label>
<Input v-model="selectedSsh.key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
</div>
<div v-if="selectedSsh.auth_method === 'key'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshKeyPassphrase") }}</Label>
<PasswordInput v-model="selectedSsh.key_passphrase" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
</div>
<div v-if="selectedSsh.auth_method === 'none'" class="grid grid-cols-4 items-center gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.sshAuthMethodNoneHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex cursor-pointer items-center gap-2">
<input v-model="selectedSsh.expose_lan" type="checkbox" class="mr-0" />
<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-xs">{{ t("connection.sshConnectTimeout") }}</Label>
<Input v-model.number="selectedSsh.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
</div>
</template>
<template v-else-if="selectedProxy">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyType") }}</Label>
<Select :model-value="selectedProxy.proxy_type || 'socks5'" @update:model-value="updateProxyType">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="http">HTTP CONNECT</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyHost") }}</Label>
<Input v-model="selectedProxy.host" class="col-span-2" placeholder="127.0.0.1" />
<Input v-model.number="selectedProxy.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyUsername") }}</Label>
<Input v-model="selectedProxy.username" class="col-span-3" :placeholder="t('connection.proxyUsernamePlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.proxyPassword") }}</Label>
<PasswordInput v-model="selectedProxy.password" class="col-span-3" :placeholder="t('connection.proxyPasswordPlaceholder')" />
</div>
</template>
<template v-else-if="selectedHttp">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelUrl") }}</Label>
<Input v-model="selectedHttp.url" class="col-span-3" placeholder="https://dbx.example.com/dbx_tunnel.php" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelToken") }}</Label>
<PasswordInput v-model="selectedHttp.token" class="col-span-3" :placeholder="t('connection.httpTunnelTokenPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.httpTunnelConnectTimeout") }}</Label>
<Input v-model.number="selectedHttp.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
</div>
</template>
</template>
<div class="flex items-center gap-2">
<Button type="button" size="sm" :disabled="!isDirty || isSaving" @click="save">
<Loader2 v-if="isSaving" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("settings.tunnelsSave") }}
</Button>
<Button type="button" variant="outline" size="sm" :disabled="!isDirty || isSaving" @click="resetDraft">
{{ t("settings.tunnelsReset") }}
</Button>
<p v-if="isDirty" class="text-xs text-muted-foreground">{{ t("settings.tunnelsUnsavedHint") }}</p>
</div>
</div>
</template>

View File

@ -46,6 +46,7 @@ import {
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard } from "@/lib/common/clipboard";
@ -1244,13 +1245,14 @@ const appSupportInfoLabels = computed<AppSupportInfoLabels>(() => ({
unknown: t("settings.supportInfoUnknown"),
}));
const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : []));
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "editor", label: t("settings.editorTab") },
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
{ value: "tunnels", label: t("settings.tunnelsTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
{ value: "snippets", label: t("settings.snippetsTab") },
...(isWeb ? [] : [{ value: "sync" as const, label: t("settings.syncTab") }]),
@ -4496,6 +4498,10 @@ onUnmounted(cleanupPreviewEditor);
</div>
</section>
<section v-else-if="activeSettingsTab === 'tunnels'" class="flex flex-col gap-5 py-2">
<TunnelProfileManager />
</section>
<section v-else-if="activeSettingsTab === 'about'" class="flex flex-col gap-5 py-2">
<div class="rounded-lg border p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">

View File

@ -50,6 +50,7 @@ const emit = defineEmits<{
connectSucceeded: [name: string];
connectFailed: [message: string];
openDriverStore: [];
openTunnelProfileSettings: [];
openLineageTarget: [
target: {
connectionId: string;
@ -136,6 +137,7 @@ watch(
@connect-succeeded="emit('connectSucceeded', $event)"
@connect-failed="emit('connectFailed', $event)"
@open-driver-store="emit('openDriverStore')"
@open-tunnel-profile-settings="emit('openTunnelProfileSettings')"
/>
<DangerConfirmDialog
v-if="showDangerDialog"

View File

@ -410,6 +410,12 @@ export default {
proxyUsernamePlaceholder: "Optional",
proxyPassword: "Proxy Password",
proxyPasswordPlaceholder: "Optional",
tunnelProfile: "Tunnel Profile",
tunnelProfileCustom: "Custom (this connection only)",
tunnelProfileManaged: "Managed by a shared tunnel profile. Edit it in Settings > Tunnels — changes apply to every connection using it on its next connection.",
tunnelProfileMissing: "The referenced tunnel profile no longer exists. Select another profile or switch to custom.",
tunnelProfileMissingName: "Missing tunnel profile",
tunnelProfileManage: "Manage",
httpTunnel: "HTTP Tunnel",
httpTunnelAdd: "Add HTTP tunnel",
httpTunnelUrl: "Tunnel Script URL",
@ -3082,6 +3088,21 @@ export default {
appearanceTab: "Appearance",
navigationTab: "Navigation",
dataTab: "Data",
tunnelsTab: "Tunnels",
tunnelsDescription: "Reusable SSH / proxy / HTTP tunnel configurations. Configure once, then select the profile from a connection's tunnel tab; edits here apply to every connection using the profile.",
tunnelsEmpty: "No tunnel profiles yet. Create one here, then select it in a connection's tunnel settings.",
tunnelsAddSsh: "Add SSH",
tunnelsAddProxy: "Add Proxy",
tunnelsAddHttp: "Add HTTP Tunnel",
tunnelsProfileName: "Profile Name",
tunnelsProfileNamePlaceholder: "e.g. Office bastion",
tunnelsDelete: "Delete",
tunnelsUnnamedProfile: "Unnamed profile",
tunnelsSave: "Save",
tunnelsReset: "Reset",
tunnelsSaved: "Tunnel profiles saved",
tunnelsSaveFailed: "Failed to save tunnel profiles: {message}",
tunnelsUnsavedHint: "Unsaved changes",
redisTab: "Redis",
shortcutsTab: "Shortcuts",
snippetsTab: "Snippets",

View File

@ -391,6 +391,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Contraseña del proxy",
proxyPasswordPlaceholder: "Opcional",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (solo esta conexión)",
tunnelProfileManaged: "Gestionado por un perfil de túnel compartido. Edítalo en Ajustes > Túneles; los cambios se aplican a todas las conexiones que lo usan en su próxima conexión.",
tunnelProfileMissing: "El perfil de túnel referenciado ya no existe. Selecciona otro perfil o cambia a personalizado.",
tunnelProfileMissingName: "Perfil de túnel no encontrado",
tunnelProfileManage: "Gestionar",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC heredado",
jdbcUrl: "URL JDBC",
@ -2934,6 +2940,21 @@ export default withEnglishFallback({
appearanceTab: "Apariencia",
navigationTab: "Navegación",
dataTab: "Datos",
tunnelsTab: "Túneles",
tunnelsDescription: "Configuraciones reutilizables de túneles SSH / proxy / HTTP. Configura una vez y selecciona el perfil desde la pestaña de túnel de una conexión; los cambios aquí se aplican a todas las conexiones que usan el perfil.",
tunnelsEmpty: "Aún no hay perfiles de túnel. Crea uno aquí y selecciónalo en la configuración de túnel de una conexión.",
tunnelsAddSsh: "Añadir SSH",
tunnelsAddProxy: "Añadir proxy",
tunnelsAddHttp: "Añadir túnel HTTP",
tunnelsProfileName: "Nombre del perfil",
tunnelsProfileNamePlaceholder: "p. ej. Bastión de la oficina",
tunnelsDelete: "Eliminar",
tunnelsUnnamedProfile: "Perfil sin nombre",
tunnelsSave: "Guardar",
tunnelsReset: "Restablecer",
tunnelsSaved: "Perfiles de túnel guardados",
tunnelsSaveFailed: "Error al guardar los perfiles de túnel: {message}",
tunnelsUnsavedHint: "Cambios sin guardar",
redisTab: "Redis",
shortcutsTab: "Atajos",
snippetsTab: "Fragmentos",

View File

@ -389,6 +389,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opzionale",
proxyPassword: "Password Proxy",
proxyPasswordPlaceholder: "Opzionale",
tunnelProfile: "Profilo tunnel",
tunnelProfileCustom: "Personalizzato (solo questa connessione)",
tunnelProfileManaged: "Gestito da un profilo tunnel condiviso. Modificalo in Impostazioni > Tunnel; le modifiche si applicano a tutte le connessioni che lo usano alla prossima connessione.",
tunnelProfileMissing: "Il profilo tunnel a cui si fa riferimento non esiste più. Seleziona un altro profilo o passa a personalizzato.",
tunnelProfileMissingName: "Profilo tunnel mancante",
tunnelProfileManage: "Gestisci",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC legacy",
jdbcUrl: "URL JDBC",
@ -2932,6 +2938,21 @@ export default withEnglishFallback({
appearanceTab: "Aspetto",
navigationTab: "Navigazione",
dataTab: "Dati",
tunnelsTab: "Tunnel",
tunnelsDescription: "Configurazioni riutilizzabili di tunnel SSH / proxy / HTTP. Configura una volta, poi seleziona il profilo dalla scheda tunnel di una connessione; le modifiche qui si applicano a tutte le connessioni che usano il profilo.",
tunnelsEmpty: "Nessun profilo tunnel. Creane uno qui, poi selezionalo nelle impostazioni tunnel di una connessione.",
tunnelsAddSsh: "Aggiungi SSH",
tunnelsAddProxy: "Aggiungi proxy",
tunnelsAddHttp: "Aggiungi tunnel HTTP",
tunnelsProfileName: "Nome profilo",
tunnelsProfileNamePlaceholder: "es. Bastion dell'ufficio",
tunnelsDelete: "Elimina",
tunnelsUnnamedProfile: "Profilo senza nome",
tunnelsSave: "Salva",
tunnelsReset: "Ripristina",
tunnelsSaved: "Profili tunnel salvati",
tunnelsSaveFailed: "Impossibile salvare i profili tunnel: {message}",
tunnelsUnsavedHint: "Modifiche non salvate",
redisTab: "Redis",
shortcutsTab: "Scorciatoie",
snippetsTab: "Snippet",

View File

@ -383,6 +383,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "任意",
proxyPassword: "プロキシパスワード",
proxyPasswordPlaceholder: "任意",
tunnelProfile: "トンネルプロファイル",
tunnelProfileCustom: "カスタム(この接続のみ)",
tunnelProfileManaged: "共有トンネルプロファイルで管理されています。設定 > トンネル で編集すると、次回接続時にこのプロファイルを使用するすべての接続に反映されます。",
tunnelProfileMissing: "参照しているトンネルプロファイルが存在しません。別のプロファイルを選択するか、カスタムに切り替えてください。",
tunnelProfileMissingName: "プロファイルが見つかりません",
tunnelProfileManage: "管理",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "Legacy JDBC",
jdbcUrl: "JDBC URL",
@ -2933,6 +2939,21 @@ export default withEnglishFallback({
appearanceTab: "外観",
navigationTab: "ナビゲーション",
dataTab: "データ",
tunnelsTab: "トンネル",
tunnelsDescription: "再利用可能な SSH / プロキシ / HTTP トンネル設定です。一度設定すれば、接続のトンネルタブからプロファイルを選択するだけで使えます。ここでの編集は、このプロファイルを使用するすべての接続に反映されます。",
tunnelsEmpty: "トンネルプロファイルがまだありません。ここで作成し、接続のトンネル設定で選択してください。",
tunnelsAddSsh: "SSH を追加",
tunnelsAddProxy: "プロキシを追加",
tunnelsAddHttp: "HTTP トンネルを追加",
tunnelsProfileName: "プロファイル名",
tunnelsProfileNamePlaceholder: "例:オフィスの踏み台サーバー",
tunnelsDelete: "削除",
tunnelsUnnamedProfile: "名称未設定のプロファイル",
tunnelsSave: "保存",
tunnelsReset: "リセット",
tunnelsSaved: "トンネルプロファイルを保存しました",
tunnelsSaveFailed: "トンネルプロファイルの保存に失敗しました: {message}",
tunnelsUnsavedHint: "未保存の変更があります",
redisTab: "Redis",
shortcutsTab: "ショートカット",
snippetsTab: "スニペット",

View File

@ -390,6 +390,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "Opcional",
proxyPassword: "Senha do Proxy",
proxyPasswordPlaceholder: "Opcional",
tunnelProfile: "Perfil de túnel",
tunnelProfileCustom: "Personalizado (somente esta conexão)",
tunnelProfileManaged: "Gerenciado por um perfil de túnel compartilhado. Edite-o em Configurações > Túneis; as alterações se aplicam a todas as conexões que o usam na próxima conexão.",
tunnelProfileMissing: "O perfil de túnel referenciado não existe mais. Selecione outro perfil ou mude para personalizado.",
tunnelProfileMissingName: "Perfil de túnel ausente",
tunnelProfileManage: "Gerenciar",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "JDBC legado",
jdbcUrl: "URL JDBC",
@ -2934,6 +2940,21 @@ export default withEnglishFallback({
appearanceTab: "Aparência",
navigationTab: "Navegação",
dataTab: "Dados",
tunnelsTab: "Túneis",
tunnelsDescription: "Configurações reutilizáveis de túneis SSH / proxy / HTTP. Configure uma vez e selecione o perfil na aba de túnel de uma conexão; as edições aqui se aplicam a todas as conexões que usam o perfil.",
tunnelsEmpty: "Ainda não há perfis de túnel. Crie um aqui e selecione-o nas configurações de túnel de uma conexão.",
tunnelsAddSsh: "Adicionar SSH",
tunnelsAddProxy: "Adicionar proxy",
tunnelsAddHttp: "Adicionar túnel HTTP",
tunnelsProfileName: "Nome do perfil",
tunnelsProfileNamePlaceholder: "ex.: Bastion do escritório",
tunnelsDelete: "Excluir",
tunnelsUnnamedProfile: "Perfil sem nome",
tunnelsSave: "Salvar",
tunnelsReset: "Redefinir",
tunnelsSaved: "Perfis de túnel salvos",
tunnelsSaveFailed: "Falha ao salvar os perfis de túnel: {message}",
tunnelsUnsavedHint: "Alterações não salvas",
redisTab: "Redis",
shortcutsTab: "Atalhos",
snippetsTab: "Snippets",

View File

@ -413,6 +413,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可选",
proxyPassword: "代理密码",
proxyPasswordPlaceholder: "可选",
tunnelProfile: "隧道档案",
tunnelProfileCustom: "自定义(仅此连接)",
tunnelProfileManaged: "由共享隧道档案管理。请在 设置 > 隧道维护 中编辑,修改会在下次连接时对所有使用该档案的连接生效。",
tunnelProfileMissing: "引用的隧道档案已不存在。请选择其他档案或切换为自定义配置。",
tunnelProfileMissingName: "档案已删除",
tunnelProfileManage: "管理",
httpTunnel: "HTTP 隧道",
httpTunnelAdd: "添加 HTTP 隧道",
httpTunnelUrl: "隧道脚本 URL",
@ -3081,6 +3087,21 @@ export default withEnglishFallback({
appearanceTab: "外观",
navigationTab: "导航",
dataTab: "数据",
tunnelsTab: "隧道维护",
tunnelsDescription: "可复用的 SSH / 代理 / HTTP 隧道配置。一次配置,之后在连接的隧道页签中选择档案即可;此处的修改会对所有使用该档案的连接生效。",
tunnelsEmpty: "还没有隧道档案。先在这里创建,然后在连接的隧道设置中选择使用。",
tunnelsAddSsh: "新增 SSH",
tunnelsAddProxy: "新增代理",
tunnelsAddHttp: "新增 HTTP 隧道",
tunnelsProfileName: "档案名称",
tunnelsProfileNamePlaceholder: "例如:办公室跳板机",
tunnelsDelete: "删除",
tunnelsUnnamedProfile: "未命名档案",
tunnelsSave: "保存",
tunnelsReset: "重置",
tunnelsSaved: "隧道档案已保存",
tunnelsSaveFailed: "保存隧道档案失败:{message}",
tunnelsUnsavedHint: "有未保存的修改",
redisTab: "Redis",
shortcutsTab: "快捷键",
snippetsTab: "代码片段",

View File

@ -389,6 +389,12 @@ export default withEnglishFallback({
proxyUsernamePlaceholder: "可選",
proxyPassword: "代理伺服器密碼",
proxyPasswordPlaceholder: "可選",
tunnelProfile: "隧道設定檔",
tunnelProfileCustom: "自訂(僅此連線)",
tunnelProfileManaged: "由共用隧道設定檔管理。請在 設定 > 隧道維護 中編輯,變更會在下次連線時套用到所有使用該設定檔的連線。",
tunnelProfileMissing: "引用的隧道設定檔已不存在。請選擇其他設定檔或切換為自訂。",
tunnelProfileMissingName: "設定檔已刪除",
tunnelProfileManage: "管理",
dremioArrowFlightSqlMode: "Arrow Flight SQL",
dremioLegacyJdbcMode: "Legacy JDBC",
jdbcUrl: "JDBC URL",
@ -2797,6 +2803,21 @@ export default withEnglishFallback({
securityTab: "安全",
aboutTab: "關於我們",
dataTab: "資料",
tunnelsTab: "隧道維護",
tunnelsDescription: "可重複使用的 SSH / 代理 / HTTP 隧道設定。一次設定,之後在連線的隧道頁籤中選擇設定檔即可;此處的變更會套用到所有使用該設定檔的連線。",
tunnelsEmpty: "還沒有隧道設定檔。先在這裡建立,然後在連線的隧道設定中選擇使用。",
tunnelsAddSsh: "新增 SSH",
tunnelsAddProxy: "新增代理",
tunnelsAddHttp: "新增 HTTP 隧道",
tunnelsProfileName: "設定檔名稱",
tunnelsProfileNamePlaceholder: "例如:辦公室跳板機",
tunnelsDelete: "刪除",
tunnelsUnnamedProfile: "未命名設定檔",
tunnelsSave: "儲存",
tunnelsReset: "重設",
tunnelsSaved: "隧道設定檔已儲存",
tunnelsSaveFailed: "儲存隧道設定檔失敗:{message}",
tunnelsUnsavedHint: "有未儲存的變更",
fontFamily: "字型",
uiFontFamily: "介面字型",
uiFontAppDefault: "DBX 預設",

View File

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createTunnelProfile, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import type { TunnelProfile } from "@/types/database";
function sshProfile(overrides: Partial<TunnelProfile> = {}): TunnelProfile {
return {
...createTunnelProfile("ssh"),
id: "profile-1",
name: "Bastion",
host: "bastion.example.com",
user: "deploy",
password: "s3cret",
...overrides,
} as TunnelProfile;
}
describe("tunnelProfileSummary", () => {
it("formats ssh profiles as user@host:port", () => {
expect(tunnelProfileSummary(sshProfile())).toBe("deploy@bastion.example.com:22");
});
it("formats proxy profiles as scheme://host:port", () => {
const proxy = { ...createTunnelProfile("proxy"), host: "127.0.0.1", port: 1080 } as TunnelProfile;
expect(tunnelProfileSummary(proxy)).toBe("socks5://127.0.0.1:1080");
});
it("returns the url for http tunnel profiles", () => {
const http = { ...createTunnelProfile("http_tunnel"), url: "https://example.com/dbx_tunnel.php" } as TunnelProfile;
expect(tunnelProfileSummary(http)).toBe("https://example.com/dbx_tunnel.php");
});
it("returns an empty string when the target is not configured yet", () => {
expect(tunnelProfileSummary(createTunnelProfile("ssh"))).toBe("");
});
});
describe("tunnelProfileReferenceLayer", () => {
it("keeps only identity, enabled state, and the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: false });
expect(stub.id).toBe("layer-1");
expect(stub.enabled).toBe(false);
expect(stub.profile_id).toBe("profile-1");
expect(stub.name).toBe("Bastion");
// Credentials must not be copied into the stub stored on the connection.
expect(stub.type).toBe("ssh");
if (stub.type === "ssh") {
expect(stub.host).toBe("");
expect(stub.password).toBe("");
}
});
it("generates a fresh id when there is no previous layer", () => {
const stub = tunnelProfileReferenceLayer(sshProfile());
expect(stub.id).toBeTruthy();
expect(stub.enabled).toBe(true);
});
});
describe("detachTunnelProfileLayer", () => {
it("copies the profile configuration onto the layer and drops the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, profile);
expect(detached.id).toBe("layer-1");
expect(detached.profile_id).toBeUndefined();
if (detached.type === "ssh") {
expect(detached.host).toBe("bastion.example.com");
expect(detached.password).toBe("s3cret");
} else {
throw new Error("expected ssh layer");
}
});
it("only drops the reference when the profile no longer exists", () => {
const stub = tunnelProfileReferenceLayer(sshProfile(), { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, undefined);
expect(detached.profile_id).toBeUndefined();
expect(detached.id).toBe("layer-1");
});
});

View File

@ -59,6 +59,8 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection");
export const refreshConnections = forward("refreshConnections");
export const saveConnections = forward("saveConnections");
export const loadConnections = forward("loadConnections");
export const loadTunnelProfiles = forward("loadTunnelProfiles");
export const saveTunnelProfiles = forward("saveTunnelProfiles");
export const readKeychainPassword = forward("readKeychainPassword");
export const readKeychainPasswords = forward("readKeychainPasswords");
export const decryptConfig = forward("decryptConfig");

View File

@ -33,6 +33,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
@ -236,6 +237,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return get("/api/connection/list");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return get("/api/tunnel-profiles/list");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return post("/api/tunnel-profiles/save", { profiles });
}
export async function readKeychainPassword(_service: string): Promise<string> {
return ""; // Not available in web backend
}

View File

@ -34,6 +34,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@ -1109,6 +1110,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return invoke("load_tunnel_profiles");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return invoke("save_tunnel_profiles", { profiles });
}
export async function readKeychainPassword(service: string): Promise<string> {
return invoke("read_keychain_password", { service, account: null });
}

View File

@ -0,0 +1,95 @@
import { uuid } from "@/lib/common/utils";
import type { TransportLayerConfig, TunnelProfile } from "@/types/database";
export type TunnelProfileType = TunnelProfile["type"];
export function createTunnelProfile(type: TunnelProfileType): TunnelProfile {
if (type === "proxy") {
return {
type: "proxy",
id: uuid(),
name: "",
enabled: true,
proxy_type: "socks5",
host: "",
port: 1080,
username: "",
password: "",
};
}
if (type === "http_tunnel") {
return {
type: "http_tunnel",
id: uuid(),
name: "",
enabled: true,
url: "",
token: "",
connect_timeout_secs: 10,
};
}
return {
type: "ssh",
id: uuid(),
name: "",
enabled: true,
host: "",
port: 22,
user: "root",
password: "",
key_path: "",
key_passphrase: "",
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
};
}
export function tunnelProfileSummary(profile: TunnelProfile): string {
if (profile.type === "ssh") {
if (!profile.host) return "";
const user = profile.user ? `${profile.user}@` : "";
return `${user}${profile.host}:${profile.port || 22}`;
}
if (profile.type === "proxy") {
if (!profile.host) return "";
return `${profile.proxy_type || "socks5"}://${profile.host}:${profile.port || 1080}`;
}
return profile.url || "";
}
export function layerReferencesProfile(layer: TransportLayerConfig): boolean {
return !!layer.profile_id;
}
/**
* Builds the reference stub stored on a connection for a layer that uses a
* shared profile: only identity, enabled state, and the reference survive
* the backend swaps in the profile's full configuration at connect time, and
* keeping credentials out of the stub avoids stale copies.
*/
export function tunnelProfileReferenceLayer(profile: TunnelProfile, previous?: Pick<TransportLayerConfig, "id" | "enabled">): TransportLayerConfig {
const stub = createTunnelProfile(profile.type);
stub.id = previous?.id || stub.id;
stub.enabled = previous?.enabled !== false;
stub.name = profile.name || "";
stub.profile_id = profile.id;
return stub;
}
/**
* Detaches a profile-referencing layer back into a self-contained custom
* layer by copying the profile's full configuration onto it.
*/
export function detachTunnelProfileLayer(layer: TransportLayerConfig, profile: TunnelProfile | undefined): TransportLayerConfig {
if (!profile) {
const detached = { ...layer };
delete detached.profile_id;
return detached;
}
const detached = { ...profile, id: layer.id, enabled: layer.enabled !== false };
delete detached.profile_id;
return detached;
}

View File

@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
@ -23,6 +23,7 @@ import {
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
@ -4672,7 +4673,9 @@ export const useConnectionStore = defineStore("connection", () => {
async function exportConnectionsToFile(passphrase: string) {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
const exportData = { connections: connections.value, layout: sidebarLayout.value };
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const exportData = { connections: connections.value, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles };
const json = JSON.stringify(exportData);
const payload = await encryptConfig(json, passphrase);
const content = JSON.stringify(payload, null, 2);
@ -4860,6 +4863,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> {
let imported: ConnectionConfig[] = [];
let importedLayout: SidebarLayout | undefined;
let importedTunnelProfiles: TunnelProfile[] = [];
if (!passphrase && content.trimStart().startsWith("<")) {
const { parseNavicatConnections } = await import("@/lib/imports/navicatImport");
@ -4889,6 +4893,9 @@ export const useConnectionStore = defineStore("connection", () => {
if (parsed.layout?.groups && parsed.layout?.order) {
importedLayout = parsed.layout;
}
if (Array.isArray(parsed.tunnelProfiles)) {
importedTunnelProfiles = parsed.tunnelProfiles;
}
} else {
imported = [];
}
@ -4907,12 +4914,31 @@ export const useConnectionStore = defineStore("connection", () => {
if (decrypted.layout?.groups && decrypted.layout?.order) {
importedLayout = decrypted.layout;
}
if (Array.isArray(decrypted.tunnelProfiles)) {
importedTunnelProfiles = decrypted.tunnelProfiles;
}
} else {
imported = [];
}
}
}
// Profiles keep their original ids: imported connections reference them
// via transport_layers[].profile_id, so regenerating ids would break the
// links. Same-id profiles are overwritten with the imported copy.
if (importedTunnelProfiles.length) {
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const merged = [...tunnelProfileStore.profiles];
for (const profile of importedTunnelProfiles) {
if (!profile || typeof profile.id !== "string" || !profile.id) continue;
const index = merged.findIndex((existing) => existing.id === profile.id);
if (index >= 0) merged[index] = profile;
else merged.push(profile);
}
await tunnelProfileStore.saveProfiles(merged);
}
let count = 0;
const importedConnectionIdMap = new Map<string, string>();
for (const config of imported) {

View File

@ -0,0 +1,48 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/lib/backend/api";
import type { TunnelProfile } from "@/types/database";
/**
* Shared tunnel profiles (Settings > Tunnels). Connections reference a
* profile by id via `transport_layers[].profile_id`; the backend resolves
* the reference at connect time, so profile edits reach every referencing
* connection without touching the stored connections.
*/
export const useTunnelProfileStore = defineStore("tunnelProfiles", () => {
const profiles = ref<TunnelProfile[]>([]);
const isLoaded = ref(false);
async function init() {
if (isLoaded.value) return;
await refresh();
}
async function refresh() {
try {
profiles.value = (await api.loadTunnelProfiles()) || [];
isLoaded.value = true;
} catch {
// Backend unavailable (e.g. stale web session): keep previous state and
// retry on the next init/refresh call.
}
}
function profileById(id: string | undefined): TunnelProfile | undefined {
if (!id) return undefined;
return profiles.value.find((profile) => profile.id === id);
}
async function saveProfiles(next: TunnelProfile[]) {
const previous = profiles.value;
profiles.value = next;
try {
await api.saveTunnelProfiles(next);
} catch (error) {
profiles.value = previous;
throw error;
}
}
return { profiles, isLoaded, init, refresh, profileById, saveProfiles };
});

View File

@ -165,6 +165,14 @@ export interface ConnectionConfig {
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
/**
* A shared tunnel configuration managed in Settings > Tunnels. Structurally a
* `TransportLayerConfig`; its `id` is what connection layers reference via
* `profile_id`. Edits to a profile apply to every referencing connection the
* next time it connects.
*/
export type TunnelProfile = TransportLayerConfig;
export interface SshTunnelConfig {
id: string;
name?: string;
@ -190,6 +198,12 @@ export interface SshTunnelConfig {
* for connections that already have `use_ssh_agent` configured.
*/
auth_method?: "password" | "key" | "agent" | "none";
/**
* When set, this layer references a shared tunnel profile; the profile's
* configuration replaces this layer's fields at connect time (only `id`
* and `enabled` are kept).
*/
profile_id?: string;
}
export interface SshConfigHostEntry {
@ -209,6 +223,8 @@ export interface ProxyTunnelConfig {
port: number;
username?: string;
password?: string;
/** See {@link SshTunnelConfig.profile_id}. */
profile_id?: string;
}
export interface HttpTunnelConfig {
@ -218,6 +234,8 @@ export interface HttpTunnelConfig {
url: string;
token?: string;
connect_timeout_secs?: number;
/** See {@link SshTunnelConfig.profile_id}. */
profile_id?: string;
}
export interface AttachedDatabaseConfig {

View File

@ -91,6 +91,10 @@ pub struct SyncSnapshot {
pub exported_at: String,
pub app_version: String,
pub connections: Vec<ConnectionConfig>,
/// Shared tunnel profiles (secrets scrubbed). `None` means the snapshot
/// predates tunnel profiles — applying it leaves local profiles alone.
#[serde(default)]
pub tunnel_profiles: Option<Vec<TransportLayerConfig>>,
pub sidebar_layout: Option<serde_json::Value>,
pub pinned_tree_node_ids: Vec<String>,
pub saved_sql: SavedSqlLibrary,
@ -115,6 +119,9 @@ pub struct EncryptedSecretsBlob {
pub struct SensitiveSyncPayload {
pub connection_secrets: Vec<ConnectionSecretSnapshot>,
pub ai_config: Option<AiConfig>,
/// Full tunnel profiles including their secrets.
#[serde(default)]
pub tunnel_profiles: Option<Vec<TransportLayerConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -163,21 +170,27 @@ pub async fn build_sync_snapshot(
secrets_passphrase: Option<&str>,
) -> Result<SyncSnapshot, String> {
let mut connections = storage.load_connections().await?;
let mut tunnel_profiles = storage.load_tunnel_profiles().await?;
let encrypted_secrets = match normalized_passphrase(secrets_passphrase) {
Some(passphrase) => {
Some(encrypt_sensitive_payload(&build_sensitive_payload(storage, &connections).await?, passphrase)?)
}
Some(passphrase) => Some(encrypt_sensitive_payload(
&build_sensitive_payload(storage, &connections, &tunnel_profiles).await?,
passphrase,
)?),
None => None,
};
for config in &mut connections {
scrub_connection_secrets(config);
}
for profile in &mut tunnel_profiles {
profile.scrub_secrets();
}
Ok(SyncSnapshot {
schema_version: SNAPSHOT_SCHEMA_VERSION,
exported_at: Utc::now().to_rfc3339(),
app_version: app_version.into(),
connections,
tunnel_profiles: Some(tunnel_profiles),
sidebar_layout: storage.load_sidebar_layout().await?,
pinned_tree_node_ids: storage.load_pinned_tree_node_ids().await?,
saved_sql: storage.load_saved_sql_library().await?,
@ -223,6 +236,9 @@ pub async fn apply_sync_snapshot(
}
storage.save_connection_metadata_preserving_secrets(&connections).await?;
if let Some(profiles) = &snapshot.tunnel_profiles {
storage.save_tunnel_profiles_preserving_secrets(profiles).await?;
}
if let Some(layout) = &snapshot.sidebar_layout {
storage.save_sidebar_layout(layout).await?;
}
@ -602,6 +618,7 @@ fn webdav_password_account(config: &WebDavConfig) -> String {
async fn build_sensitive_payload(
storage: &Storage,
connections: &[ConnectionConfig],
tunnel_profiles: &[TransportLayerConfig],
) -> Result<SensitiveSyncPayload, String> {
let mut connection_secrets = Vec::new();
for config in connections {
@ -648,7 +665,11 @@ async fn build_sensitive_payload(
push_nacos_external_config_secrets(&mut connection_secrets, config);
}
Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? })
Ok(SensitiveSyncPayload {
connection_secrets,
ai_config: storage.load_ai_config().await?,
tunnel_profiles: Some(tunnel_profiles.to_vec()),
})
}
fn push_mq_external_config_secrets(secrets: &mut Vec<ConnectionSecretSnapshot>, config: &ConnectionConfig) {
@ -769,6 +790,9 @@ async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPaylo
if let Some(ai_config) = &payload.ai_config {
storage.save_ai_config(ai_config).await?;
}
if let Some(profiles) = &payload.tunnel_profiles {
storage.save_tunnel_profiles(profiles).await?;
}
Ok(())
}
@ -995,15 +1019,16 @@ fn parent_collection_paths(remote_path: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload,
forget_webdav_sync_secrets_passphrase, normalized_remote_path, parent_collection_paths,
resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference, scrub_connection_secrets,
snippet_file_content, snippet_response_id, webdav_sync_secrets_status, ConnectionSecretSnapshot,
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload,
encrypt_sensitive_payload, forget_webdav_sync_secrets_passphrase, normalized_remote_path,
parent_collection_paths, resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference,
scrub_connection_secrets, snippet_file_content, snippet_response_id, webdav_sync_secrets_status,
ApplySnapshotOptions, ConnectionSecretSnapshot,
SensitiveSyncPayload,
};
use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY;
use crate::models::connection::{
default_redis_key_separator, ConnectionConfig, DatabaseType, TransportLayerConfig,
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
};
use crate::storage::Storage;
@ -1172,6 +1197,7 @@ mod tests {
color: None,
transport_layers: vec![
TransportLayerConfig::Ssh(crate::models::connection::SshTunnelConfig {
profile_id: String::new(),
id: "hop-1".to_string(),
name: String::new(),
enabled: true,
@ -1188,6 +1214,7 @@ mod tests {
auth_method: "password".to_string(),
}),
TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig {
profile_id: String::new(),
id: "http".to_string(),
name: String::new(),
enabled: true,
@ -1247,6 +1274,7 @@ mod tests {
#[test]
fn encrypted_sensitive_payload_round_trips() {
let payload = SensitiveSyncPayload {
tunnel_profiles: None,
connection_secrets: vec![
ConnectionSecretSnapshot {
connection_id: "c1".to_string(),
@ -1271,6 +1299,7 @@ mod tests {
#[test]
fn encrypted_sensitive_payload_rejects_wrong_passphrase() {
let payload = SensitiveSyncPayload {
tunnel_profiles: None,
connection_secrets: vec![ConnectionSecretSnapshot {
connection_id: "c1".to_string(),
key: "password".to_string(),
@ -1389,4 +1418,41 @@ mod tests {
secret.connection_id == "nacos" && secret.key == NACOS_AUTH_PASSWORD_KEY && secret.secret == "nacos-secret"
}));
}
#[tokio::test]
async fn sync_snapshot_round_trips_tunnel_profiles() {
let storage = Storage::open(&temp_db_path("tunnel-profiles-src")).await.unwrap();
let profile = TransportLayerConfig::Ssh(SshTunnelConfig {
id: "profile-1".to_string(),
name: "Bastion".to_string(),
enabled: true,
host: "bastion.example.com".to_string(),
port: 22,
user: "deploy".to_string(),
password: "tunnel-secret".to_string(),
key_path: String::new(),
key_passphrase: String::new(),
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
profile_id: String::new(),
});
storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap();
// The plain snapshot carries the profiles with secrets scrubbed.
let public_profiles = snapshot.tunnel_profiles.as_ref().expect("tunnel profiles in snapshot");
let public_json = serde_json::to_string(public_profiles).unwrap();
assert!(!public_json.contains("tunnel-secret"));
// Applying with the passphrase restores the full profile on the target.
let target = Storage::open(&temp_db_path("tunnel-profiles-dst")).await.unwrap();
apply_sync_snapshot(&target, &snapshot, ApplySnapshotOptions { secrets_passphrase: Some("sync-pass") })
.await
.unwrap();
assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![profile]);
}
}

View File

@ -23,6 +23,7 @@ use crate::db::proxy_tunnel::ProxyTunnelManager;
use crate::db::ssh_tunnel::TunnelManager;
use crate::models::connection::{
parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType,
TransportLayerConfig,
};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
@ -1376,12 +1377,61 @@ impl AppState {
Ok(pool_key)
}
/// Returns the enabled transport layers for a connection with tunnel
/// profile references resolved: a layer carrying a `profile_id` is
/// replaced by the shared profile from storage (Settings > Tunnels), so
/// edits to a profile take effect for every connection referencing it.
/// Fails when a referenced profile no longer exists — connecting without
/// the intended tunnel would silently bypass it.
pub async fn resolved_transport_layers(
&self,
config: &ConnectionConfig,
) -> Result<Vec<TransportLayerConfig>, String> {
let layers = config.effective_transport_layers();
if layers.iter().all(|layer| layer.profile_id().is_empty()) {
return Ok(layers);
}
let profiles: HashMap<String, TransportLayerConfig> = self
.storage
.load_tunnel_profiles()
.await?
.into_iter()
.map(|profile| (profile.id().to_string(), profile))
.collect();
layers
.into_iter()
.map(|layer| {
let profile_id = layer.profile_id();
if profile_id.is_empty() {
return Ok(layer);
}
let Some(profile) = profiles.get(profile_id) else {
let label = if layer.name().is_empty() { profile_id } else { layer.name() };
return Err(format!(
"Tunnel profile '{label}' referenced by this connection no longer exists. Re-create it in Settings > Tunnels or edit the connection's tunnel settings."
));
};
// Validate the stored reference again at connect time because synced or
// externally supplied configs may bypass the editor's type constraints.
if !layer.same_type_as(profile) {
return Err(format!(
"Tunnel profile '{}' has a different type than the referencing transport layer.",
if layer.name().is_empty() { profile_id } else { layer.name() }
));
}
Ok(layer.resolved_from_profile(profile))
})
.collect()
}
pub async fn connection_host_port(
&self,
connection_id: &str,
config: &ConnectionConfig,
) -> Result<(String, u16), String> {
let transport_layers = config.effective_transport_layers();
let transport_layers = self.resolved_transport_layers(config).await?;
if transport_layers.is_empty() {
return Ok((config.host.clone(), config.port));
}
@ -1406,7 +1456,7 @@ impl AppState {
connection_id: &str,
config: &ConnectionConfig,
) -> Result<redis::aio::MultiplexedConnection, String> {
let transport_layers = config.effective_transport_layers();
let transport_layers = self.resolved_transport_layers(config).await?;
if transport_layers.is_empty() {
return db::redis_driver::connect_sentinel(config).await;
}
@ -1536,7 +1586,7 @@ impl AppState {
connection_id: &str,
config: &ConnectionConfig,
) -> Result<db::redis_driver::RedisClusterPool, String> {
let transport_layers = config.effective_transport_layers();
let transport_layers = self.resolved_transport_layers(config).await?;
if transport_layers.is_empty() {
return db::redis_driver::connect_cluster(config).await;
}
@ -3098,8 +3148,8 @@ mod tests {
use crate::database_capabilities;
use crate::db;
use crate::models::connection::{
default_connect_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, ProxyTunnelConfig,
ProxyType, TransportLayerConfig,
default_connect_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, HttpTunnelConfig,
ProxyTunnelConfig, ProxyType, SshTunnelConfig, TransportLayerConfig,
};
use crate::query;
use crate::schema;
@ -4352,11 +4402,141 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
fn ssh_layer(id: &str, profile_id: &str) -> SshTunnelConfig {
SshTunnelConfig {
id: id.to_string(),
name: String::new(),
enabled: true,
host: String::new(),
port: 22,
user: String::new(),
password: String::new(),
key_path: String::new(),
key_passphrase: String::new(),
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: String::new(),
profile_id: profile_id.to_string(),
}
}
fn proxy_layer(id: &str, profile_id: &str) -> ProxyTunnelConfig {
ProxyTunnelConfig {
id: id.to_string(),
name: String::new(),
enabled: true,
proxy_type: ProxyType::Socks5,
host: String::new(),
port: 1080,
username: String::new(),
password: String::new(),
profile_id: profile_id.to_string(),
}
}
fn http_tunnel_layer(id: &str, profile_id: &str) -> HttpTunnelConfig {
HttpTunnelConfig {
id: id.to_string(),
name: String::new(),
enabled: true,
url: String::new(),
token: String::new(),
connect_timeout_secs: 10,
profile_id: profile_id.to_string(),
}
}
#[tokio::test]
async fn resolved_transport_layers_substitutes_shared_profiles() {
let (state, dir) = test_app_state().await;
let mut profile = ssh_layer("shared-bastion", "");
profile.name = "Bastion".to_string();
profile.host = "bastion.example.com".to_string();
profile.user = "deploy".to_string();
profile.password = "s3cret".to_string();
profile.auth_method = "password".to_string();
state.storage.save_tunnel_profiles(&[TransportLayerConfig::Ssh(profile)]).await.unwrap();
let mut config = mysql_config(Some("app"));
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("layer-1", "shared-bastion"))];
let resolved = state.resolved_transport_layers(&config).await.unwrap();
assert_eq!(resolved.len(), 1);
match &resolved[0] {
TransportLayerConfig::Ssh(ssh) => {
// Profile supplies the configuration; the layer keeps its identity.
assert_eq!(ssh.id, "layer-1");
assert_eq!(ssh.profile_id, "shared-bastion");
assert_eq!(ssh.host, "bastion.example.com");
assert_eq!(ssh.user, "deploy");
assert_eq!(ssh.password, "s3cret");
}
other => panic!("expected ssh layer, got {other:?}"),
}
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn resolved_transport_layers_fails_closed_on_missing_profile() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(Some("app"));
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("layer-1", "deleted-profile"))];
let err = state.resolved_transport_layers(&config).await.unwrap_err();
assert!(err.contains("no longer exists"), "unexpected error: {err}");
// Disabled reference layers are filtered out before resolution, so a
// dangling reference on a disabled layer must not block connecting.
let mut disabled = ssh_layer("layer-1", "deleted-profile");
disabled.enabled = false;
config.transport_layers = vec![TransportLayerConfig::Ssh(disabled)];
assert!(state.resolved_transport_layers(&config).await.unwrap().is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn resolved_transport_layers_rejects_mismatched_profile_types() {
let (state, dir) = test_app_state().await;
let mismatches = [
(
TransportLayerConfig::Ssh(ssh_layer("layer", "shared")),
TransportLayerConfig::Proxy(proxy_layer("shared", "")),
),
(
TransportLayerConfig::Proxy(proxy_layer("layer", "shared")),
TransportLayerConfig::HttpTunnel(http_tunnel_layer("shared", "")),
),
(
TransportLayerConfig::HttpTunnel(http_tunnel_layer("layer", "shared")),
TransportLayerConfig::Ssh(ssh_layer("shared", "")),
),
];
for (layer, profile) in mismatches {
state.storage.save_tunnel_profiles(&[profile]).await.unwrap();
let mut config = mysql_config(Some("app"));
config.transport_layers = vec![layer];
let error = state.resolved_transport_layers(&config).await.unwrap_err();
assert!(error.contains("different type"));
}
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn proxy_connection_uses_local_forward_endpoint() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(Some("app"));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: "proxy".to_string(),
name: String::new(),
enabled: true,
@ -4405,6 +4585,7 @@ mod tests {
"auth": { "kind": "none" }
}));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: "proxy".to_string(),
name: String::new(),
enabled: true,
@ -4462,6 +4643,7 @@ mod tests {
"auth": { "kind": "none" }
}));
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: "proxy".to_string(),
name: String::new(),
enabled: true,

View File

@ -770,6 +770,7 @@ mod tests {
fn ssh_hop(id: &str, password: &str, passphrase: &str) -> SshTunnelConfig {
SshTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,
@ -789,6 +790,7 @@ mod tests {
fn http_tunnel(id: &str, token: &str) -> TransportLayerConfig {
TransportLayerConfig::HttpTunnel(HttpTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,

View File

@ -870,6 +870,7 @@ mod tests {
fn hop(id: &str, host: &str, port: u16) -> SshTunnelConfig {
SshTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,

View File

@ -247,6 +247,7 @@ mod tests {
fn ssh_layer(id: &str, host: &str, port: u16) -> TransportLayerConfig {
TransportLayerConfig::Ssh(SshTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,
@ -266,6 +267,7 @@ mod tests {
fn proxy_layer(id: &str, host: &str, port: u16) -> TransportLayerConfig {
TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,
@ -279,6 +281,7 @@ mod tests {
fn http_tunnel_layer(id: &str, url: &str) -> TransportLayerConfig {
TransportLayerConfig::HttpTunnel(HttpTunnelConfig {
profile_id: String::new(),
id: id.to_string(),
name: String::new(),
enabled: true,

View File

@ -108,6 +108,15 @@ pub enum TransportLayerConfig {
}
impl TransportLayerConfig {
pub fn same_type_as(&self, other: &TransportLayerConfig) -> bool {
matches!(
(self, other),
(TransportLayerConfig::Ssh(_), TransportLayerConfig::Ssh(_))
| (TransportLayerConfig::Proxy(_), TransportLayerConfig::Proxy(_))
| (TransportLayerConfig::HttpTunnel(_), TransportLayerConfig::HttpTunnel(_))
)
}
pub fn id(&self) -> &str {
match self {
TransportLayerConfig::Ssh(layer) => &layer.id,
@ -116,6 +125,56 @@ impl TransportLayerConfig {
}
}
pub fn profile_id(&self) -> &str {
match self {
TransportLayerConfig::Ssh(layer) => &layer.profile_id,
TransportLayerConfig::Proxy(layer) => &layer.profile_id,
TransportLayerConfig::HttpTunnel(layer) => &layer.profile_id,
}
}
/// Builds the concrete layer used at connect time for a layer that
/// references a shared tunnel profile: the profile supplies the whole
/// configuration while the referencing layer keeps its own identity,
/// enabled flag, and profile reference.
pub fn resolved_from_profile(&self, profile: &TransportLayerConfig) -> TransportLayerConfig {
let mut resolved = profile.clone();
let (id, enabled, profile_id) = (self.id().to_string(), self.enabled(), self.profile_id().to_string());
match &mut resolved {
TransportLayerConfig::Ssh(layer) => {
layer.id = id;
layer.enabled = enabled;
layer.profile_id = profile_id;
}
TransportLayerConfig::Proxy(layer) => {
layer.id = id;
layer.enabled = enabled;
layer.profile_id = profile_id;
}
TransportLayerConfig::HttpTunnel(layer) => {
layer.id = id;
layer.enabled = enabled;
layer.profile_id = profile_id;
}
}
resolved
}
pub fn scrub_secrets(&mut self) {
match self {
TransportLayerConfig::Ssh(layer) => {
layer.password = String::new();
layer.key_passphrase = String::new();
}
TransportLayerConfig::Proxy(layer) => {
layer.password = String::new();
}
TransportLayerConfig::HttpTunnel(layer) => {
layer.token = String::new();
}
}
}
pub fn name(&self) -> &str {
match self {
TransportLayerConfig::Ssh(layer) => &layer.name,
@ -181,6 +240,12 @@ pub struct SshTunnelConfig {
/// only tries that method (after the standard `none` probe).
#[serde(default)]
pub auth_method: String,
/// When non-empty, this layer references a shared tunnel profile
/// (Settings > Tunnels). The profile's configuration replaces this
/// layer's own fields at connect time; only `id` and `enabled` are
/// kept from the referencing layer.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub profile_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -201,6 +266,9 @@ pub struct ProxyTunnelConfig {
pub username: String,
#[serde(default)]
pub password: String,
/// See [`SshTunnelConfig::profile_id`].
#[serde(default, skip_serializing_if = "String::is_empty")]
pub profile_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -217,6 +285,9 @@ pub struct HttpTunnelConfig {
pub token: String,
#[serde(default = "default_http_tunnel_connect_timeout_secs")]
pub connect_timeout_secs: u64,
/// See [`SshTunnelConfig::profile_id`].
#[serde(default, skip_serializing_if = "String::is_empty")]
pub profile_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -2028,6 +2099,7 @@ mod tests {
fn serialized_connection_config_omits_legacy_transport_fields() {
let mut config = mysql_config("root", "", None);
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
profile_id: String::new(),
id: "proxy".to_string(),
name: String::new(),
enabled: true,

View File

@ -179,6 +179,7 @@ mod tests {
fn config(host: &str) -> SshTunnelConfig {
SshTunnelConfig {
profile_id: String::new(),
id: "1".to_string(),
name: String::new(),
enabled: true,

View File

@ -224,6 +224,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[
provider TEXT PRIMARY KEY,
config_json TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS tunnel_profiles (
id TEXT PRIMARY KEY,
config_json TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS ai_conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
@ -761,6 +765,104 @@ impl Storage {
}
}
// Tunnel profiles — shared transport-layer configurations managed in
// Settings and referenced from connections via `profile_id`. Secrets stay
// inline in `config_json`; that matches the plaintext-at-rest posture of
// `connection_secrets` in the same database file.
impl Storage {
pub async fn load_tunnel_profiles(&self) -> Result<Vec<TransportLayerConfig>, String> {
let rows: Vec<String> = self
.with_conn(|conn| {
let mut stmt = conn
.prepare("SELECT config_json FROM tunnel_profiles ORDER BY rowid")
.map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
})
.await?;
let mut profiles = Vec::new();
for json in rows {
match serde_json::from_str::<TransportLayerConfig>(&json) {
Ok(profile) => profiles.push(profile),
Err(e) => warn!("Failed to deserialize tunnel profile: {}", e),
}
}
Ok(profiles)
}
pub async fn save_tunnel_profiles(&self, profiles: &[TransportLayerConfig]) -> Result<(), String> {
for profile in profiles {
if profile.id().trim().is_empty() {
return Err("Tunnel profile id must not be empty".to_string());
}
}
let profiles = profiles.to_vec();
self.with_conn(move |conn| {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute("DELETE FROM tunnel_profiles", []).map_err(|e| e.to_string())?;
for profile in &profiles {
let json = serde_json::to_string(profile).map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO tunnel_profiles (id, config_json) VALUES (?1, ?2)",
params![profile.id(), json],
)
.map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())
})
.await
}
/// Replaces the profile catalog while keeping the secrets already stored
/// for a profile when the incoming copy has them scrubbed. Used when
/// applying sync snapshots, whose plain (non-encrypted) part strips
/// tunnel secrets.
pub async fn save_tunnel_profiles_preserving_secrets(
&self,
profiles: &[TransportLayerConfig],
) -> Result<(), String> {
let existing: HashMap<String, TransportLayerConfig> =
self.load_tunnel_profiles().await?.into_iter().map(|p| (p.id().to_string(), p)).collect();
let merged: Vec<TransportLayerConfig> = profiles
.iter()
.map(|profile| {
let mut profile = profile.clone();
if let Some(previous) = existing.get(profile.id()) {
merge_missing_tunnel_profile_secrets(&mut profile, previous);
}
profile
})
.collect();
self.save_tunnel_profiles(&merged).await
}
}
fn merge_missing_tunnel_profile_secrets(profile: &mut TransportLayerConfig, previous: &TransportLayerConfig) {
match (profile, previous) {
(TransportLayerConfig::Ssh(current), TransportLayerConfig::Ssh(previous)) => {
if current.password.is_empty() {
current.password = previous.password.clone();
}
if current.key_passphrase.is_empty() {
current.key_passphrase = previous.key_passphrase.clone();
}
}
(TransportLayerConfig::Proxy(current), TransportLayerConfig::Proxy(previous)) => {
if current.password.is_empty() {
current.password = previous.password.clone();
}
}
(TransportLayerConfig::HttpTunnel(current), TransportLayerConfig::HttpTunnel(previous)) => {
if current.token.is_empty() {
current.token = previous.token.clone();
}
}
_ => {}
}
}
// App Settings
impl Storage {
@ -2393,7 +2495,7 @@ mod tests {
use crate::connection_secrets::{
MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::models::connection::{ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig};
use crate::saved_sql::SavedSqlFile;
use std::time::{SystemTime, UNIX_EPOCH};
@ -2407,6 +2509,65 @@ mod tests {
std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}", std::process::id()))
}
fn ssh_profile(id: &str, password: &str) -> TransportLayerConfig {
TransportLayerConfig::Ssh(SshTunnelConfig {
id: id.to_string(),
name: "Bastion".to_string(),
enabled: true,
host: "bastion.example.com".to_string(),
port: 22,
user: "deploy".to_string(),
password: password.to_string(),
key_path: String::new(),
key_passphrase: String::new(),
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: String::new(),
auth_method: "password".to_string(),
profile_id: String::new(),
})
}
#[tokio::test]
async fn tunnel_profiles_roundtrip_and_preserve_secrets() {
let path = temp_db_path("tunnel-profiles");
let storage = Storage::open(&path).await.unwrap();
let profile = ssh_profile("profile-1", "s3cret");
storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap();
assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![profile.clone()]);
// Applying a scrubbed copy (e.g. from a sync snapshot) keeps stored secrets.
let mut scrubbed = profile.clone();
scrubbed.scrub_secrets();
storage.save_tunnel_profiles_preserving_secrets(&[scrubbed.clone()]).await.unwrap();
match &storage.load_tunnel_profiles().await.unwrap()[0] {
TransportLayerConfig::Ssh(ssh) => assert_eq!(ssh.password, "s3cret"),
other => panic!("expected ssh profile, got {other:?}"),
}
// A plain save is exact: clearing a secret really clears it.
storage.save_tunnel_profiles(&[scrubbed.clone()]).await.unwrap();
assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![scrubbed]);
storage.save_tunnel_profiles(&[]).await.unwrap();
assert!(storage.load_tunnel_profiles().await.unwrap().is_empty());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn tunnel_profiles_reject_empty_ids() {
let path = temp_db_path("tunnel-profiles-empty-id");
let storage = Storage::open(&path).await.unwrap();
let profile = ssh_profile("", "secret");
assert!(storage.save_tunnel_profiles(&[profile]).await.is_err());
let _ = std::fs::remove_file(path);
}
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),

View File

@ -244,6 +244,9 @@ async fn main() {
// System
.route("/system/fonts", get(routes::jdbc::list_system_fonts))
.route("/ssh/config-hosts", get(routes::ssh_config::list_ssh_config_hosts))
// Tunnel profiles
.route("/tunnel-profiles/list", get(routes::tunnel_profiles::load_tunnel_profiles))
.route("/tunnel-profiles/save", post(routes::tunnel_profiles::save_tunnel_profiles))
// Agent drivers
.route("/agents/installed-local", get(routes::agents::list_installed_agents_local))
.route("/agents/installed", get(routes::agents::list_installed_agents))

View File

@ -30,5 +30,6 @@ pub mod table_export;
pub mod table_import;
pub mod text_export;
pub mod transfer;
pub mod tunnel_profiles;
pub mod update;
pub mod zookeeper;

View File

@ -0,0 +1,28 @@
use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use dbx_core::models::connection::TransportLayerConfig;
use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveTunnelProfilesRequest {
pub profiles: Vec<TransportLayerConfig>,
}
pub async fn load_tunnel_profiles(
State(state): State<Arc<WebState>>,
) -> Result<Json<Vec<TransportLayerConfig>>, AppError> {
state.app.storage.load_tunnel_profiles().await.map(Json).map_err(AppError)
}
pub async fn save_tunnel_profiles(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveTunnelProfilesRequest>,
) -> Result<Json<()>, AppError> {
state.app.storage.save_tunnel_profiles(&body.profiles).await.map(Json).map_err(AppError)
}

View File

@ -43,6 +43,7 @@ pub mod table_export;
pub mod table_import;
pub mod text_export;
pub mod transfer;
pub mod tunnel_profiles;
pub mod update;
pub mod xlsx_export;
pub mod zookeeper_cmd;

View File

@ -0,0 +1,19 @@
use std::sync::Arc;
use dbx_core::models::connection::TransportLayerConfig;
use tauri::State;
use super::connection::AppState;
#[tauri::command]
pub async fn load_tunnel_profiles(state: State<'_, Arc<AppState>>) -> Result<Vec<TransportLayerConfig>, String> {
state.storage.load_tunnel_profiles().await
}
#[tauri::command]
pub async fn save_tunnel_profiles(
state: State<'_, Arc<AppState>>,
profiles: Vec<TransportLayerConfig>,
) -> Result<(), String> {
state.storage.save_tunnel_profiles(&profiles).await
}

View File

@ -1239,6 +1239,8 @@ pub fn run() {
commands::agents::import_agent_jar_cmd,
commands::system_fonts::list_system_fonts,
commands::ssh_config::list_ssh_config_hosts,
commands::tunnel_profiles::load_tunnel_profiles,
commands::tunnel_profiles::save_tunnel_profiles,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")