diff --git a/scripts/update.sh b/scripts/update.sh index 421c388..00b893b 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -81,6 +81,9 @@ fi echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}" +# Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp +find /tmp -maxdepth 1 \( -name 'nexus_cluster_extract_*' -o -name 'nexus_cluster_tar_*' -o -name 'nexus_cluster_recv_*' -o -name 'nexus_cluster_sync_*' -o -name 'nexus_cluster_db_*' \) -mmin +10 -exec rm -rf {} + 2>/dev/null || true + # 1. Sauvegarde préventive automatique (Disaster Recovery complète) echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}" php -r ' diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 3af6e5b..0aed668 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -495,101 +495,98 @@ class ClusterManager throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } + $this->cleanupStaleClusterTempFiles(); $uniq = getmypid() . '_' . bin2hex(random_bytes(4)); // Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite - $tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite'; - @unlink($tmpDbDump); - try { - $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump)); - } catch (\Throwable $e) { - shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true"); - } - - if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) { - $this->db->exec('PRAGMA wal_checkpoint(FULL);'); - @copy($dbPath, $tmpDbDump); - } - - $dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump); - if ($dbIntegrity !== 'ok') { - @unlink($tmpDbDump); - throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); - } - + $tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite'; $tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz'; - $tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3'; - $hasAstdb = false; + $tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3'; + $tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq; + $hasAstdb = false; - // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) - $astdbPath = '/var/lib/asterisk/astdb.sqlite3'; - if (file_exists($astdbPath) && is_readable($astdbPath)) { - @unlink($tmpAstdb); + try { + @unlink($tmpDbDump); try { - if (class_exists(\SQLite3::class)) { - $srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY); - $dstAst = new \SQLite3($tmpAstdb); - $srcAst->backup($dstAst); - $srcAst->close(); - $dstAst->close(); - } else { - shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); - } - } catch (\Throwable) { - @copy($astdbPath, $tmpAstdb); + $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump)); + } catch (\Throwable $e) { + shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true"); } - if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { - $integrity = $this->checkSqliteIntegrity($tmpAstdb); - if ($integrity === 'ok') { - // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) - try { - $astPdo = new PDO('sqlite:' . $tmpAstdb); - $astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'"); - $astPdo = null; - } catch (\Throwable) { - shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); + + if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) { + $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + @copy($dbPath, $tmpDbDump); + } + + $dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump); + if ($dbIntegrity !== 'ok') { + throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); + } + + // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) + $astdbPath = '/var/lib/asterisk/astdb.sqlite3'; + if (file_exists($astdbPath) && is_readable($astdbPath)) { + @unlink($tmpAstdb); + try { + if (class_exists(\SQLite3::class)) { + $srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY); + $dstAst = new \SQLite3($tmpAstdb); + $srcAst->backup($dstAst); + $srcAst->close(); + $dstAst->close(); + } else { + shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); + } + } catch (\Throwable) { + @copy($astdbPath, $tmpAstdb); + } + if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { + $integrity = $this->checkSqliteIntegrity($tmpAstdb); + if ($integrity === 'ok') { + // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) + try { + $astPdo = new PDO('sqlite:' . $tmpAstdb); + $astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'"); + $astPdo = null; + } catch (\Throwable) { + shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); + } + $hasAstdb = true; } - $hasAstdb = true; - } else { - @unlink($tmpAstdb); } } - } - // Préparation du dossier temporaire de tarage pour isoler database.sqlite - $tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq; - @mkdir($tmpDbDir, 0755, true); - rename($tmpDbDump, $tmpDbDir . '/database.sqlite'); + // Préparation du dossier temporaire de tarage pour isoler database.sqlite + @mkdir($tmpDbDir, 0755, true); + rename($tmpDbDump, $tmpDbDir . '/database.sqlite'); - $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite"; - 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 (is_dir('/var/www/nexus-pbx/public/avatars')) { - $cmd .= " -C /var/www/nexus-pbx/public avatars"; - } - if ($hasAstdb) { - $cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb)); - } - exec($cmd . " 2>&1", $tarOut, $tarCode); + $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite"; + 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 (is_dir('/var/www/nexus-pbx/public/avatars')) { + $cmd .= " -C /var/www/nexus-pbx/public avatars"; + } + if ($hasAstdb) { + $cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb)); + } + exec($cmd . " 2>&1", $tarOut, $tarCode); - @unlink($tmpDbDir . '/database.sqlite'); - @rmdir($tmpDbDir); - if ($hasAstdb) { + if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) { + throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode})."); + } + + $archiveData = file_get_contents($tmpArchive); + } finally { + @unlink($tmpDbDump); @unlink($tmpAstdb); - } - - if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) { @unlink($tmpArchive); - throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode})."); + shell_exec('rm -rf ' . escapeshellarg($tmpDbDir) . ' 2>/dev/null || true'); } - $archiveData = file_get_contents($tmpArchive); - @unlink($tmpArchive); - // Collecte des adresses IP bannies dans Fail2ban (jails actives) $bannedIps = $this->getActiveFail2banBans(); @@ -694,110 +691,114 @@ class ClusterManager throw new RuntimeException("Paquet de réplication vide ou corrompu."); } - $uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4)); - $archiveRaw = base64_decode($data['archive_b64']); - $tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz'; - file_put_contents($tmpArchive, $archiveRaw); + $this->cleanupStaleClusterTempFiles(); + $uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4)); + $archiveRaw = base64_decode($data['archive_b64'], true); + if ($archiveRaw === false || $archiveRaw === '') { + throw new RuntimeException("Archive de réplication invalide (décodage base64)."); + } - // Extraction sécurisée dans un dossier temporaire pour validation préalable + $tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz'; $tmpExtractDir = '/tmp/nexus_cluster_extract_' . $uniqRecv; - @mkdir($tmpExtractDir, 0755, true); - shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true"); - - $extractedDb = $tmpExtractDir . '/database.sqlite'; - if (!file_exists($extractedDb) || filesize($extractedDb) === 0) { - @unlink($tmpArchive); - shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); - throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide."); - } - - // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local - $check = $this->checkSqliteIntegrity($extractedDb); - if ($check !== 'ok') { - shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); - @unlink($tmpArchive); - throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application."); - } - - // Sauvegarde d'urgence de la base actuelle avant synchronisation - $dbPath = Database::getDatabasePath(); - if (file_exists($dbPath)) { - @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); - } - - // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master $localClusterSettings = $settings; $localSettingsRow = null; + try { - $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); - } catch (\Throwable $e) {} - - // Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore) - // Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM, - // et n'invalide aucune lecture concurrente dans PHP-FPM. - if (file_exists($dbPath)) { - $this->restoreSqliteDatabase($extractedDb, $dbPath); - } else { - @copy($extractedDb, $dbPath); - @chmod($dbPath, 0660); - @chown($dbPath, 'www-data'); - @chgrp($dbPath, 'asterisk'); - } - - @unlink($extractedDb); - - // Contrôle d'intégrité immédiat après synchronisation - $postCheck = $this->checkSqliteIntegrity($dbPath); - if ($postCheck !== 'ok') { - if (file_exists($dbPath . '.pre_cluster_sync.bak')) { - $this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath); + $written = @file_put_contents($tmpArchive, $archiveRaw); + if ($written === false || $written !== strlen($archiveRaw)) { + throw new RuntimeException("Espace insuffisant dans /tmp lors de l'écriture de l'archive HA."); } + + // Extraction sécurisée dans un dossier temporaire pour validation préalable + @mkdir($tmpExtractDir, 0755, true); + exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>&1", $extOut, $extCode); + + $extractedDb = $tmpExtractDir . '/database.sqlite'; + if (!file_exists($extractedDb) || filesize($extractedDb) === 0) { + $detail = !empty($extOut) ? ' (' . implode(' | ', array_slice($extOut, 0, 2)) . ')' : ''; + throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide{$detail}."); + } + + // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local + $check = $this->checkSqliteIntegrity($extractedDb); + if ($check !== 'ok') { + throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application."); + } + + // Sauvegarde d'urgence de la base actuelle avant synchronisation + $dbPath = Database::getDatabasePath(); + if (file_exists($dbPath)) { + @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + } + + // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master + try { + $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + } catch (\Throwable $e) {} + + // Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore) + if (file_exists($dbPath)) { + $this->restoreSqliteDatabase($extractedDb, $dbPath); + } else { + @copy($extractedDb, $dbPath); + @chmod($dbPath, 0660); + @chown($dbPath, 'www-data'); + @chgrp($dbPath, 'asterisk'); + } + + @unlink($extractedDb); + + // Contrôle d'intégrité immédiat après synchronisation + $postCheck = $this->checkSqliteIntegrity($dbPath); + if ($postCheck !== 'ok') { + if (file_exists($dbPath . '.pre_cluster_sync.bak')) { + $this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath); + } + throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); + } + + // Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) + $extractedCustom = $tmpExtractDir . '/custom'; + if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true"); + } + + // Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) + $extractedVm = $tmpExtractDir . '/voicemail'; + if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); + } + + // Synchronisation miroir des avatars de l'annuaire / UCP + $extractedAvatars = $tmpExtractDir . '/avatars'; + if (is_dir($extractedAvatars)) { + if (!is_dir('/var/www/nexus-pbx/public/avatars')) { + @mkdir('/var/www/nexus-pbx/public/avatars', 0775, true); + } + shell_exec("rsync -a --delete " . escapeshellarg($extractedAvatars . '/') . " /var/www/nexus-pbx/public/avatars/ 2>/dev/null || true"); + shell_exec("chown -R www-data:asterisk /var/www/nexus-pbx/public/avatars 2>/dev/null || true"); + shell_exec("chmod -R 775 /var/www/nexus-pbx/public/avatars 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_' . $uniqRecv . '.sqlite3'; + if (!file_exists($extractedAstdb)) { + $candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: []; + if (!empty($candidates)) { + $extractedAstdb = $candidates[0]; + } + } + if (file_exists($extractedAstdb)) { + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); + @unlink($extractedAstdb); + } + } finally { shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); @unlink($tmpArchive); - throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); } - // Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) - $extractedCustom = $tmpExtractDir . '/custom'; - if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { - shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); - shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true"); - } - - // Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) - $extractedVm = $tmpExtractDir . '/voicemail'; - if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { - shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); - shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); - } - - // Synchronisation miroir des avatars de l'annuaire / UCP - $extractedAvatars = $tmpExtractDir . '/avatars'; - if (is_dir($extractedAvatars)) { - if (!is_dir('/var/www/nexus-pbx/public/avatars')) { - @mkdir('/var/www/nexus-pbx/public/avatars', 0775, true); - } - shell_exec("rsync -a --delete " . escapeshellarg($extractedAvatars . '/') . " /var/www/nexus-pbx/public/avatars/ 2>/dev/null || true"); - shell_exec("chown -R www-data:asterisk /var/www/nexus-pbx/public/avatars 2>/dev/null || true"); - shell_exec("chmod -R 775 /var/www/nexus-pbx/public/avatars 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_' . $uniqRecv . '.sqlite3'; - if (!file_exists($extractedAstdb)) { - $candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: []; - if (!empty($candidates)) { - $extractedAstdb = $candidates[0]; - } - } - if (file_exists($extractedAstdb)) { - shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); - @unlink($extractedAstdb); - } - - shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true'); - @unlink($tmpArchive); - // Réouverture de la connexion DB fraîche $newDb = Database::getConnection(true); $this->db = $newDb; @@ -1119,6 +1120,31 @@ class ClusterManager ]; } + private function cleanupStaleClusterTempFiles(): void + { + $cutoff = time() - 600; + $patterns = [ + '/tmp/nexus_cluster_extract_*', + '/tmp/nexus_cluster_tar_*', + '/tmp/nexus_cluster_recv_*', + '/tmp/nexus_cluster_sync_*', + '/tmp/nexus_cluster_db_*', + '/tmp/astdb_cluster_sync*', + ]; + foreach ($patterns as $pattern) { + foreach (glob($pattern) ?: [] as $path) { + $mtime = @filemtime($path); + if ($mtime !== false && $mtime < $cutoff) { + if (is_dir($path)) { + shell_exec('rm -rf ' . escapeshellarg($path) . ' 2>/dev/null || true'); + } else { + @unlink($path); + } + } + } + } + } + private function checkSqliteIntegrity(string $filePath): string { if (!file_exists($filePath) || filesize($filePath) === 0) { diff --git a/version.json b/version.json index d845092..3e1071e 100644 --- a/version.json +++ b/version.json @@ -1,9 +1,10 @@ { - "version": "2.5.1", + "version": "2.5.2", "codename": "Pulsar UCP", "release_date": "2026-11-11", "min_php_version": "8.2", "changelog": [ + "Correctif v2.5.2 : purge systématique try/finally + auto-nettoyage des dossiers temporaires HA /tmp (élimine la saturation tmpfs 100% qui bloquait la réplication)", "Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel", "Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp", "Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page",