Seguridad (crítico): - Los webhooks de Bold y dLocal solo validaban la firma si el atacante la enviaba: sin cabecera se aceptaba cualquier payload. Ahora es obligatoria. - GET /pago-exitoso marcaba contratos como pagados leyendo un query param del navegador. Ahora solo muestra estado; la confirmación la hace la verificación contra la API de la pasarela o el webhook firmado. - /uploads se servía como estático público: se descargaban RUTs, facturas y entregables sabiendo la ruta. Ahora exige sesión. - Los secretos JWT no se podían sobreescribir por entorno (faltaba el tag env:) y su valor estaba en el repo, permitiendo firmarse una sesión de admin. Ahora son configurables y el arranque se detiene si siguen con el valor publicado. - .env y session.db salen del control de versiones. - Query Runner, gestión de usuarios/roles/módulos y seeds quedan restringidos a administradores; antes bastaba con tener sesión. Pasarelas de pago: - dLocal generaba enlaces que nunca se reconciliaban: mandaba el ID numérico en vez de "contrato-N", la URL de retorno apuntaba a la API de dLocal y nunca se enviaba notification_url, así que su webhook jamás se disparaba. - PayPal solo tenía pantalla de configuración. Se implementa el servicio completo (OAuth, orden, captura, verificación de webhook) y queda seleccionable como pasarela. - La moneda estaba fija en COP: un contrato en USD generaba un cobro por esa cifra en pesos. Contratos: - pago_confirmado nunca volvía a false, así que el segundo ciclo de renovación no se cobraba aunque el cliente pagara. Se reinicia al generar enlace nuevo. - Los contratos vencidos nunca cambiaban de estado y recibían correo a diario de forma indefinida; ahora se cierran tras 30 días de gracia. Otros: - Coolify: coolifyCall ignoraba el status HTTP y reportaba errores como éxito. El agente pasa de 10 a cobertura completa (servicios, bases de datos, variables de entorno, proyectos, equipos y recursos de servidor). - SeedBalanceData ya no corre en cada arranque (recreaba transacciones borradas); ahora se invoca con SEED_BALANCE=1. - Los seeds dejan de devolver permisos revocados en cada despliegue. - Timeouts en las llamadas HTTP a Telegram y dLocal que podían colgarse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
490 lines
15 KiB
Go
490 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
type cronProcInfo struct {
|
|
Nombre string `json:"nombre"`
|
|
CPU float64 `json:"cpu"`
|
|
RAMMB float64 `json:"ram_mb"`
|
|
}
|
|
|
|
var cronScheduler *cron.Cron
|
|
|
|
// IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go.
|
|
func IniciarCron() {
|
|
cronScheduler = cron.New()
|
|
|
|
// Vencimientos próximos — todos los días a las 8:00 AM
|
|
if _, err := cronScheduler.AddFunc("0 8 * * *", ProcesarVencimientosProximos); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea vencimientos_proximo: %v", err)
|
|
return
|
|
}
|
|
|
|
// Contratos ya vencidos — todos los días a las 9:00 AM
|
|
if _, err := cronScheduler.AddFunc("0 9 * * *", ProcesarYaVencidos); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea ya_vencido: %v", err)
|
|
return
|
|
}
|
|
|
|
// Polling Bold — cada 15 minutos para detectar pagos sin webhook
|
|
if _, err := cronScheduler.AddFunc("*/15 * * * *", VerificarPagosBoldPendientes); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea bold_polling: %v", err)
|
|
return
|
|
}
|
|
|
|
// Salud de servidores — cada 5 minutos
|
|
if _, err := cronScheduler.AddFunc("*/5 * * * *", VerificarSaludServidores); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea salud_servidores: %v", err)
|
|
return
|
|
}
|
|
|
|
// Purga de historial de métricas — cada noche a las 3 AM (retención 7 días)
|
|
if _, err := cronScheduler.AddFunc("0 3 * * *", func() {
|
|
if err := models.PurgarMetricasHistory(7); err != nil {
|
|
log.Printf("[CRON] Error purgando historial métricas: %v", err)
|
|
} else {
|
|
log.Println("[CRON] Historial de métricas purgado (>7 días)")
|
|
}
|
|
if err := models.PurgarUrlMonitorLogs(7); err != nil {
|
|
log.Printf("[CRON] Error purgando logs URL monitor: %v", err)
|
|
}
|
|
}); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
|
|
return
|
|
}
|
|
|
|
// Monitor de URLs — cada minuto
|
|
if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil {
|
|
log.Printf("[CRON] Error registrando tarea url_monitor: %v", err)
|
|
return
|
|
}
|
|
|
|
cronScheduler.Start()
|
|
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
|
}
|
|
|
|
// DetenerCron para graceful shutdown
|
|
func DetenerCron() {
|
|
if cronScheduler != nil {
|
|
cronScheduler.Stop()
|
|
}
|
|
}
|
|
|
|
// ProcesarVencimientos mantiene compatibilidad para llamadas manuales
|
|
func ProcesarVencimientos() {
|
|
ProcesarVencimientosProximos()
|
|
ProcesarYaVencidos()
|
|
}
|
|
|
|
// ProcesarVencimientosProximos procesa reglas de tipo "vencimiento_proximo"
|
|
func ProcesarVencimientosProximos() {
|
|
log.Println("[CRON] Procesando vencimientos próximos...")
|
|
|
|
reglas, err := models.GetReglasByTipoEvento("vencimiento_proximo")
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo reglas vencimiento_proximo: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, regla := range reglas {
|
|
contratos, err := models.GetContratosProximosVencer(regla.DiasAntes)
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo contratos para regla %d: %v", regla.ID, err)
|
|
continue
|
|
}
|
|
procesarContratos(®la, contratos)
|
|
}
|
|
|
|
log.Println("[CRON] Vencimientos próximos finalizado")
|
|
}
|
|
|
|
// diasGraciaVencimiento es cuántos días se sigue insistiendo a un contrato ya
|
|
// vencido antes de darlo por perdido y dejar de notificar.
|
|
const diasGraciaVencimiento = 30
|
|
|
|
// ProcesarYaVencidos procesa reglas de tipo "ya_vencido"
|
|
func ProcesarYaVencidos() {
|
|
log.Println("[CRON] Procesando contratos ya vencidos...")
|
|
|
|
// Cerrar primero los que llevan demasiado tiempo vencidos sin pagar, para que
|
|
// dejen de recibir el correo diario de forma indefinida.
|
|
if _, err := models.MarcarContratosVencidos(diasGraciaVencimiento); err != nil {
|
|
log.Printf("[CRON] Error marcando contratos vencidos: %v", err)
|
|
}
|
|
|
|
reglas, err := models.GetReglasByTipoEvento("ya_vencido")
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo reglas ya_vencido: %v", err)
|
|
return
|
|
}
|
|
if len(reglas) == 0 {
|
|
return
|
|
}
|
|
|
|
contratos, err := models.GetContratosYaVencidos()
|
|
if err != nil {
|
|
log.Printf("[CRON] Error obteniendo contratos vencidos: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, regla := range reglas {
|
|
procesarContratos(®la, contratos)
|
|
}
|
|
|
|
log.Println("[CRON] Ya vencidos finalizado")
|
|
}
|
|
|
|
// procesarContratos aplica filtros y envía notificaciones para una regla y lista de contratos
|
|
func procesarContratos(regla *models.NotificacionRegla, contratos []models.Contrato) {
|
|
if len(contratos) == 0 {
|
|
return
|
|
}
|
|
|
|
// Filtrar por AplicaA
|
|
var filtrados []models.Contrato
|
|
for _, c := range contratos {
|
|
switch regla.AplicaA {
|
|
case "renovable":
|
|
for _, s := range c.Servicios {
|
|
if s.Tipo == "renovable" {
|
|
filtrados = append(filtrados, c)
|
|
break
|
|
}
|
|
}
|
|
case "unico":
|
|
for _, s := range c.Servicios {
|
|
if s.Tipo == "unico" {
|
|
filtrados = append(filtrados, c)
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
filtrados = append(filtrados, c)
|
|
}
|
|
}
|
|
if len(filtrados) == 0 {
|
|
return
|
|
}
|
|
|
|
// Agrupar por cliente
|
|
porCliente := make(map[uint][]models.Contrato)
|
|
for _, c := range filtrados {
|
|
porCliente[c.ClienteID] = append(porCliente[c.ClienteID], c)
|
|
}
|
|
|
|
for clienteID, grupoContratos := range porCliente {
|
|
if models.YaEnviadoHoy(clienteID, regla.ID) {
|
|
log.Printf("[CRON] Ya enviado hoy a cliente %d para regla %d — saltando", clienteID, regla.ID)
|
|
continue
|
|
}
|
|
cliente := &grupoContratos[0].Cliente
|
|
if err := EnviarNotificacionGrupo(regla, cliente, grupoContratos, ""); err != nil {
|
|
log.Printf("[CRON] Error enviando a cliente %d: %v", clienteID, err)
|
|
} else {
|
|
log.Printf("[CRON] Enviado a cliente %d (%s) — %d contrato(s)", clienteID, cliente.Email, len(grupoContratos))
|
|
}
|
|
}
|
|
}
|
|
|
|
// VerificarPagosBoldPendientes consulta la API de Bold para cada contrato que tiene un
|
|
// enlace de pago generado pero aún no confirmado. Se ejecuta cada 15 minutos via cron.
|
|
// Esto compensa la ausencia de webhooks de Bold.
|
|
func VerificarPagosBoldPendientes() {
|
|
contratos, err := models.GetContratosConEnlacePendiente()
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Error obteniendo contratos pendientes: %v", err)
|
|
return
|
|
}
|
|
if len(contratos) == 0 {
|
|
return
|
|
}
|
|
|
|
boldCfg, err := models.GetBoldConfig()
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Sin configuración Bold activa: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("[BOLD-POLL] Verificando %d contrato(s) con enlace pendiente...", len(contratos))
|
|
|
|
for _, c := range contratos {
|
|
paid, paymentID, monto, err := CheckBoldLinkPaid(boldCfg, c.EnlacePagoLinkID)
|
|
if err != nil {
|
|
log.Printf("[BOLD-POLL] Error consultando link %s (contrato %d): %v", c.EnlacePagoLinkID, c.ID, err)
|
|
continue
|
|
}
|
|
if !paid {
|
|
continue
|
|
}
|
|
|
|
ref := fmt.Sprintf("contrato-%d", c.ID)
|
|
log.Printf("[BOLD-POLL] Pago detectado para %s (payment_id=%s, monto=%d)", ref, paymentID, monto)
|
|
|
|
if ok, _ := models.MarcarContratoPagado(c.ID); ok {
|
|
go EnviarCorreoConfirmacionPago(c.ID, "bold")
|
|
log.Printf("[BOLD-POLL] Contrato %d marcado como pagado", c.ID)
|
|
}
|
|
|
|
// Enriquecer callback log si existe
|
|
go models.EnrichBoldCallbackLog(ref, c.Cliente.Email, monto)
|
|
|
|
// Guardar en webhook log como API_CHECK para trazabilidad
|
|
notifID := "api-check-" + paymentID
|
|
if paymentID != "" && !models.IsBoldNotificationDuplicate(notifID) {
|
|
_ = models.SaveBoldWebhookLog(models.BoldWebhookLog{
|
|
NotificationID: notifID,
|
|
Tipo: "API_CHECK",
|
|
PaymentID: paymentID,
|
|
Referencia: ref,
|
|
PayerEmail: c.Cliente.Email,
|
|
Monto: monto,
|
|
Procesado: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
log.Println("[BOLD-POLL] Verificación completada")
|
|
}
|
|
|
|
// ─── Salud de Servidores ─────────────────────────────────────────────────────
|
|
|
|
// VerificarSaludServidores corre cada 5 min y genera notificaciones del sistema
|
|
// cuando detecta: agente caído, recurso al límite o VPS próximo a vencer.
|
|
func VerificarSaludServidores() {
|
|
cfg := models.GetServidorAlertaUmbral()
|
|
if !cfg.Activo {
|
|
return
|
|
}
|
|
|
|
servidores, _, err := models.GetAllServidores(1000, 0, "")
|
|
if err != nil {
|
|
log.Printf("[CRON-SRV] Error obteniendo servidores: %v", err)
|
|
return
|
|
}
|
|
|
|
for _, srv := range servidores {
|
|
// 1. Agente caído (sin ping en X minutos)
|
|
if srv.AgentToken != "" && srv.AgentLastSeen != nil {
|
|
minutos := time.Since(*srv.AgentLastSeen).Minutes()
|
|
if minutos > float64(cfg.MinutosSinPing) {
|
|
titulo := fmt.Sprintf("🔴 Sin señal: %s", srv.Nombre)
|
|
if !models.YaExisteAlertaServidor(titulo, 1) {
|
|
crearNotifServidor(titulo,
|
|
fmt.Sprintf("El agente no reporta hace %.0f minutos (umbral: %d min)", minutos, cfg.MinutosSinPing),
|
|
"servidor_caido")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Recursos (CPU / RAM / Disco) al límite
|
|
if srv.MetricasJson != "" {
|
|
var m struct {
|
|
CPU struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
} `json:"cpu"`
|
|
RAM struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
} `json:"ram"`
|
|
Discos []struct {
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
Ruta string `json:"ruta"`
|
|
} `json:"discos"`
|
|
TopProcs []cronProcInfo `json:"top_procs"`
|
|
}
|
|
if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil {
|
|
checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU, m.TopProcs, "cpu")
|
|
checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM, m.TopProcs, "ram")
|
|
for _, d := range m.Discos {
|
|
label := "Disco"
|
|
if d.Ruta != "" && d.Ruta != "/" {
|
|
label = "Disco " + d.Ruta
|
|
}
|
|
checkRecurso(srv.Nombre, label, d.Porcentaje, cfg.UmbralDisco, nil, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Vencimiento próximo
|
|
if srv.Vencimiento != "" && cfg.DiasAnteVencimiento > 0 {
|
|
vence, parseErr := time.Parse("2006-01-02", srv.Vencimiento)
|
|
if parseErr == nil {
|
|
dias := int(time.Until(vence).Hours() / 24)
|
|
if dias >= 0 && dias <= cfg.DiasAnteVencimiento {
|
|
titulo := fmt.Sprintf("📅 %s vence en %s", srv.Nombre, strconv.Itoa(dias)+" días")
|
|
if dias == 0 {
|
|
titulo = fmt.Sprintf("📅 %s vence HOY", srv.Nombre)
|
|
}
|
|
if !models.YaExisteAlertaServidor(titulo, 12) {
|
|
crearNotifServidor(titulo,
|
|
fmt.Sprintf("Fecha de vencimiento: %s. Renueva a tiempo.", srv.Vencimiento),
|
|
"servidor_vence_pronto")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkRecurso(nombre, recurso string, valor float64, umbral int, procs []cronProcInfo, ordenar string) {
|
|
if valor < float64(umbral) {
|
|
return
|
|
}
|
|
titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor)
|
|
if !models.YaExisteAlertaServidor(titulo, 1) {
|
|
cuerpo := fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral)
|
|
if len(procs) > 0 {
|
|
cp := make([]cronProcInfo, len(procs))
|
|
copy(cp, procs)
|
|
if ordenar == "cpu" {
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i].CPU > cp[j].CPU })
|
|
} else {
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i].RAMMB > cp[j].RAMMB })
|
|
}
|
|
if len(cp) > 3 {
|
|
cp = cp[:3]
|
|
}
|
|
cuerpo += "\n\nTop procesos:"
|
|
for i, p := range cp {
|
|
if ordenar == "cpu" {
|
|
cuerpo += fmt.Sprintf("\n%d. %s — CPU: %.1f%%, RAM: %.0f MB", i+1, p.Nombre, p.CPU, p.RAMMB)
|
|
} else {
|
|
cuerpo += fmt.Sprintf("\n%d. %s — RAM: %.0f MB, CPU: %.1f%%", i+1, p.Nombre, p.RAMMB, p.CPU)
|
|
}
|
|
}
|
|
}
|
|
crearNotifServidor(titulo, cuerpo, "servidor_recurso_alto")
|
|
}
|
|
}
|
|
|
|
func crearNotifServidor(titulo, cuerpo, evento string) {
|
|
// Verificar si la notificación del sistema está habilitada para este evento
|
|
evtCfg := models.GetNotifConfig(evento, "admin")
|
|
// Por defecto, si no hay config, crear notificación de sistema igualmente
|
|
if evtCfg == nil || evtCfg.CanalSistema {
|
|
_ = models.CreateSistemaNotif(&models.SistemaNotificacion{
|
|
TipoUsuario: "admin",
|
|
Titulo: titulo,
|
|
Cuerpo: cuerpo,
|
|
Url: "/app/servidor-dashboard",
|
|
Icono: "🖥️",
|
|
})
|
|
}
|
|
// Telegram (si está configurado para este evento)
|
|
if evtCfg != nil && evtCfg.CanalTelegram {
|
|
msg := fmt.Sprintf("<b>%s</b>\n%s", titulo, cuerpo)
|
|
sendTelegramAdmin(msg)
|
|
}
|
|
}
|
|
|
|
// ─── Monitor de URLs ──────────────────────────────────────────────────────────
|
|
|
|
func VerificarUrlMonitors() {
|
|
monitors, err := models.GetActiveUrlMonitors()
|
|
if err != nil || len(monitors) == 0 {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
for _, m := range monitors {
|
|
m := m
|
|
if m.UltimoCheckAt != nil {
|
|
if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) {
|
|
continue
|
|
}
|
|
}
|
|
go ejecutarChequeoURL(m)
|
|
}
|
|
}
|
|
|
|
func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) }
|
|
|
|
func ejecutarChequeoURL(m models.UrlMonitor) {
|
|
statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg)
|
|
ok := err == nil && statusCode >= 200 && statusCode < 400
|
|
errStr := ""
|
|
if err != nil {
|
|
errStr = err.Error()
|
|
}
|
|
|
|
anteriorOk := m.UltimoOk == nil || *m.UltimoOk
|
|
|
|
models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok)
|
|
models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr)
|
|
|
|
if m.UltimoOk == nil {
|
|
return // primer chequeo, no alertar
|
|
}
|
|
if !ok && anteriorOk {
|
|
detalle := errStr
|
|
if detalle == "" {
|
|
detalle = fmt.Sprintf("HTTP %d", statusCode)
|
|
}
|
|
msg := fmt.Sprintf("🔴 <b>Sitio caído</b>\n<b>%s</b>\n%s\n\nError: %s",
|
|
escapeTelegramHTML(m.Nombre),
|
|
escapeTelegramHTML(m.URL),
|
|
escapeTelegramHTML(detalle))
|
|
sendTelegramAdmin(msg)
|
|
log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle)
|
|
} else if ok && !anteriorOk {
|
|
msg := fmt.Sprintf("✅ <b>Sitio recuperado</b>\n<b>%s</b>\n%s\nLatencia: <b>%dms</b>",
|
|
escapeTelegramHTML(m.Nombre),
|
|
escapeTelegramHTML(m.URL),
|
|
latMs)
|
|
sendTelegramAdmin(msg)
|
|
log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs)
|
|
}
|
|
}
|
|
|
|
func doHttpCheck(url string, timeoutSeg int) (int, int64, error) {
|
|
if timeoutSeg <= 0 {
|
|
timeoutSeg = 10
|
|
}
|
|
client := &http.Client{
|
|
Timeout: time.Duration(timeoutSeg) * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return fmt.Errorf("demasiadas redirecciones")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
start := time.Now()
|
|
req, err := http.NewRequest(http.MethodHead, url, nil)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
req.Header.Set("User-Agent", "usite-monitor/1.0")
|
|
|
|
resp, err := client.Do(req)
|
|
latMs := time.Since(start).Milliseconds()
|
|
if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) {
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
// Reintentar con GET
|
|
start = time.Now()
|
|
req2, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
req2.Header.Set("User-Agent", "usite-monitor/1.0")
|
|
resp2, err2 := client.Do(req2)
|
|
latMs = time.Since(start).Milliseconds()
|
|
if err2 != nil {
|
|
return 0, latMs, err2
|
|
}
|
|
resp2.Body.Close()
|
|
return resp2.StatusCode, latMs, nil
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, latMs, nil
|
|
}
|