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 = process.env.NODE_ENV === 'development' function debug(...args: unknown[]) { if (DEBUG) logger.debug('[SSHService]', ...args) } function logHeader(msg: string) { logger.info(`[${msg}]`) } function logInfo(label: string, value: unknown) { logger.info(`${label}: ${value}`) } function logSuccess(msg: string) { logger.info(`[OK] ${msg}`) } function logError(msg: string) { logger.error(msg) } function _logWarning(msg: string) { logger.warn(msg) } export interface SSHConfig { host: string port?: number username: string password?: string privateKey?: string passphrase?: string agent?: string readyTimeout?: number keepaliveInterval?: number } export interface Connection { id: string client: Client config: SSHConfig status: 'connecting' | 'connected' | 'disconnected' | 'error' } export interface ShellSession { id: string connectionId: string channel: ClientChannel } export interface SFTPSession { id: string connectionId: string sftp: SFTPWrapper } export class SSHService { private connections: Map = new Map() private shells: Map = new Map() private sftps: Map = 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 { debug('SSHService connect', config.host, config.port || 22, config.username) return new Promise((resolve, reject) => { const client = new Client() const connectionId = uuidv4() debug('>>> Generated connectionId:', connectionId) const connection: Connection = { id: connectionId, client, config, status: 'connecting' } this.connections.set(connectionId, connection) debug('>>> Connection stored, total connections:', this.connections.size) client.on('ready', () => { connection.status = 'connected' logger.info(`SSH connection ${connectionId} established`) debug('>>> SSH client ready, connectionId:', connectionId) resolve(connection) }) client.on('error', (err) => { connection.status = 'error' logger.error(`SSH connection ${connectionId} error:`, err) debug('>>> SSH client ERROR:', err.message) reject(err) }) client.on('close', () => { connection.status = 'disconnected' logger.info(`SSH connection ${connectionId} closed`) debug('>>> SSH client closed, connectionId:', connectionId) 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: resolvedPrivateKey, passphrase: config.passphrase, agent: config.agent, readyTimeout: config.readyTimeout || 20000, keepaliveInterval: config.keepaliveInterval || 60000, }) }) } async disconnect(connectionId: string): Promise { const connection = this.connections.get(connectionId) if (connection) { connection.client.end() this.connections.delete(connectionId) // Clean up associated shells for (const [shellId, shell] of this.shells.entries()) { if (shell.connectionId === connectionId) { shell.channel.close() this.shells.delete(shellId) } } // Clean up associated SFTP sessions for (const [sftpId, sftp] of this.sftps.entries()) { if (sftp.connectionId === connectionId) { this.sftps.delete(sftpId) } } } } disconnectAll(): void { for (const connectionId of this.connections.keys()) { this.disconnect(connectionId) } } async exec(connectionId: string, command: string): Promise { const connection = this.connections.get(connectionId) if (!connection || connection.status !== 'connected') { throw new Error('Connection not established') } return new Promise((resolve, reject) => { let stdout = '' let stderr = '' connection.client.exec(command, (err, stream) => { if (err) { reject(err) return } stream.on('close', (code: number) => { if (code !== 0) { reject(new Error(`Command failed with code ${code}: ${stderr}`)) } else { resolve(stdout) } }) stream.on('data', (data: Buffer) => { stdout += data.toString() }) stream.stderr.on('data', (data: Buffer) => { stderr += data.toString() }) }) }) } async createShell( connectionId: string, options: { cols?: number; rows?: number; term?: string } = {}, onData?: (shellId: string, data: Buffer) => void, preferredShellId?: string ): Promise { debug('SSHService createShell', connectionId, options, preferredShellId) const connection = this.connections.get(connectionId) debug('connection found:', !!connection, 'status:', connection?.status) if (!connection || connection.status !== 'connected') { throw new Error('Connection not established') } return new Promise((resolve, reject) => { debug('>>> Calling client.shell...') connection.client.shell({ cols: options.cols || 80, rows: options.rows || 24, term: options.term || 'xterm-256color' }, (err, channel) => { if (err) { debug('>>> client.shell ERROR:', err.message) reject(err) return } debug('>>> client.shell success, channel created') // 使用前端提供的 shellId 或生成新的 const shellId = preferredShellId || uuidv4() debug('>>> Using shellId:', shellId) const shell: ShellSession = { id: shellId, connectionId, channel } // Store shell BEFORE setting up callbacks this.shells.set(shellId, shell) debug('>>> Shell stored, total shells:', this.shells.size) // Set up data callback AFTER shell is stored if (onData) { debug('>>> Setting up channel.on(data) handler') channel.on('data', (data: Buffer) => { debug('SSHService channel.on(data) shellId:', shellId, 'data length:', data.length) onData(shellId, data) }) } else { debug('>>> WARNING: onData callback is undefined!') } channel.on('close', () => { debug('>>> Channel closed, shellId:', shellId) this.shells.delete(shellId) }) debug('>>> Resolving shell:', shellId) resolve(shell) }) }) } shellWrite(shellId: string, data: string | Uint8Array): void { const shell = this.shells.get(shellId) if (shell) { shell.channel.write(data) } } shellResize(shellId: string, cols: number, rows: number): void { const shell = this.shells.get(shellId) if (shell) { shell.channel.setWindow(rows, cols, 0, 0) } } getShell(shellId: string): ShellSession | undefined { return this.shells.get(shellId) } getConnection(connectionId: string): Connection | undefined { return this.connections.get(connectionId) } // ===== SFTP 操作 ===== async createSFTP(connectionId: string): Promise { logHeader('📁 SFTP CREATE') logInfo('connectionId', connectionId) const connection = this.connections.get(connectionId) if (!connection || connection.status !== 'connected') { logError('Connection not established') throw new Error('Connection not established') } return new Promise((resolve, reject) => { debug('>>> Calling client.sftp()...') connection.client.sftp((err, sftp) => { if (err) { logError(`SFTP creation failed: ${err.message}`) reject(err) return } const sftpId = uuidv4() const session: SFTPSession = { id: sftpId, connectionId, sftp } this.sftps.set(sftpId, session) logSuccess(`SFTP session created: ${sftpId}`) resolve(session) }) }) } async sftpListDirectory(sftpId: string, path: string): Promise { logHeader('📁 SFTP LIST DIRECTORY') logInfo('sftpId', sftpId) logInfo('path', path) const session = this.sftps.get(sftpId) if (!session) { logError('SFTP session not found') throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.readdir(path, (err, list) => { if (err) { logError(`List directory failed: ${err.message}`) reject(err) return } logSuccess(`Listed ${list.length} items in ${path}`) resolve(list) }) }) } async sftpUpload( sftpId: string, localPath: string, remotePath: string, onProgress?: (transferred: number, total: number) => void ): Promise { logHeader('📤 SFTP UPLOAD') logInfo('sftpId', sftpId) logInfo('localPath', localPath) logInfo('remotePath', remotePath) const session = this.sftps.get(sftpId) if (!session) { logError('SFTP session not found') throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.fastPut(localPath, remotePath, { step: (transferred: number, _chunk: number, total: number) => { debug(`Upload progress: ${transferred}/${total}`) if (onProgress) { onProgress(transferred, total) } } }, (err) => { if (err) { logError(`Upload failed: ${err.message}`) reject(err) return } logSuccess(`Upload complete: ${remotePath}`) resolve() }) }) } async sftpDownload( sftpId: string, remotePath: string, localPath: string, onProgress?: (transferred: number, total: number) => void ): Promise { logHeader('📥 SFTP DOWNLOAD') logInfo('sftpId', sftpId) logInfo('remotePath', remotePath) logInfo('localPath', localPath) const session = this.sftps.get(sftpId) if (!session) { logError('SFTP session not found') throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.fastGet(remotePath, localPath, { step: (transferred: number, _chunk: number, total: number) => { debug(`Download progress: ${transferred}/${total}`) if (onProgress) { onProgress(transferred, total) } } }, (err) => { if (err) { logError(`Download failed: ${err.message}`) reject(err) return } logSuccess(`Download complete: ${remotePath}`) resolve() }) }) } async sftpRename(sftpId: string, oldPath: string, newPath: string): Promise { const session = this.sftps.get(sftpId) if (!session) { throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.rename(oldPath, newPath, (err) => { if (err) { reject(err) return } resolve() }) }) } async sftpDelete(sftpId: string, path: string, isDirectory: boolean): Promise { const session = this.sftps.get(sftpId) if (!session) { throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { if (isDirectory) { session.sftp.rmdir(path, (err) => { if (err) { reject(err) return } resolve() }) } else { session.sftp.unlink(path, (err) => { if (err) { reject(err) return } resolve() }) } }) } async sftpMkdir(sftpId: string, path: string): Promise { const session = this.sftps.get(sftpId) if (!session) { throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.mkdir(path, (err) => { if (err) { reject(err) return } resolve() }) }) } async sftpStat(sftpId: string, filePath: string): Promise<{ size: number mode: number mtime: number atime: number isDirectory: boolean isFile: boolean isSymbolicLink: boolean }> { const session = this.sftps.get(sftpId) if (!session) { throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.stat(filePath, (err, stats) => { if (err) { reject(err) return } resolve({ size: stats.size, mode: stats.mode, mtime: stats.mtime, atime: stats.atime, isDirectory: stats.isDirectory(), isFile: stats.isFile(), isSymbolicLink: stats.isSymbolicLink(), }) }) }) } async sftpChmod(sftpId: string, filePath: string, mode: number): Promise { const session = this.sftps.get(sftpId) if (!session) { throw new Error('SFTP session not found') } return new Promise((resolve, reject) => { session.sftp.chmod(filePath, mode, (err) => { if (err) { reject(err) return } resolve() }) }) } // 端口转发 // ===== 本地端口转发 ===== async forwardLocal( connectionId: string, localPort: number, remoteHost: string, remotePort: number ): Promise<{ success: boolean; boundPort: number }> { logHeader('🔀 LOCAL PORT FORWARD') logInfo('connectionId', connectionId) logInfo('localPort', localPort) logInfo('remoteHost', remoteHost) logInfo('remotePort', remotePort) const connection = this.connections.get(connectionId) if (!connection || connection.status !== 'connected') { logError('Connection not established') throw new Error('Connection not established') } return new Promise((resolve, reject) => { debug('>>> Creating local server on port', localPort) const server = net.createServer((socket) => { debug('>>> Local connection received, forwarding to', remoteHost, remotePort) connection.client.forwardOut('127.0.0.1', 0, remoteHost, remotePort, (err, stream) => { if (err) { logError(`ForwardOut failed: ${err.message}`) socket.end() return } socket.pipe(stream).pipe(socket) debug('>>> Pipeline established') }) }) server.listen(localPort, '127.0.0.1', () => { const actualPort = (server.address() as net.AddressInfo).port logSuccess(`Local forward established: 127.0.0.1:${actualPort} -> ${remoteHost}:${remotePort}`) resolve({ success: true, boundPort: actualPort }) }) server.on('error', (err) => { logError(`Server error: ${err.message}`) reject(err) }) }) } // ===== 远程端口转发 ===== async forwardRemote( connectionId: string, remotePort: number, localHost: string, localPort: number ): Promise<{ success: boolean }> { logHeader('🔀 REMOTE PORT FORWARD') logInfo('connectionId', connectionId) logInfo('remotePort', remotePort) logInfo('localHost', localHost) logInfo('localPort', localPort) const connection = this.connections.get(connectionId) if (!connection || connection.status !== 'connected') { logError('Connection not established') throw new Error('Connection not established') } return new Promise((resolve, reject) => { connection.client.forwardIn('127.0.0.1', remotePort, (err) => { if (err) { logError(`forwardIn failed: ${err.message}`) reject(err) return } logSuccess(`Remote forward established: remote:${remotePort} -> ${localHost}:${localPort}`) resolve({ success: true }) }) }) } // ===== 动态端口转发 (SOCKS5) ===== private socksServers: Map = new Map() async forwardDynamic( connectionId: string, localPort: number ): Promise<{ success: boolean; boundPort: number }> { logHeader('🧦 SOCKS5 DYNAMIC FORWARD') logInfo('connectionId', connectionId) logInfo('localPort', localPort) const connection = this.connections.get(connectionId) if (!connection || connection.status !== 'connected') { logError('Connection not established') throw new Error('Connection not established') } return new Promise((resolve, reject) => { const server = net.createServer((socket) => { debug('>>> SOCKS5 client connected') socket.once('data', (data) => { // 解析 SOCKS5 握手 if (data.length < 3 || data[0] !== 0x05) { logError('Invalid SOCKS5 handshake') socket.end() return } // 发送握手响应 (无认证) socket.write(Buffer.from([0x05, 0x00])) socket.once('data', (data) => { // 解析 SOCKS5 请求 const req = Buffer.from(data) if (req.length < 10 || req[0] !== 0x05) { logError('Invalid SOCKS5 request') socket.end() return } const cmd = req[1] const atyp = req[3] if (cmd !== 0x01) { // CONNECT only socket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) socket.end() return } let targetHost: string let targetPort: number let headerLen: number if (atyp === 0x01) { // IPv4 targetHost = `${req[4]}.${req[5]}.${req[6]}.${req[7]}` targetPort = req.readUInt16BE(8) headerLen = 10 } else if (atyp === 0x03) { // Domain const domainLen = req[4] targetHost = req.slice(5, 5 + domainLen).toString('ascii') targetPort = req.readUInt16BE(5 + domainLen) headerLen = 5 + domainLen + 2 } else { socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) socket.end() return } logInfo('SOCKS5 target', `${targetHost}:${targetPort}`) // 通过 SSH 连接目标 connection.client.forwardOut('127.0.0.1', 0, targetHost, targetPort, (err, stream) => { if (err) { logError(`SOCKS5 forwardOut failed: ${err.message}`) socket.write(Buffer.from([0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) socket.end() return } // 发送成功响应 socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) // 建立双向管道 stream.pipe(socket) socket.pipe(stream) // 如果有残留数据,发送给 stream if (req.length > headerLen) { stream.write(req.slice(headerLen)) } logSuccess(`SOCKS5 tunnel established to ${targetHost}:${targetPort}`) }) }) }) }) server.listen(localPort, '127.0.0.1', () => { const actualPort = (server.address() as net.AddressInfo).port this.socksServers.set(`${connectionId}:${actualPort}`, server) logSuccess(`SOCKS5 proxy started on 127.0.0.1:${actualPort}`) resolve({ success: true, boundPort: actualPort }) }) server.on('error', (err) => { logError(`SOCKS5 server error: ${err.message}`) reject(err) }) }) } // 停止 SOCKS5 代理 stopDynamicForward(connectionId: string, localPort: number): void { const key = `${connectionId}:${localPort}` const server = this.socksServers.get(key) if (server) { server.close() this.socksServers.delete(key) logSuccess(`SOCKS5 proxy stopped on port ${localPort}`) } } }