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
|
||||
|
||||
Reference in New Issue
Block a user