import { spawn } from 'child_process'; import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; class SSHService { /** * Test SSH connection with actual login verification * This method tests if the user can actually log in with the provided credentials * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testConnection(server) { const { auth_type = 'password' } = server; return new Promise((resolve) => { const timeout = 15000; // 15 seconds timeout for login test let resolved = false; // Choose authentication method based on auth_type let authPromise; if (auth_type === 'key') { authPromise = this.testWithSSHKey(server); } else { // Default to password authentication authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server)); } authPromise.then(result => { if (!resolved) { resolved = true; resolve(result); } }).catch(() => { // If primary method fails, return error if (!resolved) { resolved = true; resolve({ success: false, message: `SSH login test failed for ${auth_type} authentication`, details: { method: 'auth_failed', auth_type: auth_type } }); } }); // Set up overall timeout setTimeout(() => { if (!resolved) { resolved = true; resolve({ success: false, message: 'SSH login test timeout - server did not respond within 15 seconds', details: { timeout: true, method: 'ssh_login_test' } }); } }, timeout); }); } /** * Test SSH connection using sshpass * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testWithSshpass(server) { const { ip, user, password, ssh_port = 22 } = server; if (!password) { throw new Error('Password is required for password authentication'); } return new Promise((resolve, reject) => { const timeout = 10000; let resolved = false; const sshCommand = spawn('sshpass', [ '-p', password, 'ssh', '-p', ssh_port.toString(), '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`, 'echo "SSH_LOGIN_SUCCESS"' ], { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { if (!resolved) { resolved = true; sshCommand.kill('SIGTERM'); reject(new Error('SSH login timeout')); } }, timeout); let output = ''; let errorOutput = ''; sshCommand.stdout.on('data', (data) => { output += data.toString(); }); sshCommand.stderr.on('data', (data) => { errorOutput += data.toString(); }); sshCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) { resolve({ success: true, message: 'SSH login successful - credentials verified', details: { server: server.name || 'Unknown', ip: ip, user: user, method: 'sshpass_verified' } }); } else { let errorMessage = 'SSH login failed'; if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { errorMessage = 'Authentication failed - check username and password'; } else if (errorOutput.includes('Connection refused')) { errorMessage = 'Connection refused - server may be down or SSH not running'; } else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) { errorMessage = 'Host not found - check IP address'; } else if (errorOutput.includes('Connection timed out')) { errorMessage = 'Connection timeout - server may be unreachable'; } else { errorMessage = `SSH login failed: ${errorOutput.trim()}`; } reject(new Error(errorMessage)); } } }); sshCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); reject(error); } }); }); } /** * Test SSH connection using expect * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testWithExpect(server) { const { ip, user, password, ssh_port = 22 } = server; return new Promise((resolve, reject) => { const timeout = 10000; let resolved = false; const expectScript = `#!/usr/bin/expect -f set timeout 10 spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" expect { "password:" { send "${password}\r" exp_continue } "Password:" { send "${password}\r" exp_continue } "SSH_LOGIN_SUCCESS" { exit 0 } timeout { exit 1 } eof { exit 1 } }`; const expectCommand = spawn('expect', ['-c', expectScript], { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { if (!resolved) { resolved = true; expectCommand.kill('SIGTERM'); reject(new Error('SSH login timeout')); } }, timeout); let output = ''; let errorOutput = ''; expectCommand.stdout.on('data', (data) => { output += data.toString(); }); expectCommand.stderr.on('data', (data) => { errorOutput += data.toString(); }); expectCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); if (code === 0) { resolve({ success: true, message: 'SSH login successful - credentials verified', details: { server: server.name || 'Unknown', ip: ip, user: user, method: 'expect_verified' } }); } else { let errorMessage = 'SSH login failed'; if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { errorMessage = 'Authentication failed - check username and password'; } else if (errorOutput.includes('Connection refused')) { errorMessage = 'Connection refused - server may be down or SSH not running'; } else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) { errorMessage = 'Host not found - check IP address'; } else if (errorOutput.includes('Connection timed out')) { errorMessage = 'Connection timeout - server may be unreachable'; } else { errorMessage = `SSH login failed: ${errorOutput.trim()}`; } reject(new Error(errorMessage)); } } }); expectCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); reject(error); } }); }); } /** * Test SSH connection using basic connectivity check (fallback method) * This method tests if the SSH port is open and reachable * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testConnectionBasic(server) { const { ip, user, password } = server; return new Promise((resolve) => { const timeout = 10000; // 10 seconds timeout let resolved = false; // First, test if the SSH port is open using netcat or telnet const portTestCommand = spawn('nc', ['-z', '-w', '5', ip, '22'], { stdio: ['pipe', 'pipe', 'pipe'] }); // Set up timeout const timer = setTimeout(() => { if (!resolved) { resolved = true; portTestCommand.kill('SIGTERM'); resolve({ success: false, message: 'Connection timeout - server did not respond within 10 seconds', details: { timeout: true, method: 'port_check' } }); } }, timeout); // Handle port test results portTestCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); if (code === 0) { // Port is open, now try a basic SSH connection test this.testSSHConnection(server).then(resolve).catch(() => { resolve({ success: false, message: 'SSH port is open but connection failed - check credentials', details: { portOpen: true, method: 'ssh_connection_test' } }); }); } else { resolve({ success: false, message: 'SSH port (22) is not accessible - server may be down or SSH not running', details: { portOpen: false, exitCode: code, method: 'port_check' } }); } } }); // Handle port test errors portTestCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); // If netcat is not available, try with telnet this.testWithTelnet(server).then(resolve).catch(() => { resolve({ success: false, message: 'Cannot test SSH connectivity - netcat and telnet not available', details: { error: error.message, method: 'port_check_fallback' } }); }); } }); }); } /** * Test SSH connection using telnet as fallback * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testWithTelnet(server) { const { ip } = server; return new Promise((resolve) => { const timeout = 5000; let resolved = false; const telnetCommand = spawn('timeout', ['5', 'telnet', ip, '22'], { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { if (!resolved) { resolved = true; telnetCommand.kill('SIGTERM'); resolve({ success: false, message: 'SSH port test timeout', details: { method: 'telnet_timeout' } }); } }, timeout); let output = ''; telnetCommand.stdout.on('data', (data) => { output += data.toString(); }); telnetCommand.stderr.on('data', (data) => { output += data.toString(); }); telnetCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); if (output.includes('Connected') || output.includes('SSH')) { resolve({ success: true, message: 'SSH port is accessible - basic connectivity confirmed', details: { portOpen: true, method: 'telnet_test' } }); } else { resolve({ success: false, message: 'SSH port is not accessible', details: { portOpen: false, output: output.trim(), method: 'telnet_test' } }); } } }); telnetCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); resolve({ success: false, message: 'Cannot test SSH connectivity - required tools not available', details: { error: error.message, method: 'telnet_error' } }); } }); }); } /** * Test actual SSH connection (without password authentication) * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testSSHConnection(server) { const { ip, user, ssh_port = 22 } = server; return new Promise((resolve) => { const timeout = 5000; let resolved = false; const sshCommand = spawn('ssh', [ '-p', ssh_port.toString(), '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'PasswordAuthentication=no', '-o', 'PubkeyAuthentication=no', '-o', 'PreferredAuthentications=none', `${user}@${ip}`, 'exit' ], { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { if (!resolved) { resolved = true; sshCommand.kill('SIGTERM'); resolve({ success: false, message: 'SSH connection timeout', details: { method: 'ssh_timeout' } }); } }, timeout); let errorOutput = ''; sshCommand.stderr.on('data', (data) => { errorOutput += data.toString(); }); sshCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); // SSH connection was established but authentication failed // This is actually a good sign - it means SSH is working if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { resolve({ success: true, message: 'SSH service is running and accessible - authentication required', details: { server: server.name || 'Unknown', ip: ip, user: user, method: 'ssh_auth_required' } }); } else if (errorOutput.includes('Connection refused')) { resolve({ success: false, message: 'SSH connection refused - service may not be running', details: { error: errorOutput.trim(), method: 'ssh_connection_refused' } }); } else { resolve({ success: false, message: `SSH connection failed: ${errorOutput.trim()}`, details: { error: errorOutput.trim(), method: 'ssh_connection_failed' } }); } } }); sshCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); resolve({ success: false, message: `SSH command failed: ${error.message}`, details: { error: error.message, method: 'ssh_command_error' } }); } }); }); } /** * Test SSH connection using SSH key authentication * @param {import('../types/server').Server} server - Server configuration * @returns {Promise} Connection test result */ async testWithSSHKey(server) { const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server; if (!ssh_key_path || !existsSync(ssh_key_path)) { throw new Error('SSH key file not found'); } return new Promise((resolve, reject) => { const timeout = 10000; let resolved = false; try { // Build SSH command const sshArgs = [ '-i', ssh_key_path, '-p', ssh_port.toString(), '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'PasswordAuthentication=no', '-o', 'PubkeyAuthentication=yes', `${user}@${ip}`, 'echo "SSH_LOGIN_SUCCESS"' ]; // Use sshpass if passphrase is provided let command, args; if (ssh_key_passphrase) { command = 'sshpass'; args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs]; } else { command = 'ssh'; args = sshArgs; } const sshCommand = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); const timer = setTimeout(() => { if (!resolved) { resolved = true; sshCommand.kill('SIGTERM'); reject(new Error('SSH key login timeout')); } }, timeout); let output = ''; let errorOutput = ''; sshCommand.stdout.on('data', (data) => { output += data.toString(); }); sshCommand.stderr.on('data', (data) => { errorOutput += data.toString(); }); sshCommand.on('close', (code) => { if (!resolved) { resolved = true; clearTimeout(timer); if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) { resolve({ success: true, message: 'SSH key authentication successful - credentials verified', details: { server: server.name || 'Unknown', ip: ip, user: user, method: 'ssh_key_verified' } }); } else { let errorMessage = 'SSH key authentication failed'; if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { errorMessage = 'SSH key authentication failed - check key and permissions'; } else if (errorOutput.includes('Connection refused')) { errorMessage = 'Connection refused - server may be down or SSH not running'; } else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) { errorMessage = 'Host not found - check IP address'; } else if (errorOutput.includes('Connection timed out')) { errorMessage = 'Connection timeout - server may be unreachable'; } else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) { errorMessage = 'Invalid SSH key format'; } else if (errorOutput.includes('Enter passphrase')) { errorMessage = 'SSH key passphrase required but not provided'; } else { errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`; } reject(new Error(errorMessage)); } } }); sshCommand.on('error', (error) => { if (!resolved) { resolved = true; clearTimeout(timer); reject(error); } }); } catch (error) { if (!resolved) { resolved = true; reject(error); } } }); } /** * Generate SSH key pair for a server * @param {number} serverId - Server ID for key file naming * @returns {Promise<{privateKey: string, publicKey: string}>} */ async generateKeyPair(serverId) { const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys'); const keyPath = join(sshKeysDir, `server_${serverId}_key`); return new Promise((resolve, reject) => { const sshKeygen = spawn('ssh-keygen', [ '-t', 'ed25519', '-f', keyPath, '-N', '', // No passphrase '-C', 'pve-scripts-local' ], { stdio: ['pipe', 'pipe', 'pipe'] }); let errorOutput = ''; sshKeygen.stderr.on('data', (data) => { errorOutput += data.toString(); }); sshKeygen.on('close', (code) => { if (code === 0) { try { // Read the generated private key const privateKey = readFileSync(keyPath, 'utf8'); // Read the generated public key const publicKeyPath = keyPath + '.pub'; const publicKey = readFileSync(publicKeyPath, 'utf8'); // Set proper permissions chmodSync(keyPath, 0o600); chmodSync(publicKeyPath, 0o644); resolve({ privateKey, publicKey: publicKey.trim() }); } catch (error) { reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`)); } } else { reject(new Error(`ssh-keygen failed: ${errorOutput}`)); } }); sshKeygen.on('error', (error) => { reject(new Error(`Failed to run ssh-keygen: ${error.message}`)); }); }); } /** * Get public key from private key file * @param {string} keyPath - Path to private key file * @returns {string} Public key content */ getPublicKey(keyPath) { const publicKeyPath = keyPath + '.pub'; if (!existsSync(publicKeyPath)) { throw new Error('Public key file not found'); } return readFileSync(publicKeyPath, 'utf8').trim(); } } // Singleton instance /** @type {SSHService | null} */ let sshInstance = null; export function getSSHService() { if (!sshInstance) { sshInstance = new SSHService(); } return sshInstance; } export default SSHService;