update
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
// 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<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 = {
|
||||
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<void> {
|
||||
if (clients.has(id)) {
|
||||
await disconnect(id)
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let sshClient: SSHClient | undefined
|
||||
let tunnelServer: ReturnType<typeof createServer> | 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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user