import { Client as SSHClient } from 'ssh2';
import net from 'net';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import type { Logger } from '../utils/logger.js';
// Lock file paths (Windows compatible)
const LOCK_DIR = path.join(os.tmpdir(), 'ludus_mcp_tunnel');
const TUNNEL_LOCK_FILE = path.join(LOCK_DIR, 'tunnel.lock');
const REFS_FILE = path.join(LOCK_DIR, 'refs.json');
interface TunnelState {
pid: number;
regularPort: number;
primaryPort: number;
refCount: number;
startTime: number;
}
interface TunnelReference {
pid: number;
startTime: number;
}
export interface SSHTunnelConfig {
host: string;
port: number;
username: string;
authMethod: 'key' | 'password';
privateKeyPath?: string | undefined; // Required for key auth
privateKeyPassphrase?: string | undefined; // Optional passphrase for password-protected keys
password?: string | undefined; // Required for password auth
regularPort: number; // 8080
primaryPort: number; // 8081
}
export class LudusSSHTunnelManager {
private sshConfig: SSHTunnelConfig;
private logger: Logger;
private sshConnection?: SSHClient;
private regularTunnelServer?: net.Server;
private primaryTunnelServer?: net.Server;
private instanceId: string;
constructor(sshConfig: SSHTunnelConfig, logger: Logger) {
this.sshConfig = sshConfig;
this.logger = logger;
this.instanceId = `${process.pid}-${Date.now()}`;
}
async connect(): Promise<void> {
await this.ensureLockDir();
// Try to acquire lock for tunnel management
const lockAcquired = await this.acquireLock();
if (lockAcquired) {
// We're the first instance or taking over from a dead instance
this.logger.info('Acquiring tunnel lock, starting SSH tunnels');
await this.startTunnels();
} else {
// Another instance is managing the tunnel
this.logger.info('Existing tunnel found, waiting for availability');
await this.waitForTunnels();
}
// Register this instance
await this.addReference();
}
async disconnect(): Promise<void> {
const shouldShutdownTunnel = await this.removeReference();
if (shouldShutdownTunnel) {
this.logger.info('Last instance disconnecting, shutting down SSH tunnels');
if (this.regularTunnelServer) {
this.regularTunnelServer.close();
}
if (this.primaryTunnelServer) {
this.primaryTunnelServer.close();
}
if (this.sshConnection) {
this.sshConnection.end();
}
await this.releaseLock();
}
}
/**
* Check if tunnels are healthy and responding
*/
async checkTunnelHealth(): Promise<{ regularHealthy: boolean; primaryHealthy: boolean }> {
const regularHealthy = await this.isTunnelPortOpen(this.sshConfig.regularPort, 3); // Quick check, 3 attempts
const primaryHealthy = await this.isTunnelPortOpen(this.sshConfig.primaryPort, 3);
this.logger.debug('Tunnel health check completed', {
regularPort: this.sshConfig.regularPort,
regularHealthy,
primaryPort: this.sshConfig.primaryPort,
primaryHealthy
});
return { regularHealthy, primaryHealthy };
}
/**
* Attempt to reconnect tunnels if they're not healthy
*/
async ensureTunnelsHealthy(): Promise<void> {
const health = await this.checkTunnelHealth();
if (!health.regularHealthy || !health.primaryHealthy) {
this.logger.warn('Tunnels not healthy, attempting recovery', {
regularHealthy: health.regularHealthy,
primaryHealthy: health.primaryHealthy
});
// Try to reconnect
try {
await this.reconnectTunnels();
} catch (error) {
this.logger.error('Failed to reconnect tunnels', { error });
throw new Error(`Tunnel reconnection failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
/**
* Reconnect SSH tunnels
*/
private async reconnectTunnels(): Promise<void> {
// Close existing connections
if (this.regularTunnelServer) {
this.regularTunnelServer.close();
}
if (this.primaryTunnelServer) {
this.primaryTunnelServer.close();
}
if (this.sshConnection) {
this.sshConnection.end();
}
// Wait a moment for cleanup
await new Promise(resolve => setTimeout(resolve, 1000));
// Re-establish tunnels
await this.startTunnels();
this.logger.info('SSH tunnels reconnected successfully');
}
private async ensureLockDir(): Promise<void> {
try {
await fs.mkdir(LOCK_DIR, { recursive: true });
} catch (error) {
// Directory might already exist
}
}
private async acquireLock(): Promise<boolean> {
try {
// Check if lock exists and if the process is still running
const lockData = await this.readLockFile();
if (lockData && await this.isProcessRunning(lockData.pid)) {
return false;
}
// Create or update lock file
await this.writeLockFile({
pid: process.pid,
regularPort: this.sshConfig.regularPort,
primaryPort: this.sshConfig.primaryPort,
refCount: 0,
startTime: Date.now()
});
return true;
} catch (error) {
// If we can't read the lock, try to create it
try {
await this.writeLockFile({
pid: process.pid,
regularPort: this.sshConfig.regularPort,
primaryPort: this.sshConfig.primaryPort,
refCount: 0,
startTime: Date.now()
});
return true;
} catch {
return false;
}
}
}
private async releaseLock(): Promise<void> {
try {
await fs.unlink(TUNNEL_LOCK_FILE);
await fs.unlink(REFS_FILE);
} catch (error) {
// Files might not exist
}
}
private async readLockFile(): Promise<TunnelState | null> {
try {
const data = await fs.readFile(TUNNEL_LOCK_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
private async writeLockFile(state: TunnelState): Promise<void> {
await fs.writeFile(TUNNEL_LOCK_FILE, JSON.stringify(state, null, 2));
}
private async isProcessRunning(pid: number): Promise<boolean> {
try {
// Send signal 0 to check if process exists
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
private async isTunnelPortOpen(port: number, maxAttempts = 30): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const isOpen = await new Promise<boolean>((resolve) => {
const socket = new net.Socket();
socket.once('connect', () => {
socket.end();
resolve(true);
});
socket.once('error', () => {
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
if (isOpen) return true;
} catch {
// Continue trying
}
// Wait 1 second before trying again
await new Promise(resolve => setTimeout(resolve, 1000));
}
return false;
}
private async startTunnels(): Promise<void> {
return new Promise((resolve, reject) => {
const ssh = new SSHClient();
ssh.on('ready', () => {
this.logger.info('SSH connection established for tunnel manager');
// Create regular operations tunnel (8080)
this.regularTunnelServer = net.createServer((socket) => {
ssh.forwardOut(
socket.remoteAddress ?? '',
socket.remotePort ?? 0,
'127.0.0.1',
8080,
(err: Error | undefined, stream: any) => {
if (err) {
socket.end();
return this.logger.error('Regular tunnel forward error', { error: err });
}
// Handle connection errors gracefully
socket.on('error', (socketErr) => {
this.logger.warn('Regular tunnel socket error', { error: socketErr });
socket.destroy();
});
stream.on('error', (streamErr: Error) => {
this.logger.warn('Regular tunnel stream error', { error: streamErr });
socket.destroy();
});
socket.pipe(stream).pipe(socket);
}
);
});
// Create primary operations tunnel (8081)
this.primaryTunnelServer = net.createServer((socket) => {
ssh.forwardOut(
socket.remoteAddress ?? '',
socket.remotePort ?? 0,
'127.0.0.1',
8081,
(err: Error | undefined, stream: any) => {
if (err) {
socket.end();
return this.logger.error('Primary tunnel forward error', { error: err });
}
// Handle connection errors gracefully
socket.on('error', (socketErr) => {
this.logger.warn('Primary tunnel socket error', { error: socketErr });
socket.destroy();
});
stream.on('error', (streamErr: Error) => {
this.logger.warn('Primary tunnel stream error', { error: streamErr });
socket.destroy();
});
socket.pipe(stream).pipe(socket);
}
);
});
// Start both tunnel servers
let serversStarted = 0;
const onServerReady = () => {
serversStarted++;
if (serversStarted === 2) {
this.logger.info('Both SSH tunnels established', {
regularPort: this.sshConfig.regularPort,
primaryPort: this.sshConfig.primaryPort
});
this.sshConnection = ssh;
resolve();
}
};
this.regularTunnelServer.listen(this.sshConfig.regularPort, '127.0.0.1', onServerReady);
this.primaryTunnelServer.listen(this.sshConfig.primaryPort, '127.0.0.1', onServerReady);
this.regularTunnelServer.on('error', (err) => {
this.logger.error('Regular tunnel server error', { error: err });
reject(err);
});
this.primaryTunnelServer.on('error', (err) => {
this.logger.error('Primary tunnel server error', { error: err });
reject(err);
});
});
ssh.on('error', (err: Error) => {
// Provide specific error messages for common SSH key issues
if (err.message.includes('Encrypted private key detected, but no passphrase given')) {
this.logger.error('SSH key is password-protected but no passphrase was provided', { error: err });
reject(new Error('SSH key is password-protected. Please run setup again and provide the passphrase.'));
} else if (err.message.includes('Cannot parse privateKey')) {
this.logger.error('SSH key passphrase is incorrect or key is corrupted', { error: err });
reject(new Error('SSH key passphrase is incorrect or the key file is corrupted. Please run setup again.'));
} else {
this.logger.error('SSH connection error', { error: err });
reject(err);
}
});
ssh.on('close', () => {
this.logger.warn('SSH connection closed unexpectedly');
// Clean up tunnel servers if they exist
if (this.regularTunnelServer) {
this.regularTunnelServer.close();
}
if (this.primaryTunnelServer) {
this.primaryTunnelServer.close();
}
});
// Load private key and connect
if (this.sshConfig.authMethod === 'key') {
if (!this.sshConfig.privateKeyPath) {
reject(new Error('Private key path is required for key authentication.'));
return;
}
fs.readFile(this.sshConfig.privateKeyPath)
.then(keyBuffer => {
const connectConfig = {
host: this.sshConfig.host,
port: this.sshConfig.port,
username: this.sshConfig.username,
privateKey: keyBuffer,
...(this.sshConfig.privateKeyPassphrase && {
passphrase: this.sshConfig.privateKeyPassphrase
}),
// Add connection resilience settings
readyTimeout: 20000, // 20 seconds to connect
keepaliveInterval: 60000, // Send keepalive every 60 seconds
keepaliveCountMax: 3 // Drop after 3 failed keepalives
};
this.logger.info('Connecting to SSH host with private key', {
host: this.sshConfig.host,
port: this.sshConfig.port,
username: this.sshConfig.username,
hasPassphrase: !!this.sshConfig.privateKeyPassphrase
});
ssh.connect(connectConfig);
})
.catch((err: Error) => {
this.logger.error('Failed to read private key', {
path: this.sshConfig.privateKeyPath,
error: err
});
reject(err);
});
} else if (this.sshConfig.authMethod === 'password') {
if (!this.sshConfig.password) {
reject(new Error('Password is required for password authentication.'));
return;
}
const connectConfig = {
host: this.sshConfig.host,
port: this.sshConfig.port,
username: this.sshConfig.username,
password: this.sshConfig.password,
// Add connection resilience settings
readyTimeout: 20000, // 20 seconds to connect
keepaliveInterval: 60000, // Send keepalive every 60 seconds
keepaliveCountMax: 3 // Drop after 3 failed keepalives
};
this.logger.info('Connecting to SSH host with password', {
host: this.sshConfig.host,
port: this.sshConfig.port,
username: this.sshConfig.username
});
ssh.connect(connectConfig);
} else {
reject(new Error('Unsupported authentication method.'));
}
});
}
private async waitForTunnels(): Promise<void> {
this.logger.info('Waiting for existing SSH tunnels...');
const lockData = await this.readLockFile();
if (!lockData) {
throw new Error('No tunnel lock file found');
}
const regularPortOpen = await this.isTunnelPortOpen(lockData.regularPort);
const primaryPortOpen = await this.isTunnelPortOpen(lockData.primaryPort);
if (!regularPortOpen || !primaryPortOpen) {
throw new Error('Tunnel ports are not open after waiting');
}
this.logger.info('Connected to existing SSH tunnels', {
regularPort: lockData.regularPort,
primaryPort: lockData.primaryPort
});
}
private async addReference(): Promise<void> {
const refs = await this.readReferences();
refs[this.instanceId] = {
pid: process.pid,
startTime: Date.now()
};
await this.writeReferences(refs);
// Update ref count in lock file
const lockData = await this.readLockFile();
if (lockData) {
lockData.refCount = Object.keys(refs).length;
await this.writeLockFile(lockData);
}
}
private async removeReference(): Promise<boolean> {
const refs = await this.readReferences();
delete refs[this.instanceId];
// Clean up dead references
for (const [id, ref] of Object.entries(refs)) {
if (!await this.isProcessRunning(ref.pid)) {
delete refs[id];
}
}
await this.writeReferences(refs);
// Update ref count in lock file
const lockData = await this.readLockFile();
if (lockData) {
lockData.refCount = Object.keys(refs).length;
await this.writeLockFile(lockData);
}
// Return true if this was the last reference
return Object.keys(refs).length === 0;
}
private async readReferences(): Promise<Record<string, TunnelReference>> {
try {
const data = await fs.readFile(REFS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
private async writeReferences(refs: Record<string, TunnelReference>): Promise<void> {
await fs.writeFile(REFS_FILE, JSON.stringify(refs, null, 2));
}
}