From d1d42f75b59b0bf02ff6de2cf91e314efb90e5c3 Mon Sep 17 00:00:00 2001 From: sorcier Date: Sat, 7 Nov 2026 01:26:02 +0100 Subject: [PATCH] feat(cluster): add voicemail & fail2ban sync and automatic failback re-sync reconciliation --- public/api.php | 21 +++ scripts/cluster/notify_master.sh | 9 +- scripts/nexus_cluster_sync.php | 9 ++ src/ClusterManager.php | 228 +++++++++++++++++++++++++++++++ src/ConfigGenerator.php | 4 + 5 files changed, 270 insertions(+), 1 deletion(-) diff --git a/public/api.php b/public/api.php index 2b0848e..9149093 100644 --- a/public/api.php +++ b/public/api.php @@ -59,6 +59,22 @@ try { exit; } + if ($action === 'cluster_node_status') { + $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? ''; + $cm = new ClusterManager($db); + $res = $cm->getNodeStatusForPeer($rawBody ?: '', $signature); + echo json_encode($res); + exit; + } + + if ($action === 'cluster_request_push') { + $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? ''; + $cm = new ClusterManager($db); + $res = $cm->handlePushRequestFromPeer($rawBody ?: '', $signature); + echo json_encode($res); + exit; + } + Auth::startSession(); // Pour les requêtes en lecture seule fréquente (polling métriques / statut), libérer immédiatement le verrou de session @@ -1889,6 +1905,11 @@ try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); + // Actualisation de l'horodatage de modification en base pour la réconciliation cluster + try { + $db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1"); + } catch (\Throwable) {} + // Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master $clusterSyncMsg = null; try { diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 005049a..307845c 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -10,7 +10,14 @@ TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE" -# 0. Ouverture immédiate des ports SIP entrants sur le nouveau MASTER (levée d'isolation) +# 0. Réconciliation préalable au Failback : si le pair était actif et a des modifications plus récentes, +# récupérer sa réplication AVANT d'ouvrir les flux SIP et de servir le trafic ! +if [ -f "/var/www/nexus-pbx/scripts/nexus_cluster_sync.php" ]; then + echo "[$TIMESTAMP] [CLUSTER] Vérification réconciliation failback avec le pair..." >> "$LOG_FILE" + php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php --failback-check >> "$LOG_FILE" 2>&1 || true +fi + +# 1. Ouverture immédiate des ports SIP entrants sur le nouveau MASTER (levée d'isolation) iptables -D INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || true iptables -D INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || true diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php index 1f4d72b..8c6d8e5 100644 --- a/scripts/nexus_cluster_sync.php +++ b/scripts/nexus_cluster_sync.php @@ -14,6 +14,15 @@ use App\Database; use App\ClusterManager; try { + // Mode réconciliation immédiate au Failback (appelé par notify_master.sh) + if (in_array('--failback-check', $argv ?? [], true)) { + $db = Database::getConnection(); + $cm = new ClusterManager($db); + $res = $cm->failbackReconcileWithPeer(); + echo date('[Y-m-d H:i:s] ') . "[CLUSTER-FAILBACK] " . ($res['message'] ?? 'OK') . "\n"; + exit(0); + } + // 0. Verrou d'exécution exclusif pour éviter les chevauchements $lockFp = @fopen('/tmp/nexus_cluster_cron.lock', 'c+'); if (!$lockFp || !@flock($lockFp, LOCK_EX | LOCK_NB)) { diff --git a/src/ClusterManager.php b/src/ClusterManager.php index eb15d60..119133e 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -543,6 +543,9 @@ class ClusterManager if (is_dir('/var/lib/asterisk/sounds/custom')) { $cmd .= " -C /var/lib/asterisk/sounds custom"; } + if (is_dir('/var/spool/asterisk/voicemail')) { + $cmd .= " -C /var/spool/asterisk voicemail"; + } if ($hasAstdb) { $cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; } @@ -562,9 +565,13 @@ class ClusterManager $archiveData = file_get_contents($tmpArchive); @unlink($tmpArchive); + // Collecte des adresses IP bannies dans Fail2ban (jails actives) + $bannedIps = $this->getActiveFail2banBans(); + $payload = [ 'timestamp' => time(), 'archive_b64' => base64_encode($archiveData), + 'banned_ips' => $bannedIps, ]; $jsonPayload = json_encode($payload); @@ -729,6 +736,12 @@ class ClusterManager shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true"); } + // Extraction de la messagerie vocale (fichiers audio, messages reçus INBOX, annonces d'accueil) + if (is_dir('/var/spool/asterisk')) { + shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/spool/asterisk voicemail 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); + } + // Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root $extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3'; if (file_exists($extractedAstdb)) { @@ -799,6 +812,14 @@ class ClusterManager shell_exec('/usr/sbin/asterisk -rx "dialplan reload" 2>/dev/null; /usr/sbin/asterisk -rx "pjsip reload" 2>/dev/null || true'); } + // Application des IP bannies Fail2ban transmises par le pair + if (!empty($data['banned_ips']) && is_array($data['banned_ips'])) { + $this->applyFail2banBans($data['banned_ips']); + } + + // Rafraîchissement du module de messagerie vocale pour actualiser les boîtes et indicateurs MWI + shell_exec('/usr/sbin/asterisk -rx "module reload app_voicemail.so" 2>/dev/null || true'); + // En mode standby / veille, s'assurer que les Trunks externes ne s'enregistrent pas pour éviter les conflits if (($localClusterSettings['role'] ?? 'backup') !== 'master') { shell_exec('/usr/sbin/asterisk -rx "pjsip send unregister *" 2>/dev/null || true'); @@ -834,6 +855,213 @@ class ClusterManager return $this->saveSettings($settings); } + /** + * Collecte les adresses IP bannies dans toutes les prisons actives de Fail2ban + */ + public function getActiveFail2banBans(): array + { + $bans = []; + $out = []; + @exec('timeout 3 sudo -n /usr/bin/fail2ban-client status 2>/dev/null', $out); + $jailList = ''; + foreach ($out as $line) { + if (stripos($line, 'Jail list:') !== false) { + $jailList = trim(explode(':', $line, 2)[1] ?? ''); + break; + } + } + if ($jailList === '') { + return []; + } + $jails = array_filter(array_map('trim', explode(',', $jailList))); + foreach ($jails as $jail) { + $jOut = []; + @exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>/dev/null', $jOut); + foreach ($jOut as $jLine) { + if (stripos($jLine, 'Banned IP list:') !== false) { + $ipsStr = trim(explode(':', $jLine, 2)[1] ?? ''); + if ($ipsStr !== '') { + $ips = array_filter(preg_split('/\s+/', $ipsStr)); + if (!empty($ips)) { + $bans[$jail] = array_values($ips); + } + } + break; + } + } + } + return $bans; + } + + /** + * Applique les adresses IP bannies transmises par le pair dans les prisons locales Fail2ban + */ + public function applyFail2banBans(array $bans): void + { + foreach ($bans as $jail => $ips) { + if (!is_array($ips)) { + continue; + } + foreach ($ips as $ip) { + $ip = trim((string)$ip); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + @exec('timeout 2 sudo -n /usr/bin/fail2ban-client set ' . escapeshellarg($jail) . ' banip ' . escapeshellarg($ip) . ' 2>&1'); + } + } + } + } + + /** + * Fournit l'état et l'horodatage de dernière modification au pair pour le contrôle de failback + */ + public function getNodeStatusForPeer(string $rawBody, string $providedSignature): array + { + $settings = $this->getSettings(); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + if ($secret === '') { + throw new RuntimeException("Secret de cluster non configuré."); + } + $expectedSignature = hash_hmac('sha256', $rawBody, $secret); + if (!hash_equals($expectedSignature, $providedSignature)) { + throw new RuntimeException("Signature de cluster invalide (accès refusé)."); + } + + $isActiveMaster = $this->isVirtualIpActive($settings['virtual_ip'] ?? ''); + $updatedAtTs = 0; + try { + $row = $this->db->query("SELECT strftime('%s', updated_at) as ts FROM cluster_settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + $updatedAtTs = (int)($row['ts'] ?? 0); + } catch (\Throwable) {} + + return [ + 'success' => true, + 'is_active_master' => $isActiveMaster, + 'last_sync_status' => $settings['last_sync_status'] ?? '', + 'updated_at_timestamp' => $updatedAtTs, + ]; + } + + /** + * Traite une demande de réplication forcée émise par le pair (lors d'un failback) + */ + public function handlePushRequestFromPeer(string $rawBody, string $providedSignature): array + { + $settings = $this->getSettings(); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + if ($secret === '') { + throw new RuntimeException("Secret de cluster non configuré."); + } + $expectedSignature = hash_hmac('sha256', $rawBody, $secret); + if (!hash_equals($expectedSignature, $providedSignature)) { + throw new RuntimeException("Signature de cluster invalide (accès refusé)."); + } + + // Pousse immédiatement la base et les fichiers audio vers le pair + return $this->pushSyncToPeer(); + } + + /** + * Réconciliation automatique au retour de failover (Failback Re-sync) + * Si le pair était Master actif et a une base plus récente, déclenche le rattrapage immédiat. + */ + public function failbackReconcileWithPeer(): array + { + $settings = $this->getSettings(); + if (empty($settings['enabled']) || empty($settings['peer_ip'])) { + return ['reconciled' => false, 'message' => 'Cluster inactif ou pair non configuré.']; + } + + $peerIp = trim((string)$settings['peer_ip']); + $peerPort = (int)($settings['peer_port'] ?? 8443); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + + // 1. Interroger l'état du pair + $statusPayload = ['action' => 'cluster_node_status', 'timestamp' => time()]; + $jsonPayload = json_encode($statusPayload); + $sig = hash_hmac('sha256', $jsonPayload, $secret); + + $targetUrl = "https://{$peerIp}:{$peerPort}/api.php?action=cluster_node_status"; + $ch = curl_init($targetUrl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Nexus-Cluster-Signature: ' . $sig, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); + curl_setopt($ch, CURLOPT_TIMEOUT, 4); + $response = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200 || empty($response)) { + return ['reconciled' => false, 'message' => "Pair injoignable (HTTP {$httpCode}), conservation de la base locale."]; + } + + $peerData = json_decode((string)$response, true); + if (empty($peerData['success'])) { + return ['reconciled' => false, 'message' => 'Réponse négative du pair : ' . ($peerData['error'] ?? 'Inconnue')]; + } + + $peerLastSyncStatus = $peerData['last_sync_status'] ?? ''; + $peerUpdatedAt = (int)($peerData['updated_at_timestamp'] ?? 0); + + $localUpdatedAt = 0; + try { + $row = $this->db->query("SELECT strftime('%s', updated_at) as ts FROM cluster_settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + $localUpdatedAt = (int)($row['ts'] ?? 0); + } catch (\Throwable) {} + + // Le pair a fonctionné en Master actif et dispose de données plus récentes + $peerWasActive = ($peerLastSyncStatus === 'master_active' || !empty($peerData['is_active_master'])); + if ($peerWasActive && ($peerUpdatedAt > $localUpdatedAt || empty($localUpdatedAt))) { + // Demande au pair d'émettre sa réplication vers nous + $pushReqPayload = [ + 'action' => 'cluster_request_push', + 'timestamp' => time(), + ]; + $jsonPushReq = json_encode($pushReqPayload); + $pushSig = hash_hmac('sha256', $jsonPushReq, $secret); + + $pushUrl = "https://{$peerIp}:{$peerPort}/api.php?action=cluster_request_push"; + $ch2 = curl_init($pushUrl); + curl_setopt($ch2, CURLOPT_POST, true); + curl_setopt($ch2, CURLOPT_POSTFIELDS, $jsonPushReq); + curl_setopt($ch2, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Nexus-Cluster-Signature: ' . $pushSig, + ]); + curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch2, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch2, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch2, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($ch2, CURLOPT_TIMEOUT, 20); + $pushResp = curl_exec($ch2); + $pushHttp = (int)curl_getinfo($ch2, CURLINFO_HTTP_CODE); + curl_close($ch2); + + if ($pushHttp === 200) { + return [ + 'reconciled' => true, + 'message' => "Réconciliation réussie : base, voicemails et configs synchronisés depuis le pair {$peerIp}.", + ]; + } else { + return [ + 'reconciled' => false, + 'message' => "Échec de la demande de push au pair (HTTP {$pushHttp}).", + ]; + } + } + + return [ + 'reconciled' => false, + 'message' => "Base locale déjà à jour ou plus récente que le pair (Local: {$localUpdatedAt}, Pair: {$peerUpdatedAt}).", + ]; + } + private function updateSyncStatus(string $status, string $message): void { $stmt = $this->db->prepare(' diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 44645fb..2284395 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -1874,6 +1874,10 @@ class ConfigGenerator $lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]"; $lines[] = "; ==========================================================================="; $lines[] = ""; + $lines[] = "[general](+)"; + $lines[] = "pollmailboxes = yes"; + $lines[] = "pollfreq = 10"; + $lines[] = ""; $lines[] = "[default](+)"; foreach ($extensions as $ext) {