UNPKG

ludus-mcp

Version:

MCP server for managing Ludus cybersecurity training environments through natural language commands

961 lines 37 kB
import { execSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { LudusSSHTunnelManager } from './sshTunnelManager.js'; export class LudusCliWrapper { constructor(logger, config) { this.sshTunnelPid = null; this.sshTunnelPort = 8081; this.regularTunnelPid = null; // For port 8080 this.logger = logger; this.config = config; this.baseCwd = path.join(os.homedir(), '.ludus-mcp'); // Ensure base directory exists this.ensureBaseDirectory(); // Log environment details for debugging this.logger.info('LudusCliWrapper initialized', { platform: process.platform, nodeVersion: process.version, workingDirectory: process.cwd(), baseCwd: this.baseCwd, connectionMethod: this.config.connectionMethod, sshAuthMethod: this.config.sshAuthMethod, pathEnv: process.env.PATH?.substring(0, 500) + '...', // First 500 chars of PATH userProfile: process.env.USERPROFILE || process.env.HOME, sshAgent: process.env.SSH_AUTH_SOCK || 'not set' }); // Always initialize tunnel manager for admin operations (port 8081) // Admin commands always use SSH tunnel regardless of connection method this.initializeTunnelManager().catch((error) => { // Extremely aggressive error handling for debugging let errorInfo = { timestamp: new Date().toISOString(), phase: 'constructor_catch_block' }; try { errorInfo.errorMessage = error?.message || 'no_message_property'; } catch (e) { errorInfo.errorMessage = 'message_access_failed'; } try { errorInfo.errorString = String(error); } catch (e) { errorInfo.errorString = 'string_conversion_failed'; } try { errorInfo.errorType = error?.constructor?.name || 'unknown_type'; } catch (e) { errorInfo.errorType = 'type_access_failed'; } try { errorInfo.errorKeys = Object.keys(error || {}); } catch (e) { errorInfo.errorKeys = 'keys_access_failed'; } try { errorInfo.errorToString = error?.toString?.() || 'no_toString'; } catch (e) { errorInfo.errorToString = 'toString_failed'; } this.logger.error('SSH TUNNEL CONSTRUCTOR FAILURE', errorInfo); }); } /** * Initialize the SSH tunnel manager using ssh2 library */ async initializeTunnelManager() { if (!this.config.sshHost || !this.config.sshUser) { throw new Error('SSH configuration is incomplete - missing host or user'); } if (this.config.sshAuthMethod === 'key' && !this.config.sshKeyPath) { throw new Error('SSH key path is required for key authentication'); } if (this.config.sshAuthMethod === 'password' && !this.config.sshPassword) { throw new Error('SSH password is required for password authentication'); } const tunnelConfig = { host: this.config.sshHost, port: 22, // Default SSH port username: this.config.sshUser, authMethod: this.config.sshAuthMethod, regularPort: 8080, primaryPort: 8081, privateKeyPath: this.config.sshAuthMethod === 'key' ? this.config.sshKeyPath : undefined, privateKeyPassphrase: this.config.sshAuthMethod === 'key' ? this.config.sshKeyPassphrase : undefined, password: this.config.sshAuthMethod === 'password' ? this.config.sshPassword : undefined }; this.tunnelManager = new LudusSSHTunnelManager(tunnelConfig, this.logger); try { await this.tunnelManager.connect(); this.logger.info('SSH tunnel manager initialized successfully'); } catch (error) { this.logger.error('Failed to initialize SSH tunnel manager', { error }); throw error; } } /** * Ensure SSH tunnels are healthy before executing commands */ async ensureTunnelsHealthy() { // Initialize tunnel manager if not already done if (!this.tunnelManager) { await this.initializeTunnelManager(); } if (this.tunnelManager) { try { await this.tunnelManager.ensureTunnelsHealthy(); } catch (error) { this.logger.error('Failed to ensure tunnel health', { error }); throw new Error(`SSH tunnel health check failed: ${error instanceof Error ? error.message : String(error)}`); } } else { throw new Error('Unable to initialize SSH tunnel manager for fallback'); } } /** * Check if a command requires admin API (SSH tunnel) */ isAdminCommand(command) { // Based on Ludus CLI documentation analysis (see ludus-command-api-endpoints.txt) // ONLY these commands require SSH tunnel to port 8081: // From docs/cli.md: "To use the `add` or `rm` commands, the admin API endpoint must be used." const adminCommands = [ 'users add', // Create new users - requires admin endpoint 'users rm' // Remove users - requires admin endpoint ]; // ALL other commands use regular endpoint (port 8080) including: // - users apikey, users list, users wireguard, users creds // - ALL range operations (even with --user flag) // - ALL template operations // - ALL other operations return adminCommands.some(adminCmd => command.includes(adminCmd)); } /** * Set up environment variables for regular API calls via WireGuard */ setupWireGuardEnvironment() { process.env.LUDUS_API_KEY = this.config.apiKey; process.env.LUDUS_URL = this.config.ludusUrl || 'https://198.51.100.1:8080'; process.env.LUDUS_VERIFY = this.config.verifySSL ? 'true' : 'false'; process.env.LUDUS_JSON = 'true'; // Always use JSON for MCP processing } /** * Set up environment variables for regular API calls via SSH tunnel (port 8080) */ setupSSHTunnelRegularEnvironment() { process.env.LUDUS_API_KEY = this.config.apiKey; process.env.LUDUS_URL = 'https://127.0.0.1:8080'; process.env.LUDUS_VERIFY = 'false'; // Local tunnel doesn't need SSL verification process.env.LUDUS_JSON = 'true'; } /** * Set up environment variables for admin API calls via SSH tunnel (port 8081) */ setupSSHTunnelAdminEnvironment() { process.env.LUDUS_API_KEY = this.config.apiKey; process.env.LUDUS_URL = `https://127.0.0.1:${this.sshTunnelPort}`; process.env.LUDUS_VERIFY = 'false'; // Local tunnel doesn't need SSL verification process.env.LUDUS_JSON = 'true'; } /** * Create SSH tunnel for admin operations using tunnel manager */ async createSSHTunnel() { try { this.logger.info('Creating SSH tunnel for admin operations using tunnel manager'); // Initialize tunnel manager if not already done if (!this.tunnelManager) { await this.initializeTunnelManager(); } // Ensure tunnels are healthy if (this.tunnelManager) { await this.tunnelManager.ensureTunnelsHealthy(); this.logger.info('SSH tunnel for admin operations established successfully'); return true; } else { this.logger.error('Failed to initialize tunnel manager'); return false; } } catch (error) { this.logger.error('Failed to create SSH tunnel for admin operations', { error }); return false; } } /** * Create SSH tunnel for regular operations using tunnel manager */ async createRegularOperationsTunnel() { try { this.logger.info('Creating SSH tunnel for regular operations using tunnel manager'); // Initialize tunnel manager if not already done if (!this.tunnelManager) { await this.initializeTunnelManager(); } // Ensure tunnels are healthy if (this.tunnelManager) { await this.tunnelManager.ensureTunnelsHealthy(); this.logger.info('SSH tunnel for regular operations established successfully'); return true; } else { this.logger.error('Failed to initialize tunnel manager'); return false; } } catch (error) { this.logger.error('Failed to create SSH tunnel for regular operations', { error }); return false; } } /** * Execute command with smart routing */ async executeCommand(command, args = [], workingDirectory) { const originalCwd = process.cwd(); let usingSSHFallback = false; // Build command string for admin check and logging (not execution) const fullCommand = `${command} ${args.join(' ')}`.trim(); const isAdmin = this.isAdminCommand(fullCommand); try { // Use specified working directory or default to ~/.ludus-mcp/ const targetCwd = workingDirectory || this.baseCwd; process.chdir(targetCwd); const actualRoute = isAdmin ? 'SSH tunnel' : (this.config.connectionMethod === 'ssh-tunnel' ? 'SSH tunnel' : 'WireGuard VPN'); this.logger.info('Executing Ludus command', { command: fullCommand, isAdmin, route: actualRoute, workingDirectory: targetCwd }); // Ensure connections are healthy before executing commands if (isAdmin) { // Admin commands always use SSH tunnel await this.ensureTunnelsHealthy(); } else { // Regular commands use connection method specified in config if (this.config.connectionMethod === 'ssh-tunnel') { await this.ensureTunnelsHealthy(); } else { // Check WireGuard health - if unhealthy, try SSH fallback const wgHealth = await this.checkWireGuardHealth(); if (!wgHealth.healthy) { this.logger.warn('WireGuard not healthy, attempting SSH fallback', { reason: wgHealth.message }); try { await this.ensureTunnelsHealthy(); usingSSHFallback = true; this.logger.info('SSH fallback successful - using SSH tunnel for this command'); } catch (sshError) { throw new Error(`WireGuard unavailable: ${wgHealth.message}. SSH fallback also failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`); } } } } // Set up appropriate environment and connectivity if (isAdmin) { // Admin command - use SSH tunnel (port 8081) this.setupSSHTunnelAdminEnvironment(); } else { // Regular command - use appropriate connection method if (this.config.connectionMethod === 'ssh-tunnel' || usingSSHFallback) { this.setupSSHTunnelRegularEnvironment(); } else { this.setupWireGuardEnvironment(); } } // Execute the command securely using argument array const ludusArgs = [command, ...args]; const ludusCommand = `ludus ${fullCommand}`; // For logging only let output = ''; try { // Use spawn with argument array to prevent command injection const ludusProcess = spawn('ludus', ludusArgs, { stdio: ['pipe', 'pipe', 'pipe'], shell: false // Prevent shell interpretation }); let stdout = ''; let stderr = ''; ludusProcess.stdout.on('data', (data) => { stdout += data.toString(); }); ludusProcess.stderr.on('data', (data) => { stderr += data.toString(); }); await new Promise((resolve, reject) => { ludusProcess.on('close', (code) => { // Ludus CLI outputs to stderr even on success output = stderr || stdout || ''; if (output && stderr) { this.logger.debug('Using stderr output from Ludus CLI', { stderr: stderr.substring(0, 200) + '...' }); } resolve(); }); ludusProcess.on('error', (error) => { reject(error); }); // Set timeout setTimeout(() => { ludusProcess.kill(); reject(new Error('Command timeout')); }, 30000); }); } catch (error) { // Fallback to execSync approach - use spawn instead to maintain security try { // Note: execSync with string commands is still potentially vulnerable // This is kept as fallback only - primary spawn approach above is secure output = execSync(ludusCommand, { encoding: 'utf-8', timeout: 30000, maxBuffer: 1024 * 1024 * 10 // 10MB buffer }); } catch (execError) { if (execError.stderr && execError.stderr.toString().trim().length > 0) { output = execError.stderr.toString(); this.logger.debug('Using stderr output from Ludus CLI (fallback)', { stderr: execError.stderr.toString().substring(0, 200) + '...' }); } else if (execError.stdout && execError.stdout.toString().trim().length > 0) { output = execError.stdout.toString(); } else { throw error; } } } // Parse JSON output if possible let parsedData; if (output.trim().startsWith('{') || output.trim().startsWith('[')) { try { parsedData = JSON.parse(output); } catch (parseError) { // If parsing fails, use raw output parsedData = output; } } else { // Not JSON, use raw output parsedData = output; } // Tunnel cleanup is handled automatically by tunnel manager // Clear environment variables delete process.env.LUDUS_API_KEY; delete process.env.LUDUS_URL; delete process.env.LUDUS_VERIFY; delete process.env.LUDUS_JSON; return { success: true, data: parsedData, message: output, rawOutput: output }; } catch (error) { this.logger.error('Command execution failed', { command: fullCommand, error: error.message, workingDirectory, stdout: error.stdout?.toString(), stderr: error.stderr?.toString() }); // Tunnel cleanup is handled automatically by tunnel manager // Clear environment variables delete process.env.LUDUS_API_KEY; delete process.env.LUDUS_URL; delete process.env.LUDUS_VERIFY; delete process.env.LUDUS_JSON; return { success: false, message: `Command failed: ${error.message}`, rawOutput: error.stdout?.toString() || error.stderr?.toString() }; } finally { // Always restore original working directory process.chdir(originalCwd); } } /** * Execute arbitrary Ludus CLI command */ async executeArbitraryCommand(command, args = []) { return this.executeCommand(command, args); } /** * List user ranges (current user or specific user for admin) */ async listUserRanges(user) { const args = ['list']; if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Deploy range with full CLI options support */ async deployRange(options = {}) { try { const { user, configPath, force, tags, limit, onlyRoles, verboseAnsible } = options; // First set config if provided if (configPath) { const configArgs = ['config', 'set', '-f', configPath]; if (user) { configArgs.push('--user', user); } if (force) { configArgs.push('--force'); } const configResult = await this.executeCommand('range', configArgs); if (!configResult.success) { return configResult; } } // Then deploy with all options const deployArgs = ['deploy']; if (user) { deployArgs.push('--user', user); } if (force) { deployArgs.push('--force'); } if (tags) { deployArgs.push('--tags', tags); } if (limit) { deployArgs.push('--limit', limit); } if (onlyRoles) { deployArgs.push('--only-roles', onlyRoles); } if (verboseAnsible) { deployArgs.push('--verbose-ansible'); } return this.executeCommand('range', deployArgs); } catch (error) { return { success: false, message: `Range deployment failed: ${error.message}` }; } } /** * Get available deployment tags */ async getTags(user) { const args = ['gettags']; if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Abort range deployment */ async abortRange(user) { const args = ['abort']; if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Get range status (current user or specific user for admin) */ async getRangeStatus(user) { const args = ['list']; // 'list' is alias for 'status' in Ludus CLI if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Destroy range - permanently remove all VMs and free resources */ async destroyRange(user, noPrompt = false) { const args = ['rm']; if (noPrompt) { args.push('--no-prompt'); } if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Get RDP connection files for Windows VMs */ async getRangeRdpInfo(user) { // Create user-specific directory for file downloads const userDir = this.ensureUserDirectory(user); const rdpPath = path.join(userDir, 'rdp.zip'); const args = ['rdp', '--output', rdpPath]; if (user) { args.push('--user', user); } this.logger.info('Downloading RDP configuration to specific path', { path: rdpPath, user: user || 'current-user' }); // Execute command with explicit output path const result = await this.executeCommand('range', args); if (result.success) { return { success: true, data: result.data, message: `RDP configuration saved to ${rdpPath}`, rawOutput: result.rawOutput || '' }; } return result; } /** * Get WireGuard configuration for user */ async getUserWireguardConfig(user) { const args = ['wireguard']; if (user) { args.push('--user', user); } // Create user-specific directory for file downloads const userDir = this.ensureUserDirectory(user); // Execute command to get WireGuard config const result = await this.executeCommand('user', args); if (result.success && result.data) { try { // Write WireGuard config to file const configPath = path.join(userDir, 'wireguard.conf'); fs.writeFileSync(configPath, result.data, 'utf8'); this.logger.info('WireGuard configuration written to file', { path: configPath, user: user || 'current-user' }); return { success: true, data: result.data, message: `WireGuard configuration saved to ${configPath}`, rawOutput: result.rawOutput || '' }; } catch (error) { this.logger.error('Failed to write WireGuard config to file', { error: error.message, userDir }); // Return original result if file writing fails return result; } } return result; } /** * Get /etc/hosts formatted file for range */ async getRangeEtcHosts(user) { const args = ['etc-hosts']; if (user) { args.push('--user', user); } // Create user-specific directory for file downloads const userDir = this.ensureUserDirectory(user); // Execute command to get hosts entries const result = await this.executeCommand('range', args); if (result.success && result.data) { try { // Write hosts entries to file const hostsPath = path.join(userDir, 'hosts'); fs.writeFileSync(hostsPath, result.data, 'utf8'); this.logger.info('Hosts entries written to file', { path: hostsPath, user: user || 'current-user' }); return { success: true, data: result.data, message: `Hosts entries saved to ${hostsPath}`, rawOutput: result.rawOutput || '' }; } catch (error) { this.logger.error('Failed to write hosts entries to file', { error: error.message, userDir }); // Return original result if file writing fails return result; } } return result; } /** * Power on VMs in range */ async powerOnRange(user, vmNames) { const args = ['on']; // Ludus power commands don't support --force flag if (vmNames) { args.push('--name', vmNames); } if (user) { args.push('--user', user); } return this.executeCommand('power', args); } /** * Power off VMs in range */ async powerOffRange(user, vmNames) { const args = ['off']; // Ludus power commands don't support --force flag if (vmNames) { args.push('--name', vmNames); } if (user) { args.push('--user', user); } return this.executeCommand('power', args); } /** * Get range configuration */ async getRangeConfig(user) { const args = ['config', 'get']; if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * Set range configuration from file */ async setRangeConfig(configPath, user, force = false) { const args = ['config', 'set', '-f', configPath]; if (user) { args.push('--user', user); } if (force) { args.push('--force'); } return this.executeCommand('range', args); } /** * Get range deployment logs */ async getRangeLogs(user, follow = false) { const args = ['logs']; if (follow) { args.push('-f'); } if (user) { args.push('--user', user); } return this.executeCommand('range', args); } /** * List available templates */ async listTemplates() { return this.executeCommand('templates', ['list']); } /** * Get user information for a specific user (or current user if none specified) */ async getUserInfo(user) { const args = ['list']; if (user) { args.push('--user', user); } return this.executeCommand('users', args); } /** * List all users in the system */ async listAllUsers() { return this.executeCommand('users', ['list', 'all']); } /** * Add a new user (admin operation) */ async addUser(name, userId, isAdmin = false) { const args = ['add', '--name', name, '--userid', userId]; if (isAdmin) { args.push('--admin'); } return this.executeCommand('users', args); } /** * Remove a user (admin operation) */ async removeUser(userId) { return this.executeCommand('users', ['rm', '--user', userId]); } /** * Get API key for user (admin operation) */ async getUserApiKey(userId) { return this.executeCommand('users', ['apikey', '--user', userId]); } /** * Test connectivity for both regular and admin operations */ async testConnectivity() { const result = { rangeOps: false, adminOps: false }; // Test regular API connectivity based on connection method if (this.config.connectionMethod === 'wireguard') { // Test WireGuard connectivity for regular operations try { this.logger.info('Testing WireGuard connectivity for regular operations'); // Use the new WireGuard health check (no auto-connect) const wgHealth = await this.checkWireGuardHealth(); result.rangeOps = wgHealth.healthy; this.logger.info('WireGuard connectivity test result (regular operations)', { success: result.rangeOps, message: wgHealth.message }); } catch (error) { this.logger.error('WireGuard connectivity test failed (regular operations)', { error: error.message || error, stack: error.stack }); } } else { // Test SSH tunnel connectivity for regular operations (port 8080) try { this.logger.info('Testing SSH tunnel connectivity for regular operations (port 8080)'); // Use tunnel manager to ensure tunnels are healthy if (this.tunnelManager) { await this.tunnelManager.ensureTunnelsHealthy(); } else { throw new Error('No tunnel manager available for regular operations'); } this.setupSSHTunnelRegularEnvironment(); let output = ''; try { // Use spawn to capture both stdout and stderr properly const ludusProcess = spawn('ludus', ['version'], { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; ludusProcess.stdout.on('data', (data) => { stdout += data.toString(); }); ludusProcess.stderr.on('data', (data) => { stderr += data.toString(); }); await new Promise((resolve, reject) => { ludusProcess.on('close', (code) => { // Ludus CLI outputs to stderr even on success output = stderr || stdout || ''; resolve(); }); ludusProcess.on('error', (error) => { reject(error); }); // Set timeout setTimeout(() => { ludusProcess.kill(); reject(new Error('Command timeout')); }, 5000); }); } catch (error) { // Fallback to execSync approach try { output = execSync('ludus version', { encoding: 'utf-8', timeout: 5000 }); } catch (execError) { if (execError.stderr) { output = execError.stderr.toString(); } else if (execError.stdout) { output = execError.stdout.toString(); } else { throw error; } } } result.rangeOps = output.includes('Ludus Server') || output.includes('Server version') || output.includes('server:'); this.logger.info('SSH tunnel connectivity test result (regular operations)', { success: result.rangeOps, output: output.trim() }); } catch (error) { this.logger.error('SSH tunnel connectivity test failed (regular operations)', { error: error.message || error, stack: error.stack }); } } // ALWAYS test SSH tunnel for admin operations (port 8081) regardless of connection method try { this.logger.info('Testing SSH tunnel connectivity for admin operations (port 8081)'); if (this.tunnelManager) { // Use tunnel manager to check health const health = await this.tunnelManager.checkTunnelHealth(); result.adminOps = health.primaryHealthy; this.logger.info('SSH tunnel connectivity test result (admin operations)', { success: result.adminOps }); } else { this.logger.debug('No tunnel manager available for admin operations test'); } } catch (error) { this.logger.debug('SSH tunnel connectivity test failed (admin operations will be limited)', { error }); } // Clean up environment delete process.env.LUDUS_API_KEY; delete process.env.LUDUS_URL; delete process.env.LUDUS_VERIFY; delete process.env.LUDUS_JSON; return result; } /** * Check if WireGuard is currently connected */ checkWireGuardConnectivity() { try { const result = execSync('wg show', { encoding: 'utf8', stdio: 'pipe' }); return result.trim().length > 0; } catch { return false; } } /** * Check if WireGuard is available on the system */ checkWireGuardAvailable() { try { execSync('wg --version', { stdio: 'ignore' }); return true; } catch { return false; } } /** * Check WireGuard health by pinging the WireGuard interface * Returns health status and suggestions for the user */ async checkWireGuardHealth() { try { this.logger.debug('Testing WireGuard connectivity via ping'); // Ping the WireGuard interface (2 pings for reliability) const isWindows = process.platform === 'win32'; const pingCommand = isWindows ? 'ping' : 'ping'; const pingArgs = isWindows ? ['-n', '2', '198.51.100.1'] : ['-c', '2', '198.51.100.1']; const result = execSync(`${pingCommand} ${pingArgs.join(' ')}`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' }); // Check if ping was successful (look for success indicators) const isHealthy = isWindows ? result.includes('Reply from') || result.includes('bytes=') : result.includes('bytes from') || !result.includes('100% packet loss'); if (!isHealthy) { return { healthy: false, message: 'WireGuard interface (198.51.100.1) not reachable via ping' }; } this.logger.debug('WireGuard interface ping successful'); return { healthy: true, message: 'WireGuard interface is reachable' }; } catch (error) { this.logger.debug('WireGuard ping test failed', { error: error.message }); return { healthy: false, message: `WireGuard ping test failed: ${error.message}` }; } } /** * Ensure base directory exists */ ensureBaseDirectory() { if (!fs.existsSync(this.baseCwd)) { this.logger.info('Creating base directory for user-specific files', { path: this.baseCwd }); fs.mkdirSync(this.baseCwd, { recursive: true }); } else { this.logger.debug('Base directory already exists', { path: this.baseCwd }); } } /** * Get or create user-specific directory for file downloads */ ensureUserDirectory(user) { const currentUser = user || 'current-user'; const userDir = path.join(this.baseCwd, currentUser); if (!fs.existsSync(userDir)) { this.logger.info('Creating user directory for file downloads', { user: currentUser, path: userDir }); fs.mkdirSync(userDir, { recursive: true }); } else { this.logger.debug('User directory already exists', { user: currentUser, path: userDir }); } return userDir; } /** * Cleanup resources including SSH tunnel manager */ async cleanup() { // Use new tunnel manager if available if (this.tunnelManager) { try { await this.tunnelManager.disconnect(); this.logger.info('SSH tunnel manager disconnected successfully'); } catch (error) { this.logger.error('Error disconnecting SSH tunnel manager', { error }); } } else { // Fallback to old cleanup methods // this.closeSSHTunnel(); // Removed // this.closeRegularOperationsTunnel(); // Removed } // Clear any remaining environment variables delete process.env.LUDUS_API_KEY; delete process.env.LUDUS_URL; delete process.env.LUDUS_VERIFY; delete process.env.LUDUS_JSON; } } //# sourceMappingURL=cliWrapper.js.map