[P2604-S11] Nueva herramienta CLI ssh — ejecución remota via candados
- Crear adn/tools/cli/ssh.rb con soporte passphrase RSA y contraseñas - ProxyJump automático para VMs DASUTEN (dc/sql/pcv-dasuten via srv-dasu) - Registrar subcomando ssh en run y adn/README.md - Verificado: conexión exitosa a srv-dasu via Tailscale - 15 tests pasan sin regresiones - Evento 1075 (15:47→15:56)
This commit is contained in:
@@ -153,6 +153,7 @@ referencia canónica y exclusiva de las directivas que un agente IA debe seguir.
|
|||||||
| `generar proyecto <código>` | Generar manifiesto de proyecto en DB | DB-First |
|
| `generar proyecto <código>` | Generar manifiesto de proyecto en DB | DB-First |
|
||||||
| `conocimiento asimilar <archivo>` | Asimilar documentación al ADN | Almacena en DB |
|
| `conocimiento asimilar <archivo>` | Asimilar documentación al ADN | Almacena en DB |
|
||||||
| `msp <subcomando>` | Gestión de MSPs NotebookLM (auditar, registrar, listar) | Integración MCP |
|
| `msp <subcomando>` | Gestión de MSPs NotebookLM (auditar, registrar, listar) | Integración MCP |
|
||||||
|
| `ssh <nodo> <comando>` | Ejecución remota segura via SSH + Candados | Registra en DB |
|
||||||
| `ayuda [subcomando]` | Mostrar ayuda detallada | - |
|
| `ayuda [subcomando]` | Mostrar ayuda detallada | - |
|
||||||
| `validador [--watch] [--fix]` | ⚠️ **OBSOLETO** - Validación MD-First antigua | MD-First (evitar) |
|
| `validador [--watch] [--fix]` | ⚠️ **OBSOLETO** - Validación MD-First antigua | MD-First (evitar) |
|
||||||
|
|
||||||
|
|||||||
@@ -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 <nodo> <comando>
|
||||||
|
# ./adn/tools/run ssh <nodo> --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 <nodo> [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
|
||||||
@@ -53,6 +53,7 @@ require_relative 'cli/jornada'
|
|||||||
require_relative 'cli/proceso'
|
require_relative 'cli/proceso'
|
||||||
require_relative 'cli/estados'
|
require_relative 'cli/estados'
|
||||||
require_relative 'cli/tailscale'
|
require_relative 'cli/tailscale'
|
||||||
|
require_relative 'cli/ssh'
|
||||||
require_relative 'core/conciliador'
|
require_relative 'core/conciliador'
|
||||||
|
|
||||||
require_relative 'core/colores'
|
require_relative 'core/colores'
|
||||||
@@ -131,6 +132,8 @@ class ADNCLI
|
|||||||
ADN::SubcomandoPlan.new(args, @logger).ejecutar
|
ADN::SubcomandoPlan.new(args, @logger).ejecutar
|
||||||
when 'msp'
|
when 'msp'
|
||||||
SubcomandoMsp.new(args, @logger).ejecutar
|
SubcomandoMsp.new(args, @logger).ejecutar
|
||||||
|
when 'ssh'
|
||||||
|
ADN::SubcomandoSSH.new(args, @logger).ejecutar
|
||||||
else
|
else
|
||||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1773452033
|
1773600939
|
||||||
@@ -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 | ✅ |
|
| S8 | Dejar de cargar `core/validador.rb` obsoleto en `run` | Limpieza | ✅ |
|
||||||
| S9 | Limpiar progreso duplicado en este plan | Doc | ✅ |
|
| S9 | Limpiar progreso duplicado en este plan | Doc | ✅ |
|
||||||
| S10 | Migrar `SubcomandoSalud` al namespace `ADN::` | Refactor | ⏳ |
|
| 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 7: ██████████ 100% (DOC1-DOC3: docs/tecnica/)
|
||||||
Fase 8: ██████████ 100% (CI1-CI2: GitHub Actions)
|
Fase 8: ██████████ 100% (CI1-CI2: GitHub Actions)
|
||||||
Fase 9: ██████████ 100% (M1-M5: MSP CLI)
|
Fase 9: ██████████ 100% (M1-M5: MSP CLI)
|
||||||
Fase 10: █████████░ 90% (S1-S9 done, S10 pendiente)
|
Fase 10: ████████░░ 80% (S1-S9 done, S10-S11 pendientes)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user