import { Client } from 'ssh2';
import fs from 'fs';
import { promisify } from 'util';
import crypto from 'crypto';
import { isHostKnown, getCurrentHostKey, addHostKey, updateHostKey } from './ssh-key-manager.js';
import { configLoader } from './config-loader.js';
import { logger } from './logger.js';
class SSHManager {
constructor(config) {
this.config = config;
this.client = new Client();
this.connected = false;
this.sftp = null;
this.cachedHomeDir = null;
this.autoAcceptHostKey = config.autoAcceptHostKey || false;
this.hostKeyVerification = config.hostKeyVerification !== false; // Default true
}
async connect() {
return new Promise((resolve, reject) => {
this.client.on('ready', () => {
this.connected = true;
resolve();
});
this.client.on('error', (err) => {
this.connected = false;
reject(err);
});
this.client.on('end', () => {
this.connected = false;
});
// Build connection config
const connConfig = {
host: this.config.host,
port: this.config.port || 22,
username: this.config.user,
readyTimeout: 60000, // Increased from 20000 to 60000 for slow connections
keepaliveInterval: 10000,
// Add compatibility options for problematic servers
algorithms: {
kex: ['ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521', 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1'],
cipher: ['aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'aes128-gcm', 'aes256-gcm', 'aes128-cbc', 'aes192-cbc', 'aes256-cbc'],
serverHostKey: ['ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519'],
hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1']
},
debug: (info) => {
if (info.includes('Handshake') || info.includes('error')) {
logger.debug('SSH2 Debug', { info });
}
}
};
// Add host key verification callback if enabled
if (this.hostKeyVerification) {
connConfig.hostVerifier = (hashedKey) => {
const port = this.config.port || 22;
const host = this.config.host;
// Check if host is already known
if (isHostKnown(host, port)) {
// For now, accept all known hosts
// TODO: Implement proper fingerprint comparison once we understand SSH2's hash format
logger.info('Host key verified', { host, port });
return true;
}
// Host is not known
logger.info('New host detected', { host, port });
// If autoAcceptHostKey is enabled, accept and add the key
if (this.autoAcceptHostKey) {
logger.info('Auto-accept host key', { host, port });
// Schedule key addition after connection
setImmediate(async () => {
try {
await addHostKey(host, port);
logger.info('Host key added', { host, port });
} catch (err) {
logger.warn('Failed to add host key', {
host,
port,
error: err.message
});
}
});
return true;
}
// For backward compatibility, accept new hosts by default
// In production, you might want to prompt the user or check a whitelist
logger.warn('Auto-accepting new host', { host, port });
return true;
};
}
// Add authentication (support both keyPath and keypath for compatibility)
const keyPath = this.config.keyPath || this.config.keypath;
if (keyPath) {
const resolvedKeyPath = keyPath.replace('~', process.env.HOME);
connConfig.privateKey = fs.readFileSync(resolvedKeyPath);
} else if (this.config.password) {
connConfig.password = this.config.password;
}
this.client.connect(connConfig);
});
}
async execCommand(command, options = {}) {
if (!this.connected) {
throw new Error('Not connected to SSH server');
}
const { timeout = 30000, cwd, rawCommand = false } = options;
const fullCommand = (cwd && !rawCommand) ? `cd ${cwd} && ${command}` : command;
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let completed = false;
let stream = null;
let timeoutId = null;
// Setup timeout first
if (timeout > 0) {
timeoutId = setTimeout(() => {
if (!completed) {
completed = true;
// Try multiple ways to kill the stream
if (stream) {
try {
stream.write('\x03'); // Send Ctrl+C
stream.end();
stream.destroy();
} catch (e) {
// Ignore errors
}
}
// Kill the entire client connection as last resort
try {
this.client.end();
this.connected = false;
} catch (e) {
// Ignore errors
}
reject(new Error(`Command timeout after ${timeout}ms: ${command.substring(0, 100)}...`));
}
}, timeout);
}
this.client.exec(fullCommand, (err, streamObj) => {
if (err) {
completed = true;
if (timeoutId) clearTimeout(timeoutId);
reject(err);
return;
}
stream = streamObj;
stream.on('close', (code, signal) => {
if (!completed) {
completed = true;
if (timeoutId) clearTimeout(timeoutId);
resolve({
stdout,
stderr,
code: code || 0,
signal
});
}
});
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
stream.on('error', (err) => {
if (!completed) {
completed = true;
if (timeoutId) clearTimeout(timeoutId);
reject(err);
}
});
});
});
}
async execCommandStream(command, options = {}) {
if (!this.connected) {
throw new Error('Not connected to SSH server');
}
const { cwd, onStdout, onStderr } = options;
const fullCommand = cwd ? `cd ${cwd} && ${command}` : command;
return new Promise((resolve, reject) => {
this.client.exec(fullCommand, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('close', (code, signal) => {
resolve({
stdout,
stderr,
code: code || 0,
signal,
stream
});
});
stream.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
if (onStdout) onStdout(chunk);
});
stream.stderr.on('data', (data) => {
const chunk = data.toString();
stderr += chunk;
if (onStderr) onStderr(chunk);
});
stream.on('error', reject);
});
});
}
async requestShell(options = {}) {
if (!this.connected) {
throw new Error('Not connected to SSH server');
}
return new Promise((resolve, reject) => {
this.client.shell(options, (err, stream) => {
if (err) {
reject(err);
return;
}
resolve(stream);
});
});
}
async getSFTP() {
if (this.sftp) return this.sftp;
return new Promise((resolve, reject) => {
this.client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
this.sftp = sftp;
resolve(sftp);
});
});
}
async resolveHomePath() {
if (this.cachedHomeDir) {
return this.cachedHomeDir;
}
let homeDir = null;
// Method 1: Try getent (most reliable)
try {
const result = await this.execCommand('getent passwd $USER | cut -d: -f6', {
timeout: 5000,
rawCommand: true
});
homeDir = result.stdout.trim();
if (homeDir && homeDir.startsWith('/')) {
this.cachedHomeDir = homeDir;
return homeDir;
}
} catch (err) {
// getent might not be available, try next method
}
// Method 2: Try env -i to get clean HOME
try {
const result = await this.execCommand('env -i HOME=$HOME bash -c "echo $HOME"', {
timeout: 5000,
rawCommand: true
});
homeDir = result.stdout.trim();
if (homeDir && homeDir.startsWith('/')) {
this.cachedHomeDir = homeDir;
return homeDir;
}
} catch (err) {
// env method failed, try next
}
// Method 3: Parse /etc/passwd directly
try {
const result = await this.execCommand('grep "^$USER:" /etc/passwd | cut -d: -f6', {
timeout: 5000,
rawCommand: true
});
homeDir = result.stdout.trim();
if (homeDir && homeDir.startsWith('/')) {
this.cachedHomeDir = homeDir;
return homeDir;
}
} catch (err) {
// /etc/passwd parsing failed, try last resort
}
// Method 4: Last resort - try cd ~ && pwd
try {
const result = await this.execCommand('cd ~ && pwd', {
timeout: 5000,
rawCommand: true
});
homeDir = result.stdout.trim();
if (homeDir && homeDir.startsWith('/')) {
this.cachedHomeDir = homeDir;
return homeDir;
}
} catch (err) {
// All methods failed
}
throw new Error('Unable to determine home directory on remote server');
}
async putFile(localPath, remotePath) {
// SFTP doesn't resolve ~ automatically, we need to get the real path
let resolvedRemotePath = remotePath;
if (remotePath.includes('~')) {
try {
const homeDir = await this.resolveHomePath();
// Replace ~ with the actual home directory
// Handle both ~/path and ~ alone
if (remotePath === '~') {
resolvedRemotePath = homeDir;
} else if (remotePath.startsWith('~/')) {
resolvedRemotePath = homeDir + remotePath.substring(1);
} else {
// If ~ is not at the beginning, don't replace it
resolvedRemotePath = remotePath;
}
} catch (err) {
// If we can't resolve home, throw a more descriptive error
throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`);
}
}
const sftp = await this.getSFTP();
return new Promise((resolve, reject) => {
// Check if local file exists and is readable
if (!fs.existsSync(localPath)) {
reject(new Error(`Local file does not exist: ${localPath}`));
return;
}
sftp.fastPut(localPath, resolvedRemotePath, (err) => {
if (err) reject(err);
else resolve();
});
});
}
async getFile(localPath, remotePath) {
// SFTP doesn't resolve ~ automatically, we need to get the real path
let resolvedRemotePath = remotePath;
if (remotePath.includes('~')) {
try {
const homeDir = await this.resolveHomePath();
// Replace ~ with the actual home directory
// Handle both ~/path and ~ alone
if (remotePath === '~') {
resolvedRemotePath = homeDir;
} else if (remotePath.startsWith('~/')) {
resolvedRemotePath = homeDir + remotePath.substring(1);
} else {
// If ~ is not at the beginning, don't replace it
resolvedRemotePath = remotePath;
}
} catch (err) {
// If we can't resolve home, throw a more descriptive error
throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`);
}
}
const sftp = await this.getSFTP();
return new Promise((resolve, reject) => {
sftp.fastGet(resolvedRemotePath, localPath, (err) => {
if (err) reject(err);
else resolve();
});
});
}
async putFiles(files, options = {}) {
const sftp = await this.getSFTP();
const results = [];
for (const file of files) {
try {
await this.putFile(file.local, file.remote);
results.push({ ...file, success: true });
} catch (error) {
results.push({ ...file, success: false, error: error.message });
if (options.stopOnError) break;
}
}
return results;
}
isConnected() {
return this.connected && this.client && !this.client.destroyed;
}
dispose() {
if (this.sftp) {
this.sftp.end();
this.sftp = null;
}
if (this.client) {
this.client.end();
this.connected = false;
}
}
async ping() {
try {
const result = await this.execCommand('echo "ping"', { timeout: 5000 });
return result.stdout.trim() === 'ping';
} catch (error) {
return false;
}
}
}
export default SSHManager;