// electron/main/redis/connection.ts // Redis 连接管理(支持 SSH 隧道) import Redis, { RedisOptions } from 'ioredis' 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 tlsCaPath?: string tlsCertPath?: string tlsKeyPath?: string tlsRejectUnauthorized?: boolean sentinelEnabled?: boolean sentinelHost?: string sentinelPort?: number sentinelMasterName?: string sentinelNodePassword?: string cluster?: boolean sshEnabled?: boolean sshHost?: string sshPort?: number sshUsername?: string sshPassword?: string sshPrivateKeyPath?: string sshPassphrase?: string } export type RedisClient = Redis 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 = { host: opts.host, port: opts.port, username: opts.username || undefined, password: opts.password || undefined, stringNumbers: true, enableReadyCheck: true, family: 0, connectTimeout: 10000, retryStrategy(times: number) { if (times > 3) return null return Math.min(times * 200, 1000) }, maxRetriesPerRequest: 3, } if (opts.tls) { const tlsOpts: any = { rejectUnauthorized: opts.tlsRejectUnauthorized || false, checkServerIdentity: () => undefined, } if (opts.tlsCaPath) { try { tlsOpts.ca = readFileSync(expandHomePath(opts.tlsCaPath)) } catch { /* ignore */ } } if (opts.tlsCertPath) { try { tlsOpts.cert = readFileSync(expandHomePath(opts.tlsCertPath)) } catch { /* ignore */ } } if (opts.tlsKeyPath) { try { tlsOpts.key = readFileSync(expandHomePath(opts.tlsKeyPath)) } catch { /* ignore */ } } options.tls = tlsOpts } return options } export function getClient(id: string): RedisClient | undefined { return clients.get(id)?.client } export function isClientConnected(id: string): boolean { const entry = clients.get(id) return entry?.client?.status === 'ready' } export async function connect(id: string, opts: ConnectionOptions): Promise { if (clients.has(id)) { await disconnect(id) } return new Promise(async (resolve, reject) => { let sshClient: SSHClient | undefined let tunnelServer: ReturnType | undefined try { if (opts.sentinelEnabled && opts.sentinelMasterName) { // Sentinel mode: connect via sentinel const sentinelOptions: RedisOptions = { sentinels: [{ host: opts.sentinelHost || opts.host, port: opts.sentinelPort || 26379 }], name: opts.sentinelMasterName, sentinelPassword: opts.sentinelNodePassword || undefined, username: opts.username || undefined, password: opts.password || undefined, stringNumbers: true, enableReadyCheck: true, connectTimeout: 10000, retryStrategy(times: number) { if (times > 3) return null return Math.min(times * 200, 1000) }, maxRetriesPerRequest: 3, } if (opts.tls) { sentinelOptions.tls = { rejectUnauthorized: opts.tlsRejectUnauthorized || false, checkServerIdentity: () => undefined, } } const redis = new Redis(sentinelOptions) redis.once('ready', () => { clients.set(id, { client: redis }) resolve() }) redis.once('error', (err: Error) => { redis.disconnect() reject(err) }) setTimeout(() => { if (!clients.has(id)) { redis.disconnect() reject(new Error('连接超时,请检查主机和端口')) } }, 12000) return } if (opts.cluster) { // Cluster mode const clusterOptions: any = { redisOptions: { username: opts.username || undefined, password: opts.password || undefined, tls: opts.tls ? { rejectUnauthorized: opts.tlsRejectUnauthorized || false, checkServerIdentity: () => undefined, } : undefined, }, clusterRetryStrategy(times: number) { if (times > 3) return null return Math.min(times * 200, 1000) }, enableReadyCheck: true, } const redis = new Redis.Cluster([{ host: opts.host, port: opts.port }], clusterOptions) redis.once('ready', () => { clients.set(id, { client: redis as any }) resolve() }) redis.once('error', (err: Error) => { redis.disconnect() reject(err) }) setTimeout(() => { if (!clients.has(id)) { redis.disconnect() reject(new Error('连接超时,请检查主机和端口')) } }, 12000) return } 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, { 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 entry = clients.get(id) if (entry) { entry.client.disconnect() if (entry.sshClient) entry.sshClient.end() if (entry.tunnelServer) entry.tunnelServer.close() clients.delete(id) } } export function disconnectAll(): void { for (const [id] of clients) { disconnect(id) } }