query("SELECT firewall_enabled, admin_https_port, certbot_strict_port80, fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime, fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips, ucp_enabled, ucp_https_port, ucp_http_port, fail2ban_ucp_maxretry, fail2ban_ucp_bantime FROM settings WHERE id = 1"); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return [ 'firewall_enabled' => 1, 'admin_https_port' => 8443, 'certbot_strict_port80' => 0, 'fail2ban_sip_maxretry' => 3, 'fail2ban_sip_findtime' => 600, 'fail2ban_sip_bantime' => 3600, 'fail2ban_web_maxretry' => 3, 'fail2ban_web_bantime' => 3600, 'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60', 'ucp_enabled' => 1, 'ucp_https_port' => 8444, 'ucp_http_port' => 8084, 'fail2ban_ucp_maxretry' => 3, 'fail2ban_ucp_bantime' => 3600, ]; } return [ 'firewall_enabled' => (int)($row['firewall_enabled'] ?? 1), 'admin_https_port' => (int)($row['admin_https_port'] ?? 8443), 'certbot_strict_port80' => (int)($row['certbot_strict_port80'] ?? 0), 'fail2ban_sip_maxretry' => (int)($row['fail2ban_sip_maxretry'] ?? 3), 'fail2ban_sip_findtime' => (int)($row['fail2ban_sip_findtime'] ?? 600), 'fail2ban_sip_bantime' => (int)($row['fail2ban_sip_bantime'] ?? 3600), 'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3), 'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600), 'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'), 'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1), 'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444), 'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084), 'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3), 'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600), ]; } /** * Retourne l'état complet en direct de Fail2ban et les IPs bannies. * Utilise un cache rapide de 4 secondes pour soulager le tableau de bord temps réel. */ public static function getStatus(bool $forceFresh = false): array { $cacheFile = '/tmp/nexus_fw_status_cache.json'; if (!$forceFresh && file_exists($cacheFile)) { $mtime = @filemtime($cacheFile); if ($mtime && (time() - $mtime) < 4) { $cached = json_decode((string)file_get_contents($cacheFile), true); if (is_array($cached)) { return $cached; } } } $settings = self::getSettings(); // 1. Détection service Fail2ban (Statut Systemd + vérification socket ping) $sysActive = trim((string)@shell_exec('systemctl is-active fail2ban 2>/dev/null')) === 'active'; $pingCode = -1; $pingOut = []; if ($sysActive) { exec('timeout 2 sudo -n /usr/bin/fail2ban-client ping 2>&1', $pingOut, $pingCode); } $pingOk = ($pingCode === 0 && str_contains(implode(' ', $pingOut), 'Server replied: pong')); $isRunning = $sysActive || $pingOk; $jails = []; $bannedList = []; $totalBannedCount = 0; if ($isRunning && $pingOk) { exec('timeout 2 sudo -n /usr/bin/fail2ban-client status 2>&1', $statOut); $statStr = implode("\n", $statOut); // Extraction des prisons actives if (preg_match('/Jail list:\s+(.+)$/m', $statStr, $m)) { $jailNames = array_map('trim', explode(',', $m[1])); foreach ($jailNames as $jName) { if ($jName === '') continue; $jStatus = self::getJailDetails($jName); $jails[$jName] = $jStatus; foreach ($jStatus['banned_ips'] as $bIp) { $bannedList[] = [ 'ip' => $bIp, 'jail' => $jName, 'jail_label' => self::formatJailName($jName), ]; $totalBannedCount++; } } } } // IP actuelle du client (avec résolution reverse proxy si proxy privé/local) $currentIp = self::resolveClientIp(); $status = [ 'is_running' => $isRunning, 'settings' => $settings, 'jails' => $jails, 'banned_ips' => $bannedList, 'total_banned' => $totalBannedCount, 'client_ip' => $currentIp, ]; @file_put_contents($cacheFile, json_encode($status)); return $status; } /** * Débannit une adresse IP d'une prison ou de toutes les prisons. */ public static function unbanIp(string $ip, string $jail = ''): array { $ip = trim($ip); if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { throw new InvalidArgumentException("Adresse IP invalide : {$ip}"); } $jail = trim($jail); $unbannedFrom = []; if ($jail !== '' && $jail !== 'all') { $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " unbanip " . escapeshellarg($ip) . " 2>&1"; exec($cmd, $out, $code); if ($code === 0) { $unbannedFrom[] = $jail; } } else { // Débannir de toutes les prisons connues foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) { $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1"; exec($cmd, $out, $code); if ($code === 0) { $unbannedFrom[] = $j; } } } @unlink('/tmp/nexus_fw_status_cache.json'); return [ 'success' => count($unbannedFrom) > 0, 'message' => count($unbannedFrom) > 0 ? "L'adresse IP {$ip} a été débannie avec succès (" . implode(', ', $unbannedFrom) . ")." : "L'adresse IP {$ip} n'a pas été trouvée dans les listes de bannissement actives.", ]; } /** * Bannit manuellement une adresse IP. */ public static function banIp(string $ip, string $jail = 'asterisk'): array { $ip = trim($ip); if ($ip === '' || (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) { throw new InvalidArgumentException("Adresse IP invalide : {$ip}"); } $jail = trim($jail) ?: 'asterisk'; $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($jail) . " banip " . escapeshellarg($ip) . " 2>&1"; exec($cmd, $out, $code); @unlink('/tmp/nexus_fw_status_cache.json'); return [ 'success' => $code === 0, 'message' => $code === 0 ? "L'adresse IP {$ip} a été bannie sur la règle {$jail}." : "Échec du bannissement : " . implode("\n", $out), ]; } /** * Enregistre les paramètres du pare-feu, régénère jail.local et la configuration Nginx. */ public static function saveSettings(array $data): array { $db = Database::getConnection(); $oldSettings = self::getSettings(); $strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0; $firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1; $adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443))); $sipMaxRetry = max(1, min(20, (int)($data['fail2ban_sip_maxretry'] ?? 3))); $sipFindTime = max(60, min(86400, (int)($data['fail2ban_sip_findtime'] ?? 600))); $sipBanTime = max(60, min(31536000, (int)($data['fail2ban_sip_bantime'] ?? 3600))); $webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3))); $webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600))); $ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled']; $ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port']; $ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port']; $ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry']; $ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime']; if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) { throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0."); } $reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061]; if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) { throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME)."); } if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) { throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME)."); } if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) { throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents."); } $whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? '')); $cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist))); // Toujours préserver l'adresse de bouclage locale pour les processus internes if (!in_array('127.0.0.1/8', $cleanTokens, true)) { array_unshift($cleanTokens, '127.0.0.1/8'); } if (!in_array('::1', $cleanTokens, true)) { $cleanTokens[] = '::1'; } $finalWhitelist = implode(' ', array_unique($cleanTokens)); // 1. Sauvegarde dans la base SQLite $stmt = $db->prepare("UPDATE settings SET firewall_enabled = :fe, admin_https_port = :ap, certbot_strict_port80 = :sp80, fail2ban_sip_maxretry = :smr, fail2ban_sip_findtime = :sft, fail2ban_sip_bantime = :sbt, fail2ban_web_maxretry = :wmr, fail2ban_web_bantime = :wbt, fail2ban_whitelist_ips = :wl, ucp_enabled = :ue, ucp_https_port = :uhs, ucp_http_port = :uhp, fail2ban_ucp_maxretry = :umr, fail2ban_ucp_bantime = :ubt, updated_at = CURRENT_TIMESTAMP WHERE id = 1"); $stmt->execute([ ':fe' => $firewallEnabled, ':ap' => $adminPort, ':sp80' => $strictPort80, ':smr' => $sipMaxRetry, ':sft' => $sipFindTime, ':sbt' => $sipBanTime, ':wmr' => $webMaxRetry, ':wbt' => $webBanTime, ':wl' => $finalWhitelist, ':ue' => $ucpEnabled, ':uhs' => $ucpHttpsPort, ':uhp' => $ucpHttpPort, ':umr' => $ucpMaxRetry, ':ubt' => $ucpBanTime, ]); // 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local self::ensureUcpFail2banArtifacts(); self::generateJailLocal( $finalWhitelist, $sipMaxRetry, $sipFindTime, $sipBanTime, $webMaxRetry, $webBanTime, $adminPort, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort, $ucpMaxRetry, $ucpBanTime ); // 3. Rechargement / Redémarrage propre de Fail2ban exec('timeout 3 sudo -n /usr/bin/fail2ban-client reload 2>&1', $f2bOut, $f2bCode); if ($f2bCode !== 0) { exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1'); } // 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort); self::syncUcpFirewallPorts($oldSettings, [ 'ucp_enabled' => $ucpEnabled, 'ucp_https_port' => $ucpHttpsPort, 'ucp_http_port' => $ucpHttpPort, ]); @unlink('/tmp/nexus_fw_status_cache.json'); return [ 'success' => true, 'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.', 'settings' => self::getSettings(), ]; } /** * Écrit le fichier /etc/fail2ban/jail.local */ public static function generateJailLocal( string $whitelist, int $sipMaxRetry, int $sipFindTime, int $sipBanTime, int $webMaxRetry, int $webBanTime, int $adminPort, ?int $ucpEnabled = null, ?int $ucpHttpsPort = null, ?int $ucpHttpPort = null, ?int $ucpMaxRetry = null, ?int $ucpBanTime = null ): void { if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) { $st = self::getSettings(); $ucpEnabled = $ucpEnabled ?? $st['ucp_enabled']; $ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port']; $ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port']; $ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry']; $ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime']; } self::ensureUcpFail2banArtifacts(); $portsWeb = "http,https,{$adminPort}"; $ucpPortsList = []; if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort; if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort; $ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false'; $portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444'; $conf = << 0) { $listenCustom = "\n listen {$adminHttpsPort} ssl;\n listen [::]:{$adminHttpsPort} ssl;"; } $httpsBlock = << 0) { $blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock); } if ($ucpHttpPort > 0) { $blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock); } } $fullNginxConfig = implode("\n\n", $blocks) . "\n"; file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig); @chgrp(self::NGINX_CONF_PATH, 'asterisk'); @chmod(self::NGINX_CONF_PATH, 0664); // Test de syntaxe exec('sudo -n /usr/sbin/nginx -t 2>&1', $tOut, $tCode); if ($tCode !== 0) { throw new RuntimeException("Erreur de syntaxe Nginx : " . implode("\n", $tOut)); } // Rechargement Nginx sans coupure exec('sudo -n /usr/bin/systemctl reload nginx 2>&1'); } private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string { $ucpRoot = self::UCP_ROOT_DIR; $listenDirectives = $ssl ? "listen {$port} ssl;\n listen [::]:{$port} ssl;" : "listen {$port};\n listen [::]:{$port};"; $sslDirectives = $ssl ? <</dev/null 2>&1'); return; } } if (!file_exists(self::UCP_SECURITY_LOG)) { if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) { @exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1'); return; } } @chown(self::UCP_SECURITY_LOG, 'www-data'); @chgrp(self::UCP_SECURITY_LOG, 'asterisk'); @chmod(self::UCP_SECURITY_LOG, 0664); } /** * Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull). */ public static function ensureUcpDeployed(): void { $st = self::getSettings(); if (empty($st['ucp_enabled'])) { return; } $httpsPort = (int)($st['ucp_https_port'] ?? 8444); $httpPort = (int)($st['ucp_http_port'] ?? 8084); if ($httpsPort <= 0 && $httpPort <= 0) { return; } $nginxFile = '/etc/nginx/sites-available/nexus-pbx'; $nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : ''; $hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR); $hasFilter = file_exists(self::UCP_FILTER_PATH); $hasLog = file_exists(self::UCP_SECURITY_LOG); if ($hasNginxUcp && $hasFilter && $hasLog) { return; } if (function_exists('posix_geteuid') && posix_geteuid() !== 0) { exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code); if ($code === 0) { return; } } self::saveSettings($st); } /** * Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe. */ private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void { $oldPorts = []; if (!empty($oldSettings['ucp_enabled'])) { if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port']; if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port']; } $newPorts = []; if (!empty($newSettings['ucp_enabled'])) { if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port']; if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port']; } $toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts)); $toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts)); // UFW si installé et actif $ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null')); if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) { foreach ($toRemove as $p) { @shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1'); } foreach ($newPorts as $p) { @shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1'); } } // iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT) $iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1")); if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) { foreach ($toRemove as $p) { @shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1"); } foreach ($toAdd as $p) { @shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1"); } } } /** * Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance. */ public static function resolveClientIp(): string { $remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1')); if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) { return '127.0.0.1'; } // Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback $isTrustedProxy = ( filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false ); if ($isTrustedProxy) { $candidates = []; if (!empty($_SERVER['HTTP_X_REAL_IP'])) { $candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']); } if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) { $candidates[] = trim($part); } } foreach ($candidates as $cand) { if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) { return $cand; } } } return $remote; } private static function getJailDetails(string $jail): array { exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out); $str = implode("\n", $out); $currentlyFailed = 0; $totalFailed = 0; $currentlyBanned = 0; $totalBanned = 0; $bannedIps = []; if (preg_match('/Currently failed:\s+(\d+)/', $str, $m)) { $currentlyFailed = (int)$m[1]; } if (preg_match('/Total failed:\s+(\d+)/', $str, $m)) { $totalFailed = (int)$m[1]; } // Intégrer également les événements consignés dans le journal physique if ($jail === 'asterisk') { $logIncidents = is_readable('/var/log/asterisk/security.log') ? (int)shell_exec("grep -c -E 'ChallengeResponseFailed|InvalidAccountID|FailedAuth' /var/log/asterisk/security.log 2>/dev/null") : 0; $totalFailed = max($totalFailed, $logIncidents); } elseif ($jail === 'nexus-web') { $logIncidents = is_readable('/var/log/nexus_security.log') ? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null") : 0; $totalFailed = max($totalFailed, $logIncidents); } elseif ($jail === 'nexus-ucp') { $logIncidents = is_readable(self::UCP_SECURITY_LOG) ? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null") : 0; $totalFailed = max($totalFailed, $logIncidents); } if (preg_match('/Currently banned:\s+(\d+)/', $str, $m)) { $currentlyBanned = (int)$m[1]; } if (preg_match('/Total banned:\s+(\d+)/', $str, $m)) { $totalBanned = (int)$m[1]; } if (preg_match('/Banned IP list:\s*(.*)$/m', $str, $m)) { $rawIps = trim($m[1]); if ($rawIps !== '') { $bannedIps = array_values(array_filter(preg_split('/\s+/', $rawIps))); } } return [ 'name' => $jail, 'currently_failed' => $currentlyFailed, 'total_failed' => $totalFailed, 'currently_banned' => $currentlyBanned, 'total_banned' => $totalBanned, 'banned_ips' => $bannedIps, ]; } private static function formatJailName(string $jail): string { return match ($jail) { 'asterisk' => 'Téléphonie SIP (PJSIP)', 'nexus-web' => 'Interface Web Admin', 'nexus-ucp' => 'Portail Utilisateur (UCP)', 'sshd' => 'Accès Console SSH', default => ucfirst($jail), }; } }