Update package.json for version bump to 2.0.3, add postinstall script, and include new ssh2 dependency. Enhance main.js with SSH tunnel management for database connections, including creation and closure of tunnels, and update connection handling to support SSH-enabled configurations.

This commit is contained in:
Ethanfly 2026-01-04 10:25:39 +08:00
parent d5546b524c
commit 5591081812
2 changed files with 294 additions and 100 deletions

View File

@ -3,6 +3,7 @@ import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import fs from 'fs' import fs from 'fs'
import crypto from 'crypto' import crypto from 'crypto'
import net from 'net'
import mysql from 'mysql2/promise' import mysql from 'mysql2/promise'
import pg from 'pg' import pg from 'pg'
import initSqlJs from 'sql.js' import initSqlJs from 'sql.js'
@ -10,16 +11,130 @@ import { MongoClient } from 'mongodb'
import Redis from 'ioredis' import Redis from 'ioredis'
import mssql from 'mssql' import mssql from 'mssql'
import Blowfish from 'blowfish-node' import Blowfish from 'blowfish-node'
import { Client as SSHClient } from 'ssh2'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
// 存储活动的数据库连接 // 存储活动的数据库连接
const connections = new Map() const connections = new Map()
// 存储活动的 SSH 隧道
const sshTunnels = new Map()
// 配置文件路径 // 配置文件路径
const configPath = path.join(app.getPath('userData'), 'connections.json') const configPath = path.join(app.getPath('userData'), 'connections.json')
// SQL.js 初始化 // SQL.js 初始化
let SQL = null let SQL = null
// 用于分配本地端口
let nextLocalPort = 33060
// ============ SSH 隧道管理 ============
/**
* 创建 SSH 隧道
* @param {Object} config - 连接配置
* @returns {Promise<{ssh, server, localPort, localHost}>}
*/
async function createSSHTunnel(config) {
return new Promise((resolve, reject) => {
const ssh = new SSHClient()
const localPort = nextLocalPort++
// 端口范围重置
if (nextLocalPort > 65000) nextLocalPort = 33060
let server = null
let connected = false
ssh.on('ready', () => {
console.log(`[SSH] 连接成功: ${config.sshUser}@${config.sshHost}:${config.sshPort}`)
connected = true
// 创建本地 TCP 服务器进行端口转发
server = net.createServer((socket) => {
ssh.forwardOut(
'127.0.0.1', localPort,
config.host, config.port,
(err, stream) => {
if (err) {
console.error('[SSH] 转发失败:', err.message)
socket.end()
return
}
socket.pipe(stream).pipe(socket)
}
)
})
server.listen(localPort, '127.0.0.1', () => {
console.log(`[SSH] 隧道就绪: localhost:${localPort}${config.host}:${config.port}`)
resolve({ ssh, server, localPort, localHost: '127.0.0.1' })
})
server.on('error', (err) => {
console.error('[SSH] 本地服务器错误:', err.message)
ssh.end()
reject(err)
})
})
ssh.on('error', (err) => {
console.error('[SSH] 连接错误:', err.message)
if (!connected) reject(new Error(`SSH 连接失败: ${err.message}`))
})
ssh.on('close', () => {
console.log('[SSH] 连接已关闭')
if (server) server.close()
})
// 构建 SSH 配置
const sshConfig = {
host: config.sshHost,
port: config.sshPort || 22,
username: config.sshUser,
readyTimeout: 10000,
keepaliveInterval: 10000,
}
// 密码认证
if (config.sshPassword) {
sshConfig.password = config.sshPassword
}
// 私钥认证
if (config.sshKey) {
try {
if (fs.existsSync(config.sshKey)) {
sshConfig.privateKey = fs.readFileSync(config.sshKey)
} else if (config.sshKey.includes('-----BEGIN')) {
sshConfig.privateKey = config.sshKey
}
} catch (e) {
console.warn('[SSH] 读取私钥失败:', e.message)
}
}
console.log(`[SSH] 正在连接: ${config.sshUser}@${config.sshHost}:${config.sshPort}`)
ssh.connect(sshConfig)
})
}
/**
* 关闭 SSH 隧道
*/
function closeSSHTunnel(tunnelId) {
const tunnel = sshTunnels.get(tunnelId)
if (tunnel) {
try {
if (tunnel.server) tunnel.server.close()
if (tunnel.ssh) tunnel.ssh.end()
console.log(`[SSH] 隧道已关闭: ${tunnelId}`)
} catch (e) {
console.error('[SSH] 关闭隧道失败:', e.message)
}
sshTunnels.delete(tunnelId)
}
}
let mainWindow let mainWindow
@ -91,16 +206,25 @@ app.whenReady().then(async () => {
}) })
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// 关闭所有数据库连接 // 关闭所有数据库连接和 SSH 隧道
for (const [id, connInfo] of connections) { for (const [id, connInfo] of connections) {
try { try {
closeConnection(connInfo.connection, connInfo.type) closeConnection(connInfo.connection, connInfo.type, id)
} catch (e) { } catch (e) {
console.error('关闭连接失败:', e) console.error('关闭连接失败:', e)
} }
} }
connections.clear() connections.clear()
// 清理残留的 SSH 隧道
for (const [id, tunnel] of sshTunnels) {
try {
if (tunnel.server) tunnel.server.close()
if (tunnel.ssh) tunnel.ssh.end()
} catch (e) {}
}
sshTunnels.clear()
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()
} }
@ -150,9 +274,10 @@ ipcMain.handle('config:save', async (event, connectionsList) => {
// ============ 数据库操作 ============ // ============ 数据库操作 ============
ipcMain.handle('db:test', async (event, config) => { ipcMain.handle('db:test', async (event, config) => {
try { try {
const conn = await createConnection(config) const conn = await createConnection(config, null)
await closeConnection(conn, config.type) await closeConnection(conn, config.type, null)
return { success: true, message: '连接成功' } const msg = config.sshEnabled ? '通过 SSH 隧道连接成功' : '连接成功'
return { success: true, message: msg }
} catch (e) { } catch (e) {
return { success: false, message: e.message } return { success: false, message: e.message }
} }
@ -160,9 +285,10 @@ ipcMain.handle('db:test', async (event, config) => {
ipcMain.handle('db:connect', async (event, config) => { ipcMain.handle('db:connect', async (event, config) => {
try { try {
const conn = await createConnection(config) const conn = await createConnection(config, config.id)
connections.set(config.id, { connection: conn, type: config.type, config }) connections.set(config.id, { connection: conn, type: config.type, config })
return { success: true, message: '连接成功' } const msg = config.sshEnabled ? '通过 SSH 隧道连接成功' : '连接成功'
return { success: true, message: msg }
} catch (e) { } catch (e) {
return { success: false, message: e.message } return { success: false, message: e.message }
} }
@ -171,7 +297,7 @@ ipcMain.handle('db:connect', async (event, config) => {
ipcMain.handle('db:disconnect', async (event, id) => { ipcMain.handle('db:disconnect', async (event, id) => {
const connInfo = connections.get(id) const connInfo = connections.get(id)
if (connInfo) { if (connInfo) {
await closeConnection(connInfo.connection, connInfo.type) await closeConnection(connInfo.connection, connInfo.type, id)
connections.delete(id) connections.delete(id)
} }
}) })
@ -222,15 +348,16 @@ async function ensureConnection(id) {
if (!alive && connInfo.config) { if (!alive && connInfo.config) {
console.log(`连接 ${id} 已断开,尝试重新连接...`) console.log(`连接 ${id} 已断开,尝试重新连接...`)
try { try {
// 尝试关闭旧连接(忽略错误) // 尝试关闭旧连接和 SSH 隧道
try { try {
await closeConnection(connInfo.connection, connInfo.type) await closeConnection(connInfo.connection, connInfo.type, id)
} catch (e) {} } catch (e) {}
// 重新建立连接 // 重新建立连接(包括 SSH 隧道)
const newConn = await createConnection(connInfo.config) const newConn = await createConnection(connInfo.config, id)
connections.set(id, { connection: newConn, type: connInfo.type, config: connInfo.config }) connections.set(id, { connection: newConn, type: connInfo.type, config: connInfo.config })
console.log(`连接 ${id} 重新连接成功`) const sshNote = connInfo.config.sshEnabled ? '(通过 SSH 隧道)' : ''
console.log(`连接 ${id} 重新连接成功${sshNote}`)
return connections.get(id) return connections.get(id)
} catch (e) { } catch (e) {
console.error(`连接 ${id} 重新连接失败:`, e.message) console.error(`连接 ${id} 重新连接失败:`, e.message)
@ -1139,13 +1266,35 @@ function navicatXorDecrypt(encryptedBuffer) {
} }
// ============ 数据库连接辅助函数 ============ // ============ 数据库连接辅助函数 ============
async function createConnection(config) { async function createConnection(config, connectionId = null) {
const { type, host, port, username, password, database } = config let { type, host, port, username, password, database } = config
const originalHost = host // 保存原始 hostSQLite 需要用)
// 如果启用了 SSH 隧道,先建立隧道
let tunnel = null
if (config.sshEnabled && config.sshHost) {
console.log(`[DB] 为连接创建 SSH 隧道...`)
try {
tunnel = await createSSHTunnel(config)
host = tunnel.localHost
port = tunnel.localPort
// 保存隧道(正式连接时)
if (connectionId) {
sshTunnels.set(connectionId, tunnel)
}
} catch (e) {
throw new Error(`SSH 隧道失败: ${e.message}`)
}
}
try {
let conn
switch (type) { switch (type) {
case 'mysql': case 'mysql':
case 'mariadb': case 'mariadb':
return await mysql.createConnection({ conn = await mysql.createConnection({
host, host,
port, port,
user: username, user: username,
@ -1154,6 +1303,7 @@ async function createConnection(config) {
connectTimeout: 10000, connectTimeout: 10000,
dateStrings: true dateStrings: true
}) })
break
case 'postgresql': case 'postgresql':
case 'postgres': { case 'postgres': {
@ -1166,12 +1316,13 @@ async function createConnection(config) {
connectionTimeoutMillis: 10000 connectionTimeoutMillis: 10000
}) })
await client.connect() await client.connect()
return client conn = client
break
} }
case 'sqlite': { case 'sqlite': {
await initSqlite() await initSqlite()
const dbPath = host || database const dbPath = originalHost || database // SQLite 用原始路径
let dbData = null let dbData = null
if (dbPath && fs.existsSync(dbPath)) { if (dbPath && fs.existsSync(dbPath)) {
@ -1180,7 +1331,8 @@ async function createConnection(config) {
const db = new SQL.Database(dbData) const db = new SQL.Database(dbData)
db._path = dbPath db._path = dbPath
return db conn = db
break
} }
case 'mongodb': { case 'mongodb': {
@ -1193,7 +1345,8 @@ async function createConnection(config) {
}) })
await client.connect() await client.connect()
client._database = database || 'admin' client._database = database || 'admin'
return client conn = client
break
} }
case 'redis': { case 'redis': {
@ -1206,7 +1359,8 @@ async function createConnection(config) {
lazyConnect: true lazyConnect: true
}) })
await redis.connect() await redis.connect()
return redis conn = redis
break
} }
case 'sqlserver': { case 'sqlserver': {
@ -1224,16 +1378,36 @@ async function createConnection(config) {
} }
const pool = await mssql.connect(sqlConfig) const pool = await mssql.connect(sqlConfig)
pool._database = database || 'master' pool._database = database || 'master'
return pool conn = pool
break
} }
default: default:
throw new Error(`不支持的数据库类型: ${type}`) throw new Error(`不支持的数据库类型: ${type}`)
} }
// 测试连接时,将隧道附加到连接对象
if (tunnel && !connectionId) {
conn._sshTunnel = tunnel
} }
async function closeConnection(conn, type) { return conn
} catch (e) {
// 连接失败时清理隧道
if (tunnel) {
try { try {
if (tunnel.server) tunnel.server.close()
if (tunnel.ssh) tunnel.ssh.end()
} catch (err) {}
if (connectionId) sshTunnels.delete(connectionId)
}
throw e
}
}
async function closeConnection(conn, type, connectionId = null) {
try {
// 关闭数据库连接
switch (type) { switch (type) {
case 'mysql': case 'mysql':
case 'mariadb': case 'mariadb':
@ -1260,6 +1434,19 @@ async function closeConnection(conn, type) {
await conn.close() await conn.close()
break break
} }
// 关闭测试连接的 SSH 隧道
if (conn._sshTunnel) {
try {
if (conn._sshTunnel.server) conn._sshTunnel.server.close()
if (conn._sshTunnel.ssh) conn._sshTunnel.ssh.end()
} catch (e) {}
}
// 关闭正式连接的 SSH 隧道
if (connectionId) {
closeSSHTunnel(connectionId)
}
} catch (e) { } catch (e) {
console.error('关闭连接时出错:', e) console.error('关闭连接时出错:', e)
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "easysql", "name": "easysql",
"version": "2.0.1", "version": "2.0.3",
"description": "Modern Database Management Tool", "description": "Modern Database Management Tool",
"main": "electron/main.js", "main": "electron/main.js",
"type": "module", "type": "module",
@ -15,7 +15,8 @@
"version:patch": "node scripts/bump-version.js patch", "version:patch": "node scripts/bump-version.js patch",
"version:minor": "node scripts/bump-version.js minor", "version:minor": "node scripts/bump-version.js minor",
"version:major": "node scripts/bump-version.js major", "version:major": "node scripts/bump-version.js major",
"icons": "node scripts/generate-icons.js" "icons": "node scripts/generate-icons.js",
"postinstall": "electron-builder install-app-deps"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@ -28,7 +29,8 @@
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"sql-formatter": "^15.6.12", "sql-formatter": "^15.6.12",
"sql.js": "^1.11.0" "sql.js": "^1.11.0",
"ssh2": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
@ -57,7 +59,12 @@
}, },
"files": [ "files": [
"dist/**/*", "dist/**/*",
"electron/**/*" "electron/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/ssh2/**/*",
"node_modules/cpu-features/**/*"
], ],
"win": { "win": {
"target": "nsis", "target": "nsis",