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' import type { ProxyConfig } from '../../shared/types/session.types' 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 proxy?: ProxyConfig } 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 } const sshConnectOpts = { 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, } // 如果配置了代理,先通过代理建立隧道 if (config.proxy && config.proxy.type !== 'none' && config.proxy.host) { debug('>>> Connecting through proxy:', config.proxy.type, config.proxy.host, config.proxy.port) this.connectThroughProxy(config.proxy, config.host, config.port || 22) .then((socket) => { debug('>>> Proxy tunnel established, starting SSH handshake') client.connect({ ...sshConnectOpts, sock: socket, }) }) .catch((err) => { connection.status = 'error' this.connections.delete(connectionId) logger.error(`Proxy connection failed for ${connectionId}:`, err) reject(new Error(`代理连接失败: ${err.message}`)) }) } else { debug('>>> Starting SSH client.connect (direct)...') client.connect(sshConnectOpts) } }) } /** * 通过代理建立 TCP 隧道 * 支持 HTTP CONNECT 和 SOCKS5 */ private connectThroughProxy( proxy: ProxyConfig, targetHost: string, targetPort: number ): Promise { if (proxy.type === 'http') { return this.connectThroughHttpProxy(proxy, targetHost, targetPort) } else if (proxy.type === 'socks5') { return this.connectThroughSocks5Proxy(proxy, targetHost, targetPort) } return Promise.reject(new Error(`不支持的代理类型: ${proxy.type}`)) } /** * HTTP CONNECT 代理隧道 */ private connectThroughHttpProxy( proxy: ProxyConfig, targetHost: string, targetPort: number ): Promise { return new Promise((resolve, reject) => { const socket = new net.Socket() const connectTimeout = setTimeout(() => { socket.destroy() reject(new Error('代理连接超时')) }, 15000) socket.connect(proxy.port, proxy.host, () => { clearTimeout(connectTimeout) debug('>>> Connected to HTTP proxy:', proxy.host, proxy.port) // 构建 CONNECT 请求 let connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n` // 代理认证 if (proxy.username) { const credentials = Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64') connectRequest += `Proxy-Authorization: Basic ${credentials}\r\n` } connectRequest += '\r\n' socket.write(connectRequest) let responseBuffer = '' const onData = (data: Buffer) => { responseBuffer += data.toString() // 检查是否收到完整 HTTP 响应头 const headerEnd = responseBuffer.indexOf('\r\n\r\n') if (headerEnd === -1) return socket.removeListener('data', onData) const header = responseBuffer.substring(0, headerEnd) // 解析状态码 const statusMatch = header.match(/^HTTP\/\d\.\d\s+(\d+)\s+(.*)/) if (!statusMatch) { socket.destroy() reject(new Error('代理返回无效响应')) return } const statusCode = parseInt(statusMatch[1], 10) if (statusCode >= 200 && statusCode < 300) { debug('>>> HTTP proxy tunnel established') resolve(socket) } else { socket.destroy() reject(new Error(`代理拒绝连接: ${statusCode} ${statusMatch[2]}`)) } } socket.on('data', onData) socket.on('error', (err) => { clearTimeout(connectTimeout) reject(new Error(`代理连接错误: ${err.message}`)) }) }) socket.on('error', (err) => { clearTimeout(connectTimeout) reject(new Error(`无法连接代理服务器: ${err.message}`)) }) }) } /** * SOCKS5 代理隧道 */ private connectThroughSocks5Proxy( proxy: ProxyConfig, targetHost: string, targetPort: number ): Promise { return new Promise((resolve, reject) => { const socket = new net.Socket() const connectTimeout = setTimeout(() => { socket.destroy() reject(new Error('SOCKS5 代理连接超时')) }, 15000) socket.connect(proxy.port, proxy.host, () => { clearTimeout(connectTimeout) debug('>>> Connected to SOCKS5 proxy:', proxy.host, proxy.port) // SOCKS5 握手阶段 1: 认证方法协商 let authMethods: Buffer if (proxy.username) { // 支持无认证(0x00)和用户名密码认证(0x02) authMethods = Buffer.from([0x05, 0x02, 0x00, 0x02]) } else { // 仅支持无认证 authMethods = Buffer.from([0x05, 0x01, 0x00]) } socket.write(authMethods) const handleHandshake = (data: Buffer) => { if (data.length < 2) { socket.destroy() reject(new Error('SOCKS5 握手响应无效')) return } const version = data[0] const method = data[1] if (version !== 0x05) { socket.destroy() reject(new Error('不是 SOCKS5 代理')) return } if (method === 0x02 && proxy.username) { // 用户名密码认证 socket.removeListener('data', handleHandshake) const userBuf = Buffer.from(proxy.username) const passBuf = Buffer.from(proxy.password || '') const authBuf = Buffer.alloc(3 + userBuf.length + passBuf.length) authBuf[0] = 0x01 // 认证协商版本 authBuf[1] = userBuf.length userBuf.copy(authBuf, 2) authBuf[2 + userBuf.length] = passBuf.length passBuf.copy(authBuf, 3 + userBuf.length) socket.write(authBuf) socket.once('data', (authResp: Buffer) => { if (authResp.length < 2 || authResp[1] !== 0x00) { socket.destroy() reject(new Error('SOCKS5 认证失败')) return } sendConnectRequest() }) } else if (method === 0x00) { // 无需认证 socket.removeListener('data', handleHandshake) sendConnectRequest() } else { socket.destroy() reject(new Error('SOCKS5 不支持此认证方法')) return } } const sendConnectRequest = () => { // SOCKS5 CONNECT 请求 const hostBuf = Buffer.from(targetHost) // VER(1) + CMD(1) + RSV(1) + ATYP(1) + DOMAIN_LEN(1) + DOMAIN + PORT(2) const connectBuf = Buffer.alloc(4 + 1 + hostBuf.length + 2) connectBuf[0] = 0x05 // SOCKS5 connectBuf[1] = 0x01 // CONNECT connectBuf[2] = 0x00 // 保留 connectBuf[3] = 0x03 // 域名类型 connectBuf[4] = hostBuf.length hostBuf.copy(connectBuf, 5) connectBuf.writeUInt16BE(targetPort, 5 + hostBuf.length) socket.write(connectBuf) socket.once('data', (connectResp: Buffer) => { if (connectResp.length < 4 || connectResp[1] !== 0x00) { const errorCode = connectResp.length >= 2 ? connectResp[1] : -1 const errorMessages: Record = { 0x01: 'SOCKS5 服务器错误', 0x02: 'SOCKS5 连接不允许', 0x03: 'SOCKS5 网络不可达', 0x04: 'SOCKS5 主机不可达', 0x05: 'SOCKS5 连接拒绝', 0x06: 'SOCKS5 TTL 过期', 0x07: 'SOCKS5 不支持的命令', 0x08: 'SOCKS5 地址类型不支持', } socket.destroy() reject(new Error(errorMessages[errorCode] || `SOCKS5 连接失败 (${errorCode})`)) return } debug('>>> SOCKS5 proxy tunnel established') resolve(socket) }) } socket.on('data', handleHandshake) socket.on('error', (err) => { reject(new Error(`SOCKS5 代理错误: ${err.message}`)) }) }) socket.on('error', (err) => { clearTimeout(connectTimeout) reject(new Error(`无法连接 SOCKS5 代理: ${err.message}`)) }) }) } 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}`) } } }