[Core/Infra] Provisionamiento SSH en pc-dasu0 y unificación Nginx tailscale

This commit is contained in:
Ricardo Monla
2026-03-14 10:29:54 -03:00
parent e554bb5d0d
commit b13a389568
50 changed files with 2702 additions and 561 deletions
+12
View File
@@ -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."
+6 -7
View File
@@ -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...")
+9
View File
@@ -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
+74 -2
View File
@@ -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
+2 -2
View File
@@ -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"
+202
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+4
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -1 +1 @@
1773303097
1773452033
+91 -40
View File
@@ -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*
+490
View File
@@ -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
-324
View File
@@ -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,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") {
@@ -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
-115
View File
@@ -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 "=========================================="