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:
@@ -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
|
||||
|
||||
redis.once('ready', () => {
|
||||
clients.set(id, redis)
|
||||
resolve()
|
||||
})
|
||||
|
||||
redis.once('error', (err: Error) => {
|
||||
redis.disconnect()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!clients.has(id)) {
|
||||
redis.disconnect()
|
||||
reject(new Error('连接超时,请检查主机和端口'))
|
||||
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 }
|
||||
}
|
||||
}, 12000)
|
||||
|
||||
const redisOptions = buildRedisOptions(opts)
|
||||
const redis = new Redis(redisOptions)
|
||||
|
||||
redis.once('ready', () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ConnectionConfig {
|
||||
sshUsername: string
|
||||
sshPassword: string
|
||||
sshPrivateKeyPath: string
|
||||
sshPassphrase: string
|
||||
cluster: boolean
|
||||
sentinelMasterName: string
|
||||
separator: string
|
||||
|
||||
Vendored
+1
@@ -16,6 +16,7 @@ export interface ConnectionConfig {
|
||||
sshUsername: string
|
||||
sshPassword: string
|
||||
sshPrivateKeyPath: string
|
||||
sshPassphrase: string
|
||||
cluster: boolean
|
||||
sentinelMasterName: string
|
||||
separator: string
|
||||
|
||||
Generated
+107
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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: '删除此连接?',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user