@tb.p/terminai
Version:
MCP (Model Context Protocol) server for secure SSH remote command execution. Enables AI assistants like Claude, Cursor, and VS Code to execute commands on remote servers via SSH with command validation, history tracking, and web-based configuration UI.
153 lines (130 loc) • 4.22 kB
JavaScript
import { Client } from 'ssh2';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import sshpk from 'sshpk';
export function expandPath(filePath) {
if (filePath.startsWith('~/')) {
return resolve(homedir(), filePath.slice(2));
}
return resolve(filePath);
}
function parseAndConvertKey(keyData, passphrase) {
try {
// Check if it's already in OpenSSH format
if (keyData.includes('BEGIN OPENSSH PRIVATE KEY')) {
return Buffer.from(keyData, 'utf-8');
}
// Try to parse the key with sshpk
const key = sshpk.parsePrivateKey(keyData, 'auto', { passphrase });
// For ed25519 keys, convert PKCS#8 to OpenSSH format
if (key.type === 'ed25519' || key.type === 'curve25519') {
// Convert to OpenSSH format that ssh2 prefers
return key.toBuffer('ssh');
} else {
// For RSA/ECDSA, convert to pkcs1
return key.toBuffer('pkcs1');
}
} catch (error) {
// If sshpk fails, return original key data and let ssh2 try
if (typeof keyData === 'string') {
return Buffer.from(keyData, 'utf-8');
}
return keyData;
}
}
export async function executeCommand(connection, command, timeout = 30000) {
return new Promise((resolve, reject) => {
const conn = new Client();
const startTime = Date.now();
let stdout = '';
let stderr = '';
let exitCode = null;
let timedOut = false;
const timeoutHandle = setTimeout(() => {
timedOut = true;
conn.end();
reject(new Error('Command execution timed out'));
}, timeout);
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timeoutHandle);
conn.end();
reject(err);
return;
}
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
stream.on('close', (code) => {
clearTimeout(timeoutHandle);
exitCode = code;
conn.end();
if (!timedOut) {
const duration = Date.now() - startTime;
resolve({ stdout, stderr, exitCode, duration });
}
});
});
});
conn.on('error', (err) => {
clearTimeout(timeoutHandle);
reject(err);
});
try {
const privateKeyPath = expandPath(connection.identityFile);
let privateKeyData;
try {
privateKeyData = readFileSync(privateKeyPath, 'utf-8');
} catch (readError) {
throw new Error(`Failed to read private key file: ${readError.message}`);
}
// Convert OpenSSH format keys to PEM format
const privateKey = parseAndConvertKey(privateKeyData, connection.passphrase);
const connectOptions = {
host: connection.host,
port: connection.port || 22,
username: connection.username,
privateKey,
readyTimeout: 10000,
debug: (msg) => {
// Uncomment for debugging: console.error('SSH Debug:', msg);
}
};
// Add passphrase if provided (for encrypted keys)
if (connection.passphrase) {
connectOptions.passphrase = connection.passphrase;
}
conn.connect(connectOptions);
} catch (err) {
clearTimeout(timeoutHandle);
reject(err);
}
});
}
export async function testConnection(connection) {
try {
// Validate connection details
if (!connection.identityFile) {
return { success: false, error: 'Identity file path is required' };
}
const privateKeyPath = expandPath(connection.identityFile);
// Check if file exists
try {
readFileSync(privateKeyPath, 'utf-8');
} catch (err) {
return { success: false, error: `Cannot read private key at ${privateKeyPath}: ${err.message}` };
}
const result = await executeCommand(connection, 'echo "SSH connection test"', 5000);
if (result.exitCode === 0) {
return { success: true };
}
return { success: false, error: `Command failed with exit code ${result.exitCode}` };
} catch (error) {
return { success: false, error: error.message };
}
}