From 0b740da87e090c67117b296925dc38d024b83527 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:38:03 +0100 Subject: [PATCH] feat(cluster): production-grade atomic VACUUM replication, pre-validation integrity check, Asterisk database query injection, and live reload --- scripts/nexus-cluster | 26 ++++++----- src/ClusterManager.php | 101 ++++++++++++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index ac0eb49..619121e 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -72,7 +72,7 @@ case "$CMD" in exit 0 fi - # Vérification intégrité de la source + # 1. Vérification intégrité du fichier source temporaire SRC_INTEGRITY=$(sqlite3 "$SRC" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt") if [ "$SRC_INTEGRITY" != "ok" ]; then echo "ASTDB_SOURCE_CORRUPT: $SRC_INTEGRITY" @@ -80,21 +80,23 @@ case "$CMD" in exit 1 fi - # Fusion ou remplacement propre - if [ -f "$DEST" ]; then - DEST_INTEGRITY=$(sqlite3 "$DEST" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt") - if [ "$DEST_INTEGRITY" = "ok" ]; then - # Fusion SQLite transactionnelle sans fermer les descripteurs d'Asterisk - sqlite3 "$DEST" "ATTACH '$SRC' AS src; INSERT OR REPLACE INTO astdb SELECT * FROM src.astdb; DETACH src;" 2>/dev/null || cp -f "$SRC" "$DEST" - else - cp -f "$SRC" "$DEST" - fi + chmod 644 "$SRC" 2>/dev/null || true + + # 2. Si Asterisk tourne, injection via son moteur interne database query + # Cela garantit l'absence totale de verrou SQLite (database is locked) et préserve les descripteurs ouverts + if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "database query \"ATTACH '$SRC' AS src;\"" >/dev/null 2>&1 || true + /usr/sbin/asterisk -rx "database query \"DELETE FROM astdb WHERE key LIKE '/registrar/%';\"" >/dev/null 2>&1 || true + /usr/sbin/asterisk -rx "database query \"INSERT INTO astdb SELECT * FROM src.astdb WHERE key LIKE '/registrar/%';\"" >/dev/null 2>&1 || true + /usr/sbin/asterisk -rx "database query \"INSERT OR REPLACE INTO astdb SELECT * FROM src.astdb WHERE key NOT LIKE '/registrar/%';\"" >/dev/null 2>&1 || true + /usr/sbin/asterisk -rx "database query \"DETACH src;\"" >/dev/null 2>&1 || true else + # Asterisk arrêté : copie directe intègre cp -f "$SRC" "$DEST" + chown asterisk:asterisk "$DEST" 2>/dev/null || true + chmod 664 "$DEST" 2>/dev/null || true fi - chown asterisk:asterisk "$DEST" 2>/dev/null || true - chmod 664 "$DEST" 2>/dev/null || true rm -f "$SRC" echo "ASTDB_RESTORED_OK" ;; diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 8a9d782..52341a6 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -463,8 +463,25 @@ class ClusterManager throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } - // Forcer le checkpoint SQLite WAL pour un dump intègre - $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + // Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite + $tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.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 = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); + if ($dbIntegrity !== 'ok') { + @unlink($tmpDbDump); + throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); + } $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; $tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3'; @@ -485,7 +502,12 @@ class ClusterManager } } - $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite"; + // Préparation du dossier temporaire de tarage pour isoler database.sqlite + $tmpDbDir = '/tmp/nexus_cluster_tar_' . time(); + @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"; } @@ -493,6 +515,9 @@ class ClusterManager $cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; } shell_exec($cmd); + + @unlink($tmpDbDir . '/database.sqlite'); + @rmdir($tmpDbDir); if ($hasAstdb) { @unlink($tmpAstdb); } @@ -605,10 +630,31 @@ class ClusterManager $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; file_put_contents($tmpArchive, $archiveRaw); - // Sauvegarde d'urgence de la base actuelle avant écrasement + // Extraction sécurisée dans un dossier temporaire pour validation préalable + $tmpExtractDir = '/tmp/nexus_cluster_extract_' . time(); + @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); + @rmdir($tmpExtractDir); + 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 = trim(shell_exec("sqlite3 " . escapeshellarg($extractedDb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); + if ($check !== 'ok') { + @unlink($extractedDb); + @rmdir($tmpExtractDir); + @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 remplacement $dbPath = Database::getDatabasePath(); if (file_exists($dbPath)) { - copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); } // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master @@ -618,26 +664,34 @@ class ClusterManager $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) {} - // Libérer les verrous PDO SQLite + // Libérer les verrous PDO SQLite locaux et purger les résidus WAL/SHM pour éviter tout malformed Database::resetConnection(); + @unlink($dbPath . '-wal'); + @unlink($dbPath . '-shm'); - // Extraction de l'archive - shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite 2>/dev/null || true"); + // Remplacement atomique de la base de données + rename($extractedDb, $dbPath); + @chmod($dbPath, 0660); + @chown($dbPath, 'www-data'); + @chgrp($dbPath, 'asterisk'); + + // Extraction des sons personnalisés si présents if (is_dir('/var/lib/asterisk/sounds')) { shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true"); } - // Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) - shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /tmp astdb_cluster_sync.sqlite3 2>/dev/null || true"); - if (file_exists('/tmp/astdb_cluster_sync.sqlite3')) { - // Utilisation du helper root pour fusionner de manière transactionnelle via SQLite ATTACH - // sans casser les descripteurs de fichiers d'Asterisk ni risquer d'écrasement corrompu - shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb /tmp/astdb_cluster_sync.sqlite3 2>&1'); - @unlink('/tmp/astdb_cluster_sync.sqlite3'); + + // 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)) { + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); + @unlink($extractedAstdb); } + + @rmdir($tmpExtractDir); @unlink($tmpArchive); - // Réouverture de la connexion DB - $newDb = Database::getConnection(); + // Réouverture de la connexion DB fraîche + $newDb = Database::getConnection(true); $this->db = $newDb; // Restauration de l'identité cluster et réseau propre à CE serveur @@ -686,6 +740,19 @@ class ClusterManager // Régénérer les configurations PJSIP et Asterisk ConfigGenerator::generateAndApply($newDb); + // Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM + try { + $ami = AmiClient::fromDatabase($newDb); + $ami->reloadPjsipAndDialplan(); + } catch (\Throwable $e) { + shell_exec('/usr/sbin/asterisk -rx "dialplan reload" 2>/dev/null; /usr/sbin/asterisk -rx "pjsip reload" 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'); + } + // Si le cluster est actif, actualiser le service Keepalived if (!empty($localClusterSettings['enabled'])) { $this->applyKeepalivedConfig();