feat: SSH tunnel support

- Add ssh2 dependency for SSH port forwarding
- NewConnectionDialog: SSH toggle + host/port/user/password/privateKey/passphrase form
- connection.ts: createSshTunnel() with local port forwarding
- Connect ioredis through SSH tunnel when sshEnabled
- Clean up SSH client + tunnel server on disconnect
- i18n keys for all SSH fields (zh-CN + en)
- Add sshPassphrase to all ConnectionConfig types
This commit is contained in:
2026-07-05 16:25:35 +08:00
parent bb32d2d647
commit 6e6f170403
9 changed files with 368 additions and 38 deletions
+77 -8
View File
@@ -19,6 +19,13 @@ const form = reactive({
username: '',
tls: false,
separator: ':',
sshEnabled: false,
sshHost: '',
sshPort: 22,
sshUsername: '',
sshPassword: '',
sshPrivateKeyPath: '',
sshPassphrase: '',
})
const saving = ref(false)
@@ -38,12 +45,21 @@ watch(() => props.visible, async (v) => {
username: props.connection.username,
tls: props.connection.tls,
separator: props.connection.separator,
sshEnabled: props.connection.sshEnabled || false,
sshHost: props.connection.sshHost || '',
sshPort: props.connection.sshPort || 22,
sshUsername: props.connection.sshUsername || '',
sshPassword: props.connection.sshPassword || '',
sshPrivateKeyPath: props.connection.sshPrivateKeyPath || '',
sshPassphrase: props.connection.sshPassphrase || '',
})
} else {
isEdit.value = false
Object.assign(form, {
name: '', host: '127.0.0.1', port: 6379,
auth: '', username: '', tls: false, separator: ':',
sshEnabled: false, sshHost: '', sshPort: 22,
sshUsername: '', sshPassword: '', sshPrivateKeyPath: '', sshPassphrase: '',
})
}
}
@@ -53,6 +69,16 @@ function close() {
emit('update:visible', false)
}
async function selectFile() {
const result = await window.electronAPI.dialog.openFile({
title: t('connection.sshPrivateKey'),
filters: [{ name: 'All Files', extensions: ['*'] }],
})
if (result) {
form.sshPrivateKeyPath = result.path
}
}
async function handleTest() {
if (!form.host.trim()) {
ElMessage.warning('Host is required')
@@ -66,7 +92,14 @@ async function handleTest() {
auth: form.auth || undefined,
username: form.username || undefined,
tls: form.tls,
})
sshEnabled: form.sshEnabled,
sshHost: form.sshHost.trim(),
sshPort: Number(form.sshPort) || 22,
sshUsername: form.sshUsername.trim(),
sshPassword: form.sshPassword,
sshPrivateKeyPath: form.sshPrivateKeyPath,
sshPassphrase: form.sshPassphrase,
} as any)
if (result.success) {
ElMessage.success('Connection successful')
} else {
@@ -95,12 +128,13 @@ async function handleSave() {
tlsCaPath: '',
tlsCertPath: '',
tlsKeyPath: '',
sshEnabled: false,
sshHost: '',
sshPort: 22,
sshUsername: '',
sshPassword: '',
sshPrivateKeyPath: '',
sshEnabled: form.sshEnabled,
sshHost: form.sshHost.trim(),
sshPort: Number(form.sshPort) || 22,
sshUsername: form.sshUsername.trim(),
sshPassword: form.sshPassword,
sshPrivateKeyPath: form.sshPrivateKeyPath,
sshPassphrase: form.sshPassphrase,
cluster: false,
sentinelMasterName: '',
separator: form.separator || ':',
@@ -122,7 +156,7 @@ async function handleSave() {
<el-dialog
:model-value="visible"
:title="isEdit ? t('connection.edit') : t('connection.new')"
width="480px"
width="520px"
:close-on-click-modal="false"
@update:model-value="close"
>
@@ -154,6 +188,41 @@ async function handleSave() {
<el-form-item>
<el-checkbox v-model="form.tls">{{ t('connection.tls') }}</el-checkbox>
</el-form-item>
<!-- SSH Tunnel -->
<el-divider />
<el-form-item>
<el-checkbox v-model="form.sshEnabled">{{ t('connection.ssh') }}</el-checkbox>
</el-form-item>
<template v-if="form.sshEnabled">
<el-row :gutter="12">
<el-col :span="16">
<el-form-item :label="t('connection.sshHost')">
<el-input v-model="form.sshHost" placeholder="192.168.1.1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="t('connection.sshPort')">
<el-input-number v-model="form.sshPort" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="t('connection.sshUsername')">
<el-input v-model="form.sshUsername" placeholder="root" />
</el-form-item>
<el-form-item :label="t('connection.sshPassword')">
<el-input v-model="form.sshPassword" type="password" show-password :placeholder="t('connection.sshPassword')" />
</el-form-item>
<el-form-item :label="t('connection.sshPrivateKey')">
<div style="display:flex;gap:8px;">
<el-input v-model="form.sshPrivateKeyPath" placeholder="~/.ssh/id_rsa" />
<el-button @click="selectFile">{{ t('connection.selectFile') }}</el-button>
</div>
</el-form-item>
<el-form-item :label="t('connection.sshPassphrase')">
<el-input v-model="form.sshPassphrase" type="password" show-password :placeholder="t('connection.sshPassphrase')" />
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
+7
View File
@@ -31,6 +31,13 @@ export default {
username: 'Username',
tls: 'Enable TLS',
ssh: 'SSH Tunnel',
sshHost: 'SSH Host',
sshPort: 'SSH Port',
sshUsername: 'SSH Username',
sshPassword: 'SSH Password',
sshPrivateKey: 'Private Key Path',
sshPassphrase: 'Passphrase',
selectFile: 'Select File',
connect: 'Connect',
disconnect: 'Disconnect',
deleteConfirm: 'Delete this connection?',
+7
View File
@@ -31,6 +31,13 @@ export default {
username: '用户名',
tls: '启用 TLS',
ssh: 'SSH 隧道',
sshHost: 'SSH 主机',
sshPort: 'SSH 端口',
sshUsername: 'SSH 用户名',
sshPassword: 'SSH 密码',
sshPrivateKey: '私钥路径',
sshPassphrase: '私钥密码',
selectFile: '选择文件',
connect: '连接',
disconnect: '断开',
deleteConfirm: '删除此连接?',
+17
View File
@@ -19,6 +19,7 @@ export interface ConnectionConfig {
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
sshPassphrase: string
cluster: boolean
sentinelMasterName: string
separator: string
@@ -82,6 +83,13 @@ export const useConnectionStore = defineStore('connection', () => {
password: auth || undefined,
username: conn.username || undefined,
tls: conn.tls,
sshEnabled: conn.sshEnabled,
sshHost: conn.sshHost,
sshPort: conn.sshPort,
sshUsername: conn.sshUsername,
sshPassword: conn.sshPassword,
sshPrivateKeyPath: conn.sshPrivateKeyPath,
sshPassphrase: conn.sshPassphrase,
})
if (result.success) {
activeId.value = conn.id
@@ -143,6 +151,8 @@ export const useConnectionStore = defineStore('connection', () => {
async function testConnection(opts: {
host: string; port: number; auth?: string; username?: string; tls?: boolean
sshEnabled?: boolean; sshHost?: string; sshPort?: number; sshUsername?: string
sshPassword?: string; sshPrivateKeyPath?: string; sshPassphrase?: string
}): Promise<{ success: boolean; error?: string }> {
if (!window.electronAPI) {
return { success: false, error: 'Electron API not ready' }
@@ -155,6 +165,13 @@ export const useConnectionStore = defineStore('connection', () => {
password: opts.auth || undefined,
username: opts.username || undefined,
tls: opts.tls,
sshEnabled: opts.sshEnabled,
sshHost: opts.sshHost,
sshPort: opts.sshPort,
sshUsername: opts.sshUsername,
sshPassword: opts.sshPassword,
sshPrivateKeyPath: opts.sshPrivateKeyPath,
sshPassphrase: opts.sshPassphrase,
})
if (result.success) {
await window.electronAPI.redis.disconnect(testId)