import { Client, ClientChannel } from 'ssh2' import { SFTPWrapper } from 'ssh2' import { v4 as uuidv4 } from 'uuid' import { createLogger } from '../utils/logger' import * as net from 'net' const logger = createLogger('SSHService') // ===== 彩色调试日志 ===== const DEBUG = true function debug(...args: any[]) { if (DEBUG) console.log('[SSHService]', ...args) } function logHeader(msg: string) { console.log(`%c${msg}`, 'background: #e74c3c; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;') } 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 logSuccess(msg: string) { console.log(`%c✓ ${msg}`, 'color: #27ae60; font-weight: bold;') } function logError(msg: string) { console.log(`%c✗ ${msg}`, 'color: #e74c3c; font-weight: bold;') } function logWarning(msg: string) { console.log(`%c⚠ ${msg}`, 'color: #f39c12; font-weight: bold;') } 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() async connect(config: SSHConfig): Promise { console.log('=== SSHService connect ===') console.log(' host:', config.host) console.log(' port:', config.port || 22) console.log(' username:', 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) }) debug('>>> Starting SSH client.connect...') client.connect({ host: config.host, port: config.port || 22, username: config.username, password: config.password, privateKey: config.privateKey, 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 { console.log('=== SSHService createShell ===') console.log(' connectionId:', connectionId) console.log(' options:', options) console.log(' preferredShellId:', preferredShellId) const connection = this.connections.get(connectionId) console.log(' connection found:', !!connection) console.log(' 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) => { // 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...') onData(shellId, data) debug('>>> onData callback returned') }) } 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 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}`) } } }