import { spawn } from 'child_process'; import { spawn as ptySpawn } from 'node-pty'; import { existsSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; /** * @typedef {Object} Server * @property {string} ip - Server IP address * @property {string} user - Username * @property {string} [password] - Password (optional) * @property {string} name - Server name * @property {string} [auth_type] - Authentication type ('password', 'key') * @property {string} [ssh_key] - SSH private key content * @property {string} [ssh_key_passphrase] - SSH key passphrase * @property {string} [ssh_key_path] - Path to persistent SSH key file * @property {number} [ssh_port] - SSH port (default: 22) */ class SSHExecutionService { /** * Build SSH command arguments based on authentication type * @param {Server} server - Server configuration * @returns {{command: string, args: string[]}} Command and arguments for SSH */ buildSSHCommand(server) { const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server; const baseArgs = [ '-t', '-p', ssh_port.toString(), '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'RequestTTY=yes', '-o', 'SetEnv=TERM=xterm-256color', '-o', 'SetEnv=COLUMNS=120', '-o', 'SetEnv=LINES=30', '-o', 'SetEnv=COLORTERM=truecolor', '-o', 'SetEnv=FORCE_COLOR=1', '-o', 'SetEnv=NO_COLOR=0', '-o', 'SetEnv=CLICOLOR=1', '-o', 'SetEnv=CLICOLOR_FORCE=1' ]; if (auth_type === 'key') { // SSH key authentication if (!ssh_key_path || !existsSync(ssh_key_path)) { throw new Error('SSH key file not found'); } baseArgs.push('-i', ssh_key_path); baseArgs.push('-o', 'PasswordAuthentication=no'); baseArgs.push('-o', 'PubkeyAuthentication=yes'); if (ssh_key_passphrase) { return { command: 'sshpass', args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`] }; } else { return { command: 'ssh', args: [...baseArgs, `${user}@${ip}`] }; } } else { // Password authentication (default) if (password) { return { command: 'sshpass', args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`] }; } else { throw new Error('Password is required for password authentication'); } } } /** * Execute a script on a remote server via SSH * @param {Server} server - Server configuration * @param {string} scriptPath - Path to the script * @param {Function} onData - Callback for data output * @param {Function} onError - Callback for errors * @param {Function} onExit - Callback for process exit * @param {Object} [envVars] - Optional environment variables to pass to the script * @returns {Promise} Process information */ async executeScript(server, scriptPath, onData, onError, onExit, envVars = {}) { try { await this.transferScriptsFolder(server, onData, onError); return new Promise((resolve, reject) => { const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath; try { // Build SSH command based on authentication type const { command, args } = this.buildSSHCommand(server); // Format environment variables as var_name=value pairs const envVarsString = Object.entries(envVars) .map(([key, value]) => { // Escape special characters in values const escapedValue = String(value).replace(/'/g, "'\\''"); return `${key}='${escapedValue}'`; }) .join(' '); // Build the command with environment variables let scriptCommand = `cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1`; if (envVarsString) { scriptCommand += ` && ${envVarsString} bash ${relativeScriptPath}`; } else { scriptCommand += ` && bash ${relativeScriptPath}`; } // Log the full command that will be executed console.log('='.repeat(80)); console.log(`[SSH Execution] Executing on host: ${server.ip} (${server.name || 'Unnamed'})`); console.log(`[SSH Execution] Script path: ${scriptPath}`); console.log(`[SSH Execution] Relative script path: ${relativeScriptPath}`); if (Object.keys(envVars).length > 0) { console.log(`[SSH Execution] Environment variables (${Object.keys(envVars).length} vars):`); Object.entries(envVars).forEach(([key, value]) => { console.log(` ${key}=${String(value)}`); }); } else { console.log(`[SSH Execution] No environment variables provided`); } console.log(`[SSH Execution] Full command:`); console.log(scriptCommand); console.log('='.repeat(80)); // Add the script execution command to the args args.push(scriptCommand); // Use ptySpawn for proper terminal emulation and color support const sshCommand = ptySpawn(command, args, { name: 'xterm-256color', cols: 120, rows: 30, cwd: process.cwd(), env: { ...process.env, TERM: 'xterm-256color', COLUMNS: '120', LINES: '30', SHELL: '/bin/bash', COLORTERM: 'truecolor', FORCE_COLOR: '1', NO_COLOR: '0', CLICOLOR: '1', CLICOLOR_FORCE: '1' } }); // Use pty's onData method which handles both stdout and stderr combined sshCommand.onData((data) => { // pty handles encoding automatically and preserves ANSI codes onData(data); }); sshCommand.onExit((e) => { onExit(e.exitCode); }); resolve({ process: sshCommand, kill: () => { sshCommand.kill('SIGTERM'); } }); } catch (error) { reject(error); } }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); onError(`SSH execution failed: ${errorMessage}`); throw error; } } /** * Transfer the entire scripts folder to the remote server * @param {Server} server - Server configuration * @param {Function} onData - Callback for data output * @param {Function} onError - Callback for errors * @returns {Promise} */ async transferScriptsFolder(server, onData, onError) { const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server; const cleanupTempFile = (/** @type {string | null} */ tempPath) => { if (tempPath) { try { unlinkSync(tempPath); } catch (_) { // ignore } } }; return new Promise((resolve, reject) => { /** @type {string | null} */ let tempPath = null; try { // Build rsync command based on authentication type. // Use sshpass -f with a temp file so password/passphrase never go through the shell (safe for special chars like {, $, "). let rshCommand; if (auth_type === 'key') { if (!ssh_key_path || !existsSync(ssh_key_path)) { throw new Error('SSH key file not found'); } if (ssh_key_passphrase) { tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`); writeFileSync(tempPath, ssh_key_passphrase); chmodSync(tempPath, 0o600); rshCommand = `sshpass -P passphrase -f ${tempPath} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } else { rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } } else { // Password authentication tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`); writeFileSync(tempPath, password ?? ''); chmodSync(tempPath, 0o600); rshCommand = `sshpass -f ${tempPath} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`; } const rsyncCommand = spawn('rsync', [ '-avz', '--delete', '--exclude=*.log', '--exclude=*.tmp', `--rsh=${rshCommand}`, 'scripts/', `${user}@${ip}:/tmp/scripts/` ], { stdio: ['pipe', 'pipe', 'pipe'] }); rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { const output = data.toString('utf8'); onData(output); }); rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => { const output = data.toString('utf8'); onError(output); }); rsyncCommand.on('close', (code) => { cleanupTempFile(tempPath); if (code === 0) { resolve(); } else { reject(new Error(`rsync failed with code ${code}`)); } }); rsyncCommand.on('error', (error) => { cleanupTempFile(tempPath); reject(error); }); } catch (error) { cleanupTempFile(tempPath); reject(error); } }); } /** * Execute a direct command on a remote server via SSH * @param {Server} server - Server configuration * @param {string} command - Command to execute * @param {Function} onData - Callback for data output * @param {Function} onError - Callback for errors * @param {Function} onExit - Callback for process exit * @returns {Promise} Process information */ async executeCommand(server, command, onData, onError, onExit) { return new Promise((resolve, reject) => { try { // Build SSH command based on authentication type const { command: sshCommandName, args } = this.buildSSHCommand(server); // Add the command to execute to the args args.push(command); // Use ptySpawn for proper terminal emulation and color support const sshCommand = ptySpawn(sshCommandName, args, { name: 'xterm-color', cols: 120, rows: 30, cwd: process.cwd(), env: process.env }); sshCommand.onData((data) => { onData(data); }); sshCommand.onExit((e) => { onExit(e.exitCode); }); resolve({ process: sshCommand, kill: () => { sshCommand.kill('SIGTERM'); } }); } catch (error) { reject(error); } }); } } // Singleton instance /** @type {SSHExecutionService | null} */ let sshExecutionInstance = null; export function getSSHExecutionService() { if (!sshExecutionInstance) { sshExecutionInstance = new SSHExecutionService(); } return sshExecutionInstance; } export default SSHExecutionService;