@xec-sh/core
Version:
Universal shell execution engine
646 lines • 27.2 kB
JavaScript
import { StreamHandler } from '../utils/stream.js';
import { escapeArg } from '../utils/shell-escape.js';
import { SSHKeyValidator } from '../utils/ssh-key-validator.js';
import { BaseAdapter } from './base-adapter.js';
import { SecurePasswordHandler } from '../utils/secure-password.js';
import { AdapterError, TimeoutError, ConnectionError } from '../core/error.js';
import { NodeSSH } from '../utils/ssh.js';
import { ConnectionPoolMetricsCollector } from '../utils/connection-pool-metrics.js';
export class SSHAdapter extends BaseAdapter {
constructor(config = {}) {
super(config);
this.adapterName = 'ssh';
this.connectionPool = new Map();
this.metricsCollector = new ConnectionPoolMetricsCollector();
this.activeTunnels = new Map();
this.name = this.adapterName;
this.sshConfig = {
...this.config,
connectionPool: {
enabled: config.connectionPool?.enabled ?? true,
maxConnections: config.connectionPool?.maxConnections ?? 10,
idleTimeout: config.connectionPool?.idleTimeout ?? 300000,
keepAlive: config.connectionPool?.keepAlive ?? true,
keepAliveInterval: config.connectionPool?.keepAliveInterval ?? 30000,
autoReconnect: config.connectionPool?.autoReconnect ?? true,
maxReconnectAttempts: config.connectionPool?.maxReconnectAttempts ?? 3,
reconnectDelay: config.connectionPool?.reconnectDelay ?? 1000
},
defaultConnectOptions: config.defaultConnectOptions ?? {},
multiplexing: {
enabled: config.multiplexing?.enabled ?? false,
controlPath: config.multiplexing?.controlPath,
controlPersist: config.multiplexing?.controlPersist ?? 600
},
sudo: {
enabled: config.sudo?.enabled ?? false,
password: config.sudo?.password,
prompt: config.sudo?.prompt ?? '[sudo] password',
method: config.sudo?.method ?? 'stdin',
secureHandler: config.sudo?.secureHandler
},
sftp: {
enabled: config.sftp?.enabled ?? true,
concurrency: config.sftp?.concurrency ?? 5
}
};
if (this.sshConfig.connectionPool.enabled) {
this.startPoolCleanup();
}
}
async isAvailable() {
try {
await import('ssh2');
return true;
}
catch {
return false;
}
}
async execute(command) {
const mergedCommand = this.mergeCommand(command);
const sshOptions = this.extractSSHOptions(mergedCommand);
if (!sshOptions) {
throw new AdapterError(this.adapterName, 'execute', new Error('SSH connection options not provided'));
}
this.lastUsedSSHOptions = sshOptions;
const startTime = Date.now();
let connection = null;
const commandString = this.buildCommandString(mergedCommand);
try {
connection = await this.getConnection(sshOptions);
this.emitAdapterEvent('ssh:execute', {
host: sshOptions.host,
command: commandString
});
let envPrefix = '';
if (mergedCommand.env && Object.keys(mergedCommand.env).length > 0) {
const explicitEnv = {};
for (const [key, value] of Object.entries(mergedCommand.env)) {
if (command.env && key in command.env) {
explicitEnv[key] = value;
}
}
if (Object.keys(explicitEnv).length > 0) {
const envVars = Object.entries(explicitEnv)
.map(([key, value]) => `export ${key}=${escapeArg(value)}`)
.join('; ');
envPrefix = `${envVars}; `;
}
}
const finalCommand = await this.wrapWithSudo(envPrefix + commandString, mergedCommand, connection.ssh);
const sshCommand = { ...mergedCommand };
if (!command.cwd && mergedCommand.cwd) {
delete sshCommand.cwd;
}
const connectionKey = this.getConnectionKey(sshOptions);
const result = await this.executeSSHCommand(connection.ssh, finalCommand, sshCommand, connection.host, connectionKey);
const endTime = Date.now();
return this.createResult(result.stdout, result.stderr, result.code ?? 0, undefined, commandString, startTime, endTime, { host: `${sshOptions.host}:${sshOptions.port || 22}`, originalCommand: mergedCommand });
}
catch (error) {
if (error instanceof TimeoutError) {
if (connection) {
this.removeFromPool(this.getConnectionKey(sshOptions));
}
if (!command.nothrow) {
throw error;
}
const endTime = Date.now();
return this.createResult('', error.message, 124, 'SIGTERM', commandString, startTime, endTime, { host: `${sshOptions.host}:${sshOptions.port || 22}`, originalCommand: mergedCommand });
}
if (error instanceof ConnectionError) {
throw error;
}
if (connection) {
connection.errors++;
if (connection.errors > 3) {
this.removeFromPool(this.getConnectionKey(sshOptions));
}
}
throw new AdapterError(this.adapterName, 'execute', error instanceof Error ? error : new Error(String(error)));
}
finally {
if (connection && this.sshConfig.connectionPool.enabled) {
connection.lastUsed = Date.now();
}
}
}
extractSSHOptions(command) {
if (command.adapterOptions?.type === 'ssh') {
return command.adapterOptions;
}
return null;
}
async getConnection(options) {
const key = this.getConnectionKey(options);
if (this.sshConfig.connectionPool.enabled) {
const existing = this.connectionPool.get(key);
if (existing) {
if (existing.ssh.isConnected()) {
existing.useCount++;
existing.lastUsed = Date.now();
this.metricsCollector.onConnectionReused();
this.emitAdapterEvent('ssh:pool-metrics', {
metrics: this.getPoolMetrics()
});
return existing;
}
if (this.sshConfig.connectionPool.autoReconnect) {
try {
const reconnected = await this.reconnectConnection(existing);
if (reconnected) {
return reconnected;
}
}
catch (error) {
this.removeFromPool(key);
}
}
else {
this.removeFromPool(key);
}
}
}
const validationResult = SSHKeyValidator.validateSSHOptions({
host: options.host,
username: options.username,
port: options.port,
privateKey: options.privateKey,
password: options.password
});
if (!validationResult.isValid) {
throw new ConnectionError(options.host, new Error(`Invalid SSH options: ${validationResult.issues.join(', ')}`));
}
if (options.privateKey) {
const keyValidation = await SSHKeyValidator.validatePrivateKey(options.privateKey);
if (!keyValidation.isValid) {
throw new ConnectionError(options.host, new Error(`Invalid SSH private key: ${keyValidation.issues.join(', ')}`));
}
this.emitAdapterEvent('ssh:key-validated', {
host: options.host,
keyType: keyValidation.keyType || 'unknown',
username: options.username || process.env['USER'] || 'unknown'
});
}
const ssh = new NodeSSH();
const connectOptions = {
...this.sshConfig.defaultConnectOptions,
host: options.host,
username: options.username,
port: options.port ?? 22,
privateKey: options.privateKey,
passphrase: options.passphrase,
password: options.password
};
try {
await ssh.connect(connectOptions);
this.emitAdapterEvent('ssh:connect', {
host: options.host,
port: options.port ?? 22,
username: options.username || process.env['USER'] || 'unknown'
});
this.emitAdapterEvent('connection:open', {
host: options.host,
port: options.port ?? 22,
type: 'ssh',
metadata: {
username: options.username || process.env['USER'] || 'unknown'
}
});
}
catch (error) {
throw new ConnectionError(options.host, error instanceof Error ? error : new Error(String(error)));
}
const now = Date.now();
const connection = {
ssh,
host: options.host,
lastUsed: now,
useCount: 1,
created: now,
errors: 0,
reconnectAttempts: 0,
config: options
};
if (this.sshConfig.connectionPool.enabled) {
if (this.connectionPool.size >= this.sshConfig.connectionPool.maxConnections) {
this.removeOldestIdleConnection();
}
this.connectionPool.set(key, connection);
this.metricsCollector.onConnectionCreated();
if (this.sshConfig.connectionPool.keepAlive) {
this.setupKeepAlive(connection);
}
this.emitAdapterEvent('ssh:pool-metrics', {
metrics: this.getPoolMetrics()
});
}
return connection;
}
getConnectionKey(options) {
return `${options.username}@${options.host}:${options.port ?? 22}`;
}
async executeSSHCommand(ssh, command, options = {}, host, connectionKey) {
const stdoutHandler = new StreamHandler({
encoding: this.config.encoding,
maxBuffer: this.config.maxBuffer
});
const stderrHandler = new StreamHandler({
encoding: this.config.encoding,
maxBuffer: this.config.maxBuffer
});
const execOptions = {
cwd: options.cwd,
stdin: this.convertStdin(options.stdin),
execOptions: {}
};
if (options.stdout === 'pipe') {
execOptions.onStdout = (chunk) => {
const transform = stdoutHandler.createTransform();
transform.write(chunk);
transform.end();
};
}
if (options.stderr === 'pipe') {
execOptions.onStderr = (chunk) => {
const transform = stderrHandler.createTransform();
transform.write(chunk);
transform.end();
};
}
const execPromise = ssh.execCommand(command, execOptions)
.catch(error => {
if (error.message?.includes('Socket closed') ||
error.message?.includes('Connection closed') ||
error.message?.includes('Not connected')) {
return { code: -1, stdout: '', stderr: 'Connection closed due to timeout' };
}
throw error;
});
const timeout = options.timeout ?? this.config.defaultTimeout;
const result = await this.handleTimeout(execPromise, timeout, command, () => {
});
if (options.stdout === 'pipe') {
result.stdout = stdoutHandler.getContent();
}
if (options.stderr === 'pipe') {
result.stderr = stderrHandler.getContent();
}
return result;
}
convertStdin(stdin) {
if (!stdin)
return undefined;
if (typeof stdin === 'string')
return stdin;
if (Buffer.isBuffer(stdin))
return stdin.toString();
return undefined;
}
async wrapWithSudo(command, options, ssh) {
const globalSudoEnabled = this.sshConfig.sudo.enabled;
const sshOptions = this.extractSSHOptions(options);
const commandSudoEnabled = sshOptions?.sudo?.enabled;
if (!globalSudoEnabled && !commandSudoEnabled) {
return command;
}
const sudoConfig = {
...this.sshConfig.sudo,
...(sshOptions?.sudo || {})
};
const method = sudoConfig.method || sudoConfig.passwordMethod;
if ((method === 'secure' || method === 'secure-askpass') && !this.securePasswordHandler) {
this.securePasswordHandler = sudoConfig.secureHandler || new SecurePasswordHandler();
}
return this.buildSudoCommandWithConfig(command, sudoConfig);
}
buildSudoCommandWithConfig(command, sudoConfig) {
if (!sudoConfig || !sudoConfig.enabled)
return command;
const sudoCmd = sudoConfig.user ? `sudo -u ${sudoConfig.user}` : 'sudo';
if (sudoConfig.password) {
const method = sudoConfig.method || sudoConfig.passwordMethod || 'stdin';
switch (method) {
case 'stdin':
return `echo '${sudoConfig.password}' | ${sudoCmd} -S ${command}`;
case 'echo':
console.warn('Using echo for sudo password is insecure and may expose the password in process listings');
return `echo '${sudoConfig.password}' | ${sudoCmd} -S ${command}`;
case 'askpass':
return `SUDO_ASKPASS=/tmp/askpass_$$ ${sudoCmd} -A ${command}`;
case 'secure':
case 'secure-askpass': {
const scriptId = Math.random().toString(36).substring(7);
const remoteAskpassPath = `/tmp/askpass-${scriptId}.sh`;
const escapedPassword = sudoConfig.password.replace(/'/g, "'\\''");
const remoteScript = [
`cat > ${remoteAskpassPath} << 'EOF'`,
`#!/bin/sh`,
`echo '${escapedPassword}'`,
`EOF`,
`chmod 700 ${remoteAskpassPath}`,
`SUDO_ASKPASS=${remoteAskpassPath} ${sudoCmd} -A ${command}`,
`rm -f ${remoteAskpassPath}`
].join(' && ');
return remoteScript;
}
default:
return `${sudoCmd} ${command}`;
}
}
return `${sudoCmd} ${command}`;
}
buildSudoCommand(command, sshOptions) {
const sudo = sshOptions.sudo;
if (!sudo || !sudo.enabled)
return command;
const sudoCmd = sudo.user ? `sudo -u ${sudo.user}` : 'sudo';
if (sudo.password) {
switch (sudo.passwordMethod) {
case 'stdin':
return `echo '${sudo.password}' | sudo -S ${command}`;
case 'askpass':
return `SUDO_ASKPASS=/tmp/askpass_$$ ${sudoCmd} -A ${command}`;
case 'secure':
if (this.securePasswordHandler) {
try {
const askpassPath = this.securePasswordHandler.createAskPassScript(sudo.password);
const secureCommand = `SUDO_ASKPASS=${askpassPath} ${sudoCmd} -A ${command}`;
setTimeout(() => {
try {
this.securePasswordHandler?.cleanup();
}
catch {
}
}, 1000);
return secureCommand;
}
catch (error) {
console.error('Failed to create secure askpass, falling back to stdin method:', error);
return `sudo -S ${command}`;
}
}
break;
default:
return `${sudoCmd} ${command}`;
}
}
return `${sudoCmd} ${command}`;
}
startPoolCleanup() {
this.poolCleanupInterval = setInterval(() => {
const now = Date.now();
const timeout = this.sshConfig.connectionPool.idleTimeout;
let cleaned = 0;
for (const [key, connection] of this.connectionPool.entries()) {
if (now - connection.lastUsed > timeout) {
this.removeFromPool(key);
cleaned++;
}
}
if (cleaned > 0) {
this.metricsCollector.onCleanup();
this.emitAdapterEvent('ssh:pool-cleanup', {
cleaned,
remaining: this.connectionPool.size
});
}
}, 60000);
this.poolCleanupInterval.unref();
}
removeFromPool(key) {
const connection = this.connectionPool.get(key);
if (connection) {
const [hostPort] = key.split('@').slice(-1);
const [host = 'unknown', port = '22'] = (hostPort || 'unknown:22').split(':');
this.emitAdapterEvent('ssh:disconnect', {
host,
reason: 'pool_removal'
});
this.emitAdapterEvent('connection:close', {
host,
port: parseInt(port, 10),
type: 'ssh',
reason: 'pool_removal'
});
connection.ssh.dispose();
this.connectionPool.delete(key);
this.metricsCollector.onConnectionDestroyed();
if (connection.keepAliveTimer) {
clearInterval(connection.keepAliveTimer);
}
}
}
async reconnectConnection(connection) {
const maxAttempts = this.sshConfig.connectionPool.maxReconnectAttempts ?? 3;
const delay = this.sshConfig.connectionPool.reconnectDelay ?? 1000;
if (connection.reconnectAttempts >= maxAttempts) {
this.metricsCollector.onConnectionFailed();
return null;
}
connection.reconnectAttempts++;
try {
await new Promise(resolve => setTimeout(resolve, delay * connection.reconnectAttempts));
await connection.ssh.connect({
host: connection.config.host,
username: connection.config.username,
port: connection.config.port ?? 22,
privateKey: connection.config.privateKey,
passphrase: connection.config.passphrase,
password: connection.config.password
});
connection.errors = 0;
connection.lastUsed = Date.now();
if (this.sshConfig.connectionPool.keepAlive) {
this.setupKeepAlive(connection);
}
this.emitAdapterEvent('ssh:reconnect', {
host: connection.host,
attempts: connection.reconnectAttempts
});
return connection;
}
catch (error) {
connection.errors++;
this.metricsCollector.onConnectionFailed();
throw error;
}
}
setupKeepAlive(connection) {
const interval = this.sshConfig.connectionPool.keepAliveInterval ?? 30000;
if (connection.keepAliveTimer) {
clearInterval(connection.keepAliveTimer);
}
connection.keepAliveTimer = setInterval(async () => {
try {
await connection.ssh.execCommand('echo "keep-alive"', {
cwd: '/',
execOptions: { pty: false }
});
}
catch (error) {
connection.errors++;
}
}, interval);
connection.keepAliveTimer.unref();
}
removeOldestIdleConnection() {
let oldestKey = null;
let oldestTime = Date.now();
for (const [key, connection] of this.connectionPool.entries()) {
if (connection.lastUsed < oldestTime) {
oldestTime = connection.lastUsed;
oldestKey = key;
}
}
if (oldestKey) {
this.removeFromPool(oldestKey);
}
}
getPoolMetrics() {
const connections = new Map();
for (const [key, conn] of this.connectionPool.entries()) {
connections.set(key, {
created: new Date(conn.created),
lastUsed: new Date(conn.lastUsed),
useCount: conn.useCount,
isAlive: conn.ssh.isConnected(),
errors: conn.errors
});
}
return this.metricsCollector.getMetrics(this.connectionPool.size, connections);
}
getConnectionPoolMetrics() {
return this.getPoolMetrics();
}
async dispose() {
if (this.poolCleanupInterval) {
clearInterval(this.poolCleanupInterval);
}
for (const [id, tunnel] of this.activeTunnels) {
try {
await tunnel.close();
}
catch (error) {
console.error(`Failed to close tunnel ${id}:`, error);
}
}
this.activeTunnels.clear();
for (const connection of this.connectionPool.values()) {
this.emitAdapterEvent('ssh:disconnect', {
host: connection.host,
reason: 'adapter_dispose'
});
this.emitAdapterEvent('connection:close', {
host: connection.host,
port: connection.config.port ?? 22,
type: 'ssh',
reason: 'adapter_dispose'
});
connection.ssh.dispose();
}
this.connectionPool.clear();
if (this.securePasswordHandler) {
await this.securePasswordHandler.cleanup();
this.securePasswordHandler = undefined;
}
}
async uploadFile(localPath, remotePath, options) {
if (!this.sshConfig.sftp.enabled) {
throw new AdapterError(this.adapterName, 'uploadFile', new Error('SFTP is disabled'));
}
const connection = await this.getConnection(options);
await connection.ssh.putFile(localPath, remotePath);
}
async downloadFile(remotePath, localPath, options) {
if (!this.sshConfig.sftp.enabled) {
throw new AdapterError(this.adapterName, 'downloadFile', new Error('SFTP is disabled'));
}
const connection = await this.getConnection(options);
await connection.ssh.getFile(localPath, remotePath);
}
async uploadDirectory(localPath, remotePath, options) {
if (!this.sshConfig.sftp.enabled) {
throw new AdapterError(this.adapterName, 'uploadDirectory', new Error('SFTP is disabled'));
}
const connection = await this.getConnection(options);
await connection.ssh.putDirectory(localPath, remotePath, {
concurrency: this.sshConfig.sftp.concurrency
});
}
async portForward(localPort, remoteHost, remotePort, options) {
const connection = await this.getConnection(options);
await connection.ssh.forwardOut('127.0.0.1', localPort, remoteHost, remotePort);
}
async tunnel(options) {
const sshOptions = this.lastUsedSSHOptions;
if (!sshOptions) {
throw new AdapterError(this.adapterName, 'tunnel', new Error('No SSH connection available. Execute a command first or provide connection options.'));
}
const connection = await this.getConnection(sshOptions);
const localPort = options.localPort || 0;
const localHost = options.localHost || 'localhost';
const remoteHost = options.remoteHost;
const remotePort = options.remotePort;
const net = await import('net');
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(localPort, localHost, () => {
const actualPort = server.address().port;
server.on('connection', (clientSocket) => {
const ssh2Connection = connection.ssh.connection;
ssh2Connection.forwardOut(clientSocket.remoteAddress || '127.0.0.1', clientSocket.remotePort || 0, remoteHost, remotePort, (err, stream) => {
if (err) {
clientSocket.destroy();
return;
}
clientSocket.pipe(stream).pipe(clientSocket);
stream.on('close', () => {
clientSocket.end();
});
clientSocket.on('close', () => {
stream.end();
});
});
});
const tunnelId = `${actualPort}-${remoteHost}:${remotePort}`;
const tunnel = {
localPort: actualPort,
localHost,
remoteHost,
remotePort,
isOpen: true,
open: async () => {
},
close: async () => new Promise((resolveClose) => {
server.close(() => {
this.activeTunnels.delete(tunnelId);
this.emitAdapterEvent('ssh:tunnel-closed', {
localPort: actualPort,
remoteHost: options.remoteHost,
remotePort: options.remotePort
});
resolveClose();
});
})
};
this.activeTunnels.set(tunnelId, tunnel);
this.emitAdapterEvent('ssh:tunnel-created', {
localPort: actualPort,
remoteHost: options.remoteHost,
remotePort: options.remotePort
});
this.emitAdapterEvent('tunnel:created', {
localPort: actualPort,
remoteHost: options.remoteHost,
remotePort: options.remotePort,
type: 'ssh'
});
resolve(tunnel);
});
});
}
}
//# sourceMappingURL=ssh-adapter.js.map