feat(cluster): add voicemail & fail2ban sync and automatic failback re-sync reconciliation

This commit is contained in:
sorcier committed 2026-11-07 01:26:02 +01:00
1 parent 54fc495fa7
commit d1d42f75b5
5 files changed
+270 -1

No files matched your search

+21
View File
@@ -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 {
+8 -1
View File
@@ -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
+9
View File
@@ -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)) {
+228
View File
@@ -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('
+4
View File
@@ -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) {