Skip to main content
Glama
ssh.js7.2 kB
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 };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/rpavez/ssh-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server