ssh-bridge-ai
Version:
AI-Powered SSH Tool with Bulletproof Connections & Enterprise Sandbox Security + Cursor-like Confirmation - Enable AI assistants to securely SSH into your servers with persistent sessions, keepalive, automatic recovery, sandbox command testing, and user c
753 lines (634 loc) • 21.2 kB
JavaScript
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
const EventEmitter = require('events');
// Try to import node-ssh first, fallback to ssh2 if needed
let Client;
let SSH2Client;
try {
const nodeSSH = require('node-ssh');
Client = nodeSSH.NodeSSH;
console.log('Using node-ssh for SSH connections');
} catch (error) {
console.log('node-ssh not available, falling back to ssh2');
try {
const ssh2 = require('ssh2');
SSH2Client = ssh2.Client;
console.log('Using ssh2 for SSH connections');
} catch (ssh2Error) {
throw new Error('No SSH client available. Please install node-ssh or ssh2: npm install node-ssh ssh2');
}
}
const { Config } = require('./config');
const { ValidationUtils } = require('./utils/validation');
const logger = require('./utils/logger');
const { SECURITY, NETWORK, FILESYSTEM, ERROR_CODES } = require('./utils/constants');
/**
* Enhanced SSH Client with robust connection strategies
* Implements keepalive, fallback shells, session recovery, and comprehensive error handling
*/
class EnhancedSSHClient extends EventEmitter {
constructor(connection, options = {}) {
super();
this.connection = connection;
this.options = this.buildDefaultOptions(options);
// Initialize appropriate SSH client
if (Client) {
this.ssh = new Client();
this.clientType = 'node-ssh';
} else if (SSH2Client) {
this.ssh = new SSH2Client();
this.clientType = 'ssh2';
} else {
throw new Error('No SSH client available');
}
this.config = new Config();
this.secureBuffers = [];
this.failedAttempts = 0;
// Connection state
this.isConnected = false;
this.connectionStartTime = null;
this.lastActivityTime = null;
// Parse connection string
this.parseConnection(connection);
// Initialize components
this.healthMonitor = new ConnectionHealthMonitor(this);
this.sessionRecovery = new SessionRecovery(this);
this.progressiveFallback = new ProgressiveFallback(this);
// Bind event handlers
this.bindEventHandlers();
}
buildDefaultOptions(options) {
return {
// Connection settings
port: 22,
readyTimeout: 60000,
// Keepalive settings
keepalive: true,
keepaliveInterval: 30,
keepaliveCountMax: 3,
serverAliveInterval: 30,
serverAliveCountMax: 3,
clientAliveInterval: 60,
clientAliveCountMax: 3,
keepaliveInitialDelay: 1000,
// Fallback settings
maxReconnectAttempts: 5,
reconnectDelay: 2000,
fallbackShells: ['bash', 'sh', 'dash'],
useTemporaryHome: true,
// Session recovery
sessionRecovery: true,
saveSessionState: true,
// Error handling
progressiveFallback: true,
maxFallbackLevel: 5,
// Environment overrides
environmentOverrides: {
HOME: '/tmp',
SHELL: '/bin/bash',
TERM: 'xterm-256color',
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
USER: null,
LOGNAME: null,
PWD: '/tmp'
},
...options
};
}
parseConnection(connection) {
if (!connection || typeof connection !== 'string') {
throw new Error('Connection string is required');
}
const parts = connection.split('@');
if (parts.length !== 2) {
throw new Error('Invalid connection format. Use: user@host');
}
const username = parts[0].trim();
const host = parts[1].trim();
if (!username || username.length === 0) {
throw new Error('Username cannot be empty');
}
if (!ValidationUtils.validateHostname(host)) {
throw new Error(`Invalid hostname: ${host}`);
}
this.username = username;
this.host = host;
this.connectionString = connection;
logger.debug('Connection parsed', { username, host });
}
bindEventHandlers() {
if (this.ssh) {
this.ssh.on('ready', () => {
this.isConnected = true;
this.connectionStartTime = Date.now();
this.lastActivityTime = Date.now();
this.emit('connected');
this.emit('ready');
// Start health monitoring
this.healthMonitor.startMonitoring();
// Apply environment overrides
this.applyEnvironmentOverrides();
console.log(`✅ Connected to ${this.host}:${this.options.port} as ${this.username}`);
});
this.ssh.on('error', (error) => {
this.isConnected = false;
this.emit('error', error);
this.handleConnectionError(error);
});
this.ssh.on('close', () => {
this.isConnected = false;
this.emit('disconnected');
this.handleDisconnection();
});
this.ssh.on('end', () => {
this.isConnected = false;
this.emit('ended');
this.handleDisconnection();
});
}
}
async connect() {
try {
if (this.isConnected) {
logger.info('Already connected', { connection: this.sanitizeConnectionString() });
return true;
}
if (this.failedAttempts >= SECURITY.MAX_FAILED_ATTEMPTS) {
const error = new Error('Too many failed connection attempts. Please wait before retrying.');
error.code = ERROR_CODES.SSH_CONNECTION_FAILED;
throw error;
}
// Try progressive fallback if enabled
if (this.options.progressiveFallback) {
return await this.progressiveFallback.attemptConnection();
} else {
return await this.standardConnection();
}
} catch (error) {
this.failedAttempts++;
this.secureCleanup();
throw error;
}
}
async standardConnection() {
const connectOptions = {
host: this.host,
username: this.username,
port: parseInt(this.options.port || NETWORK.DEFAULT_SSH_PORT.toString()),
// Keepalive settings
keepalive: this.options.keepalive,
keepaliveInterval: this.options.keepaliveInterval,
keepaliveCountMax: this.options.keepaliveCountMax,
serverAliveInterval: this.options.serverAliveInterval,
serverAliveCountMax: this.options.serverAliveCountMax,
clientAliveInterval: this.options.clientAliveInterval,
clientAliveCountMax: this.options.clientAliveCountMax,
keepaliveInitialDelay: this.options.keepaliveInitialDelay,
// Timeout settings
readyTimeout: this.options.readyTimeout
};
// Add authentication
if (this.options.password) {
connectOptions.password = this.options.password;
} else if (this.options.key) {
const keyContent = await this.loadAndValidateKey(this.options.key);
connectOptions.privateKey = keyContent;
} else {
// Try to find default keys
const keyPath = this.config.getDefaultSSHKey();
if (keyPath && fsSync.existsSync(keyPath)) {
const keyContent = await this.loadAndValidateKey(keyPath);
connectOptions.privateKey = keyContent;
}
}
// Validate connection parameters
if (!ValidationUtils.validateHostname(this.host)) {
throw new Error(`Invalid hostname: ${this.host}`);
}
if (!ValidationUtils.validatePort(connectOptions.port)) {
throw new Error(`Invalid port number: ${connectOptions.port}`);
}
// Connect
await this.ssh.connect(connectOptions);
// Save session state if enabled
if (this.options.saveSessionState) {
this.sessionRecovery.saveSessionState();
}
return true;
}
async connectWithFallbackShell(shellStrategy) {
const connectOptions = {
host: this.host,
username: this.username,
port: parseInt(this.options.port || NETWORK.DEFAULT_SSH_PORT.toString()),
// Keepalive settings
keepalive: this.options.keepalive,
keepaliveInterval: this.options.keepaliveInterval,
keepaliveCountMax: this.options.keepaliveCountMax,
// Shell strategy
command: shellStrategy.command,
args: shellStrategy.args
};
// Add authentication
if (this.options.password) {
connectOptions.password = this.options.password;
} else if (this.options.key) {
const keyContent = await this.loadAndValidateKey(this.options.key);
connectOptions.privateKey = keyContent;
}
// Connect with shell strategy
await this.ssh.connect(connectOptions);
return true;
}
applyEnvironmentOverrides() {
if (this.clientType === 'ssh2' && this.ssh.shell) {
// For ssh2, we need to set environment variables in the shell
Object.entries(this.options.environmentOverrides).forEach(([key, value]) => {
if (value !== null) {
this.ssh.shell.env[key] = value;
}
});
}
}
async exec(command) {
await this.connect();
try {
// Update activity time
this.lastActivityTime = Date.now();
const result = await this.ssh.execCommand(command);
return {
stdout: result.stdout,
stderr: result.stderr,
code: result.code,
exitCode: result.code
};
} catch (error) {
// Try to recover from execution errors
if (this.options.sessionRecovery) {
const recovered = await this.sessionRecovery.handleExecutionError(error);
if (recovered) {
// Retry the command
return await this.exec(command);
}
}
throw error;
}
}
async execWithKeepalive(command, keepaliveInterval = 30000) {
await this.connect();
return new Promise((resolve, reject) => {
let keepaliveTimer;
let commandCompleted = false;
const cleanup = () => {
if (keepaliveTimer) clearInterval(keepaliveTimer);
commandCompleted = true;
};
// Set up keepalive
keepaliveTimer = setInterval(() => {
if (!commandCompleted && this.isConnected) {
this.ssh.execCommand('echo "keepalive"', (err) => {
if (err) {
cleanup();
reject(new Error(`Keepalive failed: ${err.message}`));
}
});
}
}, keepaliveInterval);
// Execute command
this.ssh.execCommand(command, (err, stream) => {
if (err) {
cleanup();
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
stream.on('close', (code) => {
cleanup();
resolve({
stdout,
stderr,
code,
exitCode: code
});
});
});
});
}
async testConnectionStability(duration = 30000) {
const startTime = Date.now();
const testInterval = 5000;
const tests = [];
return new Promise((resolve) => {
const testTimer = setInterval(async () => {
try {
const testResult = await this.exec('echo "stability_test"');
tests.push({
timestamp: Date.now() - startTime,
success: true,
response: testResult.stdout.trim()
});
if (Date.now() - startTime >= duration) {
clearInterval(testTimer);
resolve({
success: true,
duration: Date.now() - startTime,
tests: tests,
averageResponseTime: this.calculateAverageResponseTime(tests)
});
}
} catch (error) {
tests.push({
timestamp: Date.now() - startTime,
success: false,
error: error.message
});
clearInterval(testTimer);
resolve({
success: false,
duration: Date.now() - startTime,
tests: tests,
failureReason: error.message
});
}
}, testInterval);
});
}
calculateAverageResponseTime(tests) {
const successfulTests = tests.filter(t => t.success);
if (successfulTests.length === 0) return 0;
const totalTime = successfulTests.reduce((sum, test) => sum + test.timestamp, 0);
return totalTime / successfulTests.length;
}
async loadAndValidateKey(keyPath) {
try {
if (!ValidationUtils.validateFilePath(keyPath)) {
throw new Error('Invalid SSH key file path');
}
const isValidKeyFile = await ValidationUtils.validateSSHKeyFile(keyPath);
if (!isValidKeyFile) {
throw new Error('SSH key file validation failed');
}
const keyContent = await fs.readFile(keyPath, 'utf8');
const buffer = Buffer.from(keyContent, 'utf8');
this.secureBuffers.push(buffer);
return keyContent;
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`SSH key file not found: ${keyPath}`);
} else if (error.code === 'EACCES') {
throw new Error(`Permission denied reading SSH key: ${keyPath}`);
} else {
throw new Error(`Error reading SSH key: ${error.message}`);
}
}
}
handleConnectionError(error) {
logger.error('SSH connection error', {
error: error.message,
host: this.host,
username: this.username
});
// Try to recover if session recovery is enabled
if (this.options.sessionRecovery) {
this.sessionRecovery.handleConnectionError(error);
}
}
async handleDisconnection() {
logger.info('SSH connection disconnected', {
host: this.host,
username: this.username,
sessionDuration: this.connectionStartTime ? Date.now() - this.connectionStartTime : 0
});
// Stop health monitoring
this.healthMonitor.stopMonitoring();
// Try to recover if session recovery is enabled
if (this.options.sessionRecovery) {
await this.sessionRecovery.handleDisconnection();
}
}
secureCleanup() {
this.secureBuffers.forEach(buffer => {
buffer.fill(0);
});
this.secureBuffers = [];
}
sanitizeConnectionString() {
if (!this.connectionString) return '';
const parts = this.connectionString.split('@');
if (parts.length === 2) {
return `${parts[0]}@${parts[1]}`;
}
return this.connectionString;
}
async dispose() {
this.healthMonitor.stopMonitoring();
if (this.ssh) {
await this.ssh.dispose();
}
this.secureCleanup();
}
}
/**
* Connection Health Monitor
* Monitors connection health and sends keepalive signals
*/
class ConnectionHealthMonitor {
constructor(sshClient) {
this.sshClient = sshClient;
this.keepaliveTimer = null;
this.healthCheckInterval = 30000;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 2000;
}
startMonitoring() {
this.keepaliveTimer = setInterval(() => {
this.sendKeepalive();
}, this.healthCheckInterval);
logger.debug('Connection health monitoring started');
}
stopMonitoring() {
if (this.keepaliveTimer) {
clearInterval(this.keepaliveTimer);
this.keepaliveTimer = null;
logger.debug('Connection health monitoring stopped');
}
}
async sendKeepalive() {
if (this.sshClient.ssh && this.sshClient.isConnected) {
try {
await this.sshClient.ssh.execCommand('echo "keepalive"');
logger.debug('Keepalive sent successfully');
} catch (error) {
logger.warn('Keepalive failed', { error: error.message });
this.handleConnectionFailure();
}
}
}
handleConnectionFailure() {
if (this.sshClient.options.sessionRecovery) {
this.sshClient.sessionRecovery.handleConnectionFailure();
}
}
}
/**
* Session Recovery
* Handles session recovery and reconnection
*/
class SessionRecovery {
constructor(sshClient) {
this.sshClient = sshClient;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000;
this.sessionState = {};
}
async handleDisconnection() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
logger.info(`Connection lost. Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
await this.delay(this.reconnectDelay * this.reconnectAttempts);
const newConnection = await this.reconnect();
if (newConnection) {
this.restoreSessionState(newConnection);
return newConnection;
}
}
throw new Error('Max reconnection attempts exceeded');
}
async handleConnectionError(error) {
logger.warn('Connection error, attempting recovery', { error: error.message });
if (this.reconnectAttempts < this.maxReconnectAttempts) {
await this.handleDisconnection();
}
}
async handleExecutionError(error) {
logger.warn('Execution error, attempting recovery', { error: error.message });
// Check if it's a connection issue
if (error.message.includes('connection') || error.message.includes('disconnected')) {
return await this.handleDisconnection();
}
return false;
}
async handleConnectionFailure() {
logger.warn('Connection failure detected, attempting recovery');
return await this.handleDisconnection();
}
async reconnect() {
try {
await this.sshClient.connect();
return this.sshClient;
} catch (error) {
logger.error('Reconnection failed', { error: error.message });
return null;
}
}
saveSessionState() {
this.sessionState = {
timestamp: Date.now(),
host: this.sshClient.host,
username: this.sshClient.username
};
}
restoreSessionState(newConnection) {
logger.info('Session state restored', {
host: this.sessionState.host,
username: this.sessionState.username
});
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
/**
* Progressive Fallback System
* Implements multiple connection strategies with fallbacks
*/
class ProgressiveFallback {
constructor(sshClient) {
this.sshClient = sshClient;
this.fallbackLevel = 0;
this.maxFallbackLevel = 5;
}
async attemptConnection() {
while (this.fallbackLevel <= this.maxFallbackLevel) {
try {
const connection = await this.tryConnectionMethod(this.fallbackLevel);
if (connection && this.sshClient.isConnected) {
return connection;
}
} catch (error) {
logger.warn(`Fallback level ${this.fallbackLevel} failed`, { error: error.message });
this.fallbackLevel++;
}
}
throw new Error('All connection methods failed');
}
async tryConnectionMethod(level) {
switch (level) {
case 0: return await this.standardConnection();
case 1: return await this.keepaliveConnection();
case 2: return await this.alternativeShellConnection();
case 3: return await this.temporaryHomeConnection();
case 4: return await this.minimalConnection();
case 5: return await this.emergencyConnection();
default: throw new Error('Invalid fallback level');
}
}
async standardConnection() {
return await this.sshClient.standardConnection();
}
async keepaliveConnection() {
// Use enhanced keepalive settings
this.sshClient.options.keepaliveInterval = 15;
this.sshClient.options.serverAliveInterval = 15;
return await this.sshClient.standardConnection();
}
async alternativeShellConnection() {
// Try different shell strategies
const shellStrategies = [
{ command: 'bash', args: ['--noprofile', '--norc'] },
{ command: 'sh', args: [] },
{ command: 'dash', args: [] },
{ command: 'bash', args: ['-i'] }
];
for (const strategy of shellStrategies) {
try {
return await this.sshClient.connectWithFallbackShell(strategy);
} catch (error) {
logger.debug(`Shell strategy ${strategy.command} failed`, { error: error.message });
continue;
}
}
throw new Error('All shell strategies failed');
}
async temporaryHomeConnection() {
// Override environment to use temporary home
this.sshClient.options.environmentOverrides.HOME = '/tmp';
this.sshClient.options.environmentOverrides.PWD = '/tmp';
return await this.sshClient.standardConnection();
}
async minimalConnection() {
// Minimal connection with basic settings
this.sshClient.options.keepalive = false;
this.sshClient.options.readyTimeout = 30000;
return await this.sshClient.standardConnection();
}
async emergencyConnection() {
// Last resort - very basic connection
this.sshClient.options.keepalive = false;
this.sshClient.options.readyTimeout = 15000;
this.sshClient.options.serverAliveInterval = 0;
this.sshClient.options.clientAliveInterval = 0;
return await this.sshClient.standardConnection();
}
}
module.exports = { EnhancedSSHClient };