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
+131 -13
View File
@@ -1,11 +1,107 @@
// src/main/redis/connection.ts
// Redis 连接管理
// electron/main/redis/connection.ts
// Redis 连接管理(支持 SSH 隧道)
import Redis, { RedisOptions } from 'ioredis'
import type { ConnectionOptions } from '../../shared/types'
import { Client as SSHClient } from 'ssh2'
import { createServer, type AddressInfo } from 'net'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import os from 'os'
export interface ConnectionOptions {
host: string
port: number
username?: string
password?: string
tls?: boolean
sshEnabled?: boolean
sshHost?: string
sshPort?: number
sshUsername?: string
sshPassword?: string
sshPrivateKeyPath?: string
sshPassphrase?: string
}
export type RedisClient = Redis
const clients = new Map<string, RedisClient>()
interface ClientEntry {
client: RedisClient
sshClient?: SSHClient
tunnelServer?: ReturnType<typeof createServer>
}
const clients = new Map<string, ClientEntry>()
function expandHomePath(p: string): string {
if (p.startsWith('~')) {
return resolve(os.homedir(), p.slice(p.startsWith('~/') ? 2 : 1))
}
return resolve(p)
}
async function createSshTunnel(opts: ConnectionOptions): Promise<{ localPort: number; sshClient: SSHClient; server: ReturnType<typeof createServer> }> {
const sshConfig: any = {
host: opts.sshHost || opts.host,
port: opts.sshPort || 22,
username: opts.sshUsername || 'root',
readyTimeout: 10000,
keepaliveInterval: 10000,
}
if (opts.sshPrivateKeyPath) {
try {
sshConfig.privateKey = readFileSync(expandHomePath(opts.sshPrivateKeyPath))
if (opts.sshPassphrase) {
sshConfig.passphrase = opts.sshPassphrase
}
} catch {
// private key file not found, fall through to password auth
}
}
if (opts.sshPassword) {
sshConfig.password = opts.sshPassword
}
return new Promise((resolve, reject) => {
const sshClient = new SSHClient()
sshClient.on('ready', () => {
// Create a local TCP server to forward connections
const server = createServer((socket) => {
sshClient.forwardOut(
'127.0.0.1',
0,
opts.host,
opts.port,
(err, stream) => {
if (err) {
socket.destroy()
return
}
socket.pipe(stream).pipe(socket)
}
)
})
server.listen(0, '127.0.0.1', () => {
const localPort = (server.address() as AddressInfo).port
resolve({ localPort, sshClient, server })
})
server.on('error', (err) => {
sshClient.end()
reject(new Error(`SSH tunnel server error: ${err.message}`))
})
})
sshClient.on('error', (err: Error) => {
reject(new Error(`SSH connection failed: ${err.message}`))
})
sshClient.connect(sshConfig)
})
}
function buildRedisOptions(opts: ConnectionOptions): RedisOptions {
const options: RedisOptions = {
@@ -35,12 +131,12 @@ function buildRedisOptions(opts: ConnectionOptions): RedisOptions {
}
export function getClient(id: string): RedisClient | undefined {
return clients.get(id)
return clients.get(id)?.client
}
export function isClientConnected(id: string): boolean {
const client = clients.get(id)
return client?.status === 'ready'
const entry = clients.get(id)
return entry?.client?.status === 'ready'
}
export async function connect(id: string, opts: ConnectionOptions): Promise<void> {
@@ -48,32 +144,54 @@ export async function connect(id: string, opts: ConnectionOptions): Promise<void
await disconnect(id)
}
return new Promise((resolve, reject) => {
const redis = new Redis(buildRedisOptions(opts))
return new Promise(async (resolve, reject) => {
let sshClient: SSHClient | undefined
let tunnelServer: ReturnType<typeof createServer> | undefined
try {
if (opts.sshEnabled) {
const tunnel = await createSshTunnel(opts)
sshClient = tunnel.sshClient
tunnelServer = tunnel.server
// Redirect Redis connection through SSH tunnel
opts = { ...opts, host: '127.0.0.1', port: tunnel.localPort }
}
const redisOptions = buildRedisOptions(opts)
const redis = new Redis(redisOptions)
redis.once('ready', () => {
clients.set(id, redis)
clients.set(id, { client: redis, sshClient, tunnelServer })
resolve()
})
redis.once('error', (err: Error) => {
redis.disconnect()
if (sshClient) sshClient.end()
if (tunnelServer) tunnelServer.close()
reject(err)
})
setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
if (sshClient) sshClient.end()
if (tunnelServer) tunnelServer.close()
reject(new Error('连接超时,请检查主机和端口'))
}
}, 12000)
} catch (err: any) {
reject(err)
}
})
}
export async function disconnect(id: string): Promise<void> {
const client = clients.get(id)
if (client) {
client.disconnect()
const entry = clients.get(id)
if (entry) {
entry.client.disconnect()
if (entry.sshClient) entry.sshClient.end()
if (entry.tunnelServer) entry.tunnelServer.close()
clients.delete(id)
}
}
+1
View File
@@ -18,6 +18,7 @@ interface ConnectionConfig {
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
sshPassphrase: string
cluster: boolean
sentinelMasterName: string
separator: string
+1
View File
@@ -16,6 +16,7 @@ export interface ConnectionConfig {
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
sshPassphrase: string
cluster: boolean
sentinelMasterName: string
separator: string
+107 -1
View File
@@ -11,10 +11,12 @@
"dependencies": {
"electron-store": "^11.0.2",
"ioredis": "^5.7.0",
"pinia": "^3.0.4"
"pinia": "^3.0.4",
"ssh2": "^1.17.0"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-vue": "^5.2.0",
"electron": "^43.0.0",
"electron-builder": "^26.15.3",
@@ -1811,6 +1813,33 @@
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18"
}
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ssh2/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2263,6 +2292,15 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -2380,6 +2418,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -2459,6 +2506,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/builder-util": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz",
@@ -2794,6 +2850,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -4536,6 +4606,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -5287,6 +5364,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-filename": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz",
@@ -5445,6 +5528,23 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -5691,6 +5791,12 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+6 -2
View File
@@ -8,7 +8,9 @@
"type": "module",
"allowScripts": {
"electron": true,
"esbuild": true
"esbuild": true,
"ssh2@1.17.0": true,
"cpu-features@0.0.10": true
},
"scripts": {
"dev": "electron-vite dev",
@@ -59,10 +61,12 @@
"dependencies": {
"electron-store": "^11.0.2",
"ioredis": "^5.7.0",
"pinia": "^3.0.4"
"pinia": "^3.0.4",
"ssh2": "^1.17.0"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-vue": "^5.2.0",
"electron": "^43.0.0",
"electron-builder": "^26.15.3",
+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)