diff --git a/electron/main/redis/connection.ts b/electron/main/redis/connection.ts index 2962fe8..5f793f0 100644 --- a/electron/main/redis/connection.ts +++ b/electron/main/redis/connection.ts @@ -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() +interface ClientEntry { + client: RedisClient + sshClient?: SSHClient + tunnelServer?: ReturnType +} + +const clients = new Map() + +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 }> { + 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 { @@ -48,32 +144,54 @@ export async function connect(id: string, opts: ConnectionOptions): Promise { - const redis = new Redis(buildRedisOptions(opts)) + return new Promise(async (resolve, reject) => { + let sshClient: SSHClient | undefined + let tunnelServer: ReturnType | 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 { - 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) } } diff --git a/electron/main/store.ts b/electron/main/store.ts index b11953b..fc2f888 100644 --- a/electron/main/store.ts +++ b/electron/main/store.ts @@ -18,6 +18,7 @@ interface ConnectionConfig { sshUsername: string sshPassword: string sshPrivateKeyPath: string + sshPassphrase: string cluster: boolean sentinelMasterName: string separator: string diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index d2bd911..ad26a4b 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -16,6 +16,7 @@ export interface ConnectionConfig { sshUsername: string sshPassword: string sshPrivateKeyPath: string + sshPassphrase: string cluster: boolean sentinelMasterName: string separator: string diff --git a/package-lock.json b/package-lock.json index 977d9c6..5ef49ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f40e245..1ad983c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/NewConnectionDialog.vue b/src/components/NewConnectionDialog.vue index 26fd0df..709364f 100644 --- a/src/components/NewConnectionDialog.vue +++ b/src/components/NewConnectionDialog.vue @@ -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() { @@ -154,6 +188,41 @@ async function handleSave() { {{ t('connection.tls') }} + + + + + {{ t('connection.ssh') }} + +