feat(cluster): add voicemail & fail2ban sync and automatic failback re-sync reconciliation
This commit is contained in:
1 parent
54fc495fa7
commit
d1d42f75b5
5 files changed
+270
-1
No files matched your search
@@ -59,6 +59,22 @@ try {
|
|||||||
exit;
|
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();
|
Auth::startSession();
|
||||||
|
|
||||||
// Pour les requêtes en lecture seule fréquente (polling métriques / statut), libérer immédiatement le verrou de session
|
// 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);
|
$ami = AmiClient::fromDatabase($db);
|
||||||
$reloadInfo = $ami->reloadPjsipAndDialplan();
|
$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
|
// Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master
|
||||||
$clusterSyncMsg = null;
|
$clusterSyncMsg = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
|||||||
|
|
||||||
echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE"
|
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 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
|
iptables -D INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ use App\Database;
|
|||||||
use App\ClusterManager;
|
use App\ClusterManager;
|
||||||
|
|
||||||
try {
|
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
|
// 0. Verrou d'exécution exclusif pour éviter les chevauchements
|
||||||
$lockFp = @fopen('/tmp/nexus_cluster_cron.lock', 'c+');
|
$lockFp = @fopen('/tmp/nexus_cluster_cron.lock', 'c+');
|
||||||
if (!$lockFp || !@flock($lockFp, LOCK_EX | LOCK_NB)) {
|
if (!$lockFp || !@flock($lockFp, LOCK_EX | LOCK_NB)) {
|
||||||
|
|||||||
@@ -543,6 +543,9 @@ class ClusterManager
|
|||||||
if (is_dir('/var/lib/asterisk/sounds/custom')) {
|
if (is_dir('/var/lib/asterisk/sounds/custom')) {
|
||||||
$cmd .= " -C /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) {
|
if ($hasAstdb) {
|
||||||
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3";
|
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3";
|
||||||
}
|
}
|
||||||
@@ -562,9 +565,13 @@ class ClusterManager
|
|||||||
$archiveData = file_get_contents($tmpArchive);
|
$archiveData = file_get_contents($tmpArchive);
|
||||||
@unlink($tmpArchive);
|
@unlink($tmpArchive);
|
||||||
|
|
||||||
|
// Collecte des adresses IP bannies dans Fail2ban (jails actives)
|
||||||
|
$bannedIps = $this->getActiveFail2banBans();
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'timestamp' => time(),
|
'timestamp' => time(),
|
||||||
'archive_b64' => base64_encode($archiveData),
|
'archive_b64' => base64_encode($archiveData),
|
||||||
|
'banned_ips' => $bannedIps,
|
||||||
];
|
];
|
||||||
|
|
||||||
$jsonPayload = json_encode($payload);
|
$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");
|
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
|
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root
|
||||||
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3';
|
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3';
|
||||||
if (file_exists($extractedAstdb)) {
|
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');
|
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
|
// En mode standby / veille, s'assurer que les Trunks externes ne s'enregistrent pas pour éviter les conflits
|
||||||
if (($localClusterSettings['role'] ?? 'backup') !== 'master') {
|
if (($localClusterSettings['role'] ?? 'backup') !== 'master') {
|
||||||
shell_exec('/usr/sbin/asterisk -rx "pjsip send unregister *" 2>/dev/null || true');
|
shell_exec('/usr/sbin/asterisk -rx "pjsip send unregister *" 2>/dev/null || true');
|
||||||
@@ -834,6 +855,213 @@ class ClusterManager
|
|||||||
return $this->saveSettings($settings);
|
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
|
private function updateSyncStatus(string $status, string $message): void
|
||||||
{
|
{
|
||||||
$stmt = $this->db->prepare('
|
$stmt = $this->db->prepare('
|
||||||
|
|||||||
@@ -1874,6 +1874,10 @@ class ConfigGenerator
|
|||||||
$lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]";
|
$lines[] = "; MESSAGERIE VOCALE (VOICEMAIL) - CONTEXTE [default]";
|
||||||
$lines[] = "; ===========================================================================";
|
$lines[] = "; ===========================================================================";
|
||||||
$lines[] = "";
|
$lines[] = "";
|
||||||
|
$lines[] = "[general](+)";
|
||||||
|
$lines[] = "pollmailboxes = yes";
|
||||||
|
$lines[] = "pollfreq = 10";
|
||||||
|
$lines[] = "";
|
||||||
$lines[] = "[default](+)";
|
$lines[] = "[default](+)";
|
||||||
|
|
||||||
foreach ($extensions as $ext) {
|
foreach ($extensions as $ext) {
|
||||||
|
|||||||
Reference in new issue
Block a user