const fs = require('fs');
const path = require('path');
const os = require('os');
const { Client } = require('ssh2');
const SSHConfig = require('ssh-config');
// Connection pool to reuse SSH connections
const connectionPool = new Map();
// Cache for parsed SSH config
let sshConfigCache = null;
/**
* Parse SSH config file and cache it
* @returns {object} Parsed SSH config
*/
function parseSSHConfig() {
if (sshConfigCache) {
return sshConfigCache;
}
const configPath = path.join(os.homedir(), '.ssh', 'config');
try {
if (!fs.existsSync(configPath)) {
throw new Error(`SSH config file not found at ${configPath}`);
}
const configContent = fs.readFileSync(configPath, 'utf8');
sshConfigCache = SSHConfig.parse(configContent);
return sshConfigCache;
} catch (error) {
throw new Error(`Failed to parse SSH config: ${error.message}`);
}
}
/**
* Get SSH config for a specific host
* @param {string} hostAlias - The host alias (e.g., "dev")
* @returns {object} SSH connection config
*/
function getHostConfig(hostAlias) {
const config = parseSSHConfig();
// Use compute to get the config for the host alias
const hostConfig = config.compute(hostAlias);
if (!hostConfig || Object.keys(hostConfig).length === 0) {
throw new Error(`No SSH config found for host "${hostAlias}" in ~/.ssh/config`);
}
// Extract config values from the computed config
// Handle IdentityFile which can be an array or string
let identityFile = hostConfig.IdentityFile;
if (Array.isArray(identityFile)) {
// Take the first identity file if multiple are specified
identityFile = identityFile[0];
}
identityFile = identityFile || `${os.homedir()}/.ssh/id_rsa`;
const configObj = {
hostname: hostConfig.HostName || hostAlias,
port: hostConfig.Port ? parseInt(hostConfig.Port, 10) : 22,
username: hostConfig.User || process.env.SSH_USERNAME || process.env.USER || 'root',
identityFile: identityFile,
proxyJump: hostConfig.ProxyJump,
proxyCommand: hostConfig.ProxyCommand,
strictHostKeyChecking: hostConfig.StrictHostKeyChecking,
userKnownHostsFile: hostConfig.UserKnownHostsFile,
// Add other SSH config options as needed
};
// Resolve identity file path (handle ~ expansion)
if (configObj.identityFile && typeof configObj.identityFile === 'string') {
if (configObj.identityFile.startsWith('~')) {
configObj.identityFile = path.join(os.homedir(), configObj.identityFile.slice(1));
}
configObj.identityFile = path.resolve(configObj.identityFile);
}
return configObj;
}
/**
* Get or create an SSH connection for a host alias
* @param {string} hostAlias - The host alias (e.g., "dev")
* @returns {Promise<Client>} - The SSH client connection
*/
function getConnection(hostAlias) {
const connectionKey = `${hostAlias}`;
// Return existing connection if available and ready
if (connectionPool.has(connectionKey)) {
const conn = connectionPool.get(connectionKey);
if (conn && conn.ready) {
return Promise.resolve(conn.client);
}
// Remove stale connection
connectionPool.delete(connectionKey);
}
// Get host config from SSH config file
let hostConfig;
try {
hostConfig = getHostConfig(hostAlias);
} catch (error) {
return Promise.reject(error);
}
// Create new connection
return new Promise((resolve, reject) => {
const conn = new Client();
let privateKey;
try {
privateKey = fs.readFileSync(hostConfig.identityFile, 'utf8');
} catch (error) {
return reject(new Error(`Failed to read private key from ${hostConfig.identityFile}: ${error.message}`));
}
const connectionOptions = {
host: hostConfig.hostname,
port: hostConfig.port,
username: hostConfig.username,
privateKey: privateKey,
readyTimeout: 20000,
keepaliveInterval: 30000,
keepaliveCountMax: 3
};
// Add proxy command if configured
if (hostConfig.proxyCommand) {
connectionOptions.agentForward = false;
// Note: ssh2 doesn't directly support ProxyCommand, but we can use it via agent forwarding
// For ProxyCommand, you might need to use a different approach or library
}
conn.on('ready', () => {
console.log(`SSH connection established to ${hostAlias} (${hostConfig.hostname}:${hostConfig.port})`);
const connectionInfo = {
client: conn,
ready: true,
hostAlias,
hostname: hostConfig.hostname,
port: hostConfig.port,
username: hostConfig.username,
lastUsed: Date.now()
};
connectionPool.set(connectionKey, connectionInfo);
// Handle connection close/error
conn.on('close', () => {
console.log(`SSH connection closed to ${hostAlias}`);
connectionInfo.ready = false;
connectionPool.delete(connectionKey);
});
conn.on('error', (err) => {
console.error(`SSH connection error to ${hostAlias}:`, err);
connectionInfo.ready = false;
connectionPool.delete(connectionKey);
});
resolve(conn);
});
conn.on('error', (err) => {
reject(err);
});
conn.connect(connectionOptions);
});
}
/**
* Execute a command on a remote host using a reusable connection
* @param {string} host - The hostname (ALWAYS IGNORED - always uses "dev" from ~/.ssh/config)
* @param {string} command - The command to execute
* @param {object} options - Connection options (ignored, uses SSH config)
* @returns {Promise<object>} - Command result with stdout, stderr, code, signal
*/
async function runRemoteCommand({ host, command, ...options }) {
// HARDCODED: Always use "dev" host alias from ~/.ssh/config
// The host parameter is completely ignored - "dev" must exist in ~/.ssh/config
const hostAlias = 'dev';
console.log(`Executing command on ${hostAlias} (ignoring host parameter "${host || 'unspecified'}"): ${command}`);
const conn = await getConnection(hostAlias);
return new Promise((resolve, reject) => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', (code, signal) => {
// Don't close the connection - keep it for reuse
console.log(`Command completed on ${hostAlias} with exit code ${code}`);
resolve({ stdout, stderr, code, signal });
});
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
});
});
}
/**
* Close all connections (cleanup)
*/
function closeAllConnections() {
for (const [key, connInfo] of connectionPool.entries()) {
if (connInfo.client) {
connInfo.client.end();
}
connectionPool.delete(key);
}
}
// Clear config cache on process exit (optional, for development)
process.on('SIGINT', () => {
console.log('Closing all SSH connections...');
closeAllConnections();
sshConfigCache = null;
process.exit(0);
});
module.exports = { runRemoteCommand, getConnection, closeAllConnections, getHostConfig };