# 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 'shellwords' require_relative '../core/colores' require_relative '../core/constants' require_relative '../core/error_handler' require_relative 'nodos/info' module ADN class SubcomandoSSH CANDADOS_PATH = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb') 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 o sin SSH") puts " Usa: ./adn/tools/run ssh --listar" exit 1 end @logger.info("Conectando a #{nodo_nombre} (#{config[:ip]}:#{config[:puerto_ssh]})") # Autorizar candados autorizar_candados case config[:auth_type] 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) meta = ADN::NodosInfo.extraer_metadata(nodo_nombre) return nil unless meta && meta[:ip] && meta[:usuario] && meta[:clave_boveda] # Inferir proxy: si el nodo tiene host (es VM), usar el host como proxy if meta[:host] && meta[:host] != nodo_nombre proxy_meta = ADN::NodosInfo.extraer_metadata(meta[:host]) meta[:proxy] = meta[:host] if proxy_meta end meta 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]} #{ssh_target}" exec(cmd) else # Comando remoto if @opciones[:verbose] puts "#{Color::DIM} [SSH] #{nodo_nombre} (#{config[:ip]}:#{config[:puerto_ssh]}) > #{comando_remoto[0..60]}...#{Color::RESET}" end cmd = "sshpass -P 'passphrase' -p '#{passphrase}' ssh #{ssh_opts} -p #{config[:puerto_ssh]} #{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] ? ADN::NodosInfo.extraer_metadata(config[:proxy]) : nil ssh_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=15" ssh_target = "#{config[:usuario]}@#{config[:ip]}" if proxy_config @logger.info("Via ProxyJump #{config[:proxy]} (#{proxy_config[:ip]})") proxy_passphrase = obtener_secreto(proxy_config[:clave_boveda]) # ProxyCommand con sshpass: el proxy usa passphrase RSA, # el destino usa password. Todo se ejecuta localmente via bash. script = <<~BASH export SSHPASS=#{password.shellescape} sshpass -e ssh \ -o StrictHostKeyChecking=no \ -o ConnectTimeout=30 \ -o ServerAliveInterval=5 \ -o ProxyCommand="sshpass -P passphrase -p '#{proxy_passphrase}' ssh -o StrictHostKeyChecking=no -p #{proxy_config[:puerto_ssh]} -W %h:%p #{proxy_config[:usuario]}@#{proxy_config[:ip]}" \ -p #{config[:puerto_ssh]} \ '#{ssh_target}' \ "#{comando_remoto.gsub('"', '\\"')}" BASH # Escribir script a archivo temporal para evitar problemas de escape tmpscript = "/tmp/.adn_ssh_cmd_#{Process.pid}.sh" tmpout = "/tmp/.adn_ssh_out_#{Process.pid}" File.write(tmpscript, script) File.chmod(0700, tmpscript) system("script", "-qc", tmpscript, tmpout) status_success = $?.success? stdout = File.exist?(tmpout) ? File.read(tmpout).gsub(/\r\n?/, "\n").strip : "" File.delete(tmpscript) if File.exist?(tmpscript) File.delete(tmpout) if File.exist?(tmpout) else ENV['SSHPASS'] = password cmd_parts = ["sshpass", "-e", "ssh"] + ssh_opts.split(' ') + ["-p", config[:puerto_ssh].to_s, ssh_target, comando_remoto] stdout, stderr, status = Open3.capture3(*cmd_parts) ENV.delete('SSHPASS') end success = defined?(status_success) ? status_success : status.success? if success puts stdout unless stdout.nil? || stdout.empty? @logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}") else puts stdout unless stdout.nil? || stdout.empty? ADN::ErrorHandler.error("Fallo en ejecución remota") exit 1 end end def listar_nodos puts "#{Color::CYAN}📡 Nodos SSH disponibles (leídos de fichas nodos/*.md):#{Color::RESET}" puts "#{Color::DIM}#{'─' * 75}#{Color::RESET}" printf " %-15s %-20s %-8s %-12s %-10s %s\n", "NODO", "IP", "PUERTO", "AUTH", "BÓVEDA", "PROXY" puts "#{Color::DIM}#{'─' * 75}#{Color::RESET}" Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).sort.each do |archivo| nombre = File.basename(archivo, '.md') next if nombre.start_with?('_') config = obtener_config(nombre) next unless config # Sin config SSH válida proxy = config[:proxy] || '-' printf " #{Color::GREEN}%-15s#{Color::RESET} %-20s %-8s %-12s %-10s %s\n", nombre, config[:ip], config[:puerto_ssh], config[:auth_type], config[:clave_boveda], proxy end puts "#{Color::DIM}#{'─' * 75}#{Color::RESET}" end end end