diff --git a/adn/README.md b/adn/README.md index 361f1c05..c540db55 100644 --- a/adn/README.md +++ b/adn/README.md @@ -153,6 +153,7 @@ referencia canónica y exclusiva de las directivas que un agente IA debe seguir. | `generar proyecto ` | Generar manifiesto de proyecto en DB | DB-First | | `conocimiento asimilar ` | Asimilar documentación al ADN | Almacena en DB | | `msp ` | Gestión de MSPs NotebookLM (auditar, registrar, listar) | Integración MCP | +| `ssh ` | Ejecución remota segura via SSH + Candados | Registra en DB | | `ayuda [subcomando]` | Mostrar ayuda detallada | - | | `validador [--watch] [--fix]` | ⚠️ **OBSOLETO** - Validación MD-First antigua | MD-First (evitar) | diff --git a/adn/tools/cli/ssh.rb b/adn/tools/cli/ssh.rb new file mode 100644 index 00000000..a7b7f3b2 --- /dev/null +++ b/adn/tools/cli/ssh.rb @@ -0,0 +1,248 @@ +# 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 diff --git a/adn/tools/run b/adn/tools/run index cebc7294..232a3408 100755 --- a/adn/tools/run +++ b/adn/tools/run @@ -53,6 +53,7 @@ require_relative 'cli/jornada' require_relative 'cli/proceso' require_relative 'cli/estados' require_relative 'cli/tailscale' +require_relative 'cli/ssh' require_relative 'core/conciliador' require_relative 'core/colores' @@ -131,6 +132,8 @@ class ADNCLI ADN::SubcomandoPlan.new(args, @logger).ejecutar when 'msp' SubcomandoMsp.new(args, @logger).ejecutar + when 'ssh' + ADN::SubcomandoSSH.new(args, @logger).ejecutar else @logger.error("Subcomando desconocido: #{subcomando}") puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}" diff --git a/adn/tools/seguridad/.session b/adn/tools/seguridad/.session index d635ea22..2c02c12f 100644 --- a/adn/tools/seguridad/.session +++ b/adn/tools/seguridad/.session @@ -1 +1 @@ -1773452033 \ No newline at end of file +1773600939 \ No newline at end of file diff --git a/docs/proy/p2604_mejoras_ADN/P2604_mejoras_ADN.md b/docs/proy/p2604_mejoras_ADN/P2604_mejoras_ADN.md index f69240b6..bf2c9be9 100644 --- a/docs/proy/p2604_mejoras_ADN/P2604_mejoras_ADN.md +++ b/docs/proy/p2604_mejoras_ADN/P2604_mejoras_ADN.md @@ -153,6 +153,7 @@ Mejoras atómicas, incrementales y reutilizables. Una herramienta por vez. | S8 | Dejar de cargar `core/validador.rb` obsoleto en `run` | Limpieza | ✅ | | S9 | Limpiar progreso duplicado en este plan | Doc | ✅ | | S10 | Migrar `SubcomandoSalud` al namespace `ADN::` | Refactor | ⏳ | +| S11 | Crear CLI `ssh` — ejecución remota reutilizable via `candados` + `EjecutorRemoto` | Herramienta | ✅ | --- @@ -168,7 +169,7 @@ Fase 6: ██████████ 100% (H1: HelpFormatter) Fase 7: ██████████ 100% (DOC1-DOC3: docs/tecnica/) Fase 8: ██████████ 100% (CI1-CI2: GitHub Actions) Fase 9: ██████████ 100% (M1-M5: MSP CLI) -Fase 10: █████████░ 90% (S1-S9 done, S10 pendiente) +Fase 10: ████████░░ 80% (S1-S9 done, S10-S11 pendientes) ``` ---