v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨

核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
This commit is contained in:
Logan.Li committed 2026-04-22 11:15:41 +08:00
1 parent 37e65be823
commit 514d62644e
77 files changed
+2563 -931

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
* 处理应用级别信息
*/
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
import { ipcMain, app } from 'electron'
// IPC channel names
export const APP_CHANNELS = {
+136 -27
View File
@@ -1,6 +1,7 @@
/**
* File IPC Handlers
* 处理本地文件操作和远程文件读写
* 安全增强:防止路径遍历攻击
*/
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
@@ -21,25 +22,106 @@ export const FILE_CHANNELS = {
LOCAL_MKDIR: 'local:mkdir',
} as const
// 允许的文件操作基础目录白名单
const ALLOWED_BASE_PATHS: string[] = []
/**
* 初始化允许的路径白名单
* 防止路径遍历攻击
*/
export function initializeAllowedPaths(): void {
ALLOWED_BASE_PATHS.push(
app.getPath('home'),
app.getPath('downloads'),
app.getPath('documents'),
app.getPath('desktop'),
app.getPath('temp'),
app.getPath('userData'),
)
// 添加平台特定的常用目录
if (process.platform === 'darwin') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp')
} else if (process.platform === 'linux') {
ALLOWED_BASE_PATHS.push('/tmp', '/var/tmp', '/home')
} else if (process.platform === 'win32') {
ALLOWED_BASE_PATHS.push(process.env.LOCALAPPDATA || '', process.env.TEMP || '')
}
}
/**
* 验证文件路径是否在允许的范围内
* 防止路径遍历攻击(如 ../../../etc/passwd)
*/
function isPathAllowed(filePath: string): boolean {
// 规范化路径(解析 .. 和 .)
const resolved = path.resolve(filePath)
// 检查是否在允许的基目录下
const isUnderAllowedPath = ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, resolved)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
if (isUnderAllowedPath) return true
// 如果路径存在,检查其实际位置(处理符号链接)
try {
const real = fs.realpathSync(resolved)
return ALLOWED_BASE_PATHS.some((base) => {
if (!base) return false
const relative = path.relative(base, real)
return !relative.startsWith('..') && !path.isAbsolute(relative)
})
} catch {
return false
}
}
/**
* 安全地规范化路径
*/
function sanitizePath(inputPath: string): string {
// 移除 null 字节
const cleaned = inputPath.replace(/\0/g, '')
// 规范化路径
return path.resolve(cleaned)
}
export function registerFileIPCHandlers(): void {
// 确保白名单已初始化
if (ALLOWED_BASE_PATHS.length === 0) {
initializeAllowedPaths()
}
// Read file (for rz)
ipcMain.handle(FILE_CHANNELS.READ, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const data = fs.readFileSync(filePath)
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const data = fs.readFileSync(safePath)
return { success: true, data: data.toString('base64') }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Write file (for sz)
ipcMain.handle(FILE_CHANNELS.WRITE, async (_event: IpcMainInvokeEvent, filePath: string, data: string) => {
try {
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const buffer = Buffer.from(data, 'base64')
fs.writeFileSync(filePath, buffer)
fs.writeFileSync(safePath, buffer)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
@@ -48,15 +130,20 @@ export function registerFileIPCHandlers(): void {
try {
const homePath = app.getPath('home')
return { success: true, path: homePath }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Read directory contents
ipcMain.handle(FILE_CHANNELS.LOCAL_READ_DIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const entries = fs.readdirSync(safePath, { withFileTypes: true })
const files = entries.map((entry) => {
const fullPath = path.join(dirPath, entry.name)
let stats: fs.Stats | null = null
@@ -84,63 +171,85 @@ export function registerFileIPCHandlers(): void {
})
return { success: true, files }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Get file stats
ipcMain.handle(FILE_CHANNELS.LOCAL_GET_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const stats = fs.lstatSync(filePath)
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
return {
success: true,
stats: {
name: path.basename(filePath),
path: filePath,
name: path.basename(safePath),
path: safePath,
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
size: stats.size,
modified: Math.floor(stats.mtime.getTime() / 1000),
permissions: formatPermissions(stats.mode),
},
}
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Delete file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_DELETE, async (_event: IpcMainInvokeEvent, filePath: string) => {
try {
const stats = fs.lstatSync(filePath)
const safePath = sanitizePath(filePath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
const stats = fs.lstatSync(safePath)
if (stats.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true })
fs.rmSync(safePath, { recursive: true, force: true })
} else {
fs.unlinkSync(filePath)
fs.unlinkSync(safePath)
}
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Rename file or directory
ipcMain.handle(FILE_CHANNELS.LOCAL_RENAME, async (_event: IpcMainInvokeEvent, oldPath: string, newPath: string) => {
try {
fs.renameSync(oldPath, newPath)
const safeOld = sanitizePath(oldPath)
const safeNew = sanitizePath(newPath)
if (!isPathAllowed(safeOld) || !isPathAllowed(safeNew)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.renameSync(safeOld, safeNew)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// Create directory
ipcMain.handle(FILE_CHANNELS.LOCAL_MKDIR, async (_event: IpcMainInvokeEvent, dirPath: string) => {
try {
fs.mkdirSync(dirPath, { recursive: true })
const safePath = sanitizePath(dirPath)
if (!isPathAllowed(safePath)) {
return { success: false, error: 'Access denied: path traversal detected' }
}
fs.mkdirSync(safePath, { recursive: true })
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
}
+15 -2
View File
@@ -16,9 +16,10 @@ import { registerSFTPIPCHandlers } from './sftp.ipc'
import { registerSessionIPCHandlers } from './session.ipc'
import { registerSettingsIPCHandlers } from './settings.ipc'
import { registerDialogIPCHandlers } from './dialog.ipc'
import { registerFileIPCHandlers } from './file.ipc'
import { registerFileIPCHandlers, initializeAllowedPaths } from './file.ipc'
import { registerZmodemIPCHandlers } from './zmodem.ipc'
import { registerAppIPCHandlers } from './app.ipc'
import { ipcMain } from 'electron'
export interface IPCOptions {
sshService: SSHService
@@ -45,7 +46,8 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
// Dialog handlers
registerDialogIPCHandlers({ mainWindowGetter })
// File handlers
// File handlers (初始化路径白名单)
initializeAllowedPaths()
registerFileIPCHandlers()
// Zmodem handlers
@@ -54,6 +56,17 @@ export function registerAllIPCHandlers(options: IPCOptions): void {
// App handlers
registerAppIPCHandlers()
// Window handlers
ipcMain.handle('window:toggleMaximize', () => {
const win = mainWindowGetter()
if (!win) return
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
})
logger.info('[IPC] All handlers registered')
}
+12 -11
View File
@@ -5,6 +5,7 @@
import { ipcMain, IpcMainInvokeEvent } from 'electron'
import type { DatabaseService } from '../services/database-service'
import type { SessionUpdateRequest } from '../../shared/types/session.types'
// IPC channel names
export const SESSION_CHANNELS = {
@@ -27,8 +28,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try {
const result = dbService.createSession(session)
return { success: true, session: result }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -37,8 +38,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try {
const sessions = dbService.getAllSessions()
return { success: true, sessions }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -47,18 +48,18 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try {
const session = dbService.getSessionById(id)
return { success: true, session }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
// Update session
ipcMain.handle(SESSION_CHANNELS.UPDATE, async (_event: IpcMainInvokeEvent, id: string, updates: any) => {
ipcMain.handle(SESSION_CHANNELS.UPDATE, async (_event: IpcMainInvokeEvent, id: string, updates: SessionUpdateRequest) => {
try {
const result = dbService.updateSession(id, updates)
return { success: true, session: result }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -67,8 +68,8 @@ export function registerSessionIPCHandlers(options: SessionIPCOptions): void {
try {
dbService.deleteSession(id)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
+4 -4
View File
@@ -24,8 +24,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
try {
const value = dbService.getSetting(key)
return { success: true, value }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -34,8 +34,8 @@ export function registerSettingsIPCHandlers(options: SettingsIPCOptions): void {
try {
dbService.setSetting(key, value)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
+38 -29
View File
@@ -26,11 +26,11 @@ export interface SFTPIPCOptions {
}
// ===== 彩色调试日志 =====
const DEBUG = true
const _DEBUG = true
function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #16a085; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
}
function logInfo(label: string, value: any) {
function logInfo(label: string, value: unknown) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #1abc9c; font-weight: bold;')
}
function logSuccess(msg: string) {
@@ -52,9 +52,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const sftp = await sshService.createSFTP(connectionId)
logSuccess(`SFTP connected: ${sftp.id}`)
return { success: true, sftpId: sftp.id }
} catch (error: any) {
logError(`SFTP connect failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`SFTP connect failed: ${message}`)
return { success: false, error: message }
}
})
@@ -68,9 +69,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const files = await sshService.sftpListDirectory(sftpId, dirPath)
logSuccess(`Listed ${files.length} items`)
return { success: true, files }
} catch (error: any) {
logError(`List failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`List failed: ${message}`)
return { success: false, error: message }
}
})
@@ -97,9 +99,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
})
logSuccess('Upload complete')
return { success: true }
} catch (error: any) {
logError(`Upload failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Upload failed: ${message}`)
return { success: false, error: message }
}
})
@@ -126,9 +129,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
})
logSuccess('Download complete')
return { success: true }
} catch (error: any) {
logError(`Download failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Download failed: ${message}`)
return { success: false, error: message }
}
})
@@ -148,9 +152,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpRename(sftpId, oldPath, newPath)
logSuccess('Rename complete')
return { success: true }
} catch (error: any) {
logError(`Rename failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Rename failed: ${message}`)
return { success: false, error: message }
}
})
@@ -170,9 +175,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpDelete(sftpId, filePath, isDirectory)
logSuccess('Delete complete')
return { success: true }
} catch (error: any) {
logError(`Delete failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Delete failed: ${message}`)
return { success: false, error: message }
}
})
@@ -190,9 +196,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpMkdir(sftpId, dirPath)
logSuccess('Mkdir complete')
return { success: true }
} catch (error: any) {
logError(`Mkdir failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Mkdir failed: ${message}`)
return { success: false, error: message }
}
})
@@ -210,9 +217,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
const stats = await sshService.sftpStat(sftpId, filePath)
logSuccess(`Stat complete: ${stats.size} bytes`)
return { success: true, stats }
} catch (error: any) {
logError(`Stat failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Stat failed: ${message}`)
return { success: false, error: message }
}
})
@@ -232,9 +240,10 @@ export function registerSFTPIPCHandlers(options: SFTPIPCOptions): void {
await sshService.sftpChmod(sftpId, filePath, mode)
logSuccess('Chmod complete')
return { success: true }
} catch (error: any) {
logError(`Chmod failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logError(`Chmod failed: ${message}`)
return { success: false, error: message }
}
})
}
+61 -63
View File
@@ -1,6 +1,7 @@
/**
* SSH IPC Handlers
* 处理 SSH 连接、Shell、会话、端口转发相关 IPC
* 安全增强:规范化错误处理,不暴露敏感信息
*/
import { ipcMain, IpcMainInvokeEvent } from 'electron'
@@ -10,22 +11,22 @@ import { createLogger } from '../utils/logger'
const logger = createLogger('SSH_IPC')
// ===== 彩色调试日志 =====
const DEBUG = true
function debug(...args: any[]) {
if (DEBUG) console.log('[SSH_IPC]', ...args)
// 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: unknown[]) {
if (DEBUG) logger.debug('[SSH_IPC]', ...args)
}
function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #9b59b6; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
logger.info(`[${msg}]`)
}
function logInfo(label: string, value: any) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;')
function logInfo(label: string, value: unknown) {
logger.info(`${label}: ${value}`)
}
function logSuccess(msg: string) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
logger.info(`[OK] ${msg}`)
}
function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
logger.error(msg)
}
// IPC channel names
@@ -49,15 +50,16 @@ export interface SSHIPCOptions {
}
export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
const { sshService, zmodemService, mainWindowGetter } = options
const { sshService, zmodemService, mainWindowGetter: _mainWindowGetter } = options
// SSH connect
ipcMain.handle(SSH_CHANNELS.CONNECT, async (_event: IpcMainInvokeEvent, config) => {
try {
const connection = await sshService.connect(config)
return { success: true, connectionId: connection.id }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
@@ -66,8 +68,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try {
await sshService.disconnect(connectionId)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
@@ -76,59 +79,49 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try {
const result = await sshService.exec(connectionId, command)
return { success: true, result }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
// SSH create shell
ipcMain.handle(SSH_CHANNELS.CREATE_SHELL, async (event: IpcMainInvokeEvent, connectionId: string, options?: { cols?: number; rows?: number }, shellId?: string) => {
console.log('=== SSH_IPC CREATE_SHELL ===')
console.log(' connectionId:', connectionId)
console.log(' options:', options)
console.log(' preferredShellId:', shellId)
debug('CREATE_SHELL connectionId:', connectionId, 'preferredId:', shellId)
try {
const preferredShellId = shellId
logger.info('[SSH_IPC] Creating shell for connection:', connectionId, 'preferredId:', preferredShellId)
debug('>>> Calling sshService.createShell...')
const shell = await sshService.createShell(connectionId, options || {}, (shellId, data) => {
// 检查 sender 是否已被销毁
if (event.sender.isDestroyed()) {
console.log('=== SHELL DATA CALLBACK - SKIPPED (sender destroyed) ===')
debug('SHELL DATA CALLBACK - SKIPPED (sender destroyed)')
return
}
const text = data.toString('utf-8')
console.log('=== SHELL DATA CALLBACK ===')
console.log(' shellId:', shellId)
console.log(' data length:', data.length)
console.log(' data preview:', text.substring(0, 100).replace(/\n/g, '\\n'))
// 打印所有收到的数据前100字符用于调试
if (text.length > 0) {
logger.info(`[SSH_IPC] Shell data from ${shellId}:`, text.substring(0, 100).replace(/\n/g, '\\n'))
}
debug('SHELL DATA CALLBACK shellId:', shellId, 'data length:', data.length)
try {
console.log('>>> Sending SHELL_DATA to renderer...')
debug('>>> Sending SHELL_DATA to renderer...')
event.sender.send(SSH_CHANNELS.SHELL_DATA, connectionId, shellId, data)
console.log('>>> SHELL_DATA sent successfully')
debug('>>> SHELL_DATA sent successfully')
} catch (err) {
console.error('>>> FAILED to send SHELL_DATA:', err)
logger.error('>>> FAILED to send SHELL_DATA:', err)
}
zmodemService.feedShellData(shellId, data)
}, preferredShellId)
logger.info('[SSH_IPC] Shell created:', shell.id)
debug('>>> Shell created successfully:', shell.id)
return { success: true, shellId: shell.id }
} catch (error: any) {
} catch (error) {
logger.error('[SSH_IPC] Shell creation failed:', error)
debug('>>> Shell creation FAILED:', error.message)
return { success: false, error: error.message }
const message = error instanceof Error ? error.message : String(error)
debug('>>> Shell creation FAILED:', message)
return { success: false, error: message }
}
})
@@ -137,8 +130,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try {
sshService.shellWrite(shellId, data)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
@@ -147,8 +141,9 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
try {
sshService.shellResize(shellId, cols, rows)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { success: false, error: message }
}
})
@@ -160,19 +155,20 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
remoteHost: string,
remotePort: number
) => {
logHeader('🔀 IPC FORWARD_LOCAL')
logHeader('FORWARD_LOCAL')
logInfo('connectionId', connectionId)
logInfo('localPort', localPort)
logInfo('remoteHost', remoteHost)
logInfo('remotePort', remotePort)
try {
const result = await sshService.forwardLocal(connectionId, localPort, remoteHost, remotePort)
logSuccess(`Local forward bound to port ${result.boundPort}`)
return { success: true, boundPort: result.boundPort }
} catch (error: any) {
logError(`Local forward failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logError(`Local forward failed: ${message}`)
return { success: false, error: message }
}
})
@@ -184,19 +180,20 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
localHost: string,
localPort: number
) => {
logHeader('🔀 IPC FORWARD_REMOTE')
logHeader('FORWARD_REMOTE')
logInfo('connectionId', connectionId)
logInfo('remotePort', remotePort)
logInfo('localHost', localHost)
logInfo('localPort', localPort)
try {
const result = await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
await sshService.forwardRemote(connectionId, remotePort, localHost, localPort)
logSuccess('Remote forward established')
return { success: true }
} catch (error: any) {
logError(`Remote forward failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logError(`Remote forward failed: ${message}`)
return { success: false, error: message }
}
})
@@ -206,17 +203,18 @@ export function registerSSHIPCHandlers(options: SSHIPCOptions): void {
connectionId: string,
localPort: number
) => {
logHeader('🧦 IPC FORWARD_DYNAMIC (SOCKS5)')
logHeader('FORWARD_DYNAMIC (SOCKS5)')
logInfo('connectionId', connectionId)
logInfo('localPort', localPort)
try {
const result = await sshService.forwardDynamic(connectionId, localPort)
logSuccess(`SOCKS5 proxy started on port ${result.boundPort}`)
return { success: true, boundPort: result.boundPort }
} catch (error: any) {
logError(`Dynamic forward failed: ${error.message}`)
return { success: false, error: error.message }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logError(`Dynamic forward failed: ${message}`)
return { success: false, error: message }
}
})
}
+6 -6
View File
@@ -77,9 +77,9 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
)
return { success: true, sessionId, fileName }
} catch (error: any) {
} catch (error: unknown) {
logger.error('Zmodem send error:', error)
return { success: false, error: error.message }
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -123,9 +123,9 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
)
return { success: true, sessionId }
} catch (error: any) {
} catch (error: unknown) {
logger.error('Zmodem receive error:', error)
return { success: false, error: error.message }
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
@@ -134,8 +134,8 @@ export function registerZmodemIPCHandlers(options: ZmodemIPCOptions): void {
try {
zmodemService.cancelSession(sessionId)
return { success: true }
} catch (error: any) {
return { success: false, error: error.message }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
}
+30 -1
View File
@@ -87,12 +87,41 @@ function createApplicationMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 OpsXShell',
message: 'OpsXShell v1.0.0',
message: 'OpsXShell v2.0.0',
detail: '跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠\n\nMIT License',
})
},
},
{ type: 'separator' },
{
label: '设置',
accelerator: process.platform === 'darwin' ? 'Cmd+,' : 'Ctrl+,',
click: () => {
mainWindow?.webContents.send('menu-open-settings')
},
},
{ type: 'separator' },
{
label: '服务',
role: 'services',
submenu: [],
},
{ type: 'separator' },
{
label: `隐藏 ${app.name}`,
accelerator: 'Cmd+H',
role: 'hide',
},
{
label: '隐藏其他',
accelerator: 'Cmd+Alt+H',
role: 'hideOthers',
},
{
label: '全部显示',
role: 'unhide',
},
{ type: 'separator' },
{
label: '退出',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
+73 -31
View File
@@ -1,32 +1,64 @@
const { contextBridge, ipcRenderer } = require('electron')
import { contextBridge, ipcRenderer } from 'electron'
import type {
SSHConnectRequest,
SSHConnectResponse,
SSHCreateShellRequest,
SessionCreateResponse,
SessionUpdateResponse,
DialogOptions,
DialogResult,
ZmodemProgress,
ZmodemComplete,
} from '../shared/types/ipc.types'
import type { Session } from '../shared/types/session.types'
// 暴露 API 到渲染进程
const api = {
// SSH 相关
sshConnect: (config: any) => ipcRenderer.invoke('ssh:connect', config),
sshDisconnect: (connectionId: string) => ipcRenderer.invoke('ssh:disconnect', connectionId),
sshExec: (connectionId: string, command: string) => ipcRenderer.invoke('ssh:exec', connectionId, command),
sshCreateShell: (connectionId: string, options?: any, shellId?: string) => ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
sshShellWrite: (shellId: string, data: string | Uint8Array) => ipcRenderer.invoke('ssh:shellWrite', shellId, data),
sshShellResize: (shellId: string, cols: number, rows: number) => ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
sshConnect: (config: SSHConnectRequest): Promise<SSHConnectResponse> =>
ipcRenderer.invoke('ssh:connect', config),
sshDisconnect: (connectionId: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:disconnect', connectionId),
sshExec: (connectionId: string, command: string): Promise<{ success: boolean; result?: string; error?: string }> =>
ipcRenderer.invoke('ssh:exec', connectionId, command),
sshCreateShell: (connectionId: string, options?: SSHCreateShellRequest, shellId?: string): Promise<{ success: boolean; shellId?: string; error?: string }> =>
ipcRenderer.invoke('ssh:createShell', connectionId, options, shellId),
sshShellWrite: (shellId: string, data: string | Uint8Array): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:shellWrite', shellId, data),
sshShellResize: (shellId: string, cols: number, rows: number): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('ssh:shellResize', shellId, cols, rows),
// 会话管理
sessionGetAll: () => ipcRenderer.invoke('session:getAll'),
sessionGetById: (id: string) => ipcRenderer.invoke('session:getById', id),
sessionCreate: (session: any) => ipcRenderer.invoke('session:create', session),
sessionUpdate: (id: string, updates: any) => ipcRenderer.invoke('session:update', id, updates),
sessionDelete: (id: string) => ipcRenderer.invoke('session:delete', id),
sessionGetAll: (): Promise<{ success: boolean; sessions?: Session[]; error?: string }> =>
ipcRenderer.invoke('session:getAll'),
sessionGetById: (id: string): Promise<{ success: boolean; session?: Session; error?: string }> =>
ipcRenderer.invoke('session:getById', id),
sessionCreate: (session: Omit<Session, 'id'>): Promise<SessionCreateResponse> =>
ipcRenderer.invoke('session:create', session),
sessionUpdate: (id: string, updates: Partial<Session>): Promise<SessionUpdateResponse> =>
ipcRenderer.invoke('session:update', id, updates),
sessionDelete: (id: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('session:delete', id),
// SFTP
sftpConnect: (connectionId: string) => ipcRenderer.invoke('sftp:connect', connectionId),
sftpListDirectory: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
sftpRename: (sftpId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
sftpMkdir: (sftpId: string, path: string) => ipcRenderer.invoke('sftp:mkdir', sftpId, path),
sftpStat: (sftpId: string, filePath: string) => ipcRenderer.invoke('sftp:stat', sftpId, filePath),
sftpChmod: (sftpId: string, filePath: string, mode: number) => ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
sftpConnect: (connectionId: string): Promise<{ success: boolean; sftpId?: string; error?: string }> =>
ipcRenderer.invoke('sftp:connect', connectionId),
sftpListDirectory: (sftpId: string, path: string): Promise<{ success: boolean; files?: unknown[]; error?: string }> =>
ipcRenderer.invoke('sftp:listDirectory', sftpId, path),
sftpUpload: (sftpId: string, localPath: string, remotePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:upload', sftpId, localPath, remotePath),
sftpDownload: (sftpId: string, remotePath: string, localPath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:download', sftpId, remotePath, localPath),
sftpRename: (sftpId: string, oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:rename', sftpId, oldPath, newPath),
sftpDelete: (sftpId: string, path: string, isDirectory: boolean): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:delete', sftpId, path, isDirectory),
sftpMkdir: (sftpId: string, path: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:mkdir', sftpId, path),
sftpStat: (sftpId: string, filePath: string): Promise<{ success: boolean; stats?: unknown; error?: string }> =>
ipcRenderer.invoke('sftp:stat', sftpId, filePath),
sftpChmod: (sftpId: string, filePath: string, mode: number): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('sftp:chmod', sftpId, filePath, mode),
// 端口转发
sshForwardLocal: (connectionId: string, localPort: number, remoteHost: string, remotePort: number) =>
@@ -37,12 +69,16 @@ const api = {
ipcRenderer.invoke('ssh:forwardDynamic', connectionId, localPort),
// 设置
settingsGet: (key: string) => ipcRenderer.invoke('settings:get', key),
settingsSet: (key: string, value: any) => ipcRenderer.invoke('settings:set', key, value),
settingsGet: (key: string): Promise<{ success: boolean; value?: unknown; error?: string }> =>
ipcRenderer.invoke('settings:get', key),
settingsSet: (key: string, value: unknown): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('settings:set', key, value),
// 对话框
showSaveDialog: (options: any) => ipcRenderer.invoke('dialog:showSaveDialog', options),
showOpenDialog: (options: any) => ipcRenderer.invoke('dialog:showOpenDialog', options),
showSaveDialog: (options: DialogOptions): Promise<DialogResult> =>
ipcRenderer.invoke('dialog:showSaveDialog', options),
showOpenDialog: (options: DialogOptions): Promise<DialogResult> =>
ipcRenderer.invoke('dialog:showOpenDialog', options),
// 文件操作
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
@@ -57,22 +93,25 @@ const api = {
localRename: (oldPath: string, newPath: string) => ipcRenderer.invoke('local:rename', oldPath, newPath),
localMkdir: (dirPath: string) => ipcRenderer.invoke('local:mkdir', dirPath),
// Zmodem rz/sz
// Zmodem
zmodemSend: (shellId: string) => ipcRenderer.invoke('zmodem:send', shellId),
zmodemReceive: (shellId: string) => ipcRenderer.invoke('zmodem:receive', shellId),
zmodemCancel: (sessionId: string) => ipcRenderer.invoke('zmodem:cancel', sessionId),
// Zmodem events
onZmodemProgress: (callback: (event: any, sessionId: string, progress: any) => void) => {
onZmodemProgress: (callback: (event: Electron.IpcRendererEvent, sessionId: string, progress: ZmodemProgress) => void) => {
ipcRenderer.on('zmodem:progress', callback)
},
onZmodemComplete: (callback: (event: any, sessionId: string, result: any) => void) => {
onZmodemComplete: (callback: (event: Electron.IpcRendererEvent, sessionId: string, result: ZmodemComplete) => void) => {
ipcRenderer.on('zmodem:complete', callback)
},
// 应用信息
getVersion: () => ipcRenderer.invoke('app:getVersion'),
// Window
toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'),
// 事件监听
onMenuNewSession: (callback: () => void) => {
ipcRenderer.on('menu-new-session', callback)
@@ -86,13 +125,16 @@ const api = {
onMenuExportSessions: (callback: () => void) => {
ipcRenderer.on('menu-export-sessions', callback)
},
onShellData: (callback: (event: any, connectionId: string, shellId: string, data: Buffer) => void) => {
onMenuOpenSettings: (callback: () => void) => {
ipcRenderer.on('menu-open-settings', callback)
},
onShellData: (callback: (event: Electron.IpcRendererEvent, connectionId: string, shellId: string, data: Buffer) => void) => {
ipcRenderer.on('ssh:shellData', callback)
},
onSFTPProgress: (callback: (event: any, sftpId: string, progress: any) => void) => {
onSFTPProgress: (callback: (event: Electron.IpcRendererEvent, sftpId: string, progress: { transferred: number; total: number; percentage: number }) => void) => {
ipcRenderer.on('sftp:progress', callback)
},
removeListener: (channel: string, callback: (...args: any[]) => void) => {
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.removeListener(channel, callback)
},
removeAllListeners: (channel: string) => {
+100 -15
View File
@@ -1,15 +1,31 @@
import crypto from 'crypto'
import { promisify } from 'util'
const ALGORITHM = 'aes-256-gcm'
const KEY_LENGTH = 32
const IV_LENGTH = 16
const TAG_LENGTH = 16
const _TAG_LENGTH = 16
const SALT_LENGTH = 32
const ITERATIONS = 100000
// OWASP 2023 建议至少 600,000 次(针对 SHA256)
const ITERATIONS = 600000
const CRYPTO_VERSION = 'v1'
// 从密码派生密钥
function deriveKey(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256')
// 异步 PBKDF2 避免阻塞事件循环
const pbkdf2Async = promisify(crypto.pbkdf2)
/**
* 从密码异步派生密钥
* 使用异步版本避免阻塞 Electron 主进程事件循环
*/
async function deriveKeyAsync(password: string, salt: Buffer): Promise<Buffer> {
return pbkdf2Async(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
}
/**
* 从密码同步派生密钥(仅在启动等必要时使用)
*/
function deriveKeySync(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha512')
}
// 生成随机密钥(用于主密钥)
@@ -17,18 +33,26 @@ export function generateMasterKey(): string {
return crypto.randomBytes(KEY_LENGTH).toString('hex')
}
// 加密数据
/**
* 加密数据
* 格式: version:salt:iv:tag:encrypted (全部 hex)
* version 用于未来加密方案升级时的安全迁移
*/
export function encrypt(plaintext: string, password: string): string {
const salt = crypto.randomBytes(SALT_LENGTH)
const key = deriveKey(password, salt)
const key = deriveKeySync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// 格式: salt:iv:tag:encrypted (全部 hex)
// 立即清零密钥内存
key.fill(0)
// 格式: version:salt:iv:tag:encrypted (全部 hex)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
@@ -36,10 +60,50 @@ export function encrypt(plaintext: string, password: string): string {
].join(':')
}
// 解密数据
/**
* 异步加密(推荐,不阻塞事件循环)
*/
export async function encryptAsync(plaintext: string, password: string): Promise<string> {
const salt = crypto.randomBytes(SALT_LENGTH)
const key = await deriveKeyAsync(password, salt)
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// 立即清零密钥内存
key.fill(0)
return [
CRYPTO_VERSION,
salt.toString('hex'),
iv.toString('hex'),
tag.toString('hex'),
encrypted.toString('hex'),
].join(':')
}
/**
* 解密数据
* 支持带版本标识的新格式和不带版本标识的旧格式(向后兼容)
*/
export function decrypt(ciphertext: string, password: string): string {
try {
const [saltHex, ivHex, tagHex, encryptedHex] = ciphertext.split(':')
const parts = ciphertext.split(':')
// 判断格式:新格式有5部分(含version),旧格式有4部分
let saltHex: string, ivHex: string, tagHex: string, encryptedHex: string
if (parts.length === 5) {
// 新格式: version:salt:iv:tag:encrypted
[, saltHex, ivHex, tagHex, encryptedHex] = parts
} else if (parts.length === 4) {
// 旧格式: salt:iv:tag:encrypted(向后兼容)
[saltHex, ivHex, tagHex, encryptedHex] = parts
} else {
throw new Error('Invalid ciphertext format')
}
if (!saltHex || !ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format')
@@ -50,22 +114,32 @@ export function decrypt(ciphertext: string, password: string): string {
const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const key = deriveKey(password, salt)
const key = deriveKeySync(password, salt)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
// 清零密钥内存
key.fill(0)
return decrypted.toString('utf8')
} catch (error) {
throw new Error('Decryption failed: ' + (error as Error).message)
}
}
// 简单加密(不带 salt,用于可逆加密)
/**
* 简单加密(使用主密钥,不经过 PBKDF2)
* 用于内部数据加密,密钥长度必须正确
*/
export function simpleEncrypt(plaintext: string, key: string): string {
const iv = crypto.randomBytes(IV_LENGTH)
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH)
const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
@@ -74,15 +148,26 @@ export function simpleEncrypt(plaintext: string, key: string): string {
return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted.toString('hex')
}
// 简单解密
/**
* 简单解密
*/
export function simpleDecrypt(ciphertext: string, key: string): string {
try {
const [ivHex, tagHex, encryptedHex] = ciphertext.split(':')
if (!ivHex || !tagHex || !encryptedHex) {
throw new Error('Invalid ciphertext format')
}
const iv = Buffer.from(ivHex, 'hex')
const tag = Buffer.from(tagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const keyBuffer = Buffer.from(key, 'hex').slice(0, KEY_LENGTH)
const keyBuffer = Buffer.from(key, 'hex')
// 验证密钥长度
if (keyBuffer.length !== KEY_LENGTH) {
throw new Error(`Invalid key length: expected ${KEY_LENGTH} bytes, got ${keyBuffer.length}`)
}
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv)
decipher.setAuthTag(tag)
+53 -47
View File
@@ -5,13 +5,14 @@ import fs from 'fs'
import { simpleEncrypt, simpleDecrypt } from './crypto'
import { keyService } from './key-service'
import { createLogger } from '../utils/logger'
import type { Session, SessionCreateRequest, SessionUpdateRequest } from '../../shared/types/session.types'
const logger = createLogger('DatabaseService')
// sql.js statement wrapper to match better-sqlite3 API
interface StatementResult {
id: number
[key: string]: any
[key: string]: unknown
}
class PreparedStatement {
@@ -23,16 +24,18 @@ class PreparedStatement {
this.sql = sql
}
run(...params: any[]): StatementResult {
this.db.run(this.sql, params as any)
return { id: 0 } as StatementResult
run(...params: unknown[]): StatementResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.db.run(this.sql, params as any[])
return { id: 0 }
}
get(...params: any[]): any {
get(...params: unknown[]): unknown {
// 使用 exec 模式获取单行
const stmt = this.db.prepare(this.sql)
stmt.bind(params as any)
let result: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
let result: unknown = null
if (stmt.step()) {
result = stmt.getAsObject()
}
@@ -40,10 +43,11 @@ class PreparedStatement {
return result
}
all(...params: any[]): any[] {
all(...params: unknown[]): unknown[] {
const stmt = this.db.prepare(this.sql)
stmt.bind(params as any)
const results: any[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stmt.bind(params as any[])
const results: unknown[] = []
while (stmt.step()) {
results.push(stmt.getAsObject())
}
@@ -200,7 +204,6 @@ export class DatabaseService {
}
try {
const decrypted = simpleDecrypt(value, this.masterKey)
logger.info('Decrypted field length:', decrypted?.length || 0)
return decrypted
} catch (error) {
logger.error('Decrypt failed:', error)
@@ -209,10 +212,10 @@ export class DatabaseService {
}
// 会话 CRUD 操作
createSession(session: any): any {
createSession(session: SessionCreateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
logger.info('Creating session with password:', session.password ? `len=${session.password.length}` : 'null/undefined')
logger.info('Creating session:', session.name, session.host)
const stmt = this.prepare(`
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags)
@@ -221,7 +224,6 @@ export class DatabaseService {
const id = `session-${Date.now()}`
const encryptedPassword = this.encryptField(session.password)
logger.info('Encrypted password:', encryptedPassword ? `len=${encryptedPassword.length}` : 'null')
stmt.run(
id,
@@ -241,56 +243,56 @@ export class DatabaseService {
return this.getSessionById(id)
}
getAllSessions(): any[] {
getAllSessions(): Session[] {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
const sessions = stmt.all()
const sessions = stmt.all() as Record<string, unknown>[]
return sessions.map((s: any) => ({
id: s.id,
name: s.name,
host: s.host,
port: s.port,
username: s.username,
authType: s.auth_type,
password: this.decryptField(s.password),
privateKey: s.private_key_path,
passphrase: this.decryptField(s.passphrase_encrypted),
group: s.group_name,
tags: s.tags ? JSON.parse(s.tags) : [],
return sessions.map((s) => ({
id: s.id as string,
name: s.name as string,
host: s.host as string,
port: s.port as number,
username: s.username as string,
authType: s.auth_type as Session['authType'],
password: this.decryptField(s.password as string | null) || undefined,
privateKey: s.private_key_path as string | undefined,
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
group: s.group_name as string | undefined,
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
}))
}
getSessionById(id: string): any {
getSessionById(id: string): Session | null {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
const session = stmt.get(id) as any
const session = stmt.get(id) as Record<string, unknown> | undefined
if (session) {
return {
id: session.id,
name: session.name,
host: session.host,
port: session.port,
username: session.username,
authType: session.auth_type,
password: this.decryptField(session.password),
privateKey: session.private_key_path,
passphrase: this.decryptField(session.passphrase_encrypted),
group: session.group_name,
tags: session.tags ? JSON.parse(session.tags) : [],
id: session.id as string,
name: session.name as string,
host: session.host as string,
port: session.port as number,
username: session.username as string,
authType: session.auth_type as Session['authType'],
password: this.decryptField(session.password as string | null) || undefined,
privateKey: session.private_key_path as string | undefined,
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
group: session.group_name as string | undefined,
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
}
}
return null
}
updateSession(id: string, updates: any): any {
updateSession(id: string, updates: SessionUpdateRequest): Session | null {
if (!this.db) throw new Error('Database not initialized')
const fields: string[] = []
const values: any[] = []
const values: unknown[] = []
if (updates.name !== undefined) {
fields.push('name = ?')
@@ -320,6 +322,10 @@ export class DatabaseService {
fields.push('passphrase_encrypted = ?')
values.push(this.encryptField(updates.passphrase))
}
if (updates.privateKey !== undefined) {
fields.push('private_key_path = ?')
values.push(updates.privateKey || null)
}
if (updates.group !== undefined) {
fields.push('group_name = ?')
values.push(updates.group)
@@ -350,15 +356,15 @@ export class DatabaseService {
}
// 设置操作
getSetting(key: string): any {
getSetting(key: string): unknown {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
const row: any = stmt.get(key)
return row ? JSON.parse(row.value) : null
const row = stmt.get(key) as Record<string, unknown> | undefined
return row ? JSON.parse(row.value as string) : null
}
setSetting(key: string, value: any): void {
setSetting(key: string, value: unknown): void {
if (!this.db) throw new Error('Database not initialized')
const stmt = this.prepare(`
+57 -9
View File
@@ -1,4 +1,4 @@
import { app } from 'electron'
import { app, safeStorage } from 'electron'
import path from 'path'
import fs from 'fs'
import crypto from 'crypto'
@@ -6,33 +6,81 @@ import { createLogger } from '../utils/logger'
const logger = createLogger('KeyService')
const KEY_FILE = 'encryption.key'
const KEY_FILE = 'encryption.key.enc'
const LEGACY_KEY_FILE = 'encryption.key'
export class KeyService {
private key: string | null = null
// 获取或创建主密钥
/**
* 获取或创建主密钥
* 使用 Electron safeStorage 加密存储,替代明文存储
*/
getMasterKey(): string {
if (this.key) return this.key
const keyPath = path.join(app.getPath('userData'), KEY_FILE)
const userDataPath = app.getPath('userData')
const keyPath = path.join(userDataPath, KEY_FILE)
const legacyKeyPath = path.join(userDataPath, LEGACY_KEY_FILE)
// 尝试读取现有密钥
// 1. 尝试读取新的加密格式密钥
if (fs.existsSync(keyPath)) {
try {
this.key = fs.readFileSync(keyPath, 'utf8').trim()
const encrypted = fs.readFileSync(keyPath)
this.key = safeStorage.decryptString(encrypted)
logger.info('Master key loaded from encrypted storage')
return this.key
} catch (error) {
logger.error('Failed to read master key:', error)
logger.error('Failed to decrypt master key, generating new one:', error)
}
}
// 生成新密钥
// 2. 迁移旧的明文密钥(向后兼容)
if (fs.existsSync(legacyKeyPath)) {
try {
this.key = fs.readFileSync(legacyKeyPath, 'utf8').trim()
// 迁移到加密存储
this.saveEncryptedKey(this.key)
// 安全删除旧文件(覆盖后删除)
this.secureDelete(legacyKeyPath)
logger.info('Migrated master key from plaintext to encrypted storage')
return this.key
} catch (error) {
logger.error('Failed to migrate legacy key:', error)
}
}
// 3. 生成新密钥
this.key = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(keyPath, this.key, { mode: 0o600 })
this.saveEncryptedKey(this.key)
logger.info('Generated new master key with encrypted storage')
return this.key
}
// 使用 safeStorage 加密保存密钥
private saveEncryptedKey(key: string): void {
const keyPath = path.join(app.getPath('userData'), KEY_FILE)
const encrypted = safeStorage.encryptString(key)
fs.writeFileSync(keyPath, encrypted, { mode: 0o600 })
}
// 安全删除文件(先覆盖再删除)
private secureDelete(filePath: string): void {
try {
const stats = fs.statSync(filePath)
const size = stats.size
// 用随机数据覆盖
const randomData = crypto.randomBytes(size)
fs.writeFileSync(filePath, randomData)
// 再用零覆盖
fs.writeFileSync(filePath, Buffer.alloc(size, 0))
// 最后删除
fs.unlinkSync(filePath)
} catch (error) {
logger.error('Failed to securely delete file:', error)
}
}
// 清除密钥(登出时)
clearKey(): void {
this.key = null
+58 -37
View File
@@ -1,30 +1,32 @@
import { Client, ClientChannel } from 'ssh2'
import { SFTPWrapper } from 'ssh2'
import { Client, ClientChannel, SFTPWrapper, FileEntryWithStats } from 'ssh2'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '../utils/logger'
import * as net from 'net'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
const logger = createLogger('SSHService')
// ===== 彩色调试日志 =====
const DEBUG = true
function debug(...args: any[]) {
if (DEBUG) console.log('[SSHService]', ...args)
// 生产环境日志:使用结构化日志,不输出敏感信息
const DEBUG = process.env.NODE_ENV === 'development'
function debug(...args: unknown[]) {
if (DEBUG) logger.debug('[SSHService]', ...args)
}
function logHeader(msg: string) {
console.log(`%c${msg}`, 'background: #e74c3c; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;')
logger.info(`[${msg}]`)
}
function logInfo(label: string, value: any) {
console.log(`%c${label}:%c ${value}`, 'background: #2c3e50; color: #fff; padding: 2px 6px; border-radius: 3px;', 'color: #3498db; font-weight: bold;')
function logInfo(label: string, value: unknown) {
logger.info(`${label}: ${value}`)
}
function logSuccess(msg: string) {
console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;')
logger.info(`[OK] ${msg}`)
}
function logError(msg: string) {
console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;')
logger.error(msg)
}
function logWarning(msg: string) {
console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;')
function _logWarning(msg: string) {
logger.warn(msg)
}
export interface SSHConfig {
@@ -63,11 +65,34 @@ export class SSHService {
private shells: Map<string, ShellSession> = new Map()
private sftps: Map<string, SFTPSession> = new Map()
private resolvePrivateKey(privateKey?: string): string | Buffer | undefined {
if (!privateKey) return undefined
// 如果已经是 PEM 格式内容,直接返回
if (privateKey.includes('-----BEGIN')) {
return privateKey
}
// 展开 ~ 为用户主目录
let keyPath = privateKey
if (keyPath.startsWith('~')) {
keyPath = path.join(os.homedir(), keyPath.slice(1))
}
// 当作文件路径读取
try {
const content = fs.readFileSync(keyPath)
debug('>>> Private key loaded from:', keyPath)
return content
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`Failed to read private key file: ${keyPath} - ${msg}`)
throw new Error(`私钥文件读取失败: ${keyPath}`)
}
}
async connect(config: SSHConfig): Promise<Connection> {
console.log('=== SSHService connect ===')
console.log(' host:', config.host)
console.log(' port:', config.port || 22)
console.log(' username:', config.username)
debug('SSHService connect', config.host, config.port || 22, config.username)
return new Promise((resolve, reject) => {
const client = new Client()
@@ -105,13 +130,24 @@ export class SSHService {
this.connections.delete(connectionId)
})
// 解析私钥(路径 → 内容)
let resolvedPrivateKey: string | Buffer | undefined
try {
resolvedPrivateKey = this.resolvePrivateKey(config.privateKey)
} catch (err) {
connection.status = 'error'
this.connections.delete(connectionId)
reject(err)
return
}
debug('>>> Starting SSH client.connect...')
client.connect({
host: config.host,
port: config.port || 22,
username: config.username,
password: config.password,
privateKey: config.privateKey,
privateKey: resolvedPrivateKey,
passphrase: config.passphrase,
agent: config.agent,
readyTimeout: config.readyTimeout || 20000,
@@ -190,14 +226,10 @@ export class SSHService {
onData?: (shellId: string, data: Buffer) => void,
preferredShellId?: string
): Promise<ShellSession> {
console.log('=== SSHService createShell ===')
console.log(' connectionId:', connectionId)
console.log(' options:', options)
console.log(' preferredShellId:', preferredShellId)
debug('SSHService createShell', connectionId, options, preferredShellId)
const connection = this.connections.get(connectionId)
console.log(' connection found:', !!connection)
console.log(' connection status:', connection?.status)
debug('connection found:', !!connection, 'status:', connection?.status)
if (!connection || connection.status !== 'connected') {
throw new Error('Connection not established')
@@ -236,19 +268,8 @@ export class SSHService {
if (onData) {
debug('>>> Setting up channel.on(data) handler')
channel.on('data', (data: Buffer) => {
// Log first few chunks for debugging
const text = data.toString('utf-8')
console.log('=== SSHService channel.on(data) ===')
console.log(' shellId:', shellId)
console.log(' data length:', data.length)
console.log(' data preview:', text.substring(0, 100).replace(/\n/g, '\\n'))
if (text.includes('Last login') || text.includes('Welcome')) {
logger.info('[SSHService] Shell data (first 200 chars):', text.substring(0, 200))
}
debug('>>> Calling onData callback...')
debug('SSHService channel.on(data) shellId:', shellId, 'data length:', data.length)
onData(shellId, data)
debug('>>> onData callback returned')
})
} else {
debug('>>> WARNING: onData callback is undefined!')
@@ -321,7 +342,7 @@ export class SSHService {
})
}
async sftpListDirectory(sftpId: string, path: string): Promise<any[]> {
async sftpListDirectory(sftpId: string, path: string): Promise<FileEntryWithStats[]> {
logHeader('📁 SFTP LIST DIRECTORY')
logInfo('sftpId', sftpId)
logInfo('path', path)
+41 -25
View File
@@ -10,17 +10,17 @@ const logger = createLogger('ZmodemService')
interface ZmodemSendSession {
type: 'send'
set_sender(func: (octets: number[]) => void): void
send_offer(params: { name: string; size: number; mtime?: number }): Promise<any>
on(event: string, handler: (data: any) => void): void
send_offer(params: { name: string; size: number; mtime?: number }): Promise<unknown>
on(event: string, handler: (data: unknown) => void): void
abort(): void
close(): Promise<void>
close(): Promise<unknown>
}
interface ZmodemReceiveSession {
type: 'receive'
set_sender(func: (octets: number[]) => void): void
start(): Promise<any>
on(event: string, handler: (data: any) => void): void
start(): Promise<unknown>
on(event: string, handler: (data: unknown) => void): void
abort(): void
}
@@ -28,6 +28,20 @@ interface ZmodemSentry {
consume(octets: number[]): void
}
interface ZmodemDetection {
get_session(): { type: string } & ZmodemSendSession
}
interface ZmodemOffer {
getDetails(): { name: string; size: number }
accept(options: { on_input: string }): Promise<ZmodemTransfer>
}
interface ZmodemTransfer {
on(event: 'input', handler: (data: Uint8Array) => void): void
on(event: 'complete', handler: () => void): void
}
export interface ZmodemProgress {
percentage: number
transferred: number
@@ -80,7 +94,7 @@ export class ZmodemService {
id: sessionId,
shellId,
type: 'send',
session: null as any,
session: null as unknown as ZmodemSendSession,
fileName,
totalSize,
transferred: 0
@@ -94,13 +108,12 @@ export class ZmodemService {
}
// Create Sentry to detect ZRINIT from server
// @ts-ignore - zmodem-ts types are incomplete
const sentry = new Zmodem.Sentry({
to_terminal: (octets: number[]) => {
// Echo to terminal
writeFunc(new Uint8Array(octets))
},
on_detect: (detection: any) => {
on_detect: (detection: ZmodemDetection) => {
const detectedSession = detection.get_session()
if (detectedSession && detectedSession.type === 'send') {
// Server is ready for file transfer (received ZRINIT)
@@ -119,9 +132,10 @@ export class ZmodemService {
this.sessions.delete(sessionId)
})
sendSession.on('error', (err: Error) => {
logger.error('Zmodem send error:', err)
onComplete?.(false, err.message)
sendSession.on('error', (err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err))
logger.error('Zmodem send error:', error)
onComplete?.(false, error.message)
})
// Send the file
@@ -181,9 +195,9 @@ export class ZmodemService {
// Transfer was skipped or rejected
onComplete?.(false, 'Transfer skipped or rejected')
}
} catch (err: any) {
} catch (err: unknown) {
logger.error('Zmodem send error:', err)
onComplete?.(false, err.message)
onComplete?.(false, err instanceof Error ? err.message : String(err))
}
}
@@ -198,7 +212,7 @@ export class ZmodemService {
}
// Also check all sessions for matching shellId
for (const [sessionId, session] of this.sessions) {
for (const [, session] of this.sessions) {
if (session.shellId === shellId && session.sentry) {
// Feed to sentry for detection
}
@@ -222,7 +236,7 @@ export class ZmodemService {
const fileName = path.basename(filePath)
// Create receive session
// @ts-ignore - zmodem-ts types are incomplete
// @ts-expect-error - zmodem-ts types are incomplete
const session: ZmodemReceiveSession = new Zmodem.ReceiveSession()
const sessionInfo: ZmodemSessionInfo = {
@@ -248,17 +262,18 @@ export class ZmodemService {
})
// Handle file offer
session.on('offer', (offer: any) => {
const details = offer.getDetails()
session.on('offer', (offer: unknown) => {
const zmodemOffer = offer as ZmodemOffer
const details = zmodemOffer.getDetails()
logger.info('Zmodem offer received:', details)
sessionInfo.fileName = details.name || fileName
sessionInfo.totalSize = details.size || 0
// Accept the offer
offer.accept({
zmodemOffer.accept({
on_input: 'spool_array'
}).then((transfer: any) => {
}).then((transfer) => {
// Handle incoming data
transfer.on('input', (data: Uint8Array) => {
sessionInfo.transferred = (sessionInfo.transferred || 0) + data.length
@@ -266,7 +281,7 @@ export class ZmodemService {
// Write to file
try {
fs.writeFileSync(filePath, data, { flag: 'a' })
} catch (err: any) {
} catch (err: unknown) {
logger.error('Error writing Zmodem file:', err)
}
@@ -302,9 +317,10 @@ export class ZmodemService {
this.sessions.delete(sessionId)
})
session.on('error', (err: Error) => {
logger.error('Zmodem receive error:', err)
onComplete?.(false, err.message)
session.on('error', (err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err))
logger.error('Zmodem receive error:', error)
onComplete?.(false, error.message)
})
// Start receiving - this sends ZRINIT to signal we're ready
@@ -324,10 +340,10 @@ export class ZmodemService {
const session = this.sessions.get(sessionId)
if (session) {
try {
const s = session.session as any
const s = session.session as { abort?: () => void; close?: () => void }
s.abort?.()
s.close?.()
} catch (err: any) {
} catch (err: unknown) {
logger.error('Error canceling Zmodem session:', err)
}
-1
View File
@@ -4,7 +4,6 @@
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "../dist-electron",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,