# frozen_string_literal: true # adn/tools/cli/ssh.rb — Ejecución remota segura via SSH + Candados # =================================================================== # Herramienta ADN reutilizable para ejecución remota. # Combina: contexto (topología) + candados (secretos) + ejecutor_remoto. # # Uso: # ./adn/tools/run ssh # ./adn/tools/run ssh --interactivo # ./adn/tools/run ssh srv-dasu "hostname; uptime" # ./adn/tools/run ssh dc-dasuten "whoami" require 'optparse' require 'open3' require_relative '../core/colores' require_relative '../core/constants' require_relative '../core/error_handler' module ADN class SubcomandoSSH CANDADOS_PATH = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb') # Mapa de nodos → configuración SSH # Prioridad: 1) DB/fichas, 2) este mapa, 3) defaults NODOS = { 'srv-dasu' => { ip: '100.112.46.104', usuario: 'rmonla', puerto: 22, auth: :passphrase, # Usa passphrase de clave RSA clave_boveda: 'rsa' # Clave en candados para passphrase }, 'dc-dasuten' => { ip: '10.0.100.10', usuario: 'Administrador', puerto: 7022, auth: :password, clave_boveda: 'admindasu', proxy: 'srv-dasu' # Requiere salto via srv-dasu }, 'sql-dasuten' => { ip: '10.0.100.11', usuario: 'Administrador', puerto: 7022, auth: :password, clave_boveda: 'admindasu', proxy: 'srv-dasu' }, 'pcv-dasu0' => { ip: '10.0.100.12', usuario: 'admindasu', puerto: 7022, auth: :password, clave_boveda: 'admindasu', proxy: 'srv-dasu' }, 'pc-dasu0' => { ip: '100.100.145.51', usuario: 'admindasu', puerto: 7022, auth: :password, clave_boveda: 'admindasu' } }.freeze def initialize(args, logger) @args = args @logger = logger @opciones = { interactivo: false, verbose: false } end def ejecutar parser = OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run ssh [comando] [OPCIONES]" opts.separator "" opts.separator "Ejecuta comandos remotos de forma segura via SSH + Candados." opts.separator "Soporta passphrase RSA y contraseñas, con ProxyJump automático." opts.separator "" opts.separator "Opciones:" opts.on("-i", "--interactivo", "Abrir sesión SSH interactiva") do @opciones[:interactivo] = true end opts.on("-v", "--verbose", "Mostrar detalles de conexión") do @opciones[:verbose] = true end opts.on("--listar", "Listar nodos configurados") do listar_nodos exit 0 end opts.on("-h", "--help", "Mostrar esta ayuda") do puts opts exit 0 end end begin parser.parse!(@args) rescue OptionParser::InvalidOption => e ADN::ErrorHandler.error("Opción inválida: #{e.message}") puts parser exit 1 end if @args.empty? ADN::ErrorHandler.error("Se requiere nombre de nodo") puts parser exit 1 end nodo_nombre = @args.shift comando_remoto = @args.join(' ') if comando_remoto.empty? && !@opciones[:interactivo] ADN::ErrorHandler.error("Se requiere un comando o --interactivo") puts "Uso: ./adn/tools/run ssh #{nodo_nombre} \"hostname; uptime\"" puts " ./adn/tools/run ssh #{nodo_nombre} --interactivo" exit 1 end conectar(nodo_nombre, comando_remoto) end private def conectar(nodo_nombre, comando_remoto) config = obtener_config(nodo_nombre) unless config ADN::ErrorHandler.error("Nodo '#{nodo_nombre}' no encontrado") puts " Nodos disponibles: #{NODOS.keys.join(', ')}" exit 1 end @logger.info("Conectando a #{nodo_nombre} (#{config[:ip]}:#{config[:puerto]})") # Autorizar candados autorizar_candados case config[:auth] when :passphrase ejecutar_con_passphrase(nodo_nombre, config, comando_remoto) when :password ejecutar_con_password(nodo_nombre, config, comando_remoto) end end def obtener_config(nodo_nombre) NODOS[nodo_nombre] end def autorizar_candados output = `ruby #{CANDADOS_PATH} authorize 2>&1` unless $?.success? ADN::ErrorHandler.error("No se pudo autorizar candados") @logger.error(output) exit 1 end end def obtener_secreto(clave) output = `ruby #{CANDADOS_PATH} get #{clave} 2>/dev/null`.strip if output.empty? || !$?.success? ADN::ErrorHandler.error("No se encontró clave '#{clave}' en la bóveda") exit 1 end output end def ejecutar_con_passphrase(nodo_nombre, config, comando_remoto) passphrase = obtener_secreto(config[:clave_boveda]) ssh_opts = "-o ConnectTimeout=15 -o StrictHostKeyChecking=no" ssh_target = "#{config[:usuario]}@#{config[:ip]}" if @opciones[:interactivo] # Sesión interactiva: usar sshpass con passphrase prompt @logger.info("Abriendo sesión interactiva a #{nodo_nombre}...") cmd = "sshpass -P 'passphrase' -p '#{passphrase}' ssh #{ssh_opts} -p #{config[:puerto]} #{ssh_target}" exec(cmd) else # Comando remoto if @opciones[:verbose] puts "#{Color::DIM} [SSH] #{nodo_nombre} (#{config[:ip]}:#{config[:puerto]}) > #{comando_remoto[0..60]}...#{Color::RESET}" end cmd = "sshpass -P 'passphrase' -p '#{passphrase}' ssh #{ssh_opts} -p #{config[:puerto]} #{ssh_target} '#{comando_remoto.gsub("'", "'\\''")}'" stdout, stderr, status = Open3.capture3(cmd) if status.success? puts stdout unless stdout.empty? @logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}") else puts stdout unless stdout.empty? $stderr.puts stderr unless stderr.empty? ADN::ErrorHandler.error("Fallo en ejecución remota (exit: #{status.exitstatus})") exit status.exitstatus || 1 end end end def ejecutar_con_password(nodo_nombre, config, comando_remoto) password = obtener_secreto(config[:clave_boveda]) proxy_config = config[:proxy] ? NODOS[config[:proxy]] : nil ssh_opts = "-o ConnectTimeout=15 -o StrictHostKeyChecking=no" ssh_target = "#{config[:usuario]}@#{config[:ip]}" # Si hay proxy, primero conectamos al proxy y desde ahí ejecutamos if proxy_config @logger.info("Via proxy #{config[:proxy]} (#{proxy_config[:ip]})") proxy_passphrase = obtener_secreto(proxy_config[:clave_boveda]) # Comando anidado: SSH al proxy → SSH al destino inner_cmd = "sshpass -p '#{password}' ssh #{ssh_opts} -p #{config[:puerto]} #{ssh_target} '#{comando_remoto.gsub("'", "'\\''")}'" outer_cmd = "sshpass -P 'passphrase' -p '#{proxy_passphrase}' ssh #{ssh_opts} -p #{proxy_config[:puerto]} #{proxy_config[:usuario]}@#{proxy_config[:ip]} \"#{inner_cmd.gsub('"', '\\"')}\"" stdout, stderr, status = Open3.capture3(outer_cmd) else cmd = "sshpass -p '#{password}' ssh #{ssh_opts} -p #{config[:puerto]} #{ssh_target} '#{comando_remoto.gsub("'", "'\\''")}'" stdout, stderr, status = Open3.capture3(cmd) end if status.success? puts stdout unless stdout.empty? @logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}") else puts stdout unless stdout.empty? $stderr.puts stderr unless stderr.empty? ADN::ErrorHandler.error("Fallo en ejecución remota (exit: #{status.exitstatus})") exit status.exitstatus || 1 end end def listar_nodos puts "#{Color::CYAN}📡 Nodos SSH configurados:#{Color::RESET}" puts "#{Color::DIM}#{'─' * 70}#{Color::RESET}" printf " %-15s %-20s %-8s %-12s %s\n", "NODO", "IP", "PUERTO", "AUTH", "PROXY" puts "#{Color::DIM}#{'─' * 70}#{Color::RESET}" NODOS.each do |nombre, cfg| proxy = cfg[:proxy] || '-' printf " #{Color::GREEN}%-15s#{Color::RESET} %-20s %-8s %-12s %s\n", nombre, cfg[:ip], cfg[:puerto], cfg[:auth], proxy end puts "#{Color::DIM}#{'─' * 70}#{Color::RESET}" end end end