[Core/Infra] Provisionamiento SSH en pc-dasu0 y unificación Nginx tailscale
This commit is contained in:
@@ -34,6 +34,7 @@ class SubcomandoAyuda
|
||||
generar bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD)
|
||||
generar nodo <nombre> Generar ficha de nodo
|
||||
#{Color::YELLOW}generar proyecto <código>#{Color::RESET} Generar manifiesto de proyecto
|
||||
#{Color::YELLOW}tailscale <acción>#{Color::RESET} Gestión de identidades Tailscale
|
||||
#{Color::YELLOW}backup <nodo> [--mode MODO]#{Color::RESET} Ejecutar backup seguro de un nodo
|
||||
#{Color::YELLOW}db <subcomando>#{Color::RESET} Operaciones de base de datos
|
||||
#{Color::YELLOW}triggers [opciones]#{Color::RESET} Gestión del motor de triggers
|
||||
@@ -149,6 +150,17 @@ class SubcomandoAyuda
|
||||
puts ""
|
||||
puts "Consulta la bitácora web para listar todos los items con estado ⏳ (En proceso)."
|
||||
puts "Ideal para monitorear procesos automatizados o jornadas abiertas."
|
||||
when 'tailscale', 'ts'
|
||||
puts "Uso: ./adn/tools/run tailscale <acción> [perfil]"
|
||||
puts ""
|
||||
puts "Gestión de identidades y perfiles de Tailscale:"
|
||||
puts " • switch <perfil> Alternar entre cuentas (dasuten, personal)"
|
||||
puts " • status Ver estado y cuenta activa"
|
||||
puts " • list Listar perfiles en caché local"
|
||||
puts ""
|
||||
puts "Ejemplos:"
|
||||
puts " ./adn/tools/run tailscale switch dasuten"
|
||||
puts " ./adn/tools/run tailscale status"
|
||||
else
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: #{subcomando}#{Color::RESET}"
|
||||
puts "Usa './adn/tools/run ayuda' para ver la lista de subcomandos."
|
||||
|
||||
@@ -89,17 +89,16 @@ module ADN
|
||||
# 2. Registrar inicio en bitácora (Bitácora-First)
|
||||
registrar_inicio_bitacora(nodo, datos)
|
||||
|
||||
# 3. Ejecución segura vía ns8-candados
|
||||
# 3. Ejecución segura vía candados
|
||||
@logger.info("Solicitando autorización y ejecutando backup remoto...")
|
||||
|
||||
comando_vzdump = "vzdump #{datos[:vmid]} --mode #{@opciones[:modo]} --storage #{@opciones[:storage]} --compress #{@opciones[:compress]}"
|
||||
cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{comando_vzdump}\""
|
||||
|
||||
# Usamos el binario de ruby y la ruta absoluta para ns8-candados
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'ns8-candados.rb')
|
||||
# Ruta absoluta a candados
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
||||
|
||||
# Ejecutar con ns8-candados run
|
||||
# Ejecutar con ns8-candados autorizando primero
|
||||
# Ejecutar con candados autorizando primero
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
task_id = nil
|
||||
@@ -285,7 +284,7 @@ module ADN
|
||||
puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}"
|
||||
puts "─" * 60
|
||||
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'ns8-candados.rb')
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
||||
# Asegurar autorización general para el lote
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
@@ -372,7 +371,7 @@ module ADN
|
||||
|
||||
# 1. Obtener lista de VMs directamente de Proxmox para nombres precisos
|
||||
host = { nombre: 'srv-pmox1', ip: '10.0.10.201' }
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'ns8-candados.rb')
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
@logger.info("Consultando inventario de VMs en Proxmox...")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# CLI: commit.rb - Herramienta para gestión de commits según principios ADN
|
||||
# ------------------------------------------------------------------------------
|
||||
# Principios aplicados:
|
||||
# 1. Menos es Más: Componente atómico y reutilizable para gestión de commits
|
||||
# 2. Armonía Integral: Val
|
||||
@@ -107,6 +107,8 @@ module ADN
|
||||
puts " #{Color::BOLD}Modo:#{Color::RESET} #{entrada['modo'] == 'P' ? 'Presencial' : 'Remoto'}"
|
||||
puts " #{Color::BOLD}IA:#{Color::RESET} #{entrada['es_ia'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Bitácora:#{Color::RESET} #{fecha_hoy} (ID: #{bitacora_id})"
|
||||
|
||||
mostrar_pendientes(db)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,7 +116,7 @@ module ADN
|
||||
def comando_evento_actualizar(args)
|
||||
if args.length < 1 || args[0].start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db evento:actualizar <ID> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\""
|
||||
puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\", --nodo \"Nombre\""
|
||||
return
|
||||
end
|
||||
|
||||
@@ -127,6 +129,7 @@ module ADN
|
||||
opt.on("--fin HH:MM", "Nueva hora de fin") { |f| opts[:fin] = f }
|
||||
opt.on("--descripcion TEXTO", "Nueva descripción") { |d| opts[:descripcion] = d }
|
||||
opt.on("--estado ESTADO", "Nuevo estado (⏳, ✅, ❌, ⚠️, ⏸️)") { |e| opts[:estado] = e }
|
||||
opt.on("--nodo NOMBRE", "Nuevo nodo") { |n| opts[:nodo_raw] = n }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
@@ -141,13 +144,27 @@ module ADN
|
||||
end
|
||||
|
||||
if opts.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --fin, --inicio, --descripcion o --estado#{Color::RESET}"
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --fin, --inicio, --descripcion, --estado o --nodo#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando evento #{id_evento}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
if opts.key?(:nodo_raw)
|
||||
if opts[:nodo_raw].nil? || opts[:nodo_raw].empty?
|
||||
opts[:nodo_id] = nil
|
||||
else
|
||||
nodo = db.find_nodo_by_nombre(opts[:nodo_raw])
|
||||
if nodo
|
||||
opts[:nodo_id] = nodo['id']
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo_raw]}' no encontrado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
opts.delete(:nodo_raw)
|
||||
end
|
||||
|
||||
begin
|
||||
res = db.update_entrada(id_evento, opts)
|
||||
if res
|
||||
@@ -160,6 +177,8 @@ module ADN
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
|
||||
mostrar_pendientes(db)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -277,6 +296,59 @@ module ADN
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error al eliminar: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
|
||||
mostrar_pendientes(db)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_pendientes(db)
|
||||
puts ""
|
||||
puts "#{Color::CYAN}⏳ Eventos pendientes de cierre:#{Color::RESET}"
|
||||
|
||||
# Filtrar por bitácora de hoy y estado '⏳' o '⏸️'
|
||||
fecha_hoy = Date.today.to_s
|
||||
bitacora = db.find_bitacora_by_fecha(fecha_hoy)
|
||||
|
||||
unless bitacora
|
||||
puts " #{Color::DIM}No hay bitácora para hoy.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
# Buscar todos los pendientes de hoy
|
||||
entradas_pendientes = db.list_entradas(
|
||||
bitacora_id: bitacora['id'],
|
||||
estado: '⏳',
|
||||
limit: 20
|
||||
)
|
||||
|
||||
# Sumamos pausados si los hay
|
||||
entradas_pausadas = db.list_entradas(
|
||||
bitacora_id: bitacora['id'],
|
||||
estado: '⏸️',
|
||||
limit: 20
|
||||
)
|
||||
|
||||
entradas = entradas_pendientes + entradas_pausadas
|
||||
|
||||
if entradas.empty?
|
||||
puts " #{Color::GREEN}¡Excelente! No hay eventos pendientes de cierre.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
# Ordenar por hora de inicio (más antiguos primero)
|
||||
entradas = entradas.sort_by { |e| e['inicio'] }
|
||||
|
||||
entradas.each do |e|
|
||||
id = e['id'].to_s
|
||||
inicio = e['inicio'][0..4] # HH:MM:SS -> HH:MM
|
||||
nodo = (e['nodo_nombre'] || '(sin nodo)')[0..14]
|
||||
desc = e['descripcion']
|
||||
desc = desc[0..47] + '...' if desc.length > 50
|
||||
estado_icon = e['estado']
|
||||
|
||||
puts " #{estado_icon} #{Color::YELLOW}ID #{id.ljust(5)}#{Color::RESET} #{inicio.ljust(5)} #{nodo.ljust(15)} #{desc}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ module ADN
|
||||
# Obtener passphrase de la bóveda
|
||||
@passphrase = nil
|
||||
begin
|
||||
candados_path = File.join(File.dirname(__FILE__), '../seguridad/ns8-candados.rb')
|
||||
candados_path = File.join(File.dirname(__FILE__), '../seguridad/candados.rb')
|
||||
if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session'))
|
||||
output = `ruby #{candados_path} get rsa 2>/dev/null`.strip
|
||||
@passphrase = output unless output.empty? || output.include?('Error')
|
||||
@@ -212,7 +212,7 @@ module ADN
|
||||
end
|
||||
|
||||
def system_ssh_status_win(ip, remote_cmd)
|
||||
candados_path = File.join(File.dirname(__FILE__), '../seguridad/ns8-candados.rb')
|
||||
candados_path = File.join(File.dirname(__FILE__), '../seguridad/candados.rb')
|
||||
|
||||
if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session'))
|
||||
shell_eval = "sshpass -e ssh -n -o ConnectTimeout=5 -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/cli/tailscale.rb — Subcomando para gestión de identidades Tailscale
|
||||
# ==============================================================================
|
||||
# Este subcomando permite alternar entre diferentes perfiles de Tailscale
|
||||
# (ej. DASUTEN vs Personal) de forma segura utilizando candados.
|
||||
#
|
||||
# Usa `candados.rb sudo` como primitiva reutilizable para ejecutar
|
||||
# comandos con privilegios (no implementa sudo propio).
|
||||
#
|
||||
# Perfiles:
|
||||
# - dasuten: pcdasu0@frlr.utn.edu.ar (ecosistema DASUTEN)
|
||||
# - personal: rmonla@gmail.com (otros ecosistemas/topologías)
|
||||
|
||||
require_relative '../core/colores'
|
||||
|
||||
module ADN
|
||||
class SubcomandoTailscale
|
||||
CANDADOS_PATH = File.expand_path('../seguridad/candados.rb', __dir__)
|
||||
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'ayuda'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
accion = @args.shift
|
||||
case accion
|
||||
when 'switch'
|
||||
ejecutar_switch
|
||||
when 'status', 'estado'
|
||||
ejecutar_status
|
||||
when 'list', 'listar'
|
||||
ejecutar_list
|
||||
when 'setup'
|
||||
ejecutar_setup
|
||||
else
|
||||
puts "#{Color::RED}✗ Acción desconocida: '#{accion}'#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda
|
||||
puts <<~AYUDA
|
||||
#{Color::CYAN}🌐 Gestión de Identidades Tailscale#{Color::RESET}
|
||||
|
||||
#{Color::YELLOW}Uso:#{Color::RESET} ./adn/tools/run tailscale <acción> [perfil]
|
||||
|
||||
#{Color::YELLOW}Acciones:#{Color::RESET}
|
||||
switch <perfil> Alternar a un perfil (dasuten, personal)
|
||||
status Ver estado actual de Tailscale y cuenta activa
|
||||
list Listar perfiles disponibles en Tailscale
|
||||
setup Configurar operador (una vez, evita pedir sudo)
|
||||
|
||||
#{Color::YELLOW}Perfiles:#{Color::RESET}
|
||||
dasuten Ecosistema DASUTEN (pcdasu0@frlr.utn.edu.ar)
|
||||
personal Otros ecosistemas (rmonla@gmail.com)
|
||||
|
||||
#{Color::YELLOW}Ejemplos:#{Color::RESET}
|
||||
./adn/tools/run tailscale setup
|
||||
./adn/tools/run tailscale switch dasuten
|
||||
./adn/tools/run tailscale switch personal
|
||||
./adn/tools/run tailscale status
|
||||
AYUDA
|
||||
end
|
||||
|
||||
# ─── Status ───────────────────────────────────────────────────────
|
||||
def ejecutar_status
|
||||
puts "#{Color::CYAN}🔍 Estado actual de Tailscale:#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'─' * 60}#{Color::RESET}"
|
||||
system('tailscale status')
|
||||
puts ""
|
||||
|
||||
cuenta = `tailscale status --json 2>/dev/null | jq -r '.User[].LoginName' 2>/dev/null`.strip
|
||||
if cuenta.empty? || cuenta == 'null'
|
||||
puts "#{Color::YELLOW}⚠ No se pudo determinar la cuenta activa.#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::BOLD}Cuenta activa:#{Color::RESET} #{Color::GREEN}#{cuenta}#{Color::RESET}"
|
||||
end
|
||||
|
||||
operador_ok = system('tailscale switch --list > /dev/null 2>&1')
|
||||
if operador_ok
|
||||
puts "#{Color::GREEN}✓ Operador configurado (switch sin sudo)#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Operador NO configurado. Ejecutá: ./adn/tools/run tailscale setup#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# ─── List ─────────────────────────────────────────────────────────
|
||||
def ejecutar_list
|
||||
puts "#{Color::CYAN}📋 Perfiles disponibles en Tailscale:#{Color::RESET}"
|
||||
|
||||
# Intentar sin sudo (operador configurado)
|
||||
output = `tailscale switch --list 2>&1`
|
||||
if $?.success?
|
||||
puts output
|
||||
else
|
||||
# Fallback: usar candados sudo (reutilizable)
|
||||
candados_sudo('tailscale', 'switch', '--list')
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Setup ────────────────────────────────────────────────────────
|
||||
def ejecutar_setup
|
||||
@logger.info("Configurando operador Tailscale para #{ENV['USER']}...")
|
||||
puts "#{Color::CYAN}⚙️ Configurando operador Tailscale...#{Color::RESET}"
|
||||
puts "#{Color::DIM}Esto permite gestionar Tailscale sin sudo en el futuro.#{Color::RESET}"
|
||||
|
||||
success = candados_sudo('tailscale', 'set', "--operator=#{ENV['USER']}")
|
||||
|
||||
if success
|
||||
puts "#{Color::GREEN}✅ Operador configurado exitosamente.#{Color::RESET}"
|
||||
@logger.exito("Operador Tailscale configurado para #{ENV['USER']}")
|
||||
else
|
||||
puts "#{Color::RED}✗ No se pudo configurar el operador.#{Color::RESET}"
|
||||
puts " Verificá la clave sudo en la bóveda: ruby candados.rb ayuda"
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Switch ───────────────────────────────────────────────────────
|
||||
def ejecutar_switch
|
||||
perfil = @args.shift
|
||||
if perfil.nil?
|
||||
puts "#{Color::RED}✗ Falta nombre del perfil (dasuten|personal)#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
case perfil.downcase
|
||||
when 'dasuten', 'd'
|
||||
switch_to('dasuten')
|
||||
when 'personal', 'p'
|
||||
switch_to('personal')
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Perfil no reconocido. Perfiles válidos: dasuten, personal#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
def switch_to(tipo)
|
||||
@logger.info("Cambiando perfil Tailscale a: #{tipo}")
|
||||
|
||||
# 1. Obtener usuario de la bóveda
|
||||
clave_user = tipo == 'dasuten' ? 'tailscale_dasuten_user' : 'tailscale_personal_user'
|
||||
user = obtener_secreto(clave_user)
|
||||
|
||||
if user.nil? || user.empty?
|
||||
@logger.error("No se encontró '#{clave_user}' en la bóveda.")
|
||||
puts "#{Color::RED}✗ Credencial '#{clave_user}' no encontrada en candados.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Cambiando a perfil: #{Color::BOLD}#{user}#{Color::RESET}..."
|
||||
|
||||
# 2. Intentar sin sudo (operador configurado)
|
||||
if system("tailscale switch '#{user}' > /dev/null 2>&1")
|
||||
exito_switch(tipo, user)
|
||||
return
|
||||
end
|
||||
|
||||
# 3. Fallback: usar candados sudo (primitiva reutilizable)
|
||||
puts "#{Color::DIM}Usando candados sudo...#{Color::RESET}"
|
||||
if candados_sudo('tailscale', 'switch', user)
|
||||
exito_switch(tipo, user)
|
||||
return
|
||||
end
|
||||
|
||||
# 4. Perfil sin sesión → login manual
|
||||
@logger.advertencia("El perfil #{tipo} no tiene sesión activa.")
|
||||
puts "#{Color::YELLOW}⚠ El perfil '#{user}' no tiene sesión en este equipo.#{Color::RESET}"
|
||||
puts " Necesitás iniciar sesión manualmente:"
|
||||
puts " #{Color::BOLD}ruby candados.rb sudo tailscale login#{Color::RESET}"
|
||||
end
|
||||
|
||||
def exito_switch(tipo, user)
|
||||
puts "#{Color::GREEN}✅ Perfil Tailscale cambiado a '#{tipo}' (#{user}).#{Color::RESET}"
|
||||
@logger.exito("Perfil Tailscale cambiado a #{tipo} (#{user})")
|
||||
puts ""
|
||||
ejecutar_status
|
||||
end
|
||||
|
||||
# ─── Utilidades ───────────────────────────────────────────────────
|
||||
|
||||
# Obtiene un secreto de la bóveda (requiere sesión abierta)
|
||||
def obtener_secreto(clave)
|
||||
output = `ruby #{CANDADOS_PATH} get #{clave} 2>/dev/null`.strip
|
||||
return nil if output.empty? || output.include?('Error') || output.include?('✗')
|
||||
output
|
||||
end
|
||||
|
||||
# Ejecuta un comando con sudo usando candados.rb sudo (primitiva reutilizable)
|
||||
def candados_sudo(*cmd_parts)
|
||||
system("ruby #{CANDADOS_PATH} sudo #{cmd_parts.join(' ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ module ADN
|
||||
class Conciliador
|
||||
def initialize(logger)
|
||||
@logger = logger
|
||||
@candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
|
||||
@candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
||||
end
|
||||
|
||||
def ejecutar(silencioso: false)
|
||||
|
||||
@@ -431,7 +431,7 @@ module BitacorasDB
|
||||
i = 1
|
||||
|
||||
params.each do |k, v|
|
||||
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia].include?(k)
|
||||
if [:inicio, :fin, :descripcion, :estado, :modo, :es_ia, :nodo_id].include?(k)
|
||||
val = ([:inicio, :fin].include?(k)) ? parse_time(v) : v
|
||||
set_clauses << "#{k} = $#{i}"
|
||||
values << val
|
||||
|
||||
@@ -19,6 +19,7 @@ ENV['TZ'] = 'America/Argentina/Buenos_Aires'
|
||||
# generar bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD)
|
||||
# generar nodo <nombre> Generar ficha de nodo
|
||||
# generar proyecto <código> Generar manifiesto de proyecto
|
||||
# tailscale <acción> [opciones] Gestión de identidades Tailscale
|
||||
# conocimiento asimilar <archivo> Asimilar documentación al ADN
|
||||
# evolucion <propuesta.md> Gestionar propuesta de evolución del ADN
|
||||
# ayuda [subcomando] Mostrar ayuda detallada
|
||||
@@ -51,6 +52,7 @@ require_relative 'cli/conocimiento'
|
||||
require_relative 'cli/jornada'
|
||||
require_relative 'cli/proceso'
|
||||
require_relative 'cli/estados'
|
||||
require_relative 'cli/tailscale'
|
||||
require_relative 'core/conciliador'
|
||||
|
||||
require_relative 'core/colores'
|
||||
@@ -110,6 +112,8 @@ class ADNCLI
|
||||
ADN::SubcomandoProceso.new(args, @logger).ejecutar
|
||||
when 'estados'
|
||||
ADN::SubcomandoEstados.new(args, @logger).ejecutar
|
||||
when 'tailscale', 'ts'
|
||||
ADN::SubcomandoTailscale.new(args, @logger).ejecutar
|
||||
when 'inicio'
|
||||
# Compatibilidad: redirigir a jornada iniciar
|
||||
ADN::SubcomandoJornada.new(['iniciar'] + args, @logger).ejecutar
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"sudo": "eyJpdiI6IkgzUnpoNHVGeWVqQUVGSDQiLCJ0YWciOiI3TmhPcjRGVFo3TFZHTUpNREVncXlBPT0iLCJkYXRhIjoiQlptVmtYbGdEcjFYWkxpZ21nPT0ifQ==",
|
||||
"rsa": "eyJpdiI6IkpoTmpZRWZMeE12eWhXSVMiLCJ0YWciOiJUUnJGY05qRlVoOTl2REplTms0ZU5RPT0iLCJkYXRhIjoiNkFIWCsyYm9ndzZKbGM3c0hRPT0ifQ==",
|
||||
"domain_admin": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
|
||||
"admindasu": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
|
||||
"win_domain": "eyJpdiI6IitrY2V0eEQxektEMFk2cGgiLCJ0YWciOiJtQmdIYU90ZTFFWUlGZElsMXBIOTJBPT0iLCJkYXRhIjoiNVVFanhaN2tIdHViZDRDRnlnPT0ifQ==",
|
||||
"deepseek_key": "eyJpdiI6InRCc2IvL3J4SGNsejgvL04iLCJ0YWciOiJBbjRlMTNXNlZzUGhNSWowSUU1a01nPT0iLCJkYXRhIjoiQXR4azk1aU9sb2QydzNwa0FMTHpjVkJKQTIvZU4wY1U2V2crK3NBbVd1UnRmUGh6In0=",
|
||||
"srv-dasu:rmonla": "eyJpdiI6IkFoczJZWUIwQ2piZzdJNzgiLCJ0YWciOiJJdDhCZndRc1RoZFhVMVQ4OEhaN1J3PT0iLCJkYXRhIjoiaUQycmxndGFsUkE2dVRaTEgrTT0ifQ==",
|
||||
"dominio-utnlarioja.intranet:monlaricardo": "eyJpdiI6InFRQ3VNYWlVSHlKMURNdzciLCJ0YWciOiJ1a3hESUZlaEt6enV3UXZxWUFrWXhRPT0iLCJkYXRhIjoiNVNaekdxVyt6dE5WQmhPaXF3PT0ifQ=="
|
||||
"dominio-utnlarioja.intranet:monlaricardo": "eyJpdiI6InFRQ3VNYWlVSHlKMURNdzciLCJ0YWciOiJ1a3hESUZlaEt6enV3UXZxWUFrWXhRPT0iLCJkYXRhIjoiNVNaekdxVyt6dE5WQmhPaXF3PT0ifQ==",
|
||||
"ns8:rmonla:sudo": "eyJpdiI6IkRqbTBhVlZSYUpVdno3TFkiLCJ0YWciOiJJSnE2ei8zZHIrV2hFQks1VjNpVGN3PT0iLCJkYXRhIjoiT3JxTjRhTmlQK2R3TlJZZk9BPT0ifQ==",
|
||||
"srv-dasu:root": "eyJpdiI6Ik5LY09JR1FvaFVPRXd3bjUiLCJ0YWciOiJwb0NLZ3A4N3VOQUZMQWhMS1hUVWZnPT0iLCJkYXRhIjoicEgvWTFmd0RhSFQ0YWRHRUxyZ1lBQT09In0="
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
1773303097
|
||||
1773452033
|
||||
@@ -1,55 +1,106 @@
|
||||
# 🔐 ns8-candados: Gestión de Secretos y Autorización SSH
|
||||
# 🔐 candados: Gestión Segura de Secretos
|
||||
|
||||
## Resumen Ejecutivo
|
||||
`ns8-candados` es una herramienta de seguridad robusta diseñada para centralizar, cifrar y gestionar secretos operativos del ecosistema `srv-ns8`. Utiliza el algoritmo industrial **AES-256-GCM** para garantizar la integridad y confidencialidad de las credenciales. Su arquitectura implementa un sistema de **Candado Temporal (MFA)** que requiere una autorización explícita para liberar secretos, protegiendo la información incluso si el servidor se ve comprometido.
|
||||
`candados` es la herramienta central de seguridad del ecosistema ADN. Cifra y gestiona secretos con **AES-256-GCM**, implementando un sistema de **Candado Temporal** que requiere autorización explícita (5 min) antes de liberar credenciales.
|
||||
|
||||
**Principio fundamental:** Las contraseñas **NUNCA** deben aparecer en pantalla, logs ni archivos temporales.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Análisis Estructural
|
||||
- **Tipo de Proyecto**: Utilidad de seguridad CLI escrita en Ruby.
|
||||
- **Organización**: Estructura modular centrada en la protección del dato:
|
||||
- `Cripto`: Motor criptográfico AES-256-GCM con IV aleatorio.
|
||||
- `Boveda`: Persistencia cifrada en `.boveda.json`.
|
||||
- **MFA / Autorización**: Sistema de sesiones temporales basadas en archivos con permisos restringidos.
|
||||
- **Archivos Fundamentales**:
|
||||
- `ns8-candados.rb`: Lógica central.
|
||||
- `.master.key` (Local): La semilla de cifrado (permisos 600).
|
||||
- `.boveda.json`: Almacén de secretos cifrados (permisos 600).
|
||||
- `.session`: Token de autorización temporal (vida: 5 min, permisos 600).
|
||||
## ⚙️ Arquitectura
|
||||
- **Cripto**: Motor AES-256-GCM con IV aleatorio y autenticación.
|
||||
- **Bóveda**: Persistencia cifrada en `.boveda.json` (permisos 600).
|
||||
- **MFA Temporal**: Sesiones de 5 min via `.session` (permisos 600).
|
||||
- **Master Key**: `.master.key` local (permisos 600, nunca se comparte).
|
||||
|
||||
## ⚙️ Configuración y Dependencias
|
||||
- **Tecnologías**: Ruby, OpenSSL.
|
||||
- **Seguridad de Archivos**: Todos los archivos sensibles se manejan con `chmod 600`, asegurando que **solo el propietario** de la herramienta pueda operar.
|
||||
- **Comandos de Gestión**:
|
||||
- `ruby ns8-candados.rb authorize`: **Abre el candado**. Habilita el acceso a secretos durante 5 minutos.
|
||||
- `ruby ns8-candados.rb cerrar`: **Cierra el candado** manualmente eliminando la sesión activa.
|
||||
- `ruby ns8-candados.rb list`: Lista las claves guardadas (requiere autorización).
|
||||
- `ruby ns8-candados.rb get <clave>`: Recupera el valor descifrado (requiere autorización).
|
||||
## 🚀 Comando Primario: `run` ★
|
||||
|
||||
## 🛡️ Auditoría (logging)
|
||||
La herramienta mantiene un archivo `access.log` en su directorio local que registra de forma inmutable:
|
||||
- **Timestamp**: Fecha y hora exacta de la acción.
|
||||
- **Usuario**: El usuario del sistema operativo que ejecutó el comando.
|
||||
- **Acción**: `AUTHORIZE`, `GET`, `LOCK`, `DENIED` o `EXPIRED`.
|
||||
- **Detalle**: Nombre de la clave solicitada (solo en comandos `get`).
|
||||
El comando `run` es la forma **recomendada** de usar credenciales. Inyecta `USR` y `PASS` directamente en el entorno del subproceso sin imprimir nada:
|
||||
|
||||
El archivo tiene permisos `600` para asegurar que solo el propietario pueda auditar los accesos.
|
||||
```bash
|
||||
# 1. Autorizar (dura 5 min)
|
||||
ruby candados.rb authorize
|
||||
|
||||
# 2. Ejecutar comando con credenciales inyectadas
|
||||
ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host "comando"'
|
||||
|
||||
# Retrocompatible con variable legacy (SSHPASS, etc.)
|
||||
ruby candados.rb run admindasu SSHPASS 'sshpass -e ssh root@host'
|
||||
```
|
||||
|
||||
**¿Qué pasa internamente?**
|
||||
1. Descifra las credenciales de la bóveda
|
||||
2. Las inyecta como `USR` y `PASS` en el entorno del subproceso
|
||||
3. Ejecuta el comando
|
||||
4. Limpia las variables al terminar
|
||||
5. **Nada se imprime en pantalla ni queda en logs**
|
||||
|
||||
## 🔒 Comando: `sudo`
|
||||
|
||||
Ejecuta comandos con privilegios, resolviendo la contraseña automáticamente:
|
||||
|
||||
```bash
|
||||
ruby candados.rb sudo tailscale set --operator=$USER
|
||||
ruby candados.rb sudo apt update
|
||||
```
|
||||
|
||||
La clave se busca automáticamente:
|
||||
1. `<hostname>:<usuario>:sudo` (ej: `ns8:rmonla:sudo`)
|
||||
2. Variantes normalizadas del hostname
|
||||
3. Fallback genérico: `sudo`
|
||||
|
||||
## 📦 Comando Secundario: `load`
|
||||
|
||||
Para uso interactivo cuando se necesitan las variables en el shell actual. **Nunca imprime la contraseña:**
|
||||
|
||||
```bash
|
||||
eval $(ruby candados.rb load srv-dasu:rmonla)
|
||||
# USR y PASS están disponibles en el shell
|
||||
```
|
||||
|
||||
Internamente escribe los exports a un archivo temporal (`/tmp/.candados_env_<hex>`) con permisos `600`, y a stdout solo emite el comando `source` + autoborrado.
|
||||
|
||||
## 🔑 Resolución Automática de Pares
|
||||
|
||||
Los comandos `run` y `load` detectan automáticamente el par usuario/contraseña:
|
||||
|
||||
| Formato de clave | USR | PASS |
|
||||
|---|---|---|
|
||||
| `srv-dasu:rmonla` | `rmonla` (parte después de `:`) | Valor cifrado |
|
||||
| `tailscale_dasuten_pass` | Busca `tailscale_dasuten_user` | Valor cifrado |
|
||||
| `admindasu` | Busca `admindasu_user` (si existe) | Valor cifrado |
|
||||
|
||||
## 📋 Referencia Rápida
|
||||
|
||||
| Comando | Seguridad | Descripción |
|
||||
|---|---|---|
|
||||
| `run <clave> <cmd>` | ✅ Nada expuesto | **★ PRIMARIO** - Ejecuta con USR/PASS inyectados |
|
||||
| `sudo <cmd>` | ✅ Nada expuesto | Ejecuta con privilegios desde bóveda |
|
||||
| `load <clave>` | ✅ Archivo temporal 600 | Carga en shell (usar con `eval`) |
|
||||
| `get <clave>` | ⚠ Imprime a stdout | Obtener valor directo (usar con cuidado) |
|
||||
| `authorize` | ✅ | Abrir candado (5 min) |
|
||||
| `set <clave>` | ✅ Input sin eco | Guardar secreto |
|
||||
| `list` | ✅ | Listar claves |
|
||||
|
||||
## 🛡️ Auditoría
|
||||
|
||||
Archivo `access.log` (permisos 600) registra:
|
||||
- **Timestamp** y **Usuario** del SO
|
||||
- **Acción**: `AUTHORIZE`, `GET`, `LOAD`, `RUN`, `SUDO`, `LOCK`, `DENIED`, `EXPIRED`
|
||||
- **Detalle**: Clave solicitada y comando ejecutado
|
||||
|
||||
## 🧠 Contexto para IA
|
||||
- **Flujo Obligatorio**: Los agentes de IA DEBEN ejecutar `authorize` antes de intentar recuperar cualquier clave.
|
||||
- **Captura Segura**: Capturar siempre la salida en variables silenciadas:
|
||||
```bash
|
||||
ruby tools/ns8-candados/ns8-candados.rb authorize
|
||||
PASS=$(ruby tools/ns8-candados/ns8-candados.rb get clave)
|
||||
# Operar y luego cerrar:
|
||||
ruby tools/ns8-candados/ns8-candados.rb cerrar
|
||||
```
|
||||
|
||||
## 🚀 Próximos Pasos
|
||||
1. **Siempre** ejecutar `authorize` antes de operar
|
||||
2. **Usar `run`** como método primario (inyecta, no imprime)
|
||||
3. **Evitar `get`** en comandos automatizados (filtra en logs)
|
||||
4. Cerrar sesión con `cerrar` cuando ya no se necesite
|
||||
|
||||
### Propuestas de Mejora
|
||||
- **Auditoría**: Implementar un `access.log` para trazar quién y cuándo abrió el candado.
|
||||
- **Notificación**: Enviar un mensaje (ej. Telegram/Mail) cada vez que se ejecute `authorize`.
|
||||
```bash
|
||||
ruby candados.rb authorize
|
||||
ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host "ls"'
|
||||
ruby candados.rb cerrar
|
||||
```
|
||||
|
||||
---
|
||||
*Generado siguiendo docs/prompt/documentacion.md*
|
||||
|
||||
Executable
+490
@@ -0,0 +1,490 @@
|
||||
#!/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 5 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__)
|
||||
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')
|
||||
|
||||
# ─── 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 5 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 > 300 # 5 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
|
||||
puts "#{C::CYAN}🔐 candados: Autorización SSH#{C::RESET}"
|
||||
puts "#{C::DIM}Limpiando identidades previas...#{C::RESET}"
|
||||
system('ssh-add -D > /dev/null 2>&1')
|
||||
|
||||
puts "#{C::YELLOW}🔑 Ingresá tu passphrase:#{C::RESET}"
|
||||
system('ssh-add')
|
||||
|
||||
system('ssh-add -l > /dev/null 2>&1')
|
||||
unless $?.success?
|
||||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Abortando.#{C::RESET}"
|
||||
end
|
||||
|
||||
puts "#{C::GREEN}🔓 SSH autorizado. Las identidades permanecen activas mientras dure la sesión del agente.#{C::RESET}"
|
||||
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?(':')
|
||||
user_value = clave.split(':').last
|
||||
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.
|
||||
require 'securerandom'
|
||||
tmpfile_path = "/tmp/.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 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
|
||||
puts <<~HELP
|
||||
#{C::CYAN}🔐 candados — Gestión segura de secretos#{C::RESET}
|
||||
|
||||
#{C::GREEN}Uso:#{C::RESET} ruby adn/tools/seguridad/candados.rb <comando> [args]
|
||||
|
||||
#{C::YELLOW}━━━ Comandos principales ━━━#{C::RESET}
|
||||
#{C::BOLD}run <clave> <cmd>#{C::RESET} #{C::GREEN}★ PRIMARIO#{C::RESET} Ejecutar comando con USR y PASS inyectados
|
||||
#{C::BOLD}sudo <comando>#{C::RESET} Ejecutar con privilegios (resuelve clave automáticamente)
|
||||
#{C::BOLD}load <clave>#{C::RESET} Cargar USR/PASS en shell actual (usar con eval)
|
||||
|
||||
#{C::YELLOW}━━━ Gestión de bóveda ━━━#{C::RESET}
|
||||
authorize Abrir candado (autorización temporal 5 min)
|
||||
cerrar Cerrar candado manualmente
|
||||
set <clave> Guardar secreto (input seguro, sin eco)
|
||||
get <clave> Obtener secreto (⚠ imprime a stdout)
|
||||
rm <clave> Eliminar secreto
|
||||
list Listar claves disponibles
|
||||
|
||||
#{C::YELLOW}━━━ Utilidades ━━━#{C::RESET}
|
||||
abrir Autorizar SSH (cargar passphrase)
|
||||
encrypt <texto> Cifrar texto plano → token
|
||||
decrypt <token> Descifrar token → texto plano
|
||||
|
||||
#{C::YELLOW}Resolución automática de claves:#{C::RESET}
|
||||
#{C::DIM}• run/load: Detecta pares USR/PASS por convención:#{C::RESET}
|
||||
srv-dasu:rmonla → USR=rmonla, PASS=(valor cifrado)
|
||||
tailscale_dasuten → busca _user/_pass automáticamente
|
||||
#{C::DIM}• sudo: Resuelve la clave de sudo automáticamente:#{C::RESET}
|
||||
<hostname>:<usuario>:sudo (ej: ns8:rmonla:sudo)
|
||||
|
||||
#{C::YELLOW}Ejemplos:#{C::RESET}
|
||||
#{C::DIM}# 1. Autorizar (dura 5 min)#{C::RESET}
|
||||
ruby candados.rb authorize
|
||||
|
||||
#{C::GREEN}# 2. Ejecutar comando con credenciales (RECOMENDADO)#{C::RESET}
|
||||
ruby candados.rb run srv-dasu:rmonla 'sshpass -p $PASS ssh $USR@host'
|
||||
ruby candados.rb run admindasu SSHPASS 'sshpass -e ssh root@host'
|
||||
|
||||
#{C::DIM}# 3. Ejecutar con sudo#{C::RESET}
|
||||
ruby candados.rb sudo tailscale set --operator=$USER
|
||||
|
||||
#{C::DIM}# 4. Cargar en shell interactivo (secundario)#{C::RESET}
|
||||
eval $(ruby candados.rb load srv-dasu:rmonla)
|
||||
|
||||
#{C::RED}⚠ Seguridad:#{C::RESET}
|
||||
#{C::DIM}• run/sudo: Las credenciales NUNCA se imprimen en pantalla ni logs.#{C::RESET}
|
||||
#{C::DIM}• load: Usa archivo temporal (600) con autoborrado, nada visible.#{C::RESET}
|
||||
#{C::DIM}• get: ⚠ Imprime el valor a stdout. Usar solo si es necesario.#{C::RESET}
|
||||
HELP
|
||||
end
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
if __FILE__ == $0
|
||||
comando = ARGV[0]
|
||||
arg = ARGV[1]
|
||||
|
||||
case comando
|
||||
when 'authorize'
|
||||
autorizar!
|
||||
when 'cerrar'
|
||||
cerrar_sesion!
|
||||
when 'sudo'
|
||||
cripto = Cripto.new(cargar_master_key)
|
||||
boveda = Boveda.new(BOVEDA)
|
||||
cmd_sudo(cripto, boveda, ARGV[1..])
|
||||
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', 'ayuda', '-h', '--help', nil
|
||||
cmd_help
|
||||
else
|
||||
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
|
||||
cmd_help
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ns8-candados — Gestión unificada de secretos y autorización SSH
|
||||
# ==============================================================================
|
||||
# 🤖 INSTRUCCIONES PARA LA IA (AGENTES EXTERNOS):
|
||||
# Esta herramienta permite obtener credenciales seguras sin exponerlas en el chat.
|
||||
# PARA USARLA CORRECTAMENTE (SIN FILTRAR SECRETOS):
|
||||
# Ejecuta el comando y asigna su salida a una variable silenciada:
|
||||
# PASS=$(ruby tools/ns8-candados/ns8-candados.rb get nombre_clave)
|
||||
# sshpass -p "$PASS" ssh usuario@servidor
|
||||
# NUNCA ejecutes `ruby ns8-candados.rb get clave` directamente en run_command sin
|
||||
# capturar o redirigir el output, porque el secreto se filtrará en la bitácora del LLM.
|
||||
# ==============================================================================
|
||||
# Uso: ruby tools/ns8-candados/ns8-candados.rb <comando> [args]
|
||||
#
|
||||
# Comandos:
|
||||
# abrir Autorizar SSH (cargar llave con passphrase)
|
||||
# encrypt <txt> Cifrar texto plano
|
||||
# decrypt <tok> Descifrar payload
|
||||
# get <clave> Obtener secreto de la bóveda (salida: solo el valor)
|
||||
# run <cl> <vr> <cmd> Ejecutar comando con secreto en variable de entorno <vr>
|
||||
# set <clave> Guardar secreto (pide input interactivo, sin eco)
|
||||
# rm <clave> Eliminar secreto de la bóveda
|
||||
# list Listar claves disponibles en la bóveda
|
||||
|
||||
require 'openssl'
|
||||
require 'base64'
|
||||
require 'json'
|
||||
require 'io/console'
|
||||
require 'fileutils'
|
||||
|
||||
# ─── Rutas ───────────────────────────────────────────────────────────
|
||||
TOOL_DIR = File.expand_path(__dir__)
|
||||
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')
|
||||
|
||||
# ─── 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"
|
||||
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 5 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 > 300 # 5 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
|
||||
puts "#{C::CYAN}🔐 ns8-candados: Autorización SSH#{C::RESET}"
|
||||
puts "#{C::DIM}Limpiando identidades previas...#{C::RESET}"
|
||||
system('ssh-add -D > /dev/null 2>&1')
|
||||
|
||||
puts "#{C::YELLOW}🔑 Ingresá tu passphrase:#{C::RESET}"
|
||||
system('ssh-add')
|
||||
|
||||
system('ssh-add -l > /dev/null 2>&1')
|
||||
unless $?.success?
|
||||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Abortando.#{C::RESET}"
|
||||
end
|
||||
|
||||
puts "#{C::GREEN}🔓 SSH autorizado. Las identidades permanecen activas mientras dure la sesión del agente.#{C::RESET}"
|
||||
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, env_var, comando)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
abort "#{C::RED}✗ Falta nombre de variable de entorno.#{C::RESET}" if env_var.nil?
|
||||
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if comando.nil? || (comando.is_a?(Array) && comando.empty?) || (comando.is_a?(String) && comando.empty?)
|
||||
|
||||
verificar_sesion!
|
||||
token = boveda.get(clave)
|
||||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
|
||||
|
||||
secret = cripto.decrypt(token)
|
||||
registrar_acceso('RUN', "Clave: #{clave}, Comando: #{comando.is_a?(Array) ? comando.join(' ') : comando}")
|
||||
|
||||
# Ejecutar el comando inyectando la variable
|
||||
ENV[env_var] = secret
|
||||
begin
|
||||
# Si comando es un array y tiene más de un elemento, lo ejecutamos directo (sin shell)
|
||||
# Si es un string o array de 1, system lo pasará al shell.
|
||||
if comando.is_a?(Array)
|
||||
if comando.length == 1 && comando[0].include?(' ')
|
||||
system(comando[0])
|
||||
else
|
||||
system(*comando)
|
||||
end
|
||||
else
|
||||
system(comando)
|
||||
end
|
||||
exit $?.exitstatus || 0
|
||||
ensure
|
||||
ENV.delete(env_var)
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_set(cripto, boveda, clave)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
$stderr.print "#{C::YELLOW}🔑 Valor para '#{clave}': #{C::RESET}"
|
||||
valor = $stdin.noecho(&:gets)&.chomp
|
||||
$stderr.puts
|
||||
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_help
|
||||
puts <<~HELP
|
||||
#{C::CYAN}🔐 ns8-candados — Gestión de secretos#{C::RESET}
|
||||
|
||||
#{C::GREEN}Uso:#{C::RESET} ruby ns8-candados.rb <comando> [args]
|
||||
|
||||
#{C::YELLOW}Comandos:#{C::RESET}
|
||||
authorize Abrir candado (autorización temporal 5 min)
|
||||
cerrar Cerrar candado manual (eliminar sesión)
|
||||
abrir Autorizar SSH (cargar passphrase)
|
||||
encrypt <texto> Cifrar texto plano → token
|
||||
decrypt <token> Descifrar token → texto plano
|
||||
get <clave> Obtener secreto de la bóveda
|
||||
run <cl> <vr> <cmd> Ejecutar comando con secreto en variable <vr>
|
||||
set <clave> Guardar secreto (input seguro)
|
||||
rm <clave> Eliminar secreto
|
||||
list Listar claves disponibles
|
||||
|
||||
#{C::YELLOW}Ejemplos:#{C::RESET}
|
||||
# Autorizar para operar
|
||||
ruby ns8-candados.rb authorize
|
||||
|
||||
#{C::DIM}# Usar de forma segura inyectando en el entorno (recomendado)#{C::RESET}
|
||||
ruby ns8-candados.rb run admindasu PASS "sshpass -e ssh user@host"
|
||||
|
||||
#{C::DIM}# Usar en scripts (salida limpia - menos seguro)#{C::RESET}
|
||||
PASS=$(ruby ns8-candados.rb get admindasu)
|
||||
sshpass -p "$PASS" ssh user@host "cmd"
|
||||
HELP
|
||||
end
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
comando = ARGV[0]
|
||||
arg = ARGV[1]
|
||||
|
||||
case comando
|
||||
when 'authorize'
|
||||
autorizar!
|
||||
when 'cerrar'
|
||||
cerrar_sesion!
|
||||
when 'encrypt', 'decrypt', 'get', '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 'run' then cmd_run(cripto, boveda, arg, ARGV[2], ARGV[3..])
|
||||
when 'set' then cmd_set(cripto, boveda, arg)
|
||||
when 'rm' then cmd_rm(boveda, arg)
|
||||
when 'list' then cmd_list(boveda)
|
||||
end
|
||||
when 'help', '-h', '--help', nil
|
||||
cmd_help
|
||||
else
|
||||
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
|
||||
cmd_help
|
||||
exit 1
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
function Log($msg) {
|
||||
Write-Host $msg -ForegroundColor Cyan
|
||||
if ($WZ_HOST) {
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://${WZ_HOST}/log" -Method Post -Body @{msg=$msg} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log " REPARANDO PERMISOS E INICIANDO SSHD "
|
||||
Log "=========================================="
|
||||
|
||||
Log "1) Bypass ExecutionPolicy para FixHostFilePermissions..."
|
||||
try {
|
||||
# Cambiamos la politica solo para este proceso
|
||||
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
|
||||
$out = & "C:\OpenSSH-Win64\FixHostFilePermissions.ps1" -Confirm:$false 2>&1
|
||||
foreach ($line in $out) { Log " -> $line" }
|
||||
Log "Script de permisos finalizado."
|
||||
} catch {
|
||||
Log "Error ejecutando script de permisos: $_"
|
||||
}
|
||||
|
||||
Log "2) Iniciando servicio sshd..."
|
||||
try {
|
||||
Start-Service sshd -ErrorAction Stop
|
||||
$svc = Get-Service sshd
|
||||
Log "Servicio sshd. Estado actual: $($svc.Status) !!"
|
||||
} catch {
|
||||
Log "Fallo al iniciar el servicio: $_"
|
||||
Log "Ejecutando sshd.exe -t para ver el error real de config:"
|
||||
$test = & "C:\OpenSSH-Win64\sshd.exe" -t 2>&1
|
||||
foreach ($line in $test) { Log " sshd -t: $line" }
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log " FIN REPARACION "
|
||||
Log "=========================================="
|
||||
return
|
||||
+6
-2
@@ -6,7 +6,11 @@ function Log($msg) {
|
||||
$line = "[$ts] $msg"
|
||||
Write-Host $line -ForegroundColor Cyan
|
||||
Add-Content -Path $logFile -Value $line
|
||||
try { (New-Object Net.WebClient).DownloadString("http://10.0.10.8:8000/log?msg=$msg") | Out-Null } catch {}
|
||||
try {
|
||||
if ($WZ_HOST) {
|
||||
(New-Object Net.WebClient).DownloadString("http://${WZ_HOST}/log?msg=$msg") | Out-Null
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
@@ -15,7 +19,7 @@ Log "=========================================="
|
||||
|
||||
$sshDir = "C:\OpenSSH-Win64"
|
||||
$zipPath = "C:\OpenSSH-Win64.zip"
|
||||
$sshUrl = "http://10.0.10.8:8000/payloads/OpenSSH-Win64.zip"
|
||||
$sshUrl = "http://${WZ_HOST}/payloads/OpenSSH-Win64.zip"
|
||||
|
||||
try {
|
||||
if (Test-Path "$sshDir\sshd.exe") {
|
||||
+3
-1
@@ -2,7 +2,9 @@ function Log-Msg {
|
||||
param([string]$Message)
|
||||
Write-Host $Message -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
if ($WZ_HOST) {
|
||||
Invoke-RestMethod -Uri "http://${WZ_HOST}/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ TOOL_DIR = File.expand_path(__dir__)
|
||||
PAYLOADS = File.join(TOOL_DIR, 'payloads')
|
||||
LOG_FILE = File.join(TOOL_DIR, 'telemetria.log')
|
||||
PORT = (ARGV.find { |a| a.match?(/^\d+$/) } || 8000).to_i
|
||||
EXT_PORT = (ENV['EXT_PORT'] || PORT).to_i
|
||||
require 'socket'
|
||||
def get_ip
|
||||
ENV['HOST_IP'] || Socket.ip_address_list.find { |ai| ai.ipv4? && !ai.ipv4_loopback? }&.ip_address || '127.0.0.1'
|
||||
end
|
||||
HOST_IP = get_ip
|
||||
PUBLIC_URL = "#{HOST_IP}:#{EXT_PORT}"
|
||||
|
||||
# Colores ANSI
|
||||
C = { reset: "\e[0m", green: "\e[32m", cyan: "\e[36m", yellow: "\e[33m", red: "\e[31m", dim: "\e[2m" }
|
||||
@@ -36,7 +43,7 @@ def banner
|
||||
end
|
||||
puts "#{C[:dim]}─────────────────────────────────────────────────────#{C[:reset]}"
|
||||
puts "#{C[:yellow]}En la VM (PowerShell Admin):#{C[:reset]}"
|
||||
puts " #{C[:green]}iwr 10.0.10.8:#{PORT}/zombi.ps1 -useb|iex#{C[:reset]}"
|
||||
puts " #{C[:green]}iwr #{PUBLIC_URL}/zombi.ps1 -useb|iex#{C[:reset]}"
|
||||
puts "#{C[:dim]}─────────────────────────────────────────────────────#{C[:reset]}"
|
||||
puts ''
|
||||
end
|
||||
@@ -71,8 +78,9 @@ server = WEBrick::HTTPServer.new(
|
||||
server.mount_proc '/zombi.ps1' do |_req, res|
|
||||
# Genera un loader que apunta al payload activo
|
||||
loader = <<~PS1
|
||||
$WZ_HOST = "#{PUBLIC_URL}"
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
Write-Host " ZOMBI C2 LOOP — Conectado a srv-ns8:#{PORT} " -ForegroundColor Green
|
||||
Write-Host " ZOMBI C2 LOOP — Conectado a $WZ_HOST " -ForegroundColor Green
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
Write-Host "Max 5 ciclos. CTRL+C para detener." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
@@ -82,7 +90,7 @@ server.mount_proc '/zombi.ps1' do |_req, res|
|
||||
for ($ciclo = 1; $ciclo -le $maxCiclos; $ciclo++) {
|
||||
Write-Host "[ZOMBI] Ciclo $ciclo/$maxCiclos" -ForegroundColor DarkGray
|
||||
try {
|
||||
$payload = Invoke-RestMethod -Uri "http://10.0.10.8:#{PORT}/payloads/activo.ps1" -UseBasicParsing -ErrorAction Stop
|
||||
$payload = Invoke-RestMethod -Uri "http://#{PUBLIC_URL}/payloads/activo.ps1" -UseBasicParsing -ErrorAction Stop
|
||||
if ($payload -and $payload.Trim() -ne "") {
|
||||
try {
|
||||
Invoke-Expression $payload
|
||||
@@ -1,115 +0,0 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ZOMBI_URL = "http://10.0.10.8:8000"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] $msg" -ForegroundColor Cyan
|
||||
try { Invoke-RestMethod -Uri "$ZOMBI_URL/log" -Method Post -Body @{msg="[$env:COMPUTERNAME] $msg"} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null } catch {}
|
||||
}
|
||||
|
||||
$DEST = "C:\SysDasuten"
|
||||
$ZIP = "C:\TEMP\runSysDasuten.zip"
|
||||
$SQL_SERVER = "sql-dasuten"
|
||||
$SQL_DB = "sysdasuten"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN — $env:COMPUTERNAME"
|
||||
Log "=========================================="
|
||||
|
||||
# --- PASO 1: Descargar ZIP ---
|
||||
if (-not (Test-Path $DEST\Sistema\DasutenSQL.exe)) {
|
||||
Log "PASO 1: Descargando runSysDasuten.zip (~268 MB)..."
|
||||
New-Item -ItemType Directory -Path "C:\TEMP" -Force | Out-Null
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$ZOMBI_URL/payloads/runSysDasuten.zip" -OutFile $ZIP -UseBasicParsing -ErrorAction Stop
|
||||
Log "Descarga completada: $(((Get-Item $ZIP).Length / 1MB).ToString('N0')) MB"
|
||||
} catch {
|
||||
Log "ERROR descarga: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
# --- PASO 2: Extraer ---
|
||||
Log "PASO 2: Extrayendo a $DEST..."
|
||||
New-Item -ItemType Directory -Path $DEST -Force | Out-Null
|
||||
Expand-Archive -Path $ZIP -DestinationPath "C:\" -Force
|
||||
# El zip contiene runSysDasuten/ como raiz, renombrar
|
||||
if (Test-Path "C:\runSysDasuten") {
|
||||
Copy-Item -Path "C:\runSysDasuten\*" -Destination $DEST -Recurse -Force
|
||||
Remove-Item "C:\runSysDasuten" -Recurse -Force
|
||||
}
|
||||
Remove-Item $ZIP -Force -ErrorAction SilentlyContinue
|
||||
Log "Extraido OK."
|
||||
} else {
|
||||
Log "PASO 1-2: SKIP — Ya existe $DEST\Sistema\DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 3: Modificar Kermet.ini ---
|
||||
Log "PASO 3: Configurando Kermet.ini..."
|
||||
$iniPaths = @("$DEST\Kermet.ini", "$DEST\Sistema\Kermet.ini")
|
||||
foreach ($ini in $iniPaths) {
|
||||
if (Test-Path $ini) {
|
||||
$content = Get-Content $ini -Raw
|
||||
$content = $content -replace 'SERVER=srvFENIX', "SERVER=$SQL_SERVER"
|
||||
$content = $content -replace 'SERVER=172\.16\.9\.204\\SQL2017', "SERVER=$SQL_SERVER"
|
||||
Set-Content $ini $content -Force
|
||||
Log "Actualizado: $ini -> SERVER=$SQL_SERVER"
|
||||
}
|
||||
}
|
||||
|
||||
# --- PASO 4: Instalar Fuentes ---
|
||||
Log "PASO 4: Instalando fuentes..."
|
||||
$fontDir = "$DEST\Fonts\Fonts"
|
||||
if (Test-Path $fontDir) {
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$fontsFolder = $shell.NameSpace(0x14) # Windows Fonts folder
|
||||
Get-ChildItem "$fontDir\*.ttf" | ForEach-Object {
|
||||
$fontPath = $_.FullName
|
||||
$fontName = $_.Name
|
||||
if (-not (Test-Path "C:\Windows\Fonts\$fontName")) {
|
||||
Copy-Item $fontPath "C:\Windows\Fonts\" -Force
|
||||
$regKey = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
New-ItemProperty -Path $regKey -Name $fontName -Value $fontName -PropertyType String -Force | Out-Null
|
||||
Log "Fuente instalada: $fontName"
|
||||
} else {
|
||||
Log "Fuente ya existe: $fontName"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log "WARN: No se encontro directorio de fuentes"
|
||||
}
|
||||
|
||||
# --- PASO 5: Verificar firewall SQL en sql-dasuten ---
|
||||
Log "PASO 5: Probando conectividad SQL..."
|
||||
$tcpTest = Test-NetConnection -ComputerName $SQL_SERVER -Port 1433 -WarningAction SilentlyContinue
|
||||
if ($tcpTest.TcpTestSucceeded) {
|
||||
Log "Conexion TCP a ${SQL_SERVER}:1433 OK"
|
||||
} else {
|
||||
Log "WARN: No se puede conectar a ${SQL_SERVER}:1433 — verificar firewall"
|
||||
}
|
||||
|
||||
# --- PASO 6: Probar conexion SQL con sqlcmd (si existe) ---
|
||||
$sqlcmd = Get-Command sqlcmd -ErrorAction SilentlyContinue
|
||||
if ($sqlcmd) {
|
||||
Log "PASO 6: Probando SQL con sqlcmd..."
|
||||
$result = sqlcmd -S $SQL_SERVER -E -Q "SELECT DB_NAME() AS db_actual; SELECT name FROM sys.databases WHERE name = '$SQL_DB'" -W 2>&1
|
||||
Log "SQL Result: $result"
|
||||
} else {
|
||||
Log "PASO 6: SKIP — sqlcmd no disponible (normal en Win10), probar con DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 7: Crear acceso directo ---
|
||||
Log "PASO 7: Creando acceso directo en Escritorio..."
|
||||
$desktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory')
|
||||
$shortcutPath = "$desktopPath\DasutenSQL.lnk"
|
||||
$WshShell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $WshShell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = "$DEST\Sistema\DasutenSQL.exe"
|
||||
$shortcut.WorkingDirectory = "$DEST\Sistema"
|
||||
$shortcut.Description = "Sistema DASUTEN"
|
||||
$shortcut.Save()
|
||||
Log "Acceso directo creado: $shortcutPath"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN COMPLETADO"
|
||||
Log "Ejecutar desde: $DEST\Sistema\DasutenSQL.exe"
|
||||
Log "=========================================="
|
||||
Reference in New Issue
Block a user