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

+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) {