feat(cluster): production-grade atomic VACUUM replication, pre-validation integrity check, Asterisk database query injection, and live reload

This commit is contained in:
sorcier committed 2026-11-05 23:38:03 +01:00
1 parent a537fc6469
commit 0b740da87e
2 files changed
+98 -29

No files matched your search

+15 -13
View File
@@ -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
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
rm -f "$SRC"
echo "ASTDB_RESTORED_OK"
;;
+83 -16
View File
@@ -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
// 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();