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;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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('
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user