UNPKG

@xec-sh/core

Version:

Universal shell execution engine

646 lines 27.2 kB
import { StreamHandler } from '../utils/stream.js'; import { escapeArg } from '../utils/shell-escape.js'; import { SSHKeyValidator } from '../utils/ssh-key-validator.js'; import { BaseAdapter } from './base-adapter.js'; import { SecurePasswordHandler } from '../utils/secure-password.js'; import { AdapterError, TimeoutError, ConnectionError } from '../core/error.js'; import { NodeSSH } from '../utils/ssh.js'; import { ConnectionPoolMetricsCollector } from '../utils/connection-pool-metrics.js'; export class SSHAdapter extends BaseAdapter { constructor(config = {}) { super(config); this.adapterName = 'ssh'; this.connectionPool = new Map(); this.metricsCollector = new ConnectionPoolMetricsCollector(); this.activeTunnels = new Map(); this.name = this.adapterName; this.sshConfig = { ...this.config, connectionPool: { enabled: config.connectionPool?.enabled ?? true, maxConnections: config.connectionPool?.maxConnections ?? 10, idleTimeout: config.connectionPool?.idleTimeout ?? 300000, keepAlive: config.connectionPool?.keepAlive ?? true, keepAliveInterval: config.connectionPool?.keepAliveInterval ?? 30000, autoReconnect: config.connectionPool?.autoReconnect ?? true, maxReconnectAttempts: config.connectionPool?.maxReconnectAttempts ?? 3, reconnectDelay: config.connectionPool?.reconnectDelay ?? 1000 }, defaultConnectOptions: config.defaultConnectOptions ?? {}, multiplexing: { enabled: config.multiplexing?.enabled ?? false, controlPath: config.multiplexing?.controlPath, controlPersist: config.multiplexing?.controlPersist ?? 600 }, sudo: { enabled: config.sudo?.enabled ?? false, password: config.sudo?.password, prompt: config.sudo?.prompt ?? '[sudo] password', method: config.sudo?.method ?? 'stdin', secureHandler: config.sudo?.secureHandler }, sftp: { enabled: config.sftp?.enabled ?? true, concurrency: config.sftp?.concurrency ?? 5 } }; if (this.sshConfig.connectionPool.enabled) { this.startPoolCleanup(); } } async isAvailable() { try { await import('ssh2'); return true; } catch { return false; } } async execute(command) { const mergedCommand = this.mergeCommand(command); const sshOptions = this.extractSSHOptions(mergedCommand); if (!sshOptions) { throw new AdapterError(this.adapterName, 'execute', new Error('SSH connection options not provided')); } this.lastUsedSSHOptions = sshOptions; const startTime = Date.now(); let connection = null; const commandString = this.buildCommandString(mergedCommand); try { connection = await this.getConnection(sshOptions); this.emitAdapterEvent('ssh:execute', { host: sshOptions.host, command: commandString }); let envPrefix = ''; if (mergedCommand.env && Object.keys(mergedCommand.env).length > 0) { const explicitEnv = {}; for (const [key, value] of Object.entries(mergedCommand.env)) { if (command.env && key in command.env) { explicitEnv[key] = value; } } if (Object.keys(explicitEnv).length > 0) { const envVars = Object.entries(explicitEnv) .map(([key, value]) => `export ${key}=${escapeArg(value)}`) .join('; '); envPrefix = `${envVars}; `; } } const finalCommand = await this.wrapWithSudo(envPrefix + commandString, mergedCommand, connection.ssh); const sshCommand = { ...mergedCommand }; if (!command.cwd && mergedCommand.cwd) { delete sshCommand.cwd; } const connectionKey = this.getConnectionKey(sshOptions); const result = await this.executeSSHCommand(connection.ssh, finalCommand, sshCommand, connection.host, connectionKey); const endTime = Date.now(); return this.createResult(result.stdout, result.stderr, result.code ?? 0, undefined, commandString, startTime, endTime, { host: `${sshOptions.host}:${sshOptions.port || 22}`, originalCommand: mergedCommand }); } catch (error) { if (error instanceof TimeoutError) { if (connection) { this.removeFromPool(this.getConnectionKey(sshOptions)); } if (!command.nothrow) { throw error; } const endTime = Date.now(); return this.createResult('', error.message, 124, 'SIGTERM', commandString, startTime, endTime, { host: `${sshOptions.host}:${sshOptions.port || 22}`, originalCommand: mergedCommand }); } if (error instanceof ConnectionError) { throw error; } if (connection) { connection.errors++; if (connection.errors > 3) { this.removeFromPool(this.getConnectionKey(sshOptions)); } } throw new AdapterError(this.adapterName, 'execute', error instanceof Error ? error : new Error(String(error))); } finally { if (connection && this.sshConfig.connectionPool.enabled) { connection.lastUsed = Date.now(); } } } extractSSHOptions(command) { if (command.adapterOptions?.type === 'ssh') { return command.adapterOptions; } return null; } async getConnection(options) { const key = this.getConnectionKey(options); if (this.sshConfig.connectionPool.enabled) { const existing = this.connectionPool.get(key); if (existing) { if (existing.ssh.isConnected()) { existing.useCount++; existing.lastUsed = Date.now(); this.metricsCollector.onConnectionReused(); this.emitAdapterEvent('ssh:pool-metrics', { metrics: this.getPoolMetrics() }); return existing; } if (this.sshConfig.connectionPool.autoReconnect) { try { const reconnected = await this.reconnectConnection(existing); if (reconnected) { return reconnected; } } catch (error) { this.removeFromPool(key); } } else { this.removeFromPool(key); } } } const validationResult = SSHKeyValidator.validateSSHOptions({ host: options.host, username: options.username, port: options.port, privateKey: options.privateKey, password: options.password }); if (!validationResult.isValid) { throw new ConnectionError(options.host, new Error(`Invalid SSH options: ${validationResult.issues.join(', ')}`)); } if (options.privateKey) { const keyValidation = await SSHKeyValidator.validatePrivateKey(options.privateKey); if (!keyValidation.isValid) { throw new ConnectionError(options.host, new Error(`Invalid SSH private key: ${keyValidation.issues.join(', ')}`)); } this.emitAdapterEvent('ssh:key-validated', { host: options.host, keyType: keyValidation.keyType || 'unknown', username: options.username || process.env['USER'] || 'unknown' }); } const ssh = new NodeSSH(); const connectOptions = { ...this.sshConfig.defaultConnectOptions, host: options.host, username: options.username, port: options.port ?? 22, privateKey: options.privateKey, passphrase: options.passphrase, password: options.password }; try { await ssh.connect(connectOptions); this.emitAdapterEvent('ssh:connect', { host: options.host, port: options.port ?? 22, username: options.username || process.env['USER'] || 'unknown' }); this.emitAdapterEvent('connection:open', { host: options.host, port: options.port ?? 22, type: 'ssh', metadata: { username: options.username || process.env['USER'] || 'unknown' } }); } catch (error) { throw new ConnectionError(options.host, error instanceof Error ? error : new Error(String(error))); } const now = Date.now(); const connection = { ssh, host: options.host, lastUsed: now, useCount: 1, created: now, errors: 0, reconnectAttempts: 0, config: options }; if (this.sshConfig.connectionPool.enabled) { if (this.connectionPool.size >= this.sshConfig.connectionPool.maxConnections) { this.removeOldestIdleConnection(); } this.connectionPool.set(key, connection); this.metricsCollector.onConnectionCreated(); if (this.sshConfig.connectionPool.keepAlive) { this.setupKeepAlive(connection); } this.emitAdapterEvent('ssh:pool-metrics', { metrics: this.getPoolMetrics() }); } return connection; } getConnectionKey(options) { return `${options.username}@${options.host}:${options.port ?? 22}`; } async executeSSHCommand(ssh, command, options = {}, host, connectionKey) { const stdoutHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); const stderrHandler = new StreamHandler({ encoding: this.config.encoding, maxBuffer: this.config.maxBuffer }); const execOptions = { cwd: options.cwd, stdin: this.convertStdin(options.stdin), execOptions: {} }; if (options.stdout === 'pipe') { execOptions.onStdout = (chunk) => { const transform = stdoutHandler.createTransform(); transform.write(chunk); transform.end(); }; } if (options.stderr === 'pipe') { execOptions.onStderr = (chunk) => { const transform = stderrHandler.createTransform(); transform.write(chunk); transform.end(); }; } const execPromise = ssh.execCommand(command, execOptions) .catch(error => { if (error.message?.includes('Socket closed') || error.message?.includes('Connection closed') || error.message?.includes('Not connected')) { return { code: -1, stdout: '', stderr: 'Connection closed due to timeout' }; } throw error; }); const timeout = options.timeout ?? this.config.defaultTimeout; const result = await this.handleTimeout(execPromise, timeout, command, () => { }); if (options.stdout === 'pipe') { result.stdout = stdoutHandler.getContent(); } if (options.stderr === 'pipe') { result.stderr = stderrHandler.getContent(); } return result; } convertStdin(stdin) { if (!stdin) return undefined; if (typeof stdin === 'string') return stdin; if (Buffer.isBuffer(stdin)) return stdin.toString(); return undefined; } async wrapWithSudo(command, options, ssh) { const globalSudoEnabled = this.sshConfig.sudo.enabled; const sshOptions = this.extractSSHOptions(options); const commandSudoEnabled = sshOptions?.sudo?.enabled; if (!globalSudoEnabled && !commandSudoEnabled) { return command; } const sudoConfig = { ...this.sshConfig.sudo, ...(sshOptions?.sudo || {}) }; const method = sudoConfig.method || sudoConfig.passwordMethod; if ((method === 'secure' || method === 'secure-askpass') && !this.securePasswordHandler) { this.securePasswordHandler = sudoConfig.secureHandler || new SecurePasswordHandler(); } return this.buildSudoCommandWithConfig(command, sudoConfig); } buildSudoCommandWithConfig(command, sudoConfig) { if (!sudoConfig || !sudoConfig.enabled) return command; const sudoCmd = sudoConfig.user ? `sudo -u ${sudoConfig.user}` : 'sudo'; if (sudoConfig.password) { const method = sudoConfig.method || sudoConfig.passwordMethod || 'stdin'; switch (method) { case 'stdin': return `echo '${sudoConfig.password}' | ${sudoCmd} -S ${command}`; case 'echo': console.warn('Using echo for sudo password is insecure and may expose the password in process listings'); return `echo '${sudoConfig.password}' | ${sudoCmd} -S ${command}`; case 'askpass': return `SUDO_ASKPASS=/tmp/askpass_$$ ${sudoCmd} -A ${command}`; case 'secure': case 'secure-askpass': { const scriptId = Math.random().toString(36).substring(7); const remoteAskpassPath = `/tmp/askpass-${scriptId}.sh`; const escapedPassword = sudoConfig.password.replace(/'/g, "'\\''"); const remoteScript = [ `cat > ${remoteAskpassPath} << 'EOF'`, `#!/bin/sh`, `echo '${escapedPassword}'`, `EOF`, `chmod 700 ${remoteAskpassPath}`, `SUDO_ASKPASS=${remoteAskpassPath} ${sudoCmd} -A ${command}`, `rm -f ${remoteAskpassPath}` ].join(' && '); return remoteScript; } default: return `${sudoCmd} ${command}`; } } return `${sudoCmd} ${command}`; } buildSudoCommand(command, sshOptions) { const sudo = sshOptions.sudo; if (!sudo || !sudo.enabled) return command; const sudoCmd = sudo.user ? `sudo -u ${sudo.user}` : 'sudo'; if (sudo.password) { switch (sudo.passwordMethod) { case 'stdin': return `echo '${sudo.password}' | sudo -S ${command}`; case 'askpass': return `SUDO_ASKPASS=/tmp/askpass_$$ ${sudoCmd} -A ${command}`; case 'secure': if (this.securePasswordHandler) { try { const askpassPath = this.securePasswordHandler.createAskPassScript(sudo.password); const secureCommand = `SUDO_ASKPASS=${askpassPath} ${sudoCmd} -A ${command}`; setTimeout(() => { try { this.securePasswordHandler?.cleanup(); } catch { } }, 1000); return secureCommand; } catch (error) { console.error('Failed to create secure askpass, falling back to stdin method:', error); return `sudo -S ${command}`; } } break; default: return `${sudoCmd} ${command}`; } } return `${sudoCmd} ${command}`; } startPoolCleanup() { this.poolCleanupInterval = setInterval(() => { const now = Date.now(); const timeout = this.sshConfig.connectionPool.idleTimeout; let cleaned = 0; for (const [key, connection] of this.connectionPool.entries()) { if (now - connection.lastUsed > timeout) { this.removeFromPool(key); cleaned++; } } if (cleaned > 0) { this.metricsCollector.onCleanup(); this.emitAdapterEvent('ssh:pool-cleanup', { cleaned, remaining: this.connectionPool.size }); } }, 60000); this.poolCleanupInterval.unref(); } removeFromPool(key) { const connection = this.connectionPool.get(key); if (connection) { const [hostPort] = key.split('@').slice(-1); const [host = 'unknown', port = '22'] = (hostPort || 'unknown:22').split(':'); this.emitAdapterEvent('ssh:disconnect', { host, reason: 'pool_removal' }); this.emitAdapterEvent('connection:close', { host, port: parseInt(port, 10), type: 'ssh', reason: 'pool_removal' }); connection.ssh.dispose(); this.connectionPool.delete(key); this.metricsCollector.onConnectionDestroyed(); if (connection.keepAliveTimer) { clearInterval(connection.keepAliveTimer); } } } async reconnectConnection(connection) { const maxAttempts = this.sshConfig.connectionPool.maxReconnectAttempts ?? 3; const delay = this.sshConfig.connectionPool.reconnectDelay ?? 1000; if (connection.reconnectAttempts >= maxAttempts) { this.metricsCollector.onConnectionFailed(); return null; } connection.reconnectAttempts++; try { await new Promise(resolve => setTimeout(resolve, delay * connection.reconnectAttempts)); await connection.ssh.connect({ host: connection.config.host, username: connection.config.username, port: connection.config.port ?? 22, privateKey: connection.config.privateKey, passphrase: connection.config.passphrase, password: connection.config.password }); connection.errors = 0; connection.lastUsed = Date.now(); if (this.sshConfig.connectionPool.keepAlive) { this.setupKeepAlive(connection); } this.emitAdapterEvent('ssh:reconnect', { host: connection.host, attempts: connection.reconnectAttempts }); return connection; } catch (error) { connection.errors++; this.metricsCollector.onConnectionFailed(); throw error; } } setupKeepAlive(connection) { const interval = this.sshConfig.connectionPool.keepAliveInterval ?? 30000; if (connection.keepAliveTimer) { clearInterval(connection.keepAliveTimer); } connection.keepAliveTimer = setInterval(async () => { try { await connection.ssh.execCommand('echo "keep-alive"', { cwd: '/', execOptions: { pty: false } }); } catch (error) { connection.errors++; } }, interval); connection.keepAliveTimer.unref(); } removeOldestIdleConnection() { let oldestKey = null; let oldestTime = Date.now(); for (const [key, connection] of this.connectionPool.entries()) { if (connection.lastUsed < oldestTime) { oldestTime = connection.lastUsed; oldestKey = key; } } if (oldestKey) { this.removeFromPool(oldestKey); } } getPoolMetrics() { const connections = new Map(); for (const [key, conn] of this.connectionPool.entries()) { connections.set(key, { created: new Date(conn.created), lastUsed: new Date(conn.lastUsed), useCount: conn.useCount, isAlive: conn.ssh.isConnected(), errors: conn.errors }); } return this.metricsCollector.getMetrics(this.connectionPool.size, connections); } getConnectionPoolMetrics() { return this.getPoolMetrics(); } async dispose() { if (this.poolCleanupInterval) { clearInterval(this.poolCleanupInterval); } for (const [id, tunnel] of this.activeTunnels) { try { await tunnel.close(); } catch (error) { console.error(`Failed to close tunnel ${id}:`, error); } } this.activeTunnels.clear(); for (const connection of this.connectionPool.values()) { this.emitAdapterEvent('ssh:disconnect', { host: connection.host, reason: 'adapter_dispose' }); this.emitAdapterEvent('connection:close', { host: connection.host, port: connection.config.port ?? 22, type: 'ssh', reason: 'adapter_dispose' }); connection.ssh.dispose(); } this.connectionPool.clear(); if (this.securePasswordHandler) { await this.securePasswordHandler.cleanup(); this.securePasswordHandler = undefined; } } async uploadFile(localPath, remotePath, options) { if (!this.sshConfig.sftp.enabled) { throw new AdapterError(this.adapterName, 'uploadFile', new Error('SFTP is disabled')); } const connection = await this.getConnection(options); await connection.ssh.putFile(localPath, remotePath); } async downloadFile(remotePath, localPath, options) { if (!this.sshConfig.sftp.enabled) { throw new AdapterError(this.adapterName, 'downloadFile', new Error('SFTP is disabled')); } const connection = await this.getConnection(options); await connection.ssh.getFile(localPath, remotePath); } async uploadDirectory(localPath, remotePath, options) { if (!this.sshConfig.sftp.enabled) { throw new AdapterError(this.adapterName, 'uploadDirectory', new Error('SFTP is disabled')); } const connection = await this.getConnection(options); await connection.ssh.putDirectory(localPath, remotePath, { concurrency: this.sshConfig.sftp.concurrency }); } async portForward(localPort, remoteHost, remotePort, options) { const connection = await this.getConnection(options); await connection.ssh.forwardOut('127.0.0.1', localPort, remoteHost, remotePort); } async tunnel(options) { const sshOptions = this.lastUsedSSHOptions; if (!sshOptions) { throw new AdapterError(this.adapterName, 'tunnel', new Error('No SSH connection available. Execute a command first or provide connection options.')); } const connection = await this.getConnection(sshOptions); const localPort = options.localPort || 0; const localHost = options.localHost || 'localhost'; const remoteHost = options.remoteHost; const remotePort = options.remotePort; const net = await import('net'); return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(localPort, localHost, () => { const actualPort = server.address().port; server.on('connection', (clientSocket) => { const ssh2Connection = connection.ssh.connection; ssh2Connection.forwardOut(clientSocket.remoteAddress || '127.0.0.1', clientSocket.remotePort || 0, remoteHost, remotePort, (err, stream) => { if (err) { clientSocket.destroy(); return; } clientSocket.pipe(stream).pipe(clientSocket); stream.on('close', () => { clientSocket.end(); }); clientSocket.on('close', () => { stream.end(); }); }); }); const tunnelId = `${actualPort}-${remoteHost}:${remotePort}`; const tunnel = { localPort: actualPort, localHost, remoteHost, remotePort, isOpen: true, open: async () => { }, close: async () => new Promise((resolveClose) => { server.close(() => { this.activeTunnels.delete(tunnelId); this.emitAdapterEvent('ssh:tunnel-closed', { localPort: actualPort, remoteHost: options.remoteHost, remotePort: options.remotePort }); resolveClose(); }); }) }; this.activeTunnels.set(tunnelId, tunnel); this.emitAdapterEvent('ssh:tunnel-created', { localPort: actualPort, remoteHost: options.remoteHost, remotePort: options.remotePort }); this.emitAdapterEvent('tunnel:created', { localPort: actualPort, remoteHost: options.remoteHost, remotePort: options.remotePort, type: 'ssh' }); resolve(tunnel); }); }); } } //# sourceMappingURL=ssh-adapter.js.map