664 lines
28 KiB
Ruby
Executable File
664 lines
28 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
||
# frozen_string_literal: true
|
||
|
||
# candados — Gestión unificada de secretos y autorización SSH
|
||
# ==============================================================================
|
||
# 🤖 INSTRUCCIONES PARA LA IA (AGENTES EXTERNOS):
|
||
# Esta herramienta permite obtener credenciales seguras sin exponerlas.
|
||
#
|
||
# COMANDO PRIMARIO (RECOMENDADO):
|
||
# ruby candados.rb run <clave> '<comando con $USR y $PASS>'
|
||
# → Inyecta USR y PASS en el entorno del subproceso. Nada se imprime.
|
||
#
|
||
# COMANDO SECUNDARIO (USO INTERACTIVO):
|
||
# eval $(ruby candados.rb load <clave>)
|
||
# → Carga USR y PASS en el shell actual via archivo temporal seguro (600).
|
||
#
|
||
# ⚠ NUNCA usar `get` directamente en run_command: el secreto se filtra en logs.
|
||
# ==============================================================================
|
||
# Uso: ruby adn/tools/seguridad/candados.rb <comando> [args]
|
||
#
|
||
# Comandos principales:
|
||
# run <clave> <cmd> Ejecutar comando con USR y PASS inyectados (PRIMARIO)
|
||
# sudo <cmd> Ejecutar comando con sudo desde la bóveda
|
||
# load <clave> Cargar USR/PASS en shell (eval, archivo temporal seguro)
|
||
#
|
||
# Comandos de gestión:
|
||
# authorize Abrir candado (sesión temporal 30 min)
|
||
# set <clave> Guardar secreto (input seguro, sin eco)
|
||
# get <clave> Obtener secreto (⚠ imprime en stdout)
|
||
# rm <clave> Eliminar secreto
|
||
# list Listar claves disponibles
|
||
|
||
require 'openssl'
|
||
require 'base64'
|
||
require 'json'
|
||
require 'io/console'
|
||
require 'fileutils'
|
||
|
||
# ─── Rutas ───────────────────────────────────────────────────────────
|
||
TOOL_DIR = File.expand_path(__dir__)
|
||
PROJECT_ROOT = File.expand_path('../../..', TOOL_DIR) # Desde adn/tools/candados -> adn/tools -> adn -> root
|
||
MASTER_KEY = File.join(TOOL_DIR, '.master.key')
|
||
OLD_KEY = File.expand_path('../../.master.key', TOOL_DIR)
|
||
BOVEDA = File.join(TOOL_DIR, '.boveda.json')
|
||
SESION = File.join(TOOL_DIR, '.session')
|
||
ACCESS_LOG = File.join(TOOL_DIR, 'access.log')
|
||
TMP_DIR = File.join(PROJECT_ROOT, 'tmp') # Directorio para temporales (principio 11 - 05_ia.md)
|
||
|
||
# ─── Transición de Seguridad ─────────────────────────────────────────
|
||
if File.exist?(OLD_KEY) && !File.exist?(MASTER_KEY)
|
||
FileUtils.mv(OLD_KEY, MASTER_KEY)
|
||
FileUtils.chmod(0600, MASTER_KEY)
|
||
end
|
||
|
||
# ─── Colores ─────────────────────────────────────────────────────────
|
||
module C
|
||
RESET = "\e[0m"
|
||
BOLD = "\e[1m"
|
||
GREEN = "\e[32m"
|
||
CYAN = "\e[36m"
|
||
YELLOW = "\e[33m"
|
||
RED = "\e[31m"
|
||
DIM = "\e[2m"
|
||
end
|
||
|
||
# ─── Motor Criptográfico (AES-256-GCM) ──────────────────────────────
|
||
class Cripto
|
||
ALGORITHM = 'aes-256-gcm'
|
||
|
||
def initialize(key_content)
|
||
@key = Digest::SHA256.digest(key_content.strip)
|
||
end
|
||
|
||
def encrypt(plaintext)
|
||
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
|
||
c.encrypt
|
||
c.key = @key
|
||
end
|
||
iv = cipher.random_iv
|
||
ciphertext = cipher.update(plaintext.to_s) + cipher.final
|
||
payload = { iv: b64(iv), tag: b64(cipher.auth_tag), data: b64(ciphertext) }
|
||
Base64.strict_encode64(payload.to_json)
|
||
end
|
||
|
||
def decrypt(token)
|
||
p = JSON.parse(Base64.strict_decode64(token), symbolize_names: true)
|
||
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
|
||
c.decrypt
|
||
c.key = @key
|
||
c.iv = db64(p[:iv])
|
||
c.auth_tag = db64(p[:tag])
|
||
end
|
||
cipher.update(db64(p[:data])) + cipher.final
|
||
rescue => e
|
||
abort "#{C::RED}✗ Error al descifrar: #{e.message}#{C::RESET}"
|
||
end
|
||
|
||
private
|
||
|
||
def b64(d) = Base64.strict_encode64(d)
|
||
def db64(d) = Base64.strict_decode64(d)
|
||
end
|
||
|
||
# ─── Bóveda ──────────────────────────────────────────────────────────
|
||
class Boveda
|
||
def initialize(path)
|
||
@path = path
|
||
@data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
||
end
|
||
|
||
def keys = @data.keys
|
||
def get(k) = @data[k]
|
||
def set(k, v) = (@data[k] = v) && save!
|
||
def rm(k) = @data.delete(k) && save!
|
||
def empty? = @data.empty?
|
||
|
||
private
|
||
|
||
def save!
|
||
File.write(@path, JSON.pretty_generate(@data) + "\n")
|
||
FileUtils.chmod(0600, @path)
|
||
end
|
||
end
|
||
|
||
# ─── Master Key ──────────────────────────────────────────────────────
|
||
def cargar_master_key
|
||
unless File.exist?(MASTER_KEY)
|
||
require 'securerandom'
|
||
File.write(MASTER_KEY, SecureRandom.hex(32))
|
||
FileUtils.chmod(0600, MASTER_KEY)
|
||
$stderr.puts "#{C::YELLOW}⚠ Nueva clave maestra creada en local: #{MASTER_KEY}#{C::RESET}"
|
||
end
|
||
File.read(MASTER_KEY)
|
||
end
|
||
|
||
# ─── Auditoría ───────────────────────────────────────────────────────
|
||
def registrar_acceso(accion, detalle = '')
|
||
usuario = ENV['USER'] || ENV['USERNAME'] || 'unknown'
|
||
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
||
log_entry = "[#{timestamp}] - user:#{usuario} - action:#{accion} - #{detalle}\n"
|
||
|
||
File.open(ACCESS_LOG, 'a') do |f|
|
||
f.write(log_entry)
|
||
end
|
||
FileUtils.chmod(0600, ACCESS_LOG) if File.exist?(ACCESS_LOG)
|
||
end
|
||
|
||
# ─── Gestión de Sesión (MFA) ─────────────────────────────────────────
|
||
def autorizar!
|
||
File.write(SESION, Time.now.to_i.to_s)
|
||
FileUtils.chmod(0600, SESION)
|
||
registrar_acceso('AUTHORIZE', 'Candado abierto (Sesión iniciada)')
|
||
$stderr.puts "#{C::GREEN}✓ Autorización concedida (Válida por 30 minutos).#{C::RESET}"
|
||
end
|
||
|
||
def cerrar_sesion!
|
||
if File.exist?(SESION)
|
||
FileUtils.rm_f(SESION)
|
||
registrar_acceso('LOCK', 'Candado cerrado (Sesión finalizada)')
|
||
$stderr.puts "#{C::YELLOW}🔒 Sesión cerrada. Candado puesto.#{C::RESET}"
|
||
end
|
||
end
|
||
|
||
def verificar_sesion!
|
||
unless File.exist?(SESION)
|
||
registrar_acceso('DENIED', 'Intento de acceso sin autorización')
|
||
abort "#{C::RED}✗ Error: El candado está puesto. Ejecutá 'ruby #{File.basename($0)} authorize' para abrirlo.#{C::RESET}"
|
||
end
|
||
|
||
inicio = File.read(SESION).to_i
|
||
if Time.now.to_i - inicio > 1800 # 30 minutos
|
||
cerrar_sesion!
|
||
registrar_acceso('EXPIRED', 'Sesión expirada automáticamente')
|
||
abort "#{C::RED}✗ Sesión expirada. Por seguridad, volvé a autorizar.#{C::RESET}"
|
||
end
|
||
end
|
||
|
||
# ─── Comandos ────────────────────────────────────────────────────────
|
||
|
||
def cmd_abrir(cripto, boveda)
|
||
verificar_sesion!
|
||
token = boveda.get('rsa')
|
||
abort "#{C::RED}✗ Clave 'rsa' no encontrada en la bóveda. Guardala con: ruby candados.rb set rsa#{C::RESET}" if token.nil?
|
||
|
||
passphrase = cripto.decrypt(token)
|
||
registrar_acceso('ABRIR', 'SSH key load via ssh-add')
|
||
|
||
$stderr.puts "#{C::CYAN}🔐 candados: Cargando clave SSH...#{C::RESET}"
|
||
system('ssh-add -D > /dev/null 2>&1')
|
||
|
||
# Crear script temporal para SSH_ASKPASS (no interactivo)
|
||
require 'securerandom'
|
||
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
|
||
askpass_path = File.join(TMP_DIR, ".ssh_askpass_#{SecureRandom.hex(4)}")
|
||
safe_pass = passphrase.gsub("'", "'\\''")
|
||
File.write(askpass_path, "#!/bin/sh\necho '#{safe_pass}'\n")
|
||
File.chmod(0700, askpass_path)
|
||
|
||
# Cargar clave via SSH_ASKPASS
|
||
ENV['SSH_ASKPASS'] = askpass_path
|
||
ENV['SSH_ASKPASS_REQUIRE'] = 'force'
|
||
ENV['DISPLAY'] = ENV['DISPLAY'] || ':0'
|
||
system('ssh-add < /dev/null')
|
||
|
||
# Limpiar
|
||
FileUtils.rm_f(askpass_path)
|
||
ENV.delete('SSH_ASKPASS')
|
||
ENV.delete('SSH_ASKPASS_REQUIRE')
|
||
|
||
if system('ssh-add -l > /dev/null 2>&1')
|
||
$stderr.puts "#{C::GREEN}🔓 SSH autorizado. Identidades cargadas en el agente.#{C::RESET}"
|
||
else
|
||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Verificá la passphrase en 'rsa'.#{C::RESET}"
|
||
end
|
||
end
|
||
|
||
def cmd_encrypt(cripto, texto)
|
||
abort "#{C::RED}✗ Falta texto a cifrar.#{C::RESET}" if texto.nil? || texto.empty?
|
||
puts cripto.encrypt(texto)
|
||
end
|
||
|
||
def cmd_decrypt(cripto, token)
|
||
abort "#{C::RED}✗ Falta payload a descifrar.#{C::RESET}" if token.nil? || token.empty?
|
||
puts cripto.decrypt(token)
|
||
end
|
||
|
||
def cmd_get(cripto, boveda, clave)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
verificar_sesion!
|
||
token = boveda.get(clave)
|
||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
|
||
|
||
registrar_acceso('GET', "Clave: #{clave}")
|
||
puts cripto.decrypt(token)
|
||
end
|
||
|
||
def cmd_run(cripto, boveda, clave, *args)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if args.empty? || args.all?(&:empty?)
|
||
|
||
# Retrocompatibilidad: candados run <clave> <VAR> <comando>
|
||
env_var_legacy = nil
|
||
if args.size >= 2 && args[0].match?(/^[A-Z_]+$/)
|
||
env_var_legacy = args.shift
|
||
end
|
||
comando = args
|
||
|
||
verificar_sesion!
|
||
token_pass = boveda.get(clave)
|
||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_pass.nil?
|
||
|
||
pass_value = cripto.decrypt(token_pass)
|
||
user_value = ''
|
||
|
||
# Buscar credenciales par (_user/_pass)
|
||
if clave.end_with?('_pass')
|
||
prefijo = clave.sub(/_pass$/, '')
|
||
token_user = boveda.get("#{prefijo}_user")
|
||
user_value = cripto.decrypt(token_user) if token_user
|
||
elsif clave.include?(':')
|
||
# Formato: <nodo>:<usuario>[:<tipo>]
|
||
# Ej: "srv-ns8:rmonla" → usuario="rmonla"
|
||
# "srv-ns8:rmonla:sudo" → usuario="rmonla" (no "sudo")
|
||
partes = clave.split(':')
|
||
if partes.size >= 2
|
||
# El usuario es siempre la segunda parte (índice 1)
|
||
user_value = partes[1]
|
||
end
|
||
else
|
||
token_user = boveda.get("#{clave}_user")
|
||
user_value = cripto.decrypt(token_user) if token_user
|
||
end
|
||
|
||
registrar_acceso('RUN', "Clave: #{clave}, Comando: #{comando.join(' ')}")
|
||
|
||
# Inyectar variables globalmente al entorno del comando
|
||
ENV['USR'] = user_value
|
||
ENV['PASS'] = pass_value
|
||
ENV[env_var_legacy] = pass_value if env_var_legacy
|
||
|
||
begin
|
||
if comando.length == 1
|
||
system(comando[0])
|
||
else
|
||
system(*comando)
|
||
end
|
||
exit $?.exitstatus || 0
|
||
ensure
|
||
ENV.delete('USR')
|
||
ENV.delete('PASS')
|
||
ENV.delete(env_var_legacy) if env_var_legacy
|
||
end
|
||
end
|
||
|
||
def cmd_set(cripto, boveda, clave, valor = nil)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
|
||
if valor.nil? || valor.empty?
|
||
$stderr.print "#{C::YELLOW}🔑 Valor para '#{clave}': #{C::RESET}"
|
||
valor = $stdin.noecho(&:gets)&.chomp
|
||
$stderr.puts
|
||
end
|
||
|
||
abort "#{C::RED}✗ Valor vacío.#{C::RESET}" if valor.nil? || valor.empty?
|
||
|
||
boveda.set(clave, cripto.encrypt(valor))
|
||
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' guardada en la bóveda.#{C::RESET}"
|
||
end
|
||
|
||
def cmd_rm(boveda, clave)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" unless boveda.get(clave)
|
||
boveda.rm(clave)
|
||
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' eliminada.#{C::RESET}"
|
||
end
|
||
|
||
def cmd_list(boveda)
|
||
if boveda.empty?
|
||
$stderr.puts "#{C::DIM}Bóveda vacía.#{C::RESET}"
|
||
return
|
||
end
|
||
$stderr.puts "#{C::CYAN}🔐 Claves en la bóveda:#{C::RESET}"
|
||
boveda.keys.each { |k| $stderr.puts " #{C::GREEN}•#{C::RESET} #{k}" }
|
||
end
|
||
|
||
def cmd_load(cripto, boveda, clave)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
verificar_sesion!
|
||
|
||
# Buscar credenciales por convención: <clave> contiene user y <clave> para pass,
|
||
# o <prefijo>_user y <prefijo>_pass si existen como par.
|
||
token_pass = boveda.get(clave)
|
||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_pass.nil?
|
||
|
||
pass_value = cripto.decrypt(token_pass)
|
||
user_value = ''
|
||
|
||
# Intentar encontrar el par _user/_pass automáticamente
|
||
if clave.end_with?('_pass')
|
||
prefijo = clave.sub(/_pass$/, '')
|
||
token_user = boveda.get("#{prefijo}_user")
|
||
user_value = cripto.decrypt(token_user) if token_user
|
||
elsif clave.include?(':')
|
||
# Formato "servidor:usuario" → USR es la parte después de :
|
||
user_value = clave.split(':').last
|
||
else
|
||
# Buscar si existe una clave hermana _user
|
||
token_user = boveda.get("#{clave}_user")
|
||
if token_user
|
||
user_value = cripto.decrypt(token_user)
|
||
end
|
||
end
|
||
|
||
registrar_acceso('LOAD', "Clave: #{clave}")
|
||
|
||
# Seguridad: NUNCA imprimir secretos a stdout/stderr.
|
||
# Escribir exports en un archivo temporal con permisos estrictos (600).
|
||
# A stdout solo emitimos el comando `source` + autoborrado.
|
||
# NOTA: Usamos tmp/ del proyecto (no /tmp/) para cumplir principio 11 de 05_ia.md
|
||
require 'securerandom'
|
||
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
|
||
tmpfile_path = File.join(TMP_DIR, ".candados_env_#{SecureRandom.hex(8)}")
|
||
|
||
safe_user = user_value.gsub("'", "'\\''")
|
||
safe_pass = pass_value.gsub("'", "'\\''")
|
||
|
||
File.write(tmpfile_path, "export USR='#{safe_user}'\nexport PASS='#{safe_pass}'\n")
|
||
File.chmod(0600, tmpfile_path)
|
||
|
||
# Solo el comando source + autoborrado va a stdout (evaluable por el shell)
|
||
puts "source '#{tmpfile_path}' && rm -f '#{tmpfile_path}'"
|
||
|
||
$stderr.puts "#{C::GREEN}✓ Credenciales cargadas en variables USR y PASS (archivo temporal seguro).#{C::RESET}"
|
||
end
|
||
|
||
# ─── Comando ssh (ejecutar comando remoto con password de bóveda) ─────
|
||
# Uso: candados ssh <clave> <usuario@host> '<comando>'
|
||
# - clave: <nodo>:<usuario> para SSH (busca automáticamente <nodo>:<usuario>:sudo para sudo)
|
||
# - Si existe clave :sudo separada, la usa para comandos con sudo
|
||
def cmd_ssh(cripto, boveda, clave, usuario_host, *cmd_args)
|
||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||
abort "#{C::RED}✗ Falta usuario@host.#{C::RESET}" if usuario_host.nil? || usuario_host.empty?
|
||
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if cmd_args.nil? || cmd_args.empty?
|
||
|
||
verificar_sesion!
|
||
|
||
# Obtener password SSH
|
||
token_ssh = boveda.get(clave)
|
||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token_ssh.nil?
|
||
ssh_pass = cripto.decrypt(token_ssh)
|
||
safe_ssh_pass = ssh_pass.gsub("'", "'\\''")
|
||
|
||
# Buscar automáticamente clave :sudo si existe
|
||
comando = cmd_args.join(' ')
|
||
sudo_pass = ssh_pass
|
||
|
||
# Intentar buscar clave :sudo basada en la clave SSH
|
||
if clave.include?(':') && !clave.end_with?(':sudo')
|
||
# Construir clave sudo: <nodo>:<usuario>:sudo
|
||
partes = clave.split(':')
|
||
if partes.size >= 2
|
||
clave_sudo = "#{partes[0]}:#{partes[1]}:sudo"
|
||
token_sudo = boveda.get(clave_sudo)
|
||
if token_sudo
|
||
sudo_pass = cripto.decrypt(token_sudo)
|
||
$stderr.puts "#{C::DIM}ℹ Usando clave sudo: #{clave_sudo}#{C::RESET}"
|
||
end
|
||
end
|
||
end
|
||
|
||
safe_sudo_pass = sudo_pass.gsub("'", "'\\''")
|
||
|
||
registrar_acceso('SSH', "Clave: #{clave}, Host: #{usuario_host}, Cmd: #{comando}")
|
||
|
||
# Escapar comando para bash remoto: reemplazar ' con '\''
|
||
comando_escaped = comando.gsub("'", "'\\''")
|
||
|
||
# Crear script temporal con el comando y password para sudo
|
||
require 'securerandom'
|
||
require 'fileutils'
|
||
FileUtils.mkdir_p(TMP_DIR) unless File.exist?(TMP_DIR)
|
||
script_path = File.join(TMP_DIR, ".ssh_sudo_#{SecureRandom.hex(4)}")
|
||
File.write(script_path, "#!/bin/bash\nexport DEBIAN_FRONTEND=noninteractive\necho '#{safe_sudo_pass}' | sudo -S -p '' bash -c '#{comando_escaped}'\n")
|
||
File.chmod(0700, script_path)
|
||
|
||
# Ejecutar: sshpass para SSH, luego script remoto que hace sudo
|
||
result = system("sshpass -p '#{safe_ssh_pass}' ssh -o StrictHostKeyChecking=no -tt #{usuario_host} 'bash -s' < '#{script_path}'")
|
||
FileUtils.rm_f(script_path)
|
||
exit result ? 0 : 1
|
||
end
|
||
|
||
# ─── Ayuda mejorada para cmd_help_resumida ─────────────────────────────
|
||
|
||
# ─── Comando sudo (primitiva reutilizable) ─────────────────────
|
||
# Resolución inteligente de la clave sudo:
|
||
# 1. <hostname>:<usuario>:sudo (convención específica)
|
||
# 2. sudo (fallback genérico)
|
||
def cmd_sudo(cripto, boveda, comando_args)
|
||
abort "#{C::RED}✗ Falta comando a ejecutar con sudo.#{C::RESET}" if comando_args.nil? || comando_args.empty?
|
||
verificar_sesion!
|
||
|
||
# Resolver la clave sudo con búsqueda inteligente
|
||
hostname = `hostname -s 2>/dev/null`.strip
|
||
usuario = ENV['USER'] || ENV['USERNAME'] || 'root'
|
||
|
||
# Generar variantes de hostname para búsqueda flexible
|
||
# Ej: srvNS8 → ["srvNS8", "srvns8", "ns8", "NS8"]
|
||
variantes = [hostname]
|
||
variantes << hostname.downcase
|
||
variantes << hostname.sub(/^srv[-_]?/i, '') # sin prefijo srv/srv-/srv_
|
||
variantes << hostname.sub(/^srv[-_]?/i, '').downcase
|
||
variantes.uniq!
|
||
|
||
# Buscar la primera clave que exista en la bóveda
|
||
token = nil
|
||
clave_usada = nil
|
||
|
||
variantes.each do |h|
|
||
clave = "#{h}:#{usuario}:sudo"
|
||
if boveda.get(clave)
|
||
token = boveda.get(clave)
|
||
clave_usada = clave
|
||
break
|
||
end
|
||
end
|
||
|
||
# Fallback genérico
|
||
unless token
|
||
if boveda.get('sudo')
|
||
token = boveda.get('sudo')
|
||
clave_usada = 'sudo'
|
||
end
|
||
end
|
||
|
||
if token.nil?
|
||
buscadas = variantes.map { |h| "#{h}:#{usuario}:sudo" }.join("', '")
|
||
$stderr.puts "#{C::RED}✗ No se encontró clave sudo en la bóveda.#{C::RESET}"
|
||
$stderr.puts " Buscadas: '#{buscadas}', 'sudo'"
|
||
$stderr.puts " Registrala con: ruby candados.rb set #{variantes.first}:#{usuario}:sudo"
|
||
abort
|
||
end
|
||
|
||
sudo_pass = cripto.decrypt(token)
|
||
registrar_acceso('SUDO', "Clave: #{clave_usada}, Cmd: #{comando_args.join(' ')}")
|
||
|
||
# Ejecutar con sudo -S (hereda stdout/stderr, pipe solo en stdin)
|
||
cmd_full = comando_args.join(' ')
|
||
IO.popen("sudo -S #{cmd_full}", 'w') do |pipe|
|
||
pipe.puts sudo_pass
|
||
end
|
||
exit $?.exitstatus || 0
|
||
end
|
||
|
||
def cmd_help_resumida
|
||
puts <<~HELP
|
||
#{C::CYAN}╔═══════════════════════════════════════════════════════════════════╗#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}candados — Acceso Seguro a Contraseñas (Sin Exponer)#{C::RESET} #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}╚═══════════════════════════════════════════════════════════════════╝#{C::RESET}
|
||
|
||
#{C::RED}⚠️ REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET}
|
||
|
||
#{C::GREEN}┌─────────────────────────────────────────────────────────────────┐#{C::RESET}
|
||
#{C::GREEN}│#{C::RESET} #{C::BOLD}MÉTODOS PARA CARGAR CONTRASEÑAS EN VARIABLES#{C::RESET} #{C::GREEN}│#{C::RESET}
|
||
#{C::GREEN}└─────────────────────────────────────────────────────────────────┘#{C::RESET}
|
||
|
||
#{C::BOLD}★ run <clave> 'comando'#{C::RESET} → Ejecuta con $USR y $PASS inyectadas (PRIMARIO)
|
||
#{C::BOLD}★ load <clave>#{C::RESET} → Carga $USR y $PASS en shell (usar con eval)
|
||
|
||
#{C::DIM}Ejemplo: ruby candados.rb run srv-ns8:rmonla 'sshpass -p $PASS ssh $USR@host'#{C::RESET}
|
||
|
||
#{C::YELLOW}━━━ Comandos ━━━#{C::RESET}
|
||
#{C::BOLD}run#{C::RESET} <clave> <cmd> #{C::DIM}Ejecutar con credenciales inyectadas#{C::RESET}
|
||
#{C::BOLD}load#{C::RESET} <clave> #{C::DIM}Cargar en shell actual (eval)#{C::RESET}
|
||
#{C::BOLD}sudo#{C::RESET} <comando> #{C::DIM}Ejecutar con privilegios#{C::RESET}
|
||
#{C::BOLD}ssh#{C::RESET} <clave> <u@h> <cmd> #{C::DIM}SSH remoto con password de bóveda#{C::RESET}
|
||
#{C::BOLD}authorize#{C::RESET} #{C::DIM}Abrir candado (30 min)#{C::RESET}
|
||
#{C::BOLD}list#{C::RESET} #{C::DIM}Listar claves disponibles#{C::RESET}
|
||
#{C::BOLD}set/rm#{C::RESET} <clave> #{C::DIM}Guardar/Eliminar secreto#{C::RESET}
|
||
|
||
#{C::DIM}Usá#{C::RESET} ruby candados.rb --help #{C::DIM}para ayuda completa y ejemplos detallados.#{C::RESET}
|
||
HELP
|
||
end
|
||
|
||
def cmd_help_completo
|
||
puts <<~HELP
|
||
#{C::CYAN}╔═══════════════════════════════════════════════════════════════════╗#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}candados — Acceso Seguro a Contraseñas (Sin Exponer)#{C::RESET} #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}╚═══════════════════════════════════════════════════════════════════╝#{C::RESET}
|
||
|
||
#{C::RED}⚠️ REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET}
|
||
Esta herramienta está diseñada para que las contraseñas #{C::BOLD}NUNCA#{C::RESET} se muestren en pantalla
|
||
ni queden registradas en logs, históricos de shell o archivos temporales visibles.
|
||
|
||
#{C::GREEN}┌─────────────────────────────────────────────────────────────────┐#{C::RESET}
|
||
#{C::GREEN}│#{C::RESET} #{C::BOLD}MÉTODOS PARA CARGAR CONTRASEÑAS EN VARIABLES#{C::RESET} #{C::GREEN}│#{C::RESET}
|
||
#{C::GREEN}└─────────────────────────────────────────────────────────────────┘#{C::RESET}
|
||
|
||
#{C::BOLD}★ MÉTODO 1: run (PRIMARIO - Recomendado para comandos)#{C::RESET}
|
||
Ejecuta un comando con USR y PASS inyectadas en su entorno.
|
||
La contraseña #{C::BOLD}NUNCA#{C::RESET} se imprime ni queda en logs.
|
||
|
||
#{C::DIM}ruby candados.rb run <clave> 'comando que usa $USR y $PASS'#{C::RESET}
|
||
|
||
#{C::CYAN}Ejemplos:#{C::RESET}
|
||
#{C::GREEN}✓#{C::RESET} ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host'
|
||
#{C::GREEN}✓#{C::RESET} ruby candados.rb run srv-ns8:root 'ssh $USR@$HOSTNAME "comando"'
|
||
|
||
#{C::BOLD}★ MÉTODO 2: load (Para shell interactivo)#{C::RESET}
|
||
Carga USR y PASS en variables de entorno del shell actual.
|
||
Usa un archivo temporal (600) que se auto-elimina tras el source.
|
||
|
||
#{C::DIM}eval $(ruby candados.rb load <clave>)#{C::RESET}
|
||
|
||
#{C::CYAN}Ejemplo:#{C::RESET}
|
||
#{C::GREEN}✓#{C::RESET} eval $(ruby candados.rb load srv-dasu:rmonla)
|
||
#{C::DIM} → Carga $USR y $PASS en tu shell actual#{C::RESET}
|
||
#{C::GREEN}✓#{C::RESET} sshpass -p $PASS ssh $USR@servidor
|
||
|
||
#{C::YELLOW}━━━ Otros Comandos ━━━#{C::RESET}
|
||
#{C::BOLD}sudo <comando>#{C::RESET} Ejecutar con sudo (resuelve contraseña automáticamente)
|
||
#{C::BOLD}authorize#{C::RESET} Abrir candado (sesión de 30 min, requerida para operar)
|
||
#{C::BOLD}cerrar#{C::RESET} Cerrar sesión manualmente antes de los 30 min
|
||
#{C::BOLD}abrir#{C::RESET} Cargar clave SSH en ssh-add (usa passphrase de bóveda)
|
||
|
||
#{C::YELLOW}━━━ Gestión de Bóveda ━━━#{C::RESET}
|
||
#{C::BOLD}set <clave>#{C::RESET} Guardar secreto (input seguro sin eco)
|
||
#{C::BOLD}list#{C::RESET} Listar claves disponibles (no muestra valores)
|
||
#{C::BOLD}rm <clave>#{C::RESET} Eliminar una clave de la bóveda
|
||
|
||
#{C::YELLOW}━━━ Utilidades ━━━#{C::RESET}
|
||
#{C::BOLD}encrypt <texto>#{C::RESET} Cifrar texto plano → token (para bóveda)
|
||
#{C::BOLD}decrypt <token>#{C::RESET} Descifrar token → texto plano
|
||
|
||
#{C::YELLOW}━━━ Resolución Automática de Claves ━━━#{C::RESET}
|
||
#{C::DIM}• run/load con formato `servidor:usuario`:#{C::RESET}
|
||
srv-dasu:rmonla → USR=rmonla, PASS=(descifrado de bóveda)
|
||
#{C::DIM}• run/load con formato `prefijo_pass`:#{C::RESET}
|
||
tailscale_dasuten_pass → busca automáticamente tailscale_dasuten_user
|
||
#{C::DIM}• sudo resuelve la clave automáticamente:#{C::RESET}
|
||
Busca: <hostname>:<usuario>:sudo → fallback: `sudo`
|
||
|
||
#{C::YELLOW}━━━ Flujo de Trabajo Típico ━━━#{C::RESET}
|
||
#{C::DIM}1. Autorizar (una vez cada 30 min):#{C::RESET}
|
||
ruby candados.rb authorize
|
||
|
||
#{C::DIM}2. Ejecutar comandos (Método recomendado):#{C::RESET}
|
||
ruby candados.rb run srv-ns8:rmonla 'sshpass -p $PASS ssh $USR@host "comando"'
|
||
|
||
#{C::DIM}3. O cargar en shell interactivo:#{C::RESET}
|
||
eval $(ruby candados.rb load srv-ns8:rmonla)
|
||
sshpass -p $PASS ssh $USR@host
|
||
|
||
#{C::RED}⚠️ ADVERTENCIAS DE SEGURIDAD:#{C::RESET}
|
||
#{C::BOLD}• NUNCA usar `get`#{C::RESET} en automatizaciones → imprime la contraseña a stdout
|
||
#{C::BOLD}• `run` y `sudo`#{C::RESET} → las credenciales no dejan rastro en logs
|
||
#{C::BOLD}• `load`#{C::RESET} → archivo temporal con permisos 600, auto-borrado
|
||
#{C::BOLD}• Siempre ejecutar `authorize`#{C::RESET} antes de operar (sesión de 30 min)
|
||
|
||
#{C::DIM}Documentación completa: adn/tools/candados/README.md#{C::RESET}
|
||
HELP
|
||
end
|
||
|
||
alias cmd_help cmd_help_completo
|
||
|
||
# ─── Recordatorio de Seguridad (solo en ayuda) ──────────────────
|
||
def mostrar_recordatorio_seguridad
|
||
puts <<~RECORDATORIO
|
||
#{C::CYAN}╔════════════════════════════════════════════════════════════════╗#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} 🔐 #{C::BOLD}REGLA DE ORO: NUNCA MOSTRAR CONTRASEÑAS#{C::RESET} #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} #{C::GREEN}✓ CORRECTO:#{C::RESET} ruby candados.rb run <clave> '<cmd>' #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} #{C::RED}✗ INCORRECTO:#{C::RESET} ruby candados.rb get <clave> #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}║#{C::RESET} → `run` inyecta USR/PASS sin mostrar. `get` imprime y deja rastro. #{C::CYAN}║#{C::RESET}
|
||
#{C::CYAN}╚════════════════════════════════════════════════════════════════╝#{C::RESET}
|
||
RECORDATORIO
|
||
puts
|
||
end
|
||
|
||
# ─── Main ────────────────────────────────────────────────────────────
|
||
if __FILE__ == $0
|
||
comando = ARGV[0]
|
||
arg = ARGV[1]
|
||
|
||
case comando
|
||
when 'authorize'
|
||
autorizar!
|
||
when 'cerrar'
|
||
cerrar_sesion!
|
||
when 'abrir'
|
||
cripto = Cripto.new(cargar_master_key)
|
||
boveda = Boveda.new(BOVEDA)
|
||
cmd_abrir(cripto, boveda)
|
||
when 'sudo'
|
||
cripto = Cripto.new(cargar_master_key)
|
||
boveda = Boveda.new(BOVEDA)
|
||
cmd_sudo(cripto, boveda, ARGV[1..])
|
||
when 'ssh'
|
||
cripto = Cripto.new(cargar_master_key)
|
||
boveda = Boveda.new(BOVEDA)
|
||
cmd_ssh(cripto, boveda, ARGV[1], ARGV[2], *ARGV[3..])
|
||
when 'encrypt', 'decrypt', 'get', 'load', 'run', 'set', 'rm', 'list'
|
||
cripto = Cripto.new(cargar_master_key)
|
||
boveda = Boveda.new(BOVEDA)
|
||
case comando
|
||
when 'encrypt' then cmd_encrypt(cripto, arg)
|
||
when 'decrypt' then cmd_decrypt(cripto, arg)
|
||
when 'get' then cmd_get(cripto, boveda, arg)
|
||
when 'load' then cmd_load(cripto, boveda, arg)
|
||
when 'run' then cmd_run(cripto, boveda, arg, *ARGV[2..])
|
||
when 'set' then cmd_set(cripto, boveda, arg, ARGV[2])
|
||
when 'rm' then cmd_rm(boveda, arg)
|
||
when 'list' then cmd_list(boveda)
|
||
end
|
||
when '--help', '-h'
|
||
cmd_help_completo
|
||
when 'help', 'ayuda', nil
|
||
cmd_help_resumida
|
||
else
|
||
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
|
||
$stderr.puts ""
|
||
cmd_help_resumida
|
||
exit 1
|
||
end
|
||
end
|