- Eliminar hash NODOS hardcodeado de ssh.rb - Leer IP, puerto, usuario, auth, bóveda de fichas nodos/*.md - Inferir proxy automáticamente (host != nodo → proxy) - Mejorar NodosInfo: detectar_clave_boveda, detectar_auth_type - Actualizar fichas: srv-dasu (IP Tailscale), pcv-dasu0, sql-dasuten - Formato SSH estándar: usuario@ip:port (Auth, Bóveda: clave) - 15 tests pasan sin regresiones - Evento 1077 (16:58→17:03)
251 lines
8.8 KiB
Ruby
251 lines
8.8 KiB
Ruby
# 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 'shellwords'
|
|
require_relative '../core/colores'
|
|
require_relative '../core/constants'
|
|
require_relative '../core/error_handler'
|
|
require_relative '../core/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 <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 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
|