require 'optparse' require_relative '../core/colores' module ADN class SubcomandoEstados def initialize(args, logger) @args = args @logger = logger @opciones = { verificar: false } end def ejecutar parser = OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run estados [OPCIONES]" opts.on("--verificar", "Verificar si las tareas en proceso ya terminaron") do @opciones[:verificar] = true end end parser.parse!(@args) puts "#{Color::CYAN}⏳ Consultando items en proceso (Bitácora Web)#{Color::RESET}" puts "#{Color::DIM}#{'-' * 70}#{Color::RESET}" begin require_relative '../db/core/bitacora_db' # 1. Preparar SSH si es necesario (Una sola vez) preparar_ssh if @opciones[:verificar] BitacorasDB::BitacoraDB.with_connection do |db| # Buscamos entradas con estado ⏳ para hoy o recientes entradas = db.list_entradas( estado: '⏳', desde: (Date.today - 1).to_s, limit: 20 ) if entradas.empty? puts " #{Color::GREEN}✅ No hay tareas pendientes en proceso.#{Color::RESET}" return end puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Nodo".ljust(15)} #{"Descripción".ljust(40)}#{Color::RESET}" puts "#{"─" * 5} #{"─" * 8} #{"─" * 15} #{"─" * 40}" entradas.each do |e| id = e['id'].to_s inicio = e['inicio'][0..4] # HH:MM nodo = e['nodo_nombre'] || '(sin nodo)' nodo_id = e['nodo_id'] desc = e['descripcion'] # Si se pidió verificar y es un proceso gestionable if @opciones[:verificar] && desc.start_with?('PROCESO:') && nodo_id print " #{Color::DIM}Verificando #{nodo}...#{Color::RESET} " result = tarea_status_detallado(nodo, desc) if result[:terminado] puts "#{Color::GREEN}TERMINADO#{Color::RESET} #{Color::DIM}(Proceso no encontrado)#{Color::RESET}" # Al finalizar, mantenemos el historial de viñetas final_desc = desc.split("\n")[0].gsub('Iniciando ', '').gsub('..', '.') + " (Completado y auto-confirmado)\n" + desc.split("\n")[1..].join("\n") db.update_entrada(e['id'], { estado: '✅', fin: Time.now.strftime('%H:%M'), descripcion: final_desc.strip }) next elsif result[:activo] puts "#{Color::YELLOW}ACTIVO#{Color::RESET}" ahora = Time.now.strftime('%H:%M') # Solo agregamos si no hay ya una entrada para este mismo minuto unless desc.include?("- #{ahora} >") # Limpiamos el formato anterior si existiera base_desc = desc.split(' [Último control:')[0].strip nueva_desc = "#{base_desc}\n- #{ahora} > Activo y en proceso sin problemas" db.update_entrada(e['id'], { descripcion: nueva_desc }) end else puts "#{Color::RED}PENDIENTE#{Color::RESET} #{Color::DIM}(Error)#{Color::RESET}" end end nodo_fmt = nodo[0..13] + '..' if nodo.length > 15 nodo_fmt ||= nodo desc_fmt = desc[0..37] + '..' if desc.length > 40 desc_fmt ||= desc puts "#{id.ljust(5)} #{inicio.ljust(8)} #{nodo_fmt.ljust(15)} #{desc_fmt}" end puts "#{Color::DIM}#{'-' * 70}#{Color::RESET}" puts "Total: #{entradas.length} item(s) en proceso." end rescue => e @logger.error("Error al consultar estados: #{e.message}") puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" ensure limpiar_ssh if @opciones[:verificar] end end private def preparar_ssh # Obtener passphrase de la bóveda @passphrase = nil begin candados_path = File.join(File.dirname(__FILE__), '../seguridad/candados.rb') if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session')) output = `ruby #{candados_path} get rsa 2>/dev/null`.strip @passphrase = output unless output.empty? || output.include?('Error') end rescue end return unless @passphrase # Crear script de askpass único @askpass_sh = "/tmp/adn_askpass_#{Process.pid}.sh" File.write(@askpass_sh, "#!/bin/sh\necho \"#{@passphrase}\"\n") FileUtils.chmod(0700, @askpass_sh) @ssh_env = { 'DISPLAY' => ':0', 'SSH_ASKPASS_REQUIRE' => 'force', 'SSH_ASKPASS' => @askpass_sh } # Intentar añadir la llave una sola vez si no está cargada unless system("ssh-add -l >/dev/null 2>&1") system(@ssh_env, "ssh-add /home/rmonla/.ssh/id_rsa < /dev/null 2>/dev/null") end end def limpiar_ssh File.delete(@askpass_sh) if @askpass_sh && File.exist?(@askpass_sh) end def tarea_status_detallado(nodo, descripcion) archivo_nodo = File.join(ADN::NODOS_DIR, "#{nodo}.md") return { terminado: false, activo: false, code: -1 } unless File.exist?(archivo_nodo) contenido = File.read(archivo_nodo) ip = detectar_ip(contenido) so = detectar_so(contenido) return { terminado: false, activo: false, code: -2 } unless ip if descripcion.include?('actualiza') && descripcion.include?('SO') if so == 'Linux' # Comando robusto que no se empareja a sí mismo cmd = "pgrep -x 'apt-get' || pgrep -x 'dpkg' || pgrep -x 'apt' || pgrep -x 'dpkg-deb'" exit_code = system_ssh_status(ip, cmd) return { terminado: true, activo: false, code: exit_code } if exit_code == 1 return { terminado: false, activo: true, code: exit_code } if exit_code == 0 return { terminado: false, activo: false, code: exit_code } elsif so == 'Windows' # Verificamos si procesos de actualización están corriendo # En PowerShell el errorlevel $LASTEXITCODE o $? se puede traducir cmd = "powershell -Command \"if (Get-Process TiWorker, TrustedInstaller, wuauclt -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }\"" exit_code = system_ssh_status_win(ip, cmd) return { terminado: true, activo: false, code: exit_code } if exit_code == 1 return { terminado: false, activo: true, code: exit_code } if exit_code == 0 return { terminado: false, activo: false, code: exit_code } end end { terminado: false, activo: false, code: -3 } end def detectar_ip(contenido) # 1. Buscar configuración SSH completa en tabla (user@IP:port) if contenido.match(/\|\s*\*\*SSH\*\*\s*\|\s*`?([a-zA-Z0-9_-]+@[0-9\.]+):([0-9]+)`?/) user_host = $1 port = $2 return "-p #{port} #{user_host}" end if contenido.match(/\*\*(?:IP VPN|IP Tailscale)\*\*\s*[:\|]\s*`?([0-9\.]+)`?/) return $1 end if contenido.match(/\*\*IP(?:\s+\w+)?\*\*\s*[:\|]\s*`?([0-9\.]+)`?/) return $1 end nil end def detectar_so(contenido) # 1. Buscar campo explícito OS o SO en los metadatos iniciales identidad = contenido.split('##')[0..1].join("\n") if identidad.match(/(?:- |\|)\s*\*\*(?:OS|SO|Sistema Operativo)\*\*\s*[:\|]\s*([^\|\n]+)/i) so_valor = $1.strip return 'Windows' if so_valor.match(/Windows|Win10|Server/i) return 'Linux' if so_valor.match(/Debian|Ubuntu|Linux|Proxmox|LXC/i) end # 2. Fallback por palabras clave if identidad.match(/Windows|Win10|Server Core/i) 'Windows' else 'Linux' end end def system_ssh_status(ip, remote_cmd) if @passphrase system(@ssh_env, "ssh -n -o ConnectTimeout=5 -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1") return $?.exitstatus else system("ssh -n -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1") return $?.exitstatus end end def system_ssh_status_win(ip, remote_cmd) candados_path = File.join(File.dirname(__FILE__), '../seguridad/candados.rb') if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session')) shell_eval = "sshpass -e ssh -n -o ConnectTimeout=5 -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1" system("ruby", candados_path, "run", "win_domain", "SSHPASS", shell_eval) return $?.exitstatus else # Si no hay sesión, fallar y devolver un error genérico (255) return 255 end end end end