Files
dtic-DIIAA/adn/tools/cli/ssh.rb
T

465 lines
18 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 '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
mostrar_ayuda
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 mostrar_ayuda
ayuda = ADN::HelpFormatter.generar_help(
titulo: "Ejecución Remota Segura SSH",
descripcion: "Ejecuta comandos remotos de forma segura via SSH + Candados",
uso: "./adn/tools/run ssh <nodo> [comando] [OPCIONES]",
opciones: [
{ names: ['-i', '--interactivo'], desc: "Abrir sesión SSH interactiva" },
{ names: ['-v', '--verbose'], desc: "Mostrar detalles de conexión" },
{ names: ['--listar'], desc: "Listar nodos configurados" },
{ names: ['-h', '--help'], desc: "Mostrar esta ayuda" }
],
ejemplos: [
{ cmd: "./adn/tools/run ssh srv-dasu \"hostname; uptime\"", desc: "Ejecutar comando remoto en srv-dasu" },
{ cmd: "./adn/tools/run ssh dc-dasuten \"whoami\"", desc: "Ejecutar comando remoto en dc-dasuten" },
{ cmd: "./adn/tools/run ssh srv-ns8 --interactivo", desc: "Abrir sesión SSH interactiva en srv-ns8" }
]
)
puts ayuda
end
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
# Mostrar contexto enriquecido
contexto = []
if config[:ip_lan]
contexto << "LAN:#{config[:ip_lan]}"
end
if config[:ip] && config[:ip] != config[:ip_lan]
contexto << "VPN:#{config[:ip]}"
end
contexto_str = contexto.any? ? " [#{contexto.join(' ')}]" : ""
@logger.info("Conectando a #{nodo_nombre}#{contexto_str} (#{config[:ip]}:#{config[:puerto_ssh]})")
# Autorizar candados solo si es necesario (password o passphrase)
autorizar_candados unless config[:auth_type] == :rsa_legacy
case config[:auth_type]
when :passphrase
ejecutar_con_passphrase(nodo_nombre, config, comando_remoto)
when :password
ejecutar_con_password(nodo_nombre, config, comando_remoto)
when :rsa
ejecutar_con_rsa(nodo_nombre, config, comando_remoto)
when :rsa_legacy
ejecutar_con_rsa_legacy(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]
# Extraer IP de LAN si está disponible en la ficha
meta[:ip_lan] = ADN::NodosInfo.extraer_ip_lan(nodo_nombre)
# Extraer clave SSH específica (para nodos con múltiples claves)
ruta = File.join(ADN::NODOS_DIR, "#{nodo_nombre}.md")
contenido = File.read(ruta, encoding: 'UTF-8') if File.exist?(ruta)
if contenido
meta[:clave_ssh] = ADN::NodosInfo.detectar_clave_ssh(contenido)
meta[:ssh_opts] = ADN::NodosInfo.detectar_ssh_opts(contenido)
end
# 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 imprimir_salida(nodo_nombre, texto, es_error=false)
return if texto.nil? || texto.empty?
prefix = es_error ? "#{Color::RED}[#{nodo_nombre}-ERR]#{Color::RESET} " : "#{Color::CYAN}[#{nodo_nombre}]#{Color::RESET} "
texto.lines.each do |linea|
if es_error
$stderr.puts "#{prefix}#{linea}"
else
puts "#{prefix}#{linea}"
end
end
end
def ejecutar_con_passphrase(nodo_nombre, config, comando_remoto)
passphrase = obtener_secreto(config[:clave_boveda])
proxy_config = config[:proxy] ? ADN::NodosInfo.extraer_metadata(config[:proxy]) : nil
ssh_opts = "-o ConnectTimeout=15 -o StrictHostKeyChecking=no"
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])
script = <<~BASH
sshpass -P passphrase -p '#{passphrase}' 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
tmpscript = "/tmp/.adn_ssh_pass_#{Process.pid}.sh"
File.write(tmpscript, script)
File.chmod(0700, tmpscript)
stdout, stderr, status = Open3.capture3(tmpscript)
File.delete(tmpscript) if File.exist?(tmpscript)
if status.success?
imprimir_salida(nodo_nombre, stdout)
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}")
else
imprimir_salida(nodo_nombre, stdout)
imprimir_salida(nodo_nombre, stderr, true)
ADN::ErrorHandler.error("Fallo en ejecución remota (exit: #{status.exitstatus})")
exit status.exitstatus || 1
end
elsif @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 directo
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?
imprimir_salida(nodo_nombre, stdout)
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}")
else
imprimir_salida(nodo_nombre, stdout)
imprimir_salida(nodo_nombre, stderr, true)
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
imprimir_salida(nodo_nombre, stdout)
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}")
else
imprimir_salida(nodo_nombre, stdout)
ADN::ErrorHandler.error("Fallo en ejecución remota")
exit 1
end
end
def ejecutar_con_rsa(nodo_nombre, config, comando_remoto)
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])
script = <<~BASH
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
tmpscript = "/tmp/.adn_ssh_rsa_#{Process.pid}.sh"
File.write(tmpscript, script)
File.chmod(0700, tmpscript)
stdout, stderr, status = Open3.capture3(tmpscript)
File.delete(tmpscript) if File.exist?(tmpscript)
if status.success?
imprimir_salida(nodo_nombre, stdout)
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}")
else
imprimir_salida(nodo_nombre, stdout)
imprimir_salida(nodo_nombre, stderr, true)
ADN::ErrorHandler.error("Fallo en ejecución remota (exit: #{status.exitstatus})")
exit status.exitstatus || 1
end
elsif @opciones[:interactivo]
@logger.info("Abriendo sesión interactiva a #{nodo_nombre}...")
cmd = "ssh #{ssh_opts} -p #{config[:puerto_ssh]} #{ssh_target}"
exec(cmd)
else
cmd_parts = ["ssh"] + ssh_opts.split(' ') + ["-p", config[:puerto_ssh].to_s, ssh_target, comando_remoto]
stdout, stderr, status = Open3.capture3(*cmd_parts)
if status.success?
imprimir_salida(nodo_nombre, stdout)
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre}")
else
imprimir_salida(nodo_nombre, stdout)
imprimir_salida(nodo_nombre, stderr, true)
ADN::ErrorHandler.error("Fallo en ejecución remota (exit: #{status.exitstatus})")
exit status.exitstatus || 1
end
end
end
def ejecutar_con_rsa_legacy(nodo_nombre, config, comando_remoto)
# XenServer 7.0 (OpenSSH 6.6.1) requiere algoritmos legacy ssh-rsa (SHA-1)
proxy_config = config[:proxy] ? ADN::NodosInfo.extraer_metadata(config[:proxy]) : nil
# Determinar clave SSH específica si está configurada
clave_ssh = File.expand_path(config[:clave_ssh] || "~/.ssh/id_rsa")
@logger.info("Usando clave SSH: #{clave_ssh}")
ssh_target = "#{config[:usuario]}@#{config[:ip]}"
# Opciones SSH legacy para OpenSSH < 7.0
ssh_opts = [
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=15",
"-o", "PubkeyAcceptedAlgorithms=+ssh-rsa",
"-o", "HostkeyAlgorithms=+ssh-rsa"
]
if proxy_config
@logger.info("Via ProxyJump #{config[:proxy]} (#{proxy_config[:ip]})")
proxy_passphrase = obtener_secreto(proxy_config[:clave_boveda])
cmd_parts = [
"ssh", "-i", clave_ssh,
*ssh_opts,
"-o", "ServerAliveInterval=5",
"-o", "ConnectTimeout=30",
"-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].to_s,
ssh_target,
comando_remoto
]
# Usar system para heredar entorno SSH completo
exit_code = system(*cmd_parts)
if exit_code
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre} (RSA legacy)")
else
ADN::ErrorHandler.error("Fallo en ejecución remota")
exit 1
end
elsif @opciones[:interactivo]
@logger.info("Abriendo sesión interactiva a #{nodo_nombre} (RSA legacy)...")
cmd = "ssh -i #{clave_ssh} #{ssh_opts.join(' ')} -p #{config[:puerto_ssh]} #{ssh_target}"
exec(cmd)
else
# Usar script bash para asegurar que el entorno SSH se herede correctamente
script = <<~BASH
#!/bin/bash
ssh -i #{clave_ssh} #{ssh_opts.join(' ')} -p #{config[:puerto_ssh]} #{ssh_target} '#{comando_remoto.gsub("'", "'\\''")}'
BASH
tmpscript = "/tmp/.adn_ssh_rsa_legacy_#{Process.pid}.sh"
File.write(tmpscript, script)
File.chmod(0700, tmpscript)
# Ejecutar con bash para heredar entorno
exit_code = system("/bin/bash", tmpscript)
File.delete(tmpscript) if File.exist?(tmpscript)
if exit_code
@logger.exito("Comando ejecutado exitosamente en #{nodo_nombre} (RSA legacy)")
else
ADN::ErrorHandler.error("Fallo en ejecución remota")
exit 1
end
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