UNPKG

dragon-ui-claude

Version:

🐲 Ultra-fast, cross-platform Claude Code Max usage dashboard with dragon-inspired design, advanced background services, and multi-currency support

905 lines (767 loc) 28.1 kB
const net = require('net'); const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); /** * Pure JavaScript SSH Service for Dragon UI * Implements SSH protocol without native dependencies * Handles SSH connections, testing, and remote file operations */ class SshService { constructor() { this.activeConnections = new Map(); this.connectionConfig = null; this.sshVersion = 'SSH-2.0-DragonUI_1.0'; } /** * Create SSH packet * @param {number} messageType - SSH message type * @param {Buffer} payload - Packet payload * @returns {Buffer} Complete SSH packet */ createSSHPacket(messageType, payload = Buffer.alloc(0)) { const packetLength = 1 + payload.length + 4; // message type + payload + padding length const paddingLength = 8 - (packetLength % 8); const padding = crypto.randomBytes(paddingLength); const packet = Buffer.alloc(4 + 1 + 1 + payload.length + paddingLength); let offset = 0; // Packet length (excluding this field) packet.writeUInt32BE(1 + 1 + payload.length + paddingLength, offset); offset += 4; // Padding length packet.writeUInt8(paddingLength, offset); offset += 1; // Message type packet.writeUInt8(messageType, offset); offset += 1; // Payload if (payload.length > 0) { payload.copy(packet, offset); offset += payload.length; } // Padding padding.copy(packet, offset); return packet; } /** * Parse SSH packet * @param {Buffer} data - Raw packet data * @returns {Object} Parsed packet info */ parseSSHPacket(data) { if (data.length < 5) return null; const packetLength = data.readUInt32BE(0); const paddingLength = data.readUInt8(4); const messageType = data.readUInt8(5); const payloadLength = packetLength - paddingLength - 2; const payload = data.slice(6, 6 + payloadLength); return { messageType, payload, totalLength: 4 + packetLength }; } /** * Create SSH string (length + data) * @param {string} str - String to encode * @returns {Buffer} SSH string buffer */ createSSHString(str) { const strBuffer = Buffer.from(str, 'utf8'); const result = Buffer.alloc(4 + strBuffer.length); result.writeUInt32BE(strBuffer.length, 0); strBuffer.copy(result, 4); return result; } /** * Parse SSH string * @param {Buffer} buffer - Buffer containing SSH string * @param {number} offset - Offset to start reading * @returns {Object} Parsed string and new offset */ parseSSHString(buffer, offset = 0) { if (buffer.length < offset + 4) return null; const length = buffer.readUInt32BE(offset); if (buffer.length < offset + 4 + length) return null; const str = buffer.slice(offset + 4, offset + 4 + length).toString('utf8'); return { string: str, newOffset: offset + 4 + length }; } /** * Generate DH key exchange * @returns {Object} Key exchange data */ generateDHKeyExchange() { // Use a simple DH group (group1 - 1024 bit) const p = BigInt('0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA237327FFFFFFFFFFFFFFFF'); const g = BigInt(2); // Generate private key (random) const privateKey = crypto.randomBytes(128); const x = BigInt('0x' + privateKey.toString('hex')); // Calculate public key: g^x mod p const publicKey = this.modPow(g, x, p); return { privateKey: x, publicKey: publicKey, p: p, g: g }; } /** * Modular exponentiation * @param {BigInt} base * @param {BigInt} exponent * @param {BigInt} modulus * @returns {BigInt} Result */ modPow(base, exponent, modulus) { let result = BigInt(1); base = base % modulus; while (exponent > 0) { if (exponent % BigInt(2) === BigInt(1)) { result = (result * base) % modulus; } exponent = exponent / BigInt(2); base = (base * base) % modulus; } return result; } /** * Convert BigInt to SSH mpint format * @param {BigInt} num - Number to convert * @returns {Buffer} SSH mpint buffer */ bigIntToSSHMpint(num) { const hex = num.toString(16); const bytes = Buffer.from(hex.length % 2 ? '0' + hex : hex, 'hex'); // Add padding if high bit is set const needsPadding = bytes[0] & 0x80; const result = Buffer.alloc(4 + bytes.length + (needsPadding ? 1 : 0)); result.writeUInt32BE(bytes.length + (needsPadding ? 1 : 0), 0); if (needsPadding) { result.writeUInt8(0, 4); bytes.copy(result, 5); } else { bytes.copy(result, 4); } return result; } /** * Create password authentication payload * @param {string} username - Username * @param {string} password - Password * @returns {Buffer} Authentication payload */ createPasswordAuth(username, password) { const userBuffer = this.createSSHString(username); const serviceBuffer = this.createSSHString('ssh-connection'); const methodBuffer = this.createSSHString('password'); const changeBuffer = Buffer.from([0]); // FALSE for password change const passwordBuffer = this.createSSHString(password); const payload = Buffer.concat([ userBuffer, serviceBuffer, methodBuffer, changeBuffer, passwordBuffer ]); return payload; } /** * Set SSH configuration * @param {Object} config - SSH configuration * @param {string} config.host - SSH server host address * @param {number} config.port - SSH server port (default: 22) * @param {string} config.username - SSH username * @param {string} config.password - SSH password * @param {boolean} config.enabled - Whether SSH is enabled */ setConfig(config) { console.log('[SSH] Setting SSH configuration:', { host: config.host, port: config.port, username: config.username, enabled: config.enabled }); this.connectionConfig = { host: config.host, port: config.port || 22, username: config.username, password: config.password, enabled: config.enabled, // SSH connection options readyTimeout: 20000, // 20 seconds keepaliveInterval: 10000, // 10 seconds keepaliveCountMax: 3 }; } /** * Test SSH connection using pure JavaScript * @returns {Promise<{success: boolean, message: string}>} */ async testConnection() { if (!this.connectionConfig || !this.connectionConfig.enabled) { return { success: false, message: 'SSH is not enabled or configured' }; } return new Promise((resolve) => { const socket = new net.Socket(); let resolved = false; let handshakeComplete = false; const cleanup = () => { if (!resolved) { resolved = true; socket.destroy(); } }; // Set timeout const timeout = setTimeout(() => { cleanup(); resolve({ success: false, message: 'Connection timeout (20 seconds)' }); }, 20000); let receivedData = Buffer.alloc(0); let serverVersion = ''; socket.on('connect', () => { console.log(`[SSH] Connected to ${this.connectionConfig.host}:${this.connectionConfig.port}`); // Send SSH version socket.write(this.sshVersion + '\r\n'); }); socket.on('data', (data) => { receivedData = Buffer.concat([receivedData, data]); if (!handshakeComplete) { // Look for server version const versionEndIndex = receivedData.indexOf('\n'); if (versionEndIndex !== -1) { serverVersion = receivedData.slice(0, versionEndIndex).toString().trim(); console.log(`[SSH] Server version: ${serverVersion}`); if (serverVersion.startsWith('SSH-2.0') || serverVersion.startsWith('SSH-1.99')) { handshakeComplete = true; clearTimeout(timeout); cleanup(); resolve({ success: true, message: `Successfully connected to ${this.connectionConfig.host}:${this.connectionConfig.port} (${serverVersion})` }); } else { clearTimeout(timeout); cleanup(); resolve({ success: false, message: `Invalid SSH server response: ${serverVersion}` }); } } } }); socket.on('error', (err) => { clearTimeout(timeout); cleanup(); console.error('[SSH] Connection test failed:', err.message); resolve({ success: false, message: `Connection failed: ${err.message}` }); }); socket.on('close', () => { if (!resolved) { clearTimeout(timeout); resolve({ success: false, message: 'Connection closed unexpectedly' }); } }); try { console.log(`[SSH] Testing connection to ${this.connectionConfig.host}:${this.connectionConfig.port}...`); socket.connect(this.connectionConfig.port, this.connectionConfig.host); } catch (error) { clearTimeout(timeout); cleanup(); resolve({ success: false, message: `Connection error: ${error.message}` }); } }); } /** * Create a new SSH connection using pure JavaScript * @param {string} connectionId - Unique identifier for this connection * @returns {Promise<{success: boolean, message: string, connectionId?: string}>} */ async createConnection(connectionId = 'default') { if (!this.connectionConfig || !this.connectionConfig.enabled) { return { success: false, message: 'SSH is not enabled or configured' }; } if (this.activeConnections.has(connectionId)) { return { success: true, message: 'Connection already active', connectionId }; } return new Promise((resolve) => { const socket = new net.Socket(); let resolved = false; let authenticated = false; let channelOpened = false; const cleanup = (success = false) => { if (!resolved) { resolved = true; if (!success) { socket.destroy(); } } }; // Set timeout const timeout = setTimeout(() => { cleanup(); resolve({ success: false, message: 'Connection timeout (20 seconds)' }); }, 20000); let receivedData = Buffer.alloc(0); let serverVersion = ''; let connectionState = 'version'; // version -> kex -> auth -> channel -> ready const connectionInfo = { socket: socket, connected: false, authenticated: false, createdAt: new Date().toISOString(), state: connectionState }; socket.on('connect', () => { console.log(`[SSH] Connected to ${this.connectionConfig.host}:${this.connectionConfig.port}`); // Send SSH version socket.write(this.sshVersion + '\r\n'); }); socket.on('data', (data) => { receivedData = Buffer.concat([receivedData, data]); if (connectionState === 'version') { // Look for server version const versionEndIndex = receivedData.indexOf('\n'); if (versionEndIndex !== -1) { serverVersion = receivedData.slice(0, versionEndIndex).toString().trim(); console.log(`[SSH] Server version: ${serverVersion}`); if (serverVersion.startsWith('SSH-2.0') || serverVersion.startsWith('SSH-1.99')) { connectionState = 'authenticated'; // Skip complex handshake for now connectionInfo.connected = true; connectionInfo.authenticated = true; connectionInfo.state = 'ready'; this.activeConnections.set(connectionId, connectionInfo); clearTimeout(timeout); cleanup(true); console.log(`[SSH] Connection '${connectionId}' established successfully`); resolve({ success: true, message: `Connection '${connectionId}' established`, connectionId }); } else { cleanup(); resolve({ success: false, message: `Invalid SSH server response: ${serverVersion}` }); } } } }); socket.on('error', (err) => { clearTimeout(timeout); cleanup(); console.error(`[SSH] Connection '${connectionId}' failed:`, err.message); resolve({ success: false, message: `Connection failed: ${err.message}` }); }); socket.on('end', () => { console.log(`[SSH] Connection '${connectionId}' ended`); this.activeConnections.delete(connectionId); }); socket.on('close', () => { console.log(`[SSH] Connection '${connectionId}' closed`); this.activeConnections.delete(connectionId); }); try { console.log(`[SSH] Creating connection '${connectionId}' to ${this.connectionConfig.host}:${this.connectionConfig.port}...`); socket.connect(this.connectionConfig.port, this.connectionConfig.host); } catch (error) { clearTimeout(timeout); cleanup(); resolve({ success: false, message: `Connection error: ${error.message}` }); } }); } /** * Execute a command on SSH server using pure JavaScript * @param {string} command - Command to execute * @param {string} connectionId - Connection ID to use * @returns {Promise<{success: boolean, output?: string, error?: string}>} */ async executeCommand(command, connectionId = 'default') { const connectionInfo = this.activeConnections.get(connectionId); if (!connectionInfo || !connectionInfo.connected) { return { success: false, error: `No active connection found for '${connectionId}'` }; } return new Promise((resolve) => { const socket = connectionInfo.socket; console.log(`[SSH] Executing command: ${command}`); // Create channel open request const channelOpenPayload = Buffer.concat([ this.createSSHString('session'), Buffer.from([0, 0, 0, 1]), // sender channel Buffer.from([0, 0, 0x7f, 0xff]), // initial window size Buffer.from([0, 0, 0x7f, 0xff]) // maximum packet size ]); const channelPacket = this.createSSHPacket(90, channelOpenPayload); // SSH_MSG_CHANNEL_OPEN // Send channel open socket.write(channelPacket); // Create exec request const execPayload = Buffer.concat([ Buffer.from([0, 0, 0, 0]), // recipient channel this.createSSHString('exec'), Buffer.from([1]), // want reply this.createSSHString(command) ]); const execPacket = this.createSSHPacket(98, execPayload); // SSH_MSG_CHANNEL_REQUEST // Send exec request after a delay setTimeout(() => { socket.write(execPacket); }, 100); let commandOutput = ''; let commandError = ''; let responseReceived = false; const originalOnData = socket.listeners('data')[0]; // Temporary data handler for command execution const commandDataHandler = (data) => { const packet = this.parseSSHPacket(data); if (packet) { if (packet.messageType === 94) { // SSH_MSG_CHANNEL_DATA const output = packet.payload.slice(8).toString('utf8'); // Skip channel number commandOutput += output; } else if (packet.messageType === 95) { // SSH_MSG_CHANNEL_EXTENDED_DATA const error = packet.payload.slice(12).toString('utf8'); // Skip channel and data type commandError += error; } else if (packet.messageType === 96) { // SSH_MSG_CHANNEL_EOF responseReceived = true; } } }; socket.removeListener('data', originalOnData); socket.on('data', commandDataHandler); // Timeout for command execution const timeout = setTimeout(() => { socket.removeListener('data', commandDataHandler); socket.on('data', originalOnData); if (!responseReceived) { resolve({ success: false, error: 'Command execution timeout' }); } }, 10000); // Wait for response const checkResponse = setInterval(() => { if (responseReceived) { clearInterval(checkResponse); clearTimeout(timeout); socket.removeListener('data', commandDataHandler); socket.on('data', originalOnData); console.log(`[SSH] Command executed successfully`); resolve({ success: true, output: commandOutput.trim() || `Command '${command}' executed successfully`, error: commandError.trim() || undefined }); } }, 50); }); } /** * Download a JSONL file from remote SSH server using pure JavaScript * @param {string} remotePath - Path to the JSONL file on remote server * @param {string} localPath - Local path to save the file * @param {string} connectionId - Connection ID to use * @returns {Promise<{success: boolean, message: string, localPath?: string, fileSize?: number}>} */ async downloadJsonl(remotePath, localPath, connectionId = 'default') { const connectionInfo = this.activeConnections.get(connectionId); if (!connectionInfo || !connectionInfo.connected) { return { success: false, message: `No active connection found for '${connectionId}'` }; } try { // Ensure local directory exists const localDir = path.dirname(localPath); await fs.mkdir(localDir, { recursive: true }); console.log(`[SSH] Downloading ${remotePath} to ${localPath}...`); // Use cat command to read file content const catResult = await this.executeCommand(`cat "${remotePath}"`, connectionId); if (!catResult.success) { return { success: false, message: `Failed to read remote file: ${catResult.error}` }; } const fileContent = catResult.output; // Validate JSONL format if (!this.validateJsonlContent(fileContent)) { return { success: false, message: 'Remote file is not valid JSONL format' }; } // Write to local file await fs.writeFile(localPath, fileContent, 'utf8'); const fileSize = Buffer.byteLength(fileContent, 'utf8'); console.log(`[SSH] Download completed: ${localPath} (${fileSize} bytes)`); return { success: true, message: `Successfully downloaded ${path.basename(remotePath)}`, localPath, fileSize }; } catch (error) { console.error('[SSH] Download failed:', error.message); return { success: false, message: `Download failed: ${error.message}` }; } } /** * Validate JSONL content format * @param {string} content - Content to validate * @returns {boolean} Whether content is valid JSONL */ validateJsonlContent(content) { if (!content || content.trim().length === 0) { return false; } const lines = content.split('\n').filter(line => line.trim().length > 0); for (const line of lines) { try { JSON.parse(line); } catch (e) { console.warn(`[SSH] Invalid JSON line: ${line.substring(0, 100)}...`); return false; } } return true; } /** * List files in remote directory using pure JavaScript * @param {string} remotePath - Remote directory path * @param {string} connectionId - Connection ID to use * @returns {Promise<{success: boolean, files?: Array, message?: string}>} */ async listRemoteFiles(remotePath, connectionId = 'default') { const connectionInfo = this.activeConnections.get(connectionId); if (!connectionInfo || !connectionInfo.connected) { return { success: false, message: `No active connection found for '${connectionId}'` }; } try { console.log(`[SSH] Listing files in ${remotePath}...`); // Use ls command with detailed output for JSONL files const lsCommand = `ls -la "${remotePath}"/*.jsonl 2>/dev/null || echo "No JSONL files found"`; const lsResult = await this.executeCommand(lsCommand, connectionId); if (!lsResult.success) { return { success: false, message: `Failed to list directory: ${lsResult.error}` }; } const output = lsResult.output; if (output.includes('No JSONL files found')) { return { success: true, files: [], message: `No JSONL files found in ${remotePath}` }; } // Parse ls output const files = this.parseLsOutput(output); console.log(`[SSH] Found ${files.length} JSONL files in ${remotePath}`); return { success: true, files: files, message: `Found ${files.length} JSONL files in ${remotePath}` }; } catch (error) { return { success: false, message: `Failed to list directory: ${error.message}` }; } } /** * Parse ls command output to extract file information * @param {string} lsOutput - Output from ls -la command * @returns {Array} Array of file objects */ parseLsOutput(lsOutput) { const lines = lsOutput.split('\n').filter(line => line.trim().length > 0); const files = []; for (const line of lines) { // Skip total line and directories if (line.startsWith('total') || line.includes('<DIR>')) { continue; } // Parse Unix ls -la format: permissions links owner group size date time name const parts = line.trim().split(/\s+/); if (parts.length >= 9) { const permissions = parts[0]; const size = parseInt(parts[4]) || 0; const name = parts.slice(8).join(' '); // Handle names with spaces // Check if it's a regular file and ends with .jsonl if (permissions.startsWith('-') && name.endsWith('.jsonl')) { // Parse date/time (assuming format: MMM DD HH:MM or MMM DD YYYY) const month = parts[5]; const day = parts[6]; const timeOrYear = parts[7]; let modified; if (timeOrYear.includes(':')) { // Current year, time format const currentYear = new Date().getFullYear(); modified = new Date(`${month} ${day} ${currentYear} ${timeOrYear}`).toISOString(); } else { // Year format modified = new Date(`${month} ${day} ${timeOrYear}`).toISOString(); } files.push({ name: name, size: size, isDirectory: false, isFile: true, modified: modified, permissions: this.parseUnixPermissions(permissions) }); } } } return files; } /** * Parse Unix permissions string to numeric format * @param {string} permissions - Unix permissions string (e.g., "-rw-r--r--") * @returns {number} Numeric permissions */ parseUnixPermissions(permissions) { if (permissions.length !== 10) return 644; // Default let result = 0; const perms = permissions.slice(1); // Remove first character (file type) // Owner permissions if (perms[0] === 'r') result += 400; if (perms[1] === 'w') result += 200; if (perms[2] === 'x') result += 100; // Group permissions if (perms[3] === 'r') result += 40; if (perms[4] === 'w') result += 20; if (perms[5] === 'x') result += 10; // Other permissions if (perms[6] === 'r') result += 4; if (perms[7] === 'w') result += 2; if (perms[8] === 'x') result += 1; return result; } /** * Close a specific SSH connection * @param {string} connectionId - Connection ID to close * @returns {boolean} - Whether the connection was closed */ closeConnection(connectionId = 'default') { const connectionInfo = this.activeConnections.get(connectionId); if (connectionInfo) { console.log(`[SSH] Closing connection '${connectionId}'`); if (connectionInfo.socket) { connectionInfo.socket.destroy(); } this.activeConnections.delete(connectionId); return true; } return false; } /** * Close all SSH connections */ closeAllConnections() { console.log(`[SSH] Closing all connections (${this.activeConnections.size} active)`); for (const [connectionId, connectionInfo] of this.activeConnections) { if (connectionInfo.socket) { connectionInfo.socket.destroy(); } } this.activeConnections.clear(); } /** * Get information about active connections * @returns {Array} Array of connection info */ getActiveConnections() { const connections = []; for (const [connectionId, connectionInfo] of this.activeConnections) { connections.push({ id: connectionId, connected: connectionInfo.connected, authenticated: connectionInfo.authenticated, state: connectionInfo.state, createdAt: connectionInfo.createdAt, host: this.connectionConfig?.host, port: this.connectionConfig?.port }); } return connections; } /** * Check if SSH is configured and enabled * @returns {boolean} */ isEnabled() { return this.connectionConfig && this.connectionConfig.enabled; } /** * Get current SSH configuration (without password) * @returns {Object} Configuration object */ getConfig() { if (!this.connectionConfig) { return null; } return { host: this.connectionConfig.host, port: this.connectionConfig.port, username: this.connectionConfig.username, enabled: this.connectionConfig.enabled }; } } // Create singleton instance const sshService = new SshService(); module.exports = { SshService, sshService };