All files / src/services httpErrorWatcher.js

0% Statements 0/76
0% Branches 0/40
0% Functions 0/8
0% Lines 0/66

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211                                                                                                                                                                                                                                                                                                                                                                                                                                     
/**
 * Service HTTP Error Watcher — Detecte les erreurs 5xx dans les access logs des containers.
 *
 * Fonctionnalites :
 *   - Scanne les log_paths de type docker-file (access logs) toutes les 60s
 *   - Detecte les erreurs HTTP 5xx (500, 502, 503, 504, etc.)
 *   - Envoie une alerte webhook avec les details (URL, code, IP, timestamp)
 *   - Cooldown de 5 min par URL pour eviter le spam
 *   - Parse les formats Apache et Nginx
 *
 * @module services/httpErrorWatcher
 */
 
const { pool: db } = require('../db');
const { exec } = require('./ssh');
const { notify } = require('./webhookNotifier');
const { shellEscape } = require('../utils/shell');
const { getPlanLimits } = require('../config/plans');
 
const CHECK_INTERVAL_MS = 60 * 1000; // 60 secondes
const ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 min entre alertes identiques
const LINES_TO_CHECK = 50; // Dernières 50 lignes a chaque check
 
// Cooldown par clé (serverId:url:code)
const lastAlertTime = new Map();
 
// Dernier timestamp vérifié par log_path (pour ne pas re-alerter)
const lastCheckTimestamp = new Map();
 
/**
 * Parse une ligne de log Apache/Nginx pour extraire le code HTTP.
 * Formats supportes :
 *   Apache : 10.0.1.238 - - [02/Apr/2026:09:28:29 +0200] "POST /api/machines.php?id=63 HTTP/1.1" 500 1104 "..." "..."
 *   Nginx  : 10.0.1.238 - - [02/Apr/2026:09:28:29 +0000] "GET /api/test HTTP/1.1" 502 0 "..." "..."
 *
 * @param {string} line
 * @returns {{ ip: string, method: string, url: string, status: number, timestamp: string } | null}
 */
function parseAccessLogLine(line) {
  // Format Apache/Nginx combined
  const match = /^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)]\s+"(\S+)\s+(\S+)\s+\S+"\s+(\d{3})\s+/.exec(line);
  if (!match) return null;
 
  return {
    ip: match[1],
    timestamp: match[2],
    method: match[3],
    url: match[4],
    status: Number.parseInt(match[5], 10),
  };
}
 
/**
 * Verifie si un code HTTP est une erreur serveur.
 * @param {number} status
 * @returns {boolean}
 */
function isServerError(status) {
  return status >= 500 && status < 600;
}
 
/**
 * Genere un message d'alerte lisible.
 * @param {Object} entry
 * @param {string} containerLabel
 * @returns {{ title: string, message: string, fields: Array }}
 */
function formatAlert(entry, containerLabel) {
  const statusLabels = {
    500: 'Internal Server Error',
    502: 'Bad Gateway',
    503: 'Service Unavailable',
    504: 'Gateway Timeout',
  };
 
  const label = statusLabels[entry.status] || `Erreur ${entry.status}`;
 
  return {
    title: `HTTP ${entry.status} — ${containerLabel}`,
    message: `${entry.method} ${entry.url} → ${entry.status} ${label}\nDepuis ${entry.ip} a ${entry.timestamp}`,
    fields: [
      { name: 'Container', value: containerLabel, inline: true },
      { name: 'Code', value: `${entry.status} ${label}`, inline: true },
      { name: 'URL', value: `${entry.method} ${entry.url}`, inline: false },
      { name: 'IP client', value: entry.ip, inline: true },
    ],
  };
}
 
/**
 * Verifie un log_path pour les erreurs HTTP.
 * @param {Object} logPath — { id, path, label, server_id, user_id }
 */
/**
 * Parse le chemin du log et resout le container ID.
 * @returns {{ serviceName: string, containerId: string } | null}
 */
async function resolveLogContainer(logPath) {
  const colonIndex = logPath.path.indexOf(':');
  if (colonIndex === -1) return null;
 
  const serviceName = logPath.path.substring(0, colonIndex);
  const filePath = logPath.path.substring(colonIndex + 1);
 
  // Ne verifier que les access logs (pas error logs, pas php logs)
  if (!filePath.includes('access')) return null;
 
  const safeService = shellEscape(serviceName);
  const resolveResult = await exec(
    logPath.user_id,
    logPath.server_id,
    `docker ps -q --filter name=${safeService} | head -1`,
  );
  const containerId = resolveResult.stdout.trim();
  if (!containerId) return null;
 
  return { serviceName, filePath, containerId };
}
 
/**
 * Verifie si une entree de log doit declencher une alerte.
 * @returns {boolean} — true si l'alerte a ete envoyee
 */
async function processErrorEntry(entry, logPath, serviceName, now) {
  const cooldownKey = `${logPath.server_id}:${entry.url}:${entry.status}`;
  const lastAlert = lastAlertTime.get(cooldownKey) || 0;
  if (now - lastAlert < ALERT_COOLDOWN_MS) return false;
 
  const lastCheck = lastCheckTimestamp.get(logPath.id) || '';
  if (entry.timestamp <= lastCheck) return false;
 
  lastAlertTime.set(cooldownKey, now);
  const alertData = formatAlert(entry, logPath.label || serviceName);
  await notify(logPath.user_id, 'http_error', alertData);
  console.log(`[http-watcher] ALERTE ${entry.status}: ${entry.method} ${entry.url} — ${serviceName}`);
  return true;
}
 
async function checkLogPath(logPath) {
  try {
    const resolved = await resolveLogContainer(logPath); // NOSONAR — resolveLogContainer est async
    if (!resolved) return;
 
    const { serviceName, filePath, containerId } = resolved;
    const safeFile = shellEscape(filePath);
 
    const { stdout } = await exec(
      logPath.user_id,
      logPath.server_id,
      `docker exec ${shellEscape(containerId)} tail -n ${LINES_TO_CHECK} ${safeFile} 2>/dev/null`,
    );
    if (!stdout) return;
 
    const lines = stdout.trim().split('\n');
    const now = Date.now();
 
    for (const line of lines) {
      const entry = parseAccessLogLine(line);
      if (!entry || !isServerError(entry.status)) continue;
      await processErrorEntry(entry, logPath, serviceName, now); // NOSONAR — processErrorEntry est async
    }
 
    // Memoriser le dernier timestamp pour ne pas re-traiter
    const lastLine = lines.length > 0 ? parseAccessLogLine(lines.at(-1)) : null;
    if (lastLine) {
      lastCheckTimestamp.set(logPath.id, lastLine.timestamp);
    }
  } catch (err) {
    if (!err.message.includes('Timeout')) {
      console.error(`[http-watcher] Erreur check ${logPath.label}:`, err.message);
    }
  }
}
 
/**
 * Scanne tous les log_paths de type docker-file pour les erreurs HTTP.
 */
async function checkAll() {
  try {
    const { rows: logPaths } = await db.query(
      `SELECT lp.id, lp.path, lp.label, lp.server_id, s.user_id, u.plan
       FROM log_paths lp
       JOIN servers s ON s.id = lp.server_id
       JOIN users u ON u.id = s.user_id
       WHERE lp.type = 'docker-file'`,
    );
 
    for (const lp of logPaths) {
      // Verifier que le plan de l'utilisateur autorise les alertes HTTP
      const limits = await getPlanLimits(lp.plan || 'free');
      if (!limits.http_error_alerts) continue;
 
      await checkLogPath(lp);
    }
  } catch (err) {
    console.error('[http-watcher] Erreur globale:', err.message);
  }
}
 
/**
 * Demarre le watcher HTTP en arriere-plan.
 */
function startHttpErrorWatcher() {
  console.log('[http-watcher] Demarrage de la surveillance des erreurs HTTP (check toutes les 60s)');
  setInterval(checkAll, CHECK_INTERVAL_MS);
  // Premier check apres 45 secondes
  setTimeout(checkAll, 45000);
}
 
module.exports = { startHttpErrorWatcher, parseAccessLogLine, isServerError, formatAlert };