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:
1 parent
37e65be823
commit
514d62644e
77 files changed
+2563
-931
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* 处理应用级别信息
|
||||
*/
|
||||
|
||||
import { ipcMain, IpcMainInvokeEvent, app } from 'electron'
|
||||
import { ipcMain, app } from 'electron'
|
||||
|
||||
// IPC channel names
|
||||
export const APP_CHANNELS = {
|
||||
|
||||
+136
-27
@@ -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
@@ -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
@@ -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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in new issue
Block a user