import { NodeSSH } from "node-ssh";
import type { HostConfig } from "../types.js";
import { sanitizeErrorMessage } from "../utils/error-sanitization.js";
import { HostOperationError, logError } from "../utils/errors.js";
/**
* SSH connection pool configuration
*/
export interface SSHPoolConfig {
maxConnections: number; // Max connections per host (default: 5)
idleTimeoutMs: number; // Idle timeout before closing (default: 60000)
connectionTimeoutMs: number; // Connection timeout (default: 5000)
enableHealthChecks: boolean; // Enable periodic health checks (default: true)
healthCheckIntervalMs: number; // Health check interval (default: 30000)
}
/**
* Default pool configuration
*/
export const DEFAULT_POOL_CONFIG: SSHPoolConfig = {
maxConnections: 5,
idleTimeoutMs: 60000,
connectionTimeoutMs: 5000,
enableHealthChecks: true,
healthCheckIntervalMs: 30000,
};
/**
* Pool statistics for monitoring
*/
export interface PoolStats {
poolHits: number; // Successful connection reuse
poolMisses: number; // New connections created
activeConnections: number; // Currently active
idleConnections: number; // Currently idle
totalConnections: number; // Total in pool
healthChecksPassed: number; // Successful health checks
healthCheckFailures: number; // Failed health checks
}
/**
* Connection metadata
*/
interface ConnectionMetadata {
connection: NodeSSH;
host: HostConfig;
lastUsed: number;
created: number;
healthChecksPassed: number;
healthChecksFailed: number;
isActive: boolean;
idleTimer?: NodeJS.Timeout;
}
/**
* Queue item for waiting connection requests
*/
interface QueueItem {
resolve: (conn: NodeSSH) => void;
reject: (err: Error) => void;
timer: NodeJS.Timeout;
}
/**
* SSH Connection Pool interface
*/
export interface SSHConnectionPool {
getConnection(host: HostConfig): Promise<NodeSSH>;
releaseConnection(host: HostConfig, connection: NodeSSH): Promise<void>;
closeConnection(host: HostConfig): Promise<void>;
closeAll(): Promise<void>;
getStats(): PoolStats;
}
/**
* Generate unique pool key for host
* Format: ${host.name}:${port}
*/
export function generatePoolKey(host: HostConfig): string {
const port = host.port || 22;
return `${host.name}:${port}`;
}
/**
* SSH Connection Pool Implementation
*/
export class SSHConnectionPoolImpl implements SSHConnectionPool {
private config: SSHPoolConfig;
private pool: Map<string, ConnectionMetadata[]>;
private waitQueues: Map<string, QueueItem[]>;
private stats: PoolStats;
private healthCheckTimer?: NodeJS.Timeout;
// SECURITY (CWE-404): Flag to prevent in-flight health checks from accessing disposed connections
private isShuttingDown = false;
constructor(config: Partial<SSHPoolConfig> = {}) {
this.config = { ...DEFAULT_POOL_CONFIG, ...config };
this.pool = new Map();
this.waitQueues = new Map();
this.stats = {
poolHits: 0,
poolMisses: 0,
activeConnections: 0,
idleConnections: 0,
totalConnections: 0,
healthChecksPassed: 0,
healthCheckFailures: 0,
};
if (this.config.enableHealthChecks) {
this.startHealthChecks();
}
}
getStats(): PoolStats {
return { ...this.stats };
}
getHealth(): { healthy: boolean } {
// Pool is healthy if there are no active connections with failures,
// or if health checks are passing more than failing
const hasFailures = this.stats.healthCheckFailures > 0;
const hasMorePassesThanFailures =
this.stats.healthChecksPassed >= this.stats.healthCheckFailures;
return {
healthy: !hasFailures || hasMorePassesThanFailures,
};
}
private startHealthChecks(): void {
this.scheduleNextHealthCheck();
}
private scheduleNextHealthCheck(): void {
this.healthCheckTimer = setTimeout(async () => {
// SECURITY (CWE-404): Don't run health checks during shutdown
if (this.isShuttingDown) return;
await this.performHealthChecks();
// Only reschedule after completion (prevents overlap)
if (!this.isShuttingDown) {
this.scheduleNextHealthCheck();
}
}, this.config.healthCheckIntervalMs);
}
/**
* Perform health checks on idle connections with staggered timing.
* Instead of firing all checks simultaneously (causing burst SSH traffic),
* checks are spread across the health check interval window with a small
* delay between each to reduce network pressure.
*/
private async performHealthChecks(): Promise<void> {
const checks: Array<{ poolKey: string; metadata: ConnectionMetadata }> = [];
for (const [poolKey, connections] of this.pool.entries()) {
for (const metadata of connections) {
if (!metadata.isActive && !this.isShuttingDown) {
checks.push({ poolKey, metadata });
}
}
}
if (checks.length === 0) return;
// Stagger checks: spread across 80% of the interval to leave headroom
// Minimum 100ms between checks to avoid micro-bursts
const staggerMs = Math.max(
100,
Math.floor((this.config.healthCheckIntervalMs * 0.8) / checks.length)
);
for (let i = 0; i < checks.length; i++) {
if (this.isShuttingDown) break;
const { poolKey, metadata } = checks[i];
await this.checkConnectionHealth(poolKey, metadata);
// Stagger delay between checks (skip delay after last check)
if (i < checks.length - 1 && !this.isShuttingDown) {
await new Promise<void>((resolve) => setTimeout(resolve, staggerMs));
}
}
}
private async checkConnectionHealth(
poolKey: string,
metadata: ConnectionMetadata
): Promise<void> {
try {
// Verify connection using echo command
const result = await metadata.connection.execCommand("echo ok");
if (result.code === 0) {
// Health check passed (exit code 0 indicates success)
metadata.healthChecksPassed++;
this.stats.healthChecksPassed++;
} else {
// Command failed
throw new Error("Health check command failed");
}
} catch (error) {
logError(
new HostOperationError("Health check failed", metadata.host.name, "healthCheck", error),
{
metadata: {
poolKey,
failureCount: metadata.healthChecksFailed + 1,
lastUsed: new Date(metadata.lastUsed).toISOString(),
},
}
);
metadata.healthChecksFailed++;
this.stats.healthCheckFailures++;
await this.removeConnection(poolKey, metadata);
}
}
async getConnection(host: HostConfig): Promise<NodeSSH> {
const poolKey = generatePoolKey(host);
const connections = this.pool.get(poolKey) || [];
// Try to find idle connection
const idleConnection = connections.find((c) => !c.isActive);
if (idleConnection) {
// Reuse existing connection (pool hit)
idleConnection.isActive = true;
idleConnection.lastUsed = Date.now();
this.stats.poolHits++;
this.updateConnectionStats();
return idleConnection.connection;
}
// Check if we can create new connection
if (connections.length >= this.config.maxConnections) {
// Queue the request instead of throwing immediately
return new Promise<NodeSSH>((resolve, reject) => {
const timer = setTimeout(() => {
// Remove from queue on timeout
const queue = this.waitQueues.get(poolKey) || [];
const index = queue.findIndex((item) => item.timer === timer);
if (index !== -1) {
queue.splice(index, 1);
}
reject(
new Error(
`Connection pool wait timeout for ${poolKey} (waited ${this.config.connectionTimeoutMs}ms)`
)
);
}, this.config.connectionTimeoutMs);
const queue = this.waitQueues.get(poolKey) || [];
queue.push({ resolve, reject, timer });
this.waitQueues.set(poolKey, queue);
});
}
// Create new connection (pool miss)
const connection = await this.createConnection(host);
const metadata: ConnectionMetadata = {
connection,
host,
lastUsed: Date.now(),
created: Date.now(),
healthChecksPassed: 0,
healthChecksFailed: 0,
isActive: true,
};
connections.push(metadata);
this.pool.set(poolKey, connections);
this.stats.poolMisses++;
this.updateConnectionStats();
return connection;
}
// SECURITY (CWE-316): Store private keys as Buffers for explicit zeroing on cleanup
private keyCache = new Map<string, { key: Buffer; cachedAt: number }>();
// TTL for cached keys: 5 minutes (prevents indefinite memory retention)
private static readonly KEY_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Get private key content with secure caching
* PERF-C7: Cache key content to eliminate 5-20ms sync I/O per connection
* SECURITY (CWE-316): Uses Buffer with TTL for defense-in-depth
*/
private async getPrivateKey(keyPath: string): Promise<string> {
// Check cache first (with TTL enforcement)
const cached = this.keyCache.get(keyPath);
if (cached) {
const age = Date.now() - cached.cachedAt;
if (age < SSHConnectionPoolImpl.KEY_CACHE_TTL_MS) {
return cached.key.toString("utf-8");
}
// TTL expired: zero and remove
cached.key.fill(0);
this.keyCache.delete(keyPath);
}
// Read once and cache as Buffer (async I/O)
const { promises: fs } = await import("node:fs");
const keyContent = await fs.readFile(keyPath);
this.keyCache.set(keyPath, { key: keyContent, cachedAt: Date.now() });
return keyContent.toString("utf-8");
}
/**
* Securely clear all cached SSH private keys by zeroing buffer memory
* SECURITY (CWE-316): Prevents keys from persisting in memory after cleanup
*/
private clearKeyCache(): void {
for (const entry of this.keyCache.values()) {
entry.key.fill(0);
}
this.keyCache.clear();
}
private async createConnection(host: HostConfig): Promise<NodeSSH> {
const ssh = new NodeSSH();
// SECURITY (S-M3, CWE-250): Validate username FIRST - fail fast before file I/O
// Require explicit SSH username - no root fallback to prevent unnecessary privileges
const username = host.sshUser || process.env.USER;
if (!username) {
throw new HostOperationError(
`No SSH username configured for host ${host.name}. Set sshUser in config or USER env var.`,
host.name,
"createConnection"
);
}
// Read private key content if path is provided
// Using privateKey (content) instead of privateKeyPath is more reliable
// PERF-C7: Use async cached read instead of sync readFileSync
let privateKey: string | undefined;
if (host.sshKeyPath) {
try {
privateKey = await this.getPrivateKey(host.sshKeyPath);
} catch (error) {
// SECURITY (S-H3): Sanitize error message to avoid leaking key path
const detailedMsg = `Failed to read SSH private key at ${host.sshKeyPath}`;
const genericMsg = "Failed to read SSH private key";
throw new HostOperationError(
sanitizeErrorMessage(detailedMsg, genericMsg),
host.name,
"createConnection",
error
);
}
}
// Use SSH agent if available (matches OpenSSH CLI behavior)
const agent = process.env.SSH_AUTH_SOCK || undefined;
const connectionConfig = {
host: host.host,
port: host.port || 22,
username,
privateKey,
agent,
readyTimeout: this.config.connectionTimeoutMs,
};
try {
await ssh.connect(connectionConfig);
return ssh;
} catch (error) {
throw new HostOperationError("SSH connection failed", host.name, "createConnection", error);
}
}
private updateConnectionStats(): void {
let active = 0;
let idle = 0;
let total = 0;
for (const connections of this.pool.values()) {
for (const conn of connections) {
total++;
if (conn.isActive) {
active++;
} else {
idle++;
}
}
}
this.stats.activeConnections = active;
this.stats.idleConnections = idle;
this.stats.totalConnections = total;
}
async releaseConnection(host: HostConfig, connection: NodeSSH): Promise<void> {
const poolKey = generatePoolKey(host);
const connections = this.pool.get(poolKey);
if (!connections) {
return;
}
const metadata = connections.find((c) => c.connection === connection);
if (metadata) {
// Check if there are queued requests waiting for a connection
const queue = this.waitQueues.get(poolKey);
if (queue && queue.length > 0) {
// Give connection directly to next waiting request
const queueItem = queue.shift();
if (queueItem) {
clearTimeout(queueItem.timer);
metadata.isActive = true;
metadata.lastUsed = Date.now();
this.updateConnectionStats();
queueItem.resolve(connection);
return;
}
}
// No waiting requests, mark as idle
metadata.isActive = false;
metadata.lastUsed = Date.now();
this.updateConnectionStats();
// Start idle timeout timer
this.scheduleIdleCleanup(poolKey, metadata);
}
}
private scheduleIdleCleanup(poolKey: string, metadata: ConnectionMetadata): void {
// Cancel previous timer if exists (prevents timer accumulation)
if (metadata.idleTimer) {
clearTimeout(metadata.idleTimer);
}
metadata.idleTimer = setTimeout(async () => {
const now = Date.now();
const idleTime = now - metadata.lastUsed;
// Only close if still idle and exceeded timeout
if (!metadata.isActive && idleTime >= this.config.idleTimeoutMs) {
await this.removeConnection(poolKey, metadata);
}
}, this.config.idleTimeoutMs);
}
private async removeConnection(poolKey: string, metadata: ConnectionMetadata): Promise<void> {
const connections = this.pool.get(poolKey);
if (!connections) return;
const index = connections.indexOf(metadata);
if (index !== -1) {
try {
await metadata.connection.dispose();
} catch (error) {
logError(
new HostOperationError(
"Failed to dispose SSH connection",
metadata.host.name,
"dispose",
error
),
{ metadata: { poolKey } }
);
}
connections.splice(index, 1);
if (connections.length === 0) {
this.pool.delete(poolKey);
}
this.updateConnectionStats();
}
}
async closeConnection(host: HostConfig): Promise<void> {
const poolKey = generatePoolKey(host);
const connections = this.pool.get(poolKey);
if (!connections) {
return;
}
const closePromises = connections.map(async (metadata) => {
try {
await metadata.connection.dispose();
} catch (error) {
logError(
new HostOperationError(
"Failed to dispose SSH connection during closeConnection",
metadata.host.name,
"closeConnection",
error
),
{ metadata: { poolKey } }
);
}
});
await Promise.allSettled(closePromises);
this.pool.delete(poolKey);
this.updateConnectionStats();
}
async closeAll(): Promise<void> {
// SECURITY (CWE-404): Set shutdown flag before clearing timers to prevent
// in-flight health checks from accessing disposed connections
this.isShuttingDown = true;
if (this.healthCheckTimer) {
clearTimeout(this.healthCheckTimer);
this.healthCheckTimer = undefined;
}
// SECURITY (CWE-316): Securely zero and clear cached SSH private keys
this.clearKeyCache();
const closePromises: Promise<void>[] = [];
for (const connections of this.pool.values()) {
for (const metadata of connections) {
closePromises.push(
(async (): Promise<void> => {
try {
await metadata.connection.dispose();
} catch (error) {
logError(
new HostOperationError(
"Failed to dispose SSH connection during closeAll",
metadata.host.name,
"closeAll",
error
),
{ operation: "closeAll" }
);
}
})()
);
}
}
await Promise.allSettled(closePromises);
this.pool.clear();
this.updateConnectionStats();
}
}