From 18368027ee0d4d3abe78a62a4a83e5b7eb170773 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 00:57:11 +0100 Subject: [PATCH 01/37] feat(dev): add dev environment detection and visual indicators --- .gitignore | 5 +++++ public/index.php | 23 +++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2dbd9da..4c0f15d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ Thumbs.db .idea/ .vscode/ +# Environnement local de développement (exclu de Git) +/.dev +/.env +/.env.local + diff --git a/public/index.php b/public/index.php index af2f208..4dadb83 100644 --- a/public/index.php +++ b/public/index.php @@ -19,6 +19,10 @@ $hasBranding = !empty($companyName) || !empty($companyLogo); $appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : []; $appVersion = $appVerData['version'] ?? '1.5.0'; + +// Détection de l'environnement de développement : présence de .dev local ou branche git dev +$isDevEnv = file_exists(__DIR__ . '/../.dev') + || (trim((string)@shell_exec('git -C ' . escapeshellarg(__DIR__) . ' rev-parse --abbrev-ref HEAD 2>/dev/null')) === 'dev'); ?> @@ -26,7 +30,7 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; - NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk + <?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk @@ -162,7 +166,12 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
NexusPBX Logo
-

NexusPBX

+

+ NexusPBX + + DEV + +

Supervision & Téléphonie IP • Powered by Asterisk

@@ -212,6 +221,9 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; NexusPBX v + + DEV +
Powered by Asterisk
@@ -494,6 +506,13 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
+ +
+ + DEV +
+ + +
@@ -2755,6 +2760,236 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') + + + + + diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh new file mode 100755 index 0000000..c155db0 --- /dev/null +++ b/scripts/cluster/check_asterisk.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Script de surveillance de santé d'Asterisk pour Keepalived VRRP +# ============================================================================== +set -euo pipefail + +# 1. Vérifie si le binaire et le démon asterisk tournent +if ! pidof asterisk >/dev/null 2>&1; then + exit 1 +fi + +# 2. Teste si Asterisk répond aux requêtes AMI/CLI (core ping) +PING_RESP=$(asterisk -rx "core ping" 2>/dev/null || true) +if echo "$PING_RESP" | grep -q "Pong"; then + exit 0 +fi + +# 3. Tentative alternative via core show version +if asterisk -rx "core show version" >/dev/null 2>&1; then + exit 0 +fi + +exit 1 diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh new file mode 100755 index 0000000..e38b639 --- /dev/null +++ b/scripts/cluster/notify_backup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état BACKUP / STANDBY +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] <<< Transition vers l'état BACKUP / STANDBY (En veille) >>>" >> "$LOG_FILE" + +# 1. Mise à jour de l'état en base locale +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" +exit 0 diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh new file mode 100755 index 0000000..80d19d4 --- /dev/null +++ b/scripts/cluster/notify_fault.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état FAULT / ERREUR +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] [ALERTE] ÉTAT FAULT DÉTECTÉ sur le nœud !" >> "$LOG_FILE" + +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='fault', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +exit 0 diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh new file mode 100755 index 0000000..c5a70ec --- /dev/null +++ b/scripts/cluster/notify_master.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état MASTER +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE" + +# 1. Mise à jour de l'état en base de données locale si SQLite disponible +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='master_active', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +# 2. Réactivation des enregistrements Trunks PJSIP et rechargement de la config +if pidof asterisk >/dev/null 2>&1; then + asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true + asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true +fi + +# 3. Gratuitous ARP optionnel si arping est présent +VIP=$(sqlite3 "$DB_PATH" "SELECT virtual_ip FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "") +IFACE=$(sqlite3 "$DB_PATH" "SELECT interface FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "eth0") +if [ -n "$VIP" ] && command -v arping >/dev/null 2>&1; then + arping -c 3 -U -I "$IFACE" "$VIP" >> "$LOG_FILE" 2>&1 || true +fi + +echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" +exit 0 diff --git a/src/ClusterManager.php b/src/ClusterManager.php new file mode 100644 index 0000000..9dbe372 --- /dev/null +++ b/src/ClusterManager.php @@ -0,0 +1,527 @@ +db = $db ?? Database::getConnection(); + } + + /** + * Récupère la configuration actuelle du Cluster + */ + public function getSettings(): array + { + $stmt = $this->db->query('SELECT * FROM cluster_settings WHERE id = 1'); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return [ + 'enabled' => 0, + 'role' => 'master', + 'virtual_ip' => '', + 'virtual_netmask' => '24', + 'interface' => 'eth0', + 'peer_ip' => '', + 'cluster_secret' => bin2hex(random_bytes(16)), + 'vrid' => 51, + 'priority' => 100, + 'sync_interval' => 15, + 'auto_sync' => 1, + 'last_sync_at' => null, + 'last_sync_status' => 'none', + 'last_sync_message' => '', + ]; + } + return $row; + } + + /** + * Sauvegarde la configuration du cluster + */ + public function saveSettings(array $data): array + { + $enabled = !empty($data['enabled']) ? 1 : 0; + $role = in_array($data['role'] ?? '', ['master', 'backup'], true) ? $data['role'] : 'master'; + $virtualIp = trim((string)($data['virtual_ip'] ?? '')); + $virtualNetmask = preg_replace('/[^0-9]/', '', (string)($data['virtual_netmask'] ?? '24')) ?: '24'; + $interface = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', trim((string)($data['interface'] ?? 'eth0'))) ?: 'eth0'; + $peerIp = trim((string)($data['peer_ip'] ?? '')); + $clusterSecret = trim((string)($data['cluster_secret'] ?? '')); + $vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); + $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); + $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); + $autoSync = !empty($data['auto_sync']) ? 1 : 0; + + if ($clusterSecret === '') { + $clusterSecret = bin2hex(random_bytes(16)); + } + + if ($enabled && $virtualIp === '') { + throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA."); + } + + $stmt = $this->db->prepare(' + UPDATE cluster_settings SET + enabled = :enabled, + role = :role, + virtual_ip = :vip, + virtual_netmask = :vmask, + interface = :iface, + peer_ip = :peer, + cluster_secret = :secret, + vrid = :vrid, + priority = :prio, + sync_interval = :sync_int, + auto_sync = :auto_sync, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + '); + + $stmt->execute([ + ':enabled' => $enabled, + ':role' => $role, + ':vip' => $virtualIp, + ':vmask' => $virtualNetmask, + ':iface' => $interface, + ':peer' => $peerIp, + ':secret' => $clusterSecret, + ':vrid' => $vrid, + ':prio' => $priority, + ':sync_int' => $syncInterval, + ':auto_sync' => $autoSync, + ]); + + // Application automatique de la configuration Keepalived si demandé + $applyResult = $this->applyKeepalivedConfig(); + + return [ + 'success' => true, + 'message' => 'Configuration Haute Disponibilité enregistrée.', + 'apply_result' => $applyResult, + ]; + } + + /** + * Détecte les interfaces réseau physiques et virtuelles locales avec leurs IP + */ + public function detectNetworkInterfaces(): array + { + $interfaces = []; + $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; + + foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + // Format typique ip -o: "2: eth0 inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0\..." + if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) { + $iface = $matches[1]; + $ip = $matches[2]; + $mask = $matches[3]; + + if ($iface === 'lo') { + continue; // Ignorer loopback + } + + if (!isset($interfaces[$iface])) { + $interfaces[$iface] = [ + 'name' => $iface, + 'ips' => [], + 'primary' => $ip, + 'netmask' => $mask, + ]; + } + $interfaces[$iface]['ips'][] = "{$ip}/{$mask}"; + } + } + + return array_values($interfaces); + } + + /** + * Vérifie si l'adresse IP Virtuelle (VIP) est actuellement active sur cette machine locale + */ + public function isVirtualIpActive(string $vip): bool + { + if ($vip === '') { + return false; + } + $escapedVip = escapeshellarg($vip); + $output = shell_exec("ip addr show 2>/dev/null | grep -F " . $escapedVip) ?: ''; + return trim($output) !== ''; + } + + /** + * Retourne l'état complet en temps réel du Cluster + */ + public function getClusterStatus(): array + { + $settings = $this->getSettings(); + $vip = $settings['virtual_ip'] ?? ''; + $peerIp = $settings['peer_ip'] ?? ''; + $isVipActive = $this->isVirtualIpActive($vip); + + // 1. Vérification de Keepalived + $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); + $keepalivedRunning = false; + if ($keepalivedInstalled) { + $psOut = shell_exec('pidof keepalived 2>/dev/null') ?: ''; + $keepalivedRunning = trim($psOut) !== ''; + } + + // 2. Vérification d'Asterisk + $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; + $asteriskPing = false; + if ($asteriskRunning) { + $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: ''; + $asteriskPing = str_contains($pingOut, 'Pong'); + } + + // 3. Test de connectivité vers le nœud pair (Peer Ping) + $peerReachable = false; + $peerPingMs = null; + if ($peerIp !== '') { + $escapedPeer = escapeshellarg($peerIp); + $pingOut = shell_exec("ping -c 1 -W 1 {$escapedPeer} 2>&1") ?: ''; + if (preg_match('/time=([0-9.]+)\s*ms/', $pingOut, $pm)) { + $peerReachable = true; + $peerPingMs = (float)$pm[1]; + } elseif (str_contains($pingOut, '1 received') || str_contains($pingOut, '1 packets received')) { + $peerReachable = true; + } + } + + // Rôle réel effectif + $effectiveState = 'STANDALONE'; + if (!empty($settings['enabled'])) { + if ($isVipActive) { + $effectiveState = 'MASTER'; + } else { + $effectiveState = 'BACKUP'; + } + } + + // Dernières lignes du log cluster + $recentLogs = []; + if (file_exists(self::CLUSTER_LOG_PATH)) { + $lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $recentLogs = array_slice($lines, -15); + } + + return [ + 'enabled' => (bool)$settings['enabled'], + 'configured_role' => $settings['role'], + 'effective_state' => $effectiveState, + 'virtual_ip' => $vip, + 'virtual_ip_active' => $isVipActive, + 'interface' => $settings['interface'], + 'peer_ip' => $peerIp, + 'peer_reachable' => $peerReachable, + 'peer_ping_ms' => $peerPingMs, + 'keepalived_installed' => $keepalivedInstalled, + 'keepalived_running' => $keepalivedRunning, + 'asterisk_running' => $asteriskRunning, + 'asterisk_healthy' => $asteriskPing, + 'last_sync_at' => $settings['last_sync_at'], + 'last_sync_status' => $settings['last_sync_status'], + 'last_sync_message' => $settings['last_sync_message'], + 'cluster_secret' => $settings['cluster_secret'], + 'recent_logs' => array_reverse($recentLogs), + ]; + } + + /** + * Génère la configuration officielle /etc/keepalived/keepalived.conf + */ + public function generateKeepalivedConfig(): string + { + $settings = $this->getSettings(); + $role = strtoupper($settings['role'] ?? 'MASTER'); + $iface = $settings['interface'] ?? 'eth0'; + $vip = $settings['virtual_ip'] ?? ''; + $vmask = $settings['virtual_netmask'] ?? '24'; + $vrid = (int)($settings['vrid'] ?? 51); + $prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90)); + $secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass + + $conf = []; + $conf[] = "! =============================================================================="; + $conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA"; + $conf[] = "! Date de génération : " . date('Y-m-d H:i:s'); + $conf[] = "! =============================================================================="; + $conf[] = ""; + $conf[] = "global_defs {"; + $conf[] = " router_id NEXUS_PBX_" . ($role === 'MASTER' ? '1' : '2'); + $conf[] = " enable_script_security"; + $conf[] = " script_user root"; + $conf[] = "}"; + $conf[] = ""; + $conf[] = "vrrp_script chk_asterisk {"; + $conf[] = " script \"/var/www/nexus-pbx/scripts/cluster/check_asterisk.sh\""; + $conf[] = " interval 2"; + $conf[] = " weight 2"; + $conf[] = " fall 2"; + $conf[] = " rise 2"; + $conf[] = "}"; + $conf[] = ""; + $conf[] = "vrrp_instance VI_NEXUS {"; + $conf[] = " state {$role}"; + $conf[] = " interface {$iface}"; + $conf[] = " virtual_router_id {$vrid}"; + $conf[] = " priority {$prio}"; + $conf[] = " advert_int 1"; + $conf[] = ""; + $conf[] = " authentication {"; + $conf[] = " auth_type PASS"; + $conf[] = " auth_pass {$secret}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " virtual_ipaddress {"; + $conf[] = " {$vip}/{$vmask} dev {$iface}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " track_script {"; + $conf[] = " chk_asterisk"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " notify_master \"/var/www/nexus-pbx/scripts/cluster/notify_master.sh\""; + $conf[] = " notify_backup \"/var/www/nexus-pbx/scripts/cluster/notify_backup.sh\""; + $conf[] = " notify_fault \"/var/www/nexus-pbx/scripts/cluster/notify_fault.sh\""; + $conf[] = "}"; + $conf[] = ""; + + return implode("\n", $conf); + } + + /** + * Applique la configuration Keepalived sur le système Linux + */ + public function applyKeepalivedConfig(): array + { + $settings = $this->getSettings(); + $enabled = !empty($settings['enabled']); + + if (!$enabled) { + // Désactivation du service Keepalived si le cluster est désactivé + shell_exec('systemctl stop keepalived 2>/dev/null || true'); + shell_exec('systemctl disable keepalived 2>/dev/null || true'); + return [ + 'status' => 'disabled', + 'message' => 'Cluster HA désactivé (service Keepalived stoppé).', + ]; + } + + if (empty($settings['virtual_ip'])) { + return [ + 'status' => 'error', + 'message' => 'IP Virtuelle (VIP) non renseignée.', + ]; + } + + $confContent = $this->generateKeepalivedConfig(); + if (!is_dir('/etc/keepalived')) { + mkdir('/etc/keepalived', 0755, true); + } + + file_put_contents(self::KEEPALIVED_CONF_PATH, $confContent); + chmod(self::KEEPALIVED_CONF_PATH, 0600); + + // S'assure que les scripts de cluster sont bien exécutables + shell_exec('chmod +x /var/www/nexus-pbx/scripts/cluster/*.sh 2>/dev/null || true'); + + // Activation et rechargement / démarrage de Keepalived + shell_exec('systemctl enable keepalived 2>/dev/null || true'); + shell_exec('systemctl restart keepalived 2>/dev/null || true'); + + return [ + 'status' => 'active', + 'message' => 'Configuration Keepalived générée et service redémarré avec succès.', + ]; + } + + /** + * Pousse la synchronisation complète (SQLite + Custom Sounds) vers le Nœud pair + */ + public function pushSyncToPeer(): array + { + $settings = $this->getSettings(); + $peerIp = trim((string)($settings['peer_ip'] ?? '')); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + + if ($peerIp === '') { + throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); + } + + // Création du paquet de données en mémoire ou fichier temporaire + $dbPath = Database::getDatabasePath(); + if (!file_exists($dbPath)) { + throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); + } + + // Forcer le checkpoint SQLite WAL pour avoir un dump 100% à jour + $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + + $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; + $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite"; + if (is_dir('/var/lib/asterisk/sounds/custom')) { + $cmd .= " -C /var/lib/asterisk/sounds custom"; + } + shell_exec($cmd); + + if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { + @unlink($tmpArchive); + throw new RuntimeException("Échec de génération de l'archive de réplication."); + } + + $archiveData = file_get_contents($tmpArchive); + @unlink($tmpArchive); + + $payload = [ + 'timestamp' => time(), + 'archive_b64' => base64_encode($archiveData), + ]; + + $signature = hash_hmac('sha256', json_encode($payload), $secret); + + $ch = curl_init("http://{$peerIp}/api.php?action=cluster_receive_sync"); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Nexus-Cluster-Signature: ' . $signature, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($httpCode !== 200 || !empty($err)) { + $errorMsg = "Échec d'envoi vers le pair {$peerIp} (HTTP {$httpCode}) : {$err}"; + $this->updateSyncStatus('error', $errorMsg); + throw new RuntimeException($errorMsg); + } + + $resJson = json_decode((string)$response, true); + if (empty($resJson['success'])) { + $errorMsg = "Le pair a rejeté la réplication : " . ($resJson['error'] ?? 'Erreur inconnue'); + $this->updateSyncStatus('error', $errorMsg); + throw new RuntimeException($errorMsg); + } + + $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); + + return [ + 'success' => true, + 'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp} avec succès.", + ]; + } + + /** + * Réception et application du paquet de synchronisation envoyé par le pair + */ + public function receiveSyncPayload(string $rawBody, string $providedSignature): array + { + $settings = $this->getSettings(); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + + if ($secret === '') { + throw new RuntimeException("Le secret de cluster n'est pas configuré sur ce serveur."); + } + + // Vérification signature HMAC + $expectedSignature = hash_hmac('sha256', $rawBody, $secret); + if (!hash_equals($expectedSignature, $providedSignature)) { + throw new RuntimeException("Signature de cluster invalide (accès refusé)."); + } + + $data = json_decode($rawBody, true); + if (empty($data['archive_b64'])) { + throw new RuntimeException("Paquet de réplication vide ou corrompu."); + } + + $archiveRaw = base64_decode($data['archive_b64']); + $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; + file_put_contents($tmpArchive, $archiveRaw); + + // Sauvegarde d'urgence de la DB actuelle avant écrasement + $dbPath = Database::getDatabasePath(); + if (file_exists($dbPath)) { + copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + } + + // Fermeture de la connexion PDO pour libérer les verrous SQLite + Database::resetConnection(); + + // Extraction de l'archive + shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite 2>/dev/null || true"); + if (is_dir('/var/lib/asterisk/sounds')) { + shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true"); + } + @unlink($tmpArchive); + + // Réouverture de la connexion DB et régénération des configs Asterisk + $newDb = Database::getConnection(); + $this->db = $newDb; + + // Préserver le rôle local configuré (pour ne pas qu'un master force l'esclave à se croire master) + $localRole = $settings['role'] ?? 'backup'; + $newDb->exec("UPDATE cluster_settings SET role = '{$localRole}', last_sync_at = CURRENT_TIMESTAMP, last_sync_status = 'synced', last_sync_message = 'Base et sons synchronisés depuis le Master' WHERE id = 1"); + + // Régénérer les configurations PJSIP et Asterisk + $generator = new ConfigGenerator($newDb); + $generator->generateAndApply(); + + return [ + 'success' => true, + 'message' => 'Base de données et fichiers audio appliqués avec succès.', + ]; + } + + /** + * Bascule manuelle de rôle (Switchover) + */ + public function triggerSwitchover(): array + { + $settings = $this->getSettings(); + $currentRole = $settings['role'] ?? 'master'; + $newRole = ($currentRole === 'master') ? 'backup' : 'master'; + $newPrio = ($newRole === 'master') ? 100 : 90; + + $settings['role'] = $newRole; + $settings['priority'] = $newPrio; + + return $this->saveSettings($settings); + } + + private function updateSyncStatus(string $status, string $message): void + { + $stmt = $this->db->prepare(' + UPDATE cluster_settings SET + last_sync_at = CURRENT_TIMESTAMP, + last_sync_status = :status, + last_sync_message = :msg + WHERE id = 1 + '); + $stmt->execute([':status' => $status, ':msg' => $message]); + } +} diff --git a/src/Database.php b/src/Database.php index ff74a2e..a9d5b8d 100644 --- a/src/Database.php +++ b/src/Database.php @@ -307,6 +307,27 @@ class Database created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + -- 15. Table cluster_settings : Haute Disponibilité (Cluster HA Actif/Passif Keepalived/VRRP) + CREATE TABLE IF NOT EXISTS cluster_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + role VARCHAR(16) NOT NULL DEFAULT 'master', + virtual_ip VARCHAR(45) NOT NULL DEFAULT '', + virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', + interface VARCHAR(32) NOT NULL DEFAULT 'eth0', + peer_ip VARCHAR(45) NOT NULL DEFAULT '', + cluster_secret VARCHAR(64) NOT NULL DEFAULT '', + vrid INTEGER NOT NULL DEFAULT 51, + priority INTEGER NOT NULL DEFAULT 100, + sync_interval INTEGER NOT NULL DEFAULT 15, + auto_sync INTEGER NOT NULL DEFAULT 1, + last_sync_at DATETIME DEFAULT NULL, + last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); SQL; $db->exec($schema); @@ -322,6 +343,7 @@ class Database self::migrateFeatureCodesTable($db); self::migratePhonebookContactsTable($db); self::migratePagingGroupsTable($db); + self::migrateClusterSettingsTable($db); // Création du compte admin par défaut (admin / admin123) s'il n'existe aucun utilisateur $userCount = (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn(); @@ -749,8 +771,48 @@ class Database updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); "); + } - // Aucun groupe de paging par défaut pour conserver une base propre + private static function migrateClusterSettingsTable(PDO $db): void + { + $db->exec(" + CREATE TABLE IF NOT EXISTS cluster_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + role VARCHAR(16) NOT NULL DEFAULT 'master', + virtual_ip VARCHAR(45) NOT NULL DEFAULT '', + virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', + interface VARCHAR(32) NOT NULL DEFAULT 'eth0', + peer_ip VARCHAR(45) NOT NULL DEFAULT '', + cluster_secret VARCHAR(64) NOT NULL DEFAULT '', + vrid INTEGER NOT NULL DEFAULT 51, + priority INTEGER NOT NULL DEFAULT 100, + sync_interval INTEGER NOT NULL DEFAULT 15, + auto_sync INTEGER NOT NULL DEFAULT 1, + last_sync_at DATETIME DEFAULT NULL, + last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "); + + $count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn(); + if ($count === 0) { + $defaultSecret = bin2hex(random_bytes(16)); + $stmt = $db->prepare(' + INSERT INTO cluster_settings (id, enabled, role, virtual_ip, virtual_netmask, interface, peer_ip, cluster_secret, vrid, priority, sync_interval, auto_sync) + VALUES (1, 0, :role, :vip, :vmask, :iface, :peer, :secret, 51, 100, 15, 1) + '); + $stmt->execute([ + ':role' => 'master', + ':vip' => '', + ':vmask' => '24', + ':iface' => 'eth0', + ':peer' => '', + ':secret' => $defaultSecret, + ]); + } } } From 93f842a363403908d570007512689c5675811a34 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:02:09 +0100 Subject: [PATCH 03/37] fix(cluster): inclusion de ClusterManager et autoloader PSR-4 automatique dans api.php et index.php --- public/api.php | 12 ++++++++++++ public/index.php | 10 ++++++++++ scripts/cluster/check_asterisk.sh | 1 + scripts/cluster/notify_backup.sh | 1 + scripts/cluster/notify_fault.sh | 1 + scripts/cluster/notify_master.sh | 1 + src/ClusterManager.php | 1 + 7 files changed, 27 insertions(+) diff --git a/public/api.php b/public/api.php index bc49c6d..6210192 100644 --- a/public/api.php +++ b/public/api.php @@ -12,6 +12,17 @@ require_once __DIR__ . '/../src/BackupManager.php'; require_once __DIR__ . '/../src/FirewallManager.php'; require_once __DIR__ . '/../src/PhonebookManager.php'; require_once __DIR__ . '/../src/CdrManager.php'; +require_once __DIR__ . '/../src/ClusterManager.php'; + +// Autoloader PSR-4 automatique pour toutes les classes du namespace App +spl_autoload_register(function (string $class): void { + if (str_starts_with($class, 'App\\')) { + $file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php'; + if (file_exists($file)) { + require_once $file; + } + } +}); use App\Database; use App\Auth; @@ -24,6 +35,7 @@ use App\BackupManager; use App\FirewallManager; use App\PhonebookManager; use App\CdrManager; +use App\ClusterManager; header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate'); diff --git a/public/index.php b/public/index.php index 908a6e5..a18378a 100644 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,16 @@ declare(strict_types=1); require_once __DIR__ . '/../src/Database.php'; require_once __DIR__ . '/../src/Auth.php'; +// Autoloader PSR-4 automatique pour toutes les classes du namespace App +spl_autoload_register(function (string $class): void { + if (str_starts_with($class, 'App\\')) { + $file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php'; + if (file_exists($file)) { + require_once $file; + } + } +}); + use App\Database; use App\Auth; diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh index c155db0..f6c9bd5 100755 --- a/scripts/cluster/check_asterisk.sh +++ b/scripts/cluster/check_asterisk.sh @@ -21,3 +21,4 @@ if asterisk -rx "core show version" >/dev/null 2>&1; then fi exit 1 + diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index e38b639..a681889 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -17,3 +17,4 @@ fi echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" exit 0 + diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh index 80d19d4..3406f29 100755 --- a/scripts/cluster/notify_fault.sh +++ b/scripts/cluster/notify_fault.sh @@ -15,3 +15,4 @@ if [ -f "$DB_PATH" ]; then fi exit 0 + diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index c5a70ec..84eb61f 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -30,3 +30,4 @@ fi echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" exit 0 + diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 9dbe372..a027564 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -525,3 +525,4 @@ class ClusterManager $stmt->execute([':status' => $status, ':msg' => $message]); } } + From da0805fd5f7eedd30ad9f68d06d521da8c4b191a Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:22:16 +0100 Subject: [PATCH 04/37] fix(ha): orchestration Keepalived root, support unicast VRRP, preservation identite backup et synchro HTTPS --- install.sh | 14 +- scripts/cluster/check_asterisk.sh | 8 +- scripts/cluster/notify_backup.sh | 10 +- scripts/cluster/notify_fault.sh | 2 +- scripts/cluster/notify_master.sh | 10 +- scripts/nexus-cluster | 79 +++++++++ src/ClusterManager.php | 261 ++++++++++++++++++++++-------- 7 files changed, 302 insertions(+), 82 deletions(-) create mode 100755 scripts/nexus-cluster diff --git a/install.sh b/install.sh index 355fb34..fb07033 100755 --- a/install.sh +++ b/install.sh @@ -171,8 +171,12 @@ mkdir -p /var/lib/asterisk/sounds/custom mkdir -p /var/spool/asterisk/voicemail/default mkdir -p /var/log/asterisk /var/log/asterisk/cdr-csv mkdir -p /var/log/nexus-pbx -mkdir -p /etc/keepalived +mkdir -p /etc/keepalived/scripts mkdir -p "$TARGET_DIR/scripts/cluster" +touch /var/log/nexus_cluster.log +chmod 666 /var/log/nexus_cluster.log +sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true +echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true @@ -201,7 +205,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s # Droits sudoers pour l'interface web (www-data) cat << 'EOF' > /etc/sudoers.d/nexus-pbx # Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data -www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update +www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster EOF chmod 0440 /etc/sudoers.d/nexus-pbx @@ -213,7 +217,7 @@ for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php* cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf" [Service] ProtectSystem=false -ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt +ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt /etc/keepalived EOF_OVERRIDE fi done @@ -483,10 +487,12 @@ chmod 0644 /etc/cron.d/nexus-backup touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log # CLI symlinks -chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" +chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" "$TARGET_DIR/scripts/nexus-cluster" +chmod +x "$TARGET_DIR"/scripts/cluster/*.sh 2>/dev/null || true ln -sf "$TARGET_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner ln -sf "$TARGET_DIR/scripts/update.sh" /usr/local/bin/nexus-update +ln -sf "$TARGET_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster # Bannière interactive lors de chaque connexion terminal (SSH / Console) cat << 'EOF' > /etc/profile.d/nexus-motd.sh diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh index f6c9bd5..6966ff3 100755 --- a/scripts/cluster/check_asterisk.sh +++ b/scripts/cluster/check_asterisk.sh @@ -3,22 +3,22 @@ # NexusPBX HA - Script de surveillance de santé d'Asterisk pour Keepalived VRRP # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" # 1. Vérifie si le binaire et le démon asterisk tournent if ! pidof asterisk >/dev/null 2>&1; then exit 1 fi -# 2. Teste si Asterisk répond aux requêtes AMI/CLI (core ping) -PING_RESP=$(asterisk -rx "core ping" 2>/dev/null || true) +# 2. Teste si Asterisk répond aux requêtes CLI (core ping) +PING_RESP=$(/usr/sbin/asterisk -rx "core ping" 2>/dev/null || true) if echo "$PING_RESP" | grep -q "Pong"; then exit 0 fi # 3. Tentative alternative via core show version -if asterisk -rx "core show version" >/dev/null 2>&1; then +if /usr/sbin/asterisk -rx "core show version" >/dev/null 2>&1; then exit 0 fi exit 1 - diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index a681889..10bb06a 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état BACKUP / STANDBY # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -15,6 +16,13 @@ if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true fi +# 2. Désenregistrement des Trunks PJSIP pour éviter les conflits d'appels entrants avec le Master +if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "pjsip send unregister *" >> "$LOG_FILE" 2>&1 || true + for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do + /usr/sbin/asterisk -rx "pjsip send unregister $reg" >> "$LOG_FILE" 2>&1 || true + done +fi + echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" exit 0 - diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh index 3406f29..a0bf969 100755 --- a/scripts/cluster/notify_fault.sh +++ b/scripts/cluster/notify_fault.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état FAULT / ERREUR # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -15,4 +16,3 @@ if [ -f "$DB_PATH" ]; then fi exit 0 - diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 84eb61f..12713c9 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état MASTER # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -17,8 +18,12 @@ fi # 2. Réactivation des enregistrements Trunks PJSIP et rechargement de la config if pidof asterisk >/dev/null 2>&1; then - asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true - asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true + /usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true + /usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true + for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do + /usr/sbin/asterisk -rx "pjsip send register $reg" >> "$LOG_FILE" 2>&1 || true + done + /usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true fi # 3. Gratuitous ARP optionnel si arping est présent @@ -30,4 +35,3 @@ fi echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" exit 0 - diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster new file mode 100755 index 0000000..86b398f --- /dev/null +++ b/scripts/nexus-cluster @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Script Helper Root pour l'orchestration Keepalived +# ============================================================================== +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" + +CMD="${1:-status}" + +case "$CMD" in + apply) + # 1. Activation kernel sysctl pour liaison IP non-locale (indispensable HA) + sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true + if [ ! -f /etc/sysctl.d/99-nexus-ha.conf ]; then + echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true + fi + + # 2. Vérification et installation éventuelle de Keepalived + if ! command -v keepalived >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq keepalived >/dev/null 2>&1 || true + fi + + # 3. Création et sécurisation stricte du dossier des scripts Keepalived (exigences strictes Keepalived 2.x) + mkdir -p /etc/keepalived/scripts + if [ -d "/var/www/nexus-pbx/scripts/cluster" ]; then + cp -f /var/www/nexus-pbx/scripts/cluster/*.sh /etc/keepalived/scripts/ 2>/dev/null || true + fi + chown -R root:root /etc/keepalived + chmod 755 /etc/keepalived + chmod 755 /etc/keepalived/scripts + chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true + + # 4. Déploiement de la configuration générée depuis /tmp + if [ -f "/tmp/keepalived.conf.tmp" ]; then + cp -f /tmp/keepalived.conf.tmp /etc/keepalived/keepalived.conf + chown root:root /etc/keepalived/keepalived.conf + chmod 644 /etc/keepalived/keepalived.conf + rm -f /tmp/keepalived.conf.tmp + fi + + # Fichier journal du cluster accessible en écriture + touch /var/log/nexus_cluster.log + chmod 666 /var/log/nexus_cluster.log + + # 5. Activation et démarrage du service + systemctl daemon-reload + systemctl unmask keepalived >/dev/null 2>&1 || true + systemctl enable keepalived >/dev/null 2>&1 || true + systemctl restart keepalived + echo "KEEPALIVED_APPLIED_OK" + ;; + + stop) + systemctl stop keepalived >/dev/null 2>&1 || true + systemctl disable keepalived >/dev/null 2>&1 || true + echo "KEEPALIVED_STOPPED_OK" + ;; + + status) + if systemctl is-active --quiet keepalived 2>/dev/null; then + echo "RUNNING" + else + echo "STOPPED" + fi + ;; + + check) + if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core ping" 2>/dev/null | grep -q "Pong"; then + echo "HEALTHY" + else + echo "DEGRADED" + fi + ;; + + *) + echo "Usage: $0 {apply|stop|status|check}" + exit 1 + ;; +esac diff --git a/src/ClusterManager.php b/src/ClusterManager.php index a027564..070bb73 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -16,6 +16,7 @@ class ClusterManager private PDO $db; public const KEEPALIVED_CONF_PATH = '/etc/keepalived/keepalived.conf'; public const CLUSTER_LOG_PATH = '/var/log/nexus_cluster.log'; + public const CLUSTER_HELPER_PATH = '/var/www/nexus-pbx/scripts/nexus-cluster'; public function __construct(?PDO $db = null) { @@ -75,6 +76,22 @@ class ClusterManager throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA."); } + // Empêcher l'utilisateur de configurer l'IP locale de ce serveur comme IP du pair + if ($peerIp !== '') { + $localInterfaces = $this->detectNetworkInterfaces(); + foreach ($localInterfaces as $ifaceInfo) { + if (($ifaceInfo['primary'] ?? '') === $peerIp) { + throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud."); + } + foreach ($ifaceInfo['ips'] ?? [] as $cidr) { + $ipOnly = explode('/', $cidr)[0] ?? ''; + if ($ipOnly === $peerIp) { + throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud."); + } + } + } + } + $stmt = $this->db->prepare(' UPDATE cluster_settings SET enabled = :enabled, @@ -106,12 +123,12 @@ class ClusterManager ':auto_sync' => $autoSync, ]); - // Application automatique de la configuration Keepalived si demandé + // Application automatique de la configuration Keepalived $applyResult = $this->applyKeepalivedConfig(); return [ 'success' => true, - 'message' => 'Configuration Haute Disponibilité enregistrée.', + 'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.', 'apply_result' => $applyResult, ]; } @@ -129,14 +146,13 @@ class ClusterManager if ($line === '') { continue; } - // Format typique ip -o: "2: eth0 inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0\..." if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) { $iface = $matches[1]; $ip = $matches[2]; $mask = $matches[3]; if ($iface === 'lo') { - continue; // Ignorer loopback + continue; } if (!isset($interfaces[$iface])) { @@ -162,9 +178,13 @@ class ClusterManager if ($vip === '') { return false; } - $escapedVip = escapeshellarg($vip); - $output = shell_exec("ip addr show 2>/dev/null | grep -F " . $escapedVip) ?: ''; - return trim($output) !== ''; + $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; + foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { + if (preg_match('/\sinet\s+' . preg_quote($vip, '/') . '\//', $line)) { + return true; + } + } + return false; } /** @@ -177,19 +197,23 @@ class ClusterManager $peerIp = $settings['peer_ip'] ?? ''; $isVipActive = $this->isVirtualIpActive($vip); - // 1. Vérification de Keepalived + // 1. Statut Keepalived $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); $keepalivedRunning = false; if ($keepalivedInstalled) { - $psOut = shell_exec('pidof keepalived 2>/dev/null') ?: ''; - $keepalivedRunning = trim($psOut) !== ''; + $helperStatus = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' status 2>/dev/null')); + if ($helperStatus === 'RUNNING' || trim((string)shell_exec('pidof keepalived 2>/dev/null')) !== '') { + $keepalivedRunning = true; + } } - // 2. Vérification d'Asterisk + // 2. Statut Asterisk $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; $asteriskPing = false; if ($asteriskRunning) { - $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: ''; + $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") + ?: shell_exec("sudo -n /usr/sbin/asterisk -rx 'core ping' 2>/dev/null") + ?: ''; $asteriskPing = str_contains($pingOut, 'Pong'); } @@ -207,7 +231,7 @@ class ClusterManager } } - // Rôle réel effectif + // 4. Rôle effectif $effectiveState = 'STANDALONE'; if (!empty($settings['enabled'])) { if ($isVipActive) { @@ -217,7 +241,7 @@ class ClusterManager } } - // Dernières lignes du log cluster + // 5. Dernières lignes de journalisation HA $recentLogs = []; if (file_exists(self::CLUSTER_LOG_PATH)) { $lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; @@ -256,10 +280,20 @@ class ClusterManager $iface = $settings['interface'] ?? 'eth0'; $vip = $settings['virtual_ip'] ?? ''; $vmask = $settings['virtual_netmask'] ?? '24'; + $peerIp = trim((string)($settings['peer_ip'] ?? '')); $vrid = (int)($settings['vrid'] ?? 51); $prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90)); $secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass + // Détection de l'IP locale sur l'interface pour unicast VRRP + $localIp = ''; + foreach ($this->detectNetworkInterfaces() as $net) { + if ($net['name'] === $iface) { + $localIp = $net['primary'] ?? ''; + break; + } + } + $conf = []; $conf[] = "! =============================================================================="; $conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA"; @@ -273,7 +307,7 @@ class ClusterManager $conf[] = "}"; $conf[] = ""; $conf[] = "vrrp_script chk_asterisk {"; - $conf[] = " script \"/var/www/nexus-pbx/scripts/cluster/check_asterisk.sh\""; + $conf[] = " script \"/etc/keepalived/scripts/check_asterisk.sh\""; $conf[] = " interval 2"; $conf[] = " weight 2"; $conf[] = " fall 2"; @@ -287,6 +321,16 @@ class ClusterManager $conf[] = " priority {$prio}"; $conf[] = " advert_int 1"; $conf[] = ""; + + // Unicast VRRP si l'IP du nœud pair est configurée (indispensable en environnement virtualisé type Proxmox/VMware) + if ($peerIp !== '' && $localIp !== '') { + $conf[] = " unicast_src_ip {$localIp}"; + $conf[] = " unicast_peer {"; + $conf[] = " {$peerIp}"; + $conf[] = " }"; + $conf[] = ""; + } + $conf[] = " authentication {"; $conf[] = " auth_type PASS"; $conf[] = " auth_pass {$secret}"; @@ -300,9 +344,9 @@ class ClusterManager $conf[] = " chk_asterisk"; $conf[] = " }"; $conf[] = ""; - $conf[] = " notify_master \"/var/www/nexus-pbx/scripts/cluster/notify_master.sh\""; - $conf[] = " notify_backup \"/var/www/nexus-pbx/scripts/cluster/notify_backup.sh\""; - $conf[] = " notify_fault \"/var/www/nexus-pbx/scripts/cluster/notify_fault.sh\""; + $conf[] = " notify_master \"/etc/keepalived/scripts/notify_master.sh\""; + $conf[] = " notify_backup \"/etc/keepalived/scripts/notify_backup.sh\""; + $conf[] = " notify_fault \"/etc/keepalived/scripts/notify_fault.sh\""; $conf[] = "}"; $conf[] = ""; @@ -310,7 +354,7 @@ class ClusterManager } /** - * Applique la configuration Keepalived sur le système Linux + * Applique la configuration Keepalived sur le système Linux via le helper root */ public function applyKeepalivedConfig(): array { @@ -318,9 +362,7 @@ class ClusterManager $enabled = !empty($settings['enabled']); if (!$enabled) { - // Désactivation du service Keepalived si le cluster est désactivé - shell_exec('systemctl stop keepalived 2>/dev/null || true'); - shell_exec('systemctl disable keepalived 2>/dev/null || true'); + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' stop 2>&1'); return [ 'status' => 'disabled', 'message' => 'Cluster HA désactivé (service Keepalived stoppé).', @@ -335,23 +377,21 @@ class ClusterManager } $confContent = $this->generateKeepalivedConfig(); - if (!is_dir('/etc/keepalived')) { - mkdir('/etc/keepalived', 0755, true); + file_put_contents('/tmp/keepalived.conf.tmp', $confContent); + chmod('/tmp/keepalived.conf.tmp', 0644); + + $out = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' apply 2>&1')); + + if (!str_contains($out, 'KEEPALIVED_APPLIED_OK')) { + return [ + 'status' => 'warning', + 'message' => 'Configuration transmise mais le démarrage a retourné : ' . ($out ?: 'Erreur inconnue'), + ]; } - file_put_contents(self::KEEPALIVED_CONF_PATH, $confContent); - chmod(self::KEEPALIVED_CONF_PATH, 0600); - - // S'assure que les scripts de cluster sont bien exécutables - shell_exec('chmod +x /var/www/nexus-pbx/scripts/cluster/*.sh 2>/dev/null || true'); - - // Activation et rechargement / démarrage de Keepalived - shell_exec('systemctl enable keepalived 2>/dev/null || true'); - shell_exec('systemctl restart keepalived 2>/dev/null || true'); - return [ 'status' => 'active', - 'message' => 'Configuration Keepalived générée et service redémarré avec succès.', + 'message' => 'Configuration Keepalived générée et service activé avec succès.', ]; } @@ -368,13 +408,24 @@ class ClusterManager throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); } - // Création du paquet de données en mémoire ou fichier temporaire + // Port d'administration HTTPS configuré + $adminPort = 8443; + try { + $st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + if (!empty($st['admin_https_port'])) { + $adminPort = (int)$st['admin_https_port']; + } + } catch (\Throwable $e) { + $adminPort = 8443; + } + + // Création de l'archive de réplication $dbPath = Database::getDatabasePath(); if (!file_exists($dbPath)) { throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } - // Forcer le checkpoint SQLite WAL pour avoir un dump 100% à jour + // Forcer le checkpoint SQLite WAL pour un dump intègre $this->db->exec('PRAGMA wal_checkpoint(FULL);'); $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; @@ -393,39 +444,63 @@ class ClusterManager @unlink($tmpArchive); $payload = [ - 'timestamp' => time(), + 'timestamp' => time(), 'archive_b64' => base64_encode($archiveData), ]; - $signature = hash_hmac('sha256', json_encode($payload), $secret); + $jsonPayload = json_encode($payload); + $signature = hash_hmac('sha256', $jsonPayload, $secret); - $ch = curl_init("http://{$peerIp}/api.php?action=cluster_receive_sync"); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-Nexus-Cluster-Signature: ' . $signature, - ]); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); + // URLs d'accès candidates vers l'API du second nœud + $candidateUrls = [ + "https://{$peerIp}:{$adminPort}/api.php?action=cluster_receive_sync", + ]; + if ($adminPort !== 443) { + $candidateUrls[] = "https://{$peerIp}/api.php?action=cluster_receive_sync"; + } + if ($adminPort !== 8443) { + $candidateUrls[] = "https://{$peerIp}:8443/api.php?action=cluster_receive_sync"; + } + $candidateUrls[] = "http://{$peerIp}/api.php?action=cluster_receive_sync"; + $candidateUrls = array_values(array_unique($candidateUrls)); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $err = curl_error($ch); - curl_close($ch); + $lastError = ''; + $success = false; - if ($httpCode !== 200 || !empty($err)) { - $errorMsg = "Échec d'envoi vers le pair {$peerIp} (HTTP {$httpCode}) : {$err}"; - $this->updateSyncStatus('error', $errorMsg); - throw new RuntimeException($errorMsg); + foreach ($candidateUrls as $targetUrl) { + $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: ' . $signature, + ]); + 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, 4); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($httpCode === 200 && !empty($response)) { + $resJson = json_decode((string)$response, true); + if (!empty($resJson['success'])) { + $success = true; + break; + } + $lastError = "Rejet par le pair ({$targetUrl}) : " . ($resJson['message'] ?? $resJson['error'] ?? 'Erreur inconnue'); + } else { + $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode})" . ($err ? " : {$err}" : ""); + } } - $resJson = json_decode((string)$response, true); - if (empty($resJson['success'])) { - $errorMsg = "Le pair a rejeté la réplication : " . ($resJson['error'] ?? 'Erreur inconnue'); - $this->updateSyncStatus('error', $errorMsg); - throw new RuntimeException($errorMsg); + if (!$success) { + $this->updateSyncStatus('error', $lastError); + throw new RuntimeException($lastError); } $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); @@ -463,13 +538,20 @@ class ClusterManager $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; file_put_contents($tmpArchive, $archiveRaw); - // Sauvegarde d'urgence de la DB actuelle avant écrasement + // Sauvegarde d'urgence de la base actuelle avant écrasement $dbPath = Database::getDatabasePath(); if (file_exists($dbPath)) { copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); } - // Fermeture de la connexion PDO pour libérer les verrous SQLite + // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master + $localClusterSettings = $settings; + $localSettingsRow = null; + try { + $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 Database::resetConnection(); // Extraction de l'archive @@ -479,18 +561,60 @@ class ClusterManager } @unlink($tmpArchive); - // Réouverture de la connexion DB et régénération des configs Asterisk + // Réouverture de la connexion DB $newDb = Database::getConnection(); $this->db = $newDb; - // Préserver le rôle local configuré (pour ne pas qu'un master force l'esclave à se croire master) - $localRole = $settings['role'] ?? 'backup'; - $newDb->exec("UPDATE cluster_settings SET role = '{$localRole}', last_sync_at = CURRENT_TIMESTAMP, last_sync_status = 'synced', last_sync_message = 'Base et sons synchronisés depuis le Master' WHERE id = 1"); + // Restauration de l'identité cluster et réseau propre à CE serveur + $updateStmt = $newDb->prepare(' + UPDATE cluster_settings SET + role = :role, + interface = :iface, + peer_ip = :peer_ip, + priority = :priority, + virtual_ip = :virtual_ip, + virtual_netmask = :virtual_netmask, + vrid = :vrid, + last_sync_at = CURRENT_TIMESTAMP, + last_sync_status = :status, + last_sync_message = :msg + WHERE id = 1 + '); + $updateStmt->execute([ + ':role' => $localClusterSettings['role'] ?? 'backup', + ':iface' => $localClusterSettings['interface'] ?? 'eth0', + ':peer_ip' => $localClusterSettings['peer_ip'] ?? '', + ':priority' => $localClusterSettings['priority'] ?? 90, + ':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '', + ':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24', + ':vrid' => $localClusterSettings['vrid'] ?? 51, + ':status' => 'synced', + ':msg' => 'Base et sons synchronisés depuis le Master', + ]); + + if ($localSettingsRow) { + $newDb->prepare(' + UPDATE settings SET + local_net = :net, + local_networks_json = :nets, + admin_https_port = :port + WHERE id = 1 + ')->execute([ + ':net' => $localSettingsRow['local_net'] ?? '192.168.1.0/24', + ':nets' => $localSettingsRow['local_networks_json'] ?? '["192.168.1.0/24"]', + ':port' => $localSettingsRow['admin_https_port'] ?? 8443, + ]); + } // Régénérer les configurations PJSIP et Asterisk $generator = new ConfigGenerator($newDb); $generator->generateAndApply(); + // Si le cluster est actif, actualiser le service Keepalived + if (!empty($localClusterSettings['enabled'])) { + $this->applyKeepalivedConfig(); + } + return [ 'success' => true, 'message' => 'Base de données et fichiers audio appliqués avec succès.', @@ -525,4 +649,3 @@ class ClusterManager $stmt->execute([':status' => $status, ':msg' => $message]); } } - From b76490a4dca9dcb7c5175b94505c3fa8050c6ce7 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:35:10 +0100 Subject: [PATCH 05/37] fix(cluster): support custom peer port, fix netmask & vrid persistence, and fix static call --- public/assets/app.js | 10 +++++ public/index.php | 11 +++-- src/ClusterManager.php | 97 ++++++++++++++++++++++++++++++------------ src/Database.php | 7 +++ 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/public/assets/app.js b/public/assets/app.js index 277a3e5..7525941 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -6376,12 +6376,21 @@ async function refreshClusterStatus() { const vipInput = document.getElementById('cluster-virtual-ip'); if (vipInput) vipInput.value = st.virtual_ip || ''; + const vmaskInput = document.getElementById('cluster-virtual-netmask'); + if (vmaskInput && st.virtual_netmask) vmaskInput.value = st.virtual_netmask; + const vridInput = document.getElementById('cluster-vrid'); if (vridInput && st.vrid) vridInput.value = st.vrid; const peerIpInput = document.getElementById('cluster-peer-ip'); if (peerIpInput) peerIpInput.value = st.peer_ip || ''; + const peerPortInput = document.getElementById('cluster-peer-port'); + if (peerPortInput && st.peer_port) peerPortInput.value = st.peer_port; + + const autoSyncChk = document.getElementById('cluster-auto-sync'); + if (autoSyncChk && st.auto_sync !== undefined) autoSyncChk.checked = Boolean(st.auto_sync); + const secretInput = document.getElementById('cluster-secret'); if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret; @@ -6572,6 +6581,7 @@ async function saveClusterSettings(e) { virtual_ip: form.elements['virtual_ip'].value.trim(), virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24', peer_ip: form.elements['peer_ip'].value.trim(), + peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443, cluster_secret: form.elements['cluster_secret'].value.trim(), vrid: parseInt(form.elements['vrid'].value, 10) || 51, auto_sync: form.elements['auto_sync'].checked ? 1 : 0 diff --git a/public/index.php b/public/index.php index a18378a..37f905c 100644 --- a/public/index.php +++ b/public/index.php @@ -2917,9 +2917,14 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
- - + +
+ + : + +
diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 070bb73..4de3f50 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -38,6 +38,7 @@ class ClusterManager 'virtual_netmask' => '24', 'interface' => 'eth0', 'peer_ip' => '', + 'peer_port' => 8443, 'cluster_secret' => bin2hex(random_bytes(16)), 'vrid' => 51, 'priority' => 100, @@ -48,6 +49,9 @@ class ClusterManager 'last_sync_message' => '', ]; } + if (empty($row['peer_port'])) { + $row['peer_port'] = 8443; + } return $row; } @@ -61,13 +65,35 @@ class ClusterManager $virtualIp = trim((string)($data['virtual_ip'] ?? '')); $virtualNetmask = preg_replace('/[^0-9]/', '', (string)($data['virtual_netmask'] ?? '24')) ?: '24'; $interface = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', trim((string)($data['interface'] ?? 'eth0'))) ?: 'eth0'; - $peerIp = trim((string)($data['peer_ip'] ?? '')); + $peerIpRaw = trim((string)($data['peer_ip'] ?? '')); + $peerPort = (int)($data['peer_port'] ?? 0); $clusterSecret = trim((string)($data['cluster_secret'] ?? '')); $vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); $autoSync = !empty($data['auto_sync']) ? 1 : 0; + // Détection et parsing si le port est saisi dans l'IP (ex: 10.10.11.197:2356) + if (str_contains($peerIpRaw, ':')) { + $parts = explode(':', $peerIpRaw, 2); + $peerIp = trim($parts[0]); + $parsedPort = (int)trim($parts[1]); + if ($parsedPort > 0 && $parsedPort <= 65535) { + $peerPort = $parsedPort; + } + } else { + $peerIp = $peerIpRaw; + } + + if ($peerPort <= 0 || $peerPort > 65535) { + try { + $st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + $peerPort = (int)($st['admin_https_port'] ?? 8443); + } catch (\Throwable $e) { + $peerPort = 8443; + } + } + if ($clusterSecret === '') { $clusterSecret = bin2hex(random_bytes(16)); } @@ -94,18 +120,19 @@ class ClusterManager $stmt = $this->db->prepare(' UPDATE cluster_settings SET - enabled = :enabled, - role = :role, - virtual_ip = :vip, + enabled = :enabled, + role = :role, + virtual_ip = :vip, virtual_netmask = :vmask, - interface = :iface, - peer_ip = :peer, - cluster_secret = :secret, - vrid = :vrid, - priority = :prio, - sync_interval = :sync_int, - auto_sync = :auto_sync, - updated_at = CURRENT_TIMESTAMP + interface = :iface, + peer_ip = :peer, + peer_port = :peer_port, + cluster_secret = :secret, + vrid = :vrid, + priority = :prio, + sync_interval = :sync_int, + auto_sync = :auto_sync, + updated_at = CURRENT_TIMESTAMP WHERE id = 1 '); @@ -116,6 +143,7 @@ class ClusterManager ':vmask' => $virtualNetmask, ':iface' => $interface, ':peer' => $peerIp, + ':peer_port' => $peerPort, ':secret' => $clusterSecret, ':vrid' => $vrid, ':prio' => $priority, @@ -253,6 +281,11 @@ class ClusterManager 'configured_role' => $settings['role'], 'effective_state' => $effectiveState, 'virtual_ip' => $vip, + 'virtual_netmask' => $settings['virtual_netmask'] ?? '24', + 'vrid' => (int)($settings['vrid'] ?? 51), + 'priority' => (int)($settings['priority'] ?? 100), + 'peer_port' => (int)($settings['peer_port'] ?? 8443), + 'auto_sync' => (bool)($settings['auto_sync'] ?? 1), 'virtual_ip_active' => $isVipActive, 'interface' => $settings['interface'], 'peer_ip' => $peerIp, @@ -402,22 +435,24 @@ class ClusterManager { $settings = $this->getSettings(); $peerIp = trim((string)($settings['peer_ip'] ?? '')); + $peerPort = (int)($settings['peer_port'] ?? 8443); + if ($peerPort <= 0 || $peerPort > 65535) { + $peerPort = 8443; + } $secret = trim((string)($settings['cluster_secret'] ?? '')); if ($peerIp === '') { throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); } - // Port d'administration HTTPS configuré - $adminPort = 8443; + // Port d'administration HTTPS local configuré pour éventuel fallback + $localAdminPort = 8443; try { $st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); if (!empty($st['admin_https_port'])) { - $adminPort = (int)$st['admin_https_port']; + $localAdminPort = (int)$st['admin_https_port']; } - } catch (\Throwable $e) { - $adminPort = 8443; - } + } catch (\Throwable $e) {} // Création de l'archive de réplication $dbPath = Database::getDatabasePath(); @@ -451,16 +486,19 @@ class ClusterManager $jsonPayload = json_encode($payload); $signature = hash_hmac('sha256', $jsonPayload, $secret); - // URLs d'accès candidates vers l'API du second nœud + // URLs d'accès candidates vers l'API du second nœud (priorité au port configuré) $candidateUrls = [ - "https://{$peerIp}:{$adminPort}/api.php?action=cluster_receive_sync", + "https://{$peerIp}:{$peerPort}/api.php?action=cluster_receive_sync", ]; - if ($adminPort !== 443) { - $candidateUrls[] = "https://{$peerIp}/api.php?action=cluster_receive_sync"; + if ($peerPort !== $localAdminPort) { + $candidateUrls[] = "https://{$peerIp}:{$localAdminPort}/api.php?action=cluster_receive_sync"; } - if ($adminPort !== 8443) { + if ($peerPort !== 8443 && $localAdminPort !== 8443) { $candidateUrls[] = "https://{$peerIp}:8443/api.php?action=cluster_receive_sync"; } + if ($peerPort !== 443 && $localAdminPort !== 443) { + $candidateUrls[] = "https://{$peerIp}/api.php?action=cluster_receive_sync"; + } $candidateUrls[] = "http://{$peerIp}/api.php?action=cluster_receive_sync"; $candidateUrls = array_values(array_unique($candidateUrls)); @@ -494,7 +532,9 @@ class ClusterManager } $lastError = "Rejet par le pair ({$targetUrl}) : " . ($resJson['message'] ?? $resJson['error'] ?? 'Erreur inconnue'); } else { - $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode})" . ($err ? " : {$err}" : ""); + $resJson = json_decode((string)$response, true); + $detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : ""); + $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}"; } } @@ -503,11 +543,11 @@ class ClusterManager throw new RuntimeException($lastError); } - $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); + $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}:{$peerPort}"); return [ 'success' => true, - 'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp} avec succès.", + 'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp}:{$peerPort} avec succès.", ]; } @@ -571,6 +611,7 @@ class ClusterManager role = :role, interface = :iface, peer_ip = :peer_ip, + peer_port = :peer_port, priority = :priority, virtual_ip = :virtual_ip, virtual_netmask = :virtual_netmask, @@ -584,6 +625,7 @@ class ClusterManager ':role' => $localClusterSettings['role'] ?? 'backup', ':iface' => $localClusterSettings['interface'] ?? 'eth0', ':peer_ip' => $localClusterSettings['peer_ip'] ?? '', + ':peer_port' => (int)($localClusterSettings['peer_port'] ?? 8443), ':priority' => $localClusterSettings['priority'] ?? 90, ':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '', ':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24', @@ -607,8 +649,7 @@ class ClusterManager } // Régénérer les configurations PJSIP et Asterisk - $generator = new ConfigGenerator($newDb); - $generator->generateAndApply(); + ConfigGenerator::generateAndApply($newDb); // Si le cluster est actif, actualiser le service Keepalived if (!empty($localClusterSettings['enabled'])) { diff --git a/src/Database.php b/src/Database.php index a9d5b8d..a13fed0 100644 --- a/src/Database.php +++ b/src/Database.php @@ -317,6 +317,7 @@ class Database virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', interface VARCHAR(32) NOT NULL DEFAULT 'eth0', peer_ip VARCHAR(45) NOT NULL DEFAULT '', + peer_port INTEGER NOT NULL DEFAULT 8443, cluster_secret VARCHAR(64) NOT NULL DEFAULT '', vrid INTEGER NOT NULL DEFAULT 51, priority INTEGER NOT NULL DEFAULT 100, @@ -784,6 +785,7 @@ class Database virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', interface VARCHAR(32) NOT NULL DEFAULT 'eth0', peer_ip VARCHAR(45) NOT NULL DEFAULT '', + peer_port INTEGER NOT NULL DEFAULT 8443, cluster_secret VARCHAR(64) NOT NULL DEFAULT '', vrid INTEGER NOT NULL DEFAULT 51, priority INTEGER NOT NULL DEFAULT 100, @@ -797,6 +799,11 @@ class Database ); "); + // Migration de la colonne peer_port si elle n'existe pas encore + try { + $db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443"); + } catch (\Throwable $e) {} + $count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn(); if ($count === 0) { $defaultSecret = bin2hex(random_bytes(16)); From 940ff800adc52e4f4afd32106e20d1c8f6d6695b Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:37:43 +0100 Subject: [PATCH 06/37] fix(cluster): update Asterisk CLI health check command to core show uptime --- scripts/cluster/check_asterisk.sh | 6 +++--- scripts/nexus-cluster | 2 +- src/ClusterManager.php | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh index 6966ff3..86ac16a 100755 --- a/scripts/cluster/check_asterisk.sh +++ b/scripts/cluster/check_asterisk.sh @@ -10,9 +10,9 @@ if ! pidof asterisk >/dev/null 2>&1; then exit 1 fi -# 2. Teste si Asterisk répond aux requêtes CLI (core ping) -PING_RESP=$(/usr/sbin/asterisk -rx "core ping" 2>/dev/null || true) -if echo "$PING_RESP" | grep -q "Pong"; then +# 2. Teste si Asterisk répond aux requêtes CLI (core show uptime) +RESP=$(/usr/sbin/asterisk -rx "core show uptime" 2>/dev/null || true) +if echo "$RESP" | grep -qi "uptime"; then exit 0 fi diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index 86b398f..306d735 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -65,7 +65,7 @@ case "$CMD" in ;; check) - if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core ping" 2>/dev/null | grep -q "Pong"; then + if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core show uptime" 2>/dev/null | grep -qi "uptime"; then echo "HEALTHY" else echo "DEGRADED" diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 4de3f50..fbadf7d 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -239,10 +239,10 @@ class ClusterManager $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; $asteriskPing = false; if ($asteriskRunning) { - $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") - ?: shell_exec("sudo -n /usr/sbin/asterisk -rx 'core ping' 2>/dev/null") - ?: ''; - $asteriskPing = str_contains($pingOut, 'Pong'); + $cliOut = shell_exec("sudo -n /usr/sbin/asterisk -rx 'core show uptime' 2>/dev/null") + ?: shell_exec("/usr/sbin/asterisk -rx 'core show uptime' 2>/dev/null") + ?: ''; + $asteriskPing = str_contains($cliOut, 'uptime') || str_contains($cliOut, 'System'); } // 3. Test de connectivité vers le nœud pair (Peer Ping) From 3edcabff7c1ed4e18689b979739741dd5e6ff299 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:38:35 +0100 Subject: [PATCH 07/37] fix(cluster): set keepalived vrrp_script weight to -20 for instant failover --- src/ClusterManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ClusterManager.php b/src/ClusterManager.php index fbadf7d..5e77e93 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -342,7 +342,7 @@ class ClusterManager $conf[] = "vrrp_script chk_asterisk {"; $conf[] = " script \"/etc/keepalived/scripts/check_asterisk.sh\""; $conf[] = " interval 2"; - $conf[] = " weight 2"; + $conf[] = " weight -20"; $conf[] = " fall 2"; $conf[] = " rise 2"; $conf[] = "}"; From 4c49cf8e1c60bbf26fdd8192aeb4d6fdd00e74bf Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:55:15 +0100 Subject: [PATCH 08/37] feat(installer): integrate full HA Keepalived setup, sysctl, sudoers and cluster helper into install.sh --- install.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/install.sh b/install.sh index fb07033..1361e5e 100755 --- a/install.sh +++ b/install.sh @@ -66,6 +66,8 @@ apt-get install -y --no-install-recommends \ # Composants optionnels Haute Disponibilité (Cluster HA) apt-get install -y --no-install-recommends keepalived 2>/dev/null || true +systemctl stop keepalived >/dev/null 2>&1 || true +systemctl disable keepalived >/dev/null 2>&1 || true # Vérification et installation d'Asterisk echo -e "${C_GREEN} -> Installation du moteur Asterisk PBX...${C_RESET}" @@ -494,6 +496,14 @@ ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner ln -sf "$TARGET_DIR/scripts/update.sh" /usr/local/bin/nexus-update ln -sf "$TARGET_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster +# Pré-déploiement sécurisé des scripts Keepalived +if [ -d "$TARGET_DIR/scripts/cluster" ]; then + cp -f "$TARGET_DIR/scripts/cluster/"*.sh /etc/keepalived/scripts/ 2>/dev/null || true + chown -R root:root /etc/keepalived + chmod 755 /etc/keepalived /etc/keepalived/scripts + chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true +fi + # Bannière interactive lors de chaque connexion terminal (SSH / Console) cat << 'EOF' > /etc/profile.d/nexus-motd.sh #!/bin/sh From 3f29ac6d2e260546571fb27822354b5750b099aa Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:58:58 +0100 Subject: [PATCH 09/37] feat(cluster): real-time auto-sync on apply_config + 5min cron periodic sync --- install.sh | 7 ++++++ public/api.php | 21 +++++++++++++--- scripts/nexus_cluster_sync.php | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 scripts/nexus_cluster_sync.php diff --git a/install.sh b/install.sh index 1361e5e..c5bfd8b 100755 --- a/install.sh +++ b/install.sh @@ -488,6 +488,13 @@ EOF chmod 0644 /etc/cron.d/nexus-backup touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log +# Cron synchronisation Cluster HA toutes les 5 minutes +cat << 'EOF' > /etc/cron.d/nexus-cluster-sync +# Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup +*/5 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1 +EOF +chmod 0644 /etc/cron.d/nexus-cluster-sync + # CLI symlinks chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" "$TARGET_DIR/scripts/nexus-cluster" chmod +x "$TARGET_DIR"/scripts/cluster/*.sh 2>/dev/null || true diff --git a/public/api.php b/public/api.php index 6210192..8dd542b 100644 --- a/public/api.php +++ b/public/api.php @@ -1793,12 +1793,27 @@ try { $ami = AmiClient::fromDatabase($db); $reloadInfo = $ami->reloadPjsipAndDialplan(); + // Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master + $clusterSyncMsg = null; + try { + $cm = new ClusterManager($db); + $cSet = $cm->getSettings(); + if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && ($cSet['role'] ?? '') === 'master' && !empty($cSet['peer_ip'])) { + $syncRes = $cm->pushSyncToPeer(); + $clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK'; + } + } catch (\Throwable $ce) { + error_log("[CLUSTER] Auto-sync on apply_config error: " . $ce->getMessage()); + $clusterSyncMsg = "Alerte réplication pair : " . $ce->getMessage(); + } + echo json_encode([ 'success' => true, - 'message' => 'Configurations écrites, pile PJSIP rechargée et Re-Register SIP envoyé à tous les opérateurs !', + 'message' => 'Configurations appliquées avec succès !' . ($clusterSyncMsg ? ' (' . $clusterSyncMsg . ')' : ''), 'details' => [ - 'files' => $writeStats, - 'reload' => $reloadInfo, + 'files' => $writeStats, + 'reload' => $reloadInfo, + 'cluster_sync' => $clusterSyncMsg, ], ]); break; diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php new file mode 100644 index 0000000..af9a014 --- /dev/null +++ b/scripts/nexus_cluster_sync.php @@ -0,0 +1,46 @@ +getSettings(); + + // 1. Si le cluster n'est pas activé ou que l'auto-sync est désactivée + if (empty($st['enabled']) || empty($st['auto_sync'])) { + exit(0); + } + + // 2. Seul le nœud MASTER actif émet la synchronisation vers le nœud BACKUP + $vip = $st['virtual_ip'] ?? ''; + $isVipActive = $cm->isVirtualIpActive($vip); + $configuredRole = $st['role'] ?? 'master'; + + // Un esclave (backup) ne doit jamais écraser le maître + if ($configuredRole !== 'master' && !$isVipActive) { + exit(0); + } + + if (empty($st['peer_ip'])) { + exit(0); + } + + // 3. Déclenchement de la réplication vers le pair + $result = $cm->pushSyncToPeer(); + echo date('[Y-m-d H:i:s] ') . "[CLUSTER-CRON] " . ($result['message'] ?? 'Synchronisation OK') . "\n"; +} catch (\Throwable $e) { + echo date('[Y-m-d H:i:s] ') . "[CLUSTER-CRON-ERROR] " . $e->getMessage() . "\n"; + exit(1); +} From c9ce4d7c748903dd81abe5094c04564581d32a59 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 22:54:43 +0100 Subject: [PATCH 10/37] feat(cluster): add Asterisk astdb.sqlite3 live contacts sync every 2min --- install.sh | 4 ++-- scripts/nexus_cluster_sync.php | 1 + src/ClusterManager.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index c5bfd8b..1351280 100755 --- a/install.sh +++ b/install.sh @@ -488,10 +488,10 @@ EOF chmod 0644 /etc/cron.d/nexus-backup touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log -# Cron synchronisation Cluster HA toutes les 5 minutes +# Cron synchronisation Cluster HA toutes les 2 minutes (synchronisation base + astdb contacts) cat << 'EOF' > /etc/cron.d/nexus-cluster-sync # Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup -*/5 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1 +*/2 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1 EOF chmod 0644 /etc/cron.d/nexus-cluster-sync diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php index af9a014..7a64955 100644 --- a/scripts/nexus_cluster_sync.php +++ b/scripts/nexus_cluster_sync.php @@ -44,3 +44,4 @@ try { echo date('[Y-m-d H:i:s] ') . "[CLUSTER-CRON-ERROR] " . $e->getMessage() . "\n"; exit(1); } + diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 5e77e93..462b7cc 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -464,11 +464,30 @@ class ClusterManager $this->db->exec('PRAGMA wal_checkpoint(FULL);'); $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; + $tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3'; + $hasAstdb = false; + + // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) + $astdbPath = '/var/lib/asterisk/astdb.sqlite3'; + if (file_exists($astdbPath) && is_readable($astdbPath)) { + @unlink($tmpAstdb); + shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); + if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { + $hasAstdb = true; + } + } + $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite"; if (is_dir('/var/lib/asterisk/sounds/custom')) { $cmd .= " -C /var/lib/asterisk/sounds custom"; } + if ($hasAstdb) { + $cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; + } shell_exec($cmd); + if ($hasAstdb) { + @unlink($tmpAstdb); + } if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { @unlink($tmpArchive); @@ -599,6 +618,15 @@ class ClusterManager 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')) { + copy('/tmp/astdb_cluster_sync.sqlite3', '/var/lib/asterisk/astdb.sqlite3'); + @chown('/var/lib/asterisk/astdb.sqlite3', 'asterisk'); + @chgrp('/var/lib/asterisk/astdb.sqlite3', 'asterisk'); + @chmod('/var/lib/asterisk/astdb.sqlite3', 0664); + @unlink('/tmp/astdb_cluster_sync.sqlite3'); + } @unlink($tmpArchive); // Réouverture de la connexion DB From 062495a1623c802be3e243ab95dc232ba2fb3c3b Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 22:57:44 +0100 Subject: [PATCH 11/37] chore: ignore database .bak files in storage --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4c0f15d..29ced2f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /storage/*.sqlite-shm /storage/*.sqlite-wal /storage/*.prerestore.* +/storage/*.bak +*.bak # Données dynamiques et métriques /storage/metrics_prev.json From a537fc64697c618248f9c6487db9f35f5df0b3ee Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:28:53 +0100 Subject: [PATCH 12/37] fix(cluster): ensure AstDB transactional sync, auto-healing, and Gratuitous ARP on master take-over --- install.sh | 2 +- scripts/cluster/notify_master.sh | 25 ++++++++++--- scripts/nexus-cluster | 60 ++++++++++++++++++++++++++++++-- src/ClusterManager.php | 17 ++++++--- 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/install.sh b/install.sh index 1351280..92aafed 100755 --- a/install.sh +++ b/install.sh @@ -65,7 +65,7 @@ apt-get install -y --no-install-recommends \ sshpass # Composants optionnels Haute Disponibilité (Cluster HA) -apt-get install -y --no-install-recommends keepalived 2>/dev/null || true +apt-get install -y --no-install-recommends keepalived iputils-arping 2>/dev/null || true systemctl stop keepalived >/dev/null 2>&1 || true systemctl disable keepalived >/dev/null 2>&1 || true diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 12713c9..3a0bc68 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -12,11 +12,26 @@ echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<< # 1. Mise à jour de l'état en base de données locale si SQLite disponible DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +ASTDB_PATH="/var/lib/asterisk/astdb.sqlite3" + if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='master_active', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true fi -# 2. Réactivation des enregistrements Trunks PJSIP et rechargement de la config +# 2. Vérification et réparation d'intégrité de la base SQLite AstDB (contacts téléphones) +if [ -f "$ASTDB_PATH" ]; then + INTEGRITY=$(sqlite3 "$ASTDB_PATH" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt") + if [ "$INTEGRITY" != "ok" ]; then + echo "[$TIMESTAMP] [CLUSTER] ALERTE: astdb.sqlite3 corrompu ($INTEGRITY). Auto-réparation immédiate..." >> "$LOG_FILE" + mv -f "$ASTDB_PATH" "${ASTDB_PATH}.corrupt.$(date +%s)" 2>/dev/null || true + sqlite3 "$ASTDB_PATH" "CREATE TABLE astdb(key TEXT, value TEXT, PRIMARY KEY(key));" 2>/dev/null || true + chown asterisk:asterisk "$ASTDB_PATH" 2>/dev/null || true + chmod 664 "$ASTDB_PATH" 2>/dev/null || true + echo "[$TIMESTAMP] [CLUSTER] astdb.sqlite3 recréé proprement." >> "$LOG_FILE" + fi +fi + +# 3. Réactivation des enregistrements Trunks PJSIP et rechargement de la config if pidof asterisk >/dev/null 2>&1; then /usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true @@ -26,11 +41,13 @@ if pidof asterisk >/dev/null 2>&1; then /usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true fi -# 3. Gratuitous ARP optionnel si arping est présent +# 4. Gratuitous ARP pour forcer la mise à jour immédiate des tables ARP des switches et téléphones SIP VIP=$(sqlite3 "$DB_PATH" "SELECT virtual_ip FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "") IFACE=$(sqlite3 "$DB_PATH" "SELECT interface FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "eth0") -if [ -n "$VIP" ] && command -v arping >/dev/null 2>&1; then - arping -c 3 -U -I "$IFACE" "$VIP" >> "$LOG_FILE" 2>&1 || true +VIP_CLEAN=$(echo "$VIP" | cut -d'/' -f1) +if [ -n "$VIP_CLEAN" ] && command -v arping >/dev/null 2>&1; then + echo "[$TIMESTAMP] [CLUSTER] Émission Gratuitous ARP pour $VIP_CLEAN sur $IFACE..." >> "$LOG_FILE" + arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true fi echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index 306d735..ac0eb49 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -15,9 +15,9 @@ case "$CMD" in echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true fi - # 2. Vérification et installation éventuelle de Keepalived - if ! command -v keepalived >/dev/null 2>&1; then - apt-get update -qq && apt-get install -y -qq keepalived >/dev/null 2>&1 || true + # 2. Vérification et installation éventuelle de Keepalived et arping + if ! command -v keepalived >/dev/null 2>&1 || ! command -v arping >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq keepalived iputils-arping >/dev/null 2>&1 || true fi # 3. Création et sécurisation stricte du dossier des scripts Keepalived (exigences strictes Keepalived 2.x) @@ -64,6 +64,60 @@ case "$CMD" in fi ;; + restore-astdb) + SRC="${2:-/tmp/astdb_cluster_sync.sqlite3}" + DEST="/var/lib/asterisk/astdb.sqlite3" + if [ ! -f "$SRC" ] || [ ! -s "$SRC" ]; then + echo "ASTDB_RESTORE_NO_FILE" + exit 0 + fi + + # Vérification intégrité de la source + SRC_INTEGRITY=$(sqlite3 "$SRC" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt") + if [ "$SRC_INTEGRITY" != "ok" ]; then + echo "ASTDB_SOURCE_CORRUPT: $SRC_INTEGRITY" + rm -f "$SRC" + 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 + + chown asterisk:asterisk "$DEST" 2>/dev/null || true + chmod 664 "$DEST" 2>/dev/null || true + rm -f "$SRC" + echo "ASTDB_RESTORED_OK" + ;; + + fix-astdb) + DEST="/var/lib/asterisk/astdb.sqlite3" + if [ -f "$DEST" ]; then + INTEGRITY=$(sqlite3 "$DEST" "PRAGMA integrity_check;" 2>/dev/null || echo "corrupt") + if [ "$INTEGRITY" != "ok" ]; then + mv -f "$DEST" "${DEST}.corrupt.$(date +%s)" 2>/dev/null || true + sqlite3 "$DEST" "CREATE TABLE astdb(key TEXT, value TEXT, PRIMARY KEY(key));" 2>/dev/null || true + chown asterisk:asterisk "$DEST" 2>/dev/null || true + chmod 664 "$DEST" 2>/dev/null || true + if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "pjsip reload" 2>/dev/null || true + fi + echo "ASTDB_FIXED" + exit 0 + fi + fi + echo "ASTDB_OK" + ;; + check) if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core show uptime" 2>/dev/null | grep -qi "uptime"; then echo "HEALTHY" diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 462b7cc..8a9d782 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -353,6 +353,9 @@ class ClusterManager $conf[] = " virtual_router_id {$vrid}"; $conf[] = " priority {$prio}"; $conf[] = " advert_int 1"; + $conf[] = " garp_master_delay 1"; + $conf[] = " garp_master_repeat 5"; + $conf[] = " garp_master_refresh 5"; $conf[] = ""; // Unicast VRRP si l'IP du nœud pair est configurée (indispensable en environnement virtualisé type Proxmox/VMware) @@ -473,7 +476,12 @@ class ClusterManager @unlink($tmpAstdb); shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { - $hasAstdb = true; + $integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); + if ($integrity === 'ok') { + $hasAstdb = true; + } else { + @unlink($tmpAstdb); + } } } @@ -621,10 +629,9 @@ class ClusterManager // 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')) { - copy('/tmp/astdb_cluster_sync.sqlite3', '/var/lib/asterisk/astdb.sqlite3'); - @chown('/var/lib/asterisk/astdb.sqlite3', 'asterisk'); - @chgrp('/var/lib/asterisk/astdb.sqlite3', 'asterisk'); - @chmod('/var/lib/asterisk/astdb.sqlite3', 0664); + // 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'); } @unlink($tmpArchive); From 0b740da87e090c67117b296925dc38d024b83527 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:38:03 +0100 Subject: [PATCH 13/37] feat(cluster): production-grade atomic VACUUM replication, pre-validation integrity check, Asterisk database query injection, and live reload --- scripts/nexus-cluster | 26 ++++++----- src/ClusterManager.php | 101 ++++++++++++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index ac0eb49..619121e 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -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 + 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 - chown asterisk:asterisk "$DEST" 2>/dev/null || true - chmod 664 "$DEST" 2>/dev/null || true rm -f "$SRC" echo "ASTDB_RESTORED_OK" ;; diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 8a9d782..52341a6 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -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 - $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + // 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(); From 0b654c885c1973f402e68e0f0045c7eabc7aecb6 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:41:54 +0100 Subject: [PATCH 14/37] fix(cluster): ensure only VIP holder pushes sync and prevent deadlocks with flock and strict VIP check --- public/api.php | 3 ++- scripts/nexus_cluster_sync.php | 15 +++++++++------ src/ClusterManager.php | 5 +++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/public/api.php b/public/api.php index 8dd542b..41386b4 100644 --- a/public/api.php +++ b/public/api.php @@ -1798,7 +1798,8 @@ try { try { $cm = new ClusterManager($db); $cSet = $cm->getSettings(); - if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && ($cSet['role'] ?? '') === 'master' && !empty($cSet['peer_ip'])) { + $isVipActive = $cm->isVirtualIpActive($cSet['virtual_ip'] ?? ''); + if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && $isVipActive && !empty($cSet['peer_ip'])) { $syncRes = $cm->pushSyncToPeer(); $clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK'; } diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php index 7a64955..1f4d72b 100644 --- a/scripts/nexus_cluster_sync.php +++ b/scripts/nexus_cluster_sync.php @@ -14,6 +14,12 @@ use App\Database; use App\ClusterManager; try { + // 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)) { + exit(0); + } + $db = Database::getConnection(); $cm = new ClusterManager($db); $st = $cm->getSettings(); @@ -23,13 +29,10 @@ try { exit(0); } - // 2. Seul le nœud MASTER actif émet la synchronisation vers le nœud BACKUP + // 2. Règle d'or HA : SEUL le nœud qui détient l'IP Virtuelle active (VIP) émet la réplication + // Un nœud passif/standby ne doit JAMAIS écraser le nœud actif ni émettre en boucle croisée $vip = $st['virtual_ip'] ?? ''; - $isVipActive = $cm->isVirtualIpActive($vip); - $configuredRole = $st['role'] ?? 'master'; - - // Un esclave (backup) ne doit jamais écraser le maître - if ($configuredRole !== 'master' && !$isVipActive) { + if (empty($vip) || !$cm->isVirtualIpActive($vip)) { exit(0); } diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 52341a6..c1ce44c 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -203,12 +203,13 @@ class ClusterManager */ public function isVirtualIpActive(string $vip): bool { - if ($vip === '') { + $cleanVip = explode('/', trim($vip))[0] ?? ''; + if ($cleanVip === '') { return false; } $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { - if (preg_match('/\sinet\s+' . preg_quote($vip, '/') . '\//', $line)) { + if (preg_match('/\sinet\s+' . preg_quote($cleanVip, '/') . '(\/|\s)/', $line)) { return true; } } From c93b52d99cf8d7f37732f077280cd01a2e005792 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:46:02 +0100 Subject: [PATCH 15/37] perf(core): eliminate PHP session file lock contention on API/metrics, tune PHP-FPM pool, and remove redundant Keepalived restarts on sync --- install.sh | 10 ++++++++++ public/api.php | 27 +++++++++++++++++---------- src/ClusterManager.php | 12 ++++++++---- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/install.sh b/install.sh index 92aafed..e13201c 100755 --- a/install.sh +++ b/install.sh @@ -524,6 +524,16 @@ chmod +x /etc/profile.d/nexus-motd.sh echo -e "${C_GREEN}[8/8] Démarrage et activation des services système...${C_RESET}" systemctl daemon-reload +# Optimisation de la réserve de processus PHP-FPM pour supporter la télémétrie temps réel et le cluster HA +for pool in /etc/php/*/fpm/pool.d/www.conf; do + if [ -f "$pool" ]; then + sed -i 's/^pm.max_children = .*/pm.max_children = 25/' "$pool" + sed -i 's/^pm.start_servers = .*/pm.start_servers = 4/' "$pool" + sed -i 's/^pm.min_spare_servers = .*/pm.min_spare_servers = 2/' "$pool" + sed -i 's/^pm.max_spare_servers = .*/pm.max_spare_servers = 6/' "$pool" + fi +done + # Redémarrage PHP-FPM (rechargement de l'appartenance au groupe asterisk) systemctl restart php*-fpm 2>/dev/null || true PHP_SVC=$(systemctl list-unit-files --type=service 2>/dev/null | grep -E "php.*-fpm" | awk '{print $1}' | head -n 1 || echo "php8.4-fpm") diff --git a/public/api.php b/public/api.php index 41386b4..763414b 100644 --- a/public/api.php +++ b/public/api.php @@ -42,7 +42,6 @@ header('Cache-Control: no-store, no-cache, must-revalidate'); try { $db = Database::getConnection(); - Auth::startSession(); $action = trim((string)($_GET['action'] ?? '')); $rawBody = file_get_contents('php://input'); @@ -51,6 +50,23 @@ try { $payload = $_POST; } + // Action inter-serveurs réplication Cluster (authentifiée par signature HMAC - aucune session requise) + if ($action === 'cluster_receive_sync') { + $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? ''; + $cm = new ClusterManager($db); + $res = $cm->receiveSyncPayload($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 + // Cela évite de saturer les workers PHP-FPM par des verrous de fichiers sessions concurrents + if (session_status() === PHP_SESSION_ACTIVE && ($action === 'get_metrics' || $action === 'cluster_status' || $action === 'get_system_info')) { + session_write_close(); + } + // Actions publiques (Login / Logout) if ($action === 'login') { $username = trim((string)($payload['username'] ?? '')); @@ -86,15 +102,6 @@ try { exit; } - // Action inter-serveurs réplication Cluster (authentifiée par signature HMAC) - if ($action === 'cluster_receive_sync') { - $rawBody = file_get_contents('php://input') ?: ''; - $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? ''; - $cm = new ClusterManager($db); - $res = $cm->receiveSyncPayload($rawBody, $signature); - echo json_encode($res); - exit; - } // Toutes les autres actions requièrent une session authentifiée if (!Auth::isAuthenticated()) { diff --git a/src/ClusterManager.php b/src/ClusterManager.php index c1ce44c..7341e71 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -564,13 +564,14 @@ class ClusterManager curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', + 'Expect:', // Désactive l'en-tête Expect: 100-continue pour éviter les blocages intermédiaires 'X-Nexus-Cluster-Signature: ' . $signature, ]); 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, 4); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); $response = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -754,9 +755,12 @@ class ClusterManager shell_exec('/usr/sbin/asterisk -rx "pjsip send unregister *" 2>/dev/null || true'); } - // Si le cluster est actif, actualiser le service Keepalived + // Si le cluster est activé mais que Keepalived n'est pas encore démarré, le démarrer if (!empty($localClusterSettings['enabled'])) { - $this->applyKeepalivedConfig(); + $keepalivedRunning = trim((string)shell_exec('pidof keepalived 2>/dev/null')) !== ''; + if (!$keepalivedRunning) { + $this->applyKeepalivedConfig(); + } } return [ From d2e3be335634901bb7bff204a070840d78fb2a80 Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 23:48:53 +0100 Subject: [PATCH 16/37] perf(db): eliminate redundant DDL execution on every request and add nexus-cluster to update.sh sudoers --- scripts/update.sh | 2 +- src/Database.php | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index 1a48da7..029defb 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -112,7 +112,7 @@ ln -sf "$APP_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner 2>/dev/nul # Mise à jour des privilèges sudoers (www-data) si accessible en écriture if [ -w /etc/sudoers.d ]; then cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true -www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update +www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster EOF_SUDO chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true fi diff --git a/src/Database.php b/src/Database.php index a13fed0..0bef49e 100644 --- a/src/Database.php +++ b/src/Database.php @@ -29,7 +29,7 @@ class Database mkdir($dir, 0770, true); } - $isNew = !file_exists(self::DB_PATH); + $isNew = !file_exists(self::DB_PATH) || filesize(self::DB_PATH) === 0; self::$instance = new PDO('sqlite:' . self::DB_PATH, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, @@ -41,10 +41,17 @@ class Database self::$instance->exec('PRAGMA journal_mode = WAL;'); self::$instance->exec('PRAGMA busy_timeout = 5000;'); - self::initializeSchema(); - if ($isNew) { + self::initializeSchema(); @chmod(self::DB_PATH, 0660); + } else { + // Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP + // ce qui sature SQLite de verrous exclusifs inutiles. On teste juste l'existence de la table principale. + try { + self::$instance->query("SELECT 1 FROM settings LIMIT 1"); + } catch (\Throwable $e) { + self::initializeSchema(); + } } } From 2db63d66ce6dda0b165a46beedfdf171b4b57cd3 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 00:00:33 +0100 Subject: [PATCH 17/37] feat(update): align update script with install script for HA cluster prerequisites --- scripts/update.sh | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index 029defb..bbea215 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -102,12 +102,51 @@ chmod 2775 "$APP_DIR/storage" "$APP_DIR/storage/backups" "$APP_DIR/storage/sound mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt 2>/dev/null || true chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true chmod -R g+rX /etc/letsencrypt 2>/dev/null || true -chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" 2>/dev/null || true +chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" "$APP_DIR/scripts/nexus-cluster" 2>/dev/null || true +chmod +x "$APP_DIR"/scripts/cluster/*.sh 2>/dev/null || true # Liens symboliques CLI (non-bloquants si exécuté sous sandbox PHP ProtectSystem) ln -sf "$APP_DIR/scripts/update.sh" /usr/local/bin/nexus-update 2>/dev/null || true ln -sf "$APP_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall 2>/dev/null || true ln -sf "$APP_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner 2>/dev/null || true +ln -sf "$APP_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster 2>/dev/null || true + +# Configuration sysctl pour l'IP flottante (VIP HA) +if [ -w /etc/sysctl.d ]; then + sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true + echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true +fi + +# Pré-déploiement des scripts Keepalived HA +if [ -d "$APP_DIR/scripts/cluster" ] && [ -w /etc ]; then + mkdir -p /etc/keepalived/scripts 2>/dev/null || true + cp -f "$APP_DIR/scripts/cluster/"*.sh /etc/keepalived/scripts/ 2>/dev/null || true + chown -R root:root /etc/keepalived 2>/dev/null || true + chmod 755 /etc/keepalived /etc/keepalived/scripts 2>/dev/null || true + chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true +fi + +# Cron synchronisation Cluster HA +if [ -w /etc/cron.d ]; then + cat << 'EOF_CRON' > /etc/cron.d/nexus-cluster-sync 2>/dev/null || true +# Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup +*/2 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1 +EOF_CRON + chmod 0644 /etc/cron.d/nexus-cluster-sync 2>/dev/null || true + touch /var/log/nexus_cluster.log 2>/dev/null || true + chown www-data:asterisk /var/log/nexus_cluster.log 2>/dev/null || true + chmod 666 /var/log/nexus_cluster.log 2>/dev/null || true +fi + +# Optimisation de la réserve de processus PHP-FPM +for pool in /etc/php/*/fpm/pool.d/www.conf; do + if [ -f "$pool" ] && [ -w "$pool" ]; then + sed -i 's/^pm.max_children = .*/pm.max_children = 25/' "$pool" 2>/dev/null || true + sed -i 's/^pm.start_servers = .*/pm.start_servers = 4/' "$pool" 2>/dev/null || true + sed -i 's/^pm.min_spare_servers = .*/pm.min_spare_servers = 2/' "$pool" 2>/dev/null || true + sed -i 's/^pm.max_spare_servers = .*/pm.max_spare_servers = 6/' "$pool" 2>/dev/null || true + fi +done # Mise à jour des privilèges sudoers (www-data) si accessible en écriture if [ -w /etc/sudoers.d ]; then @@ -119,9 +158,9 @@ fi # 3. Vérification des dépendances Debian echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}" -which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 || { +which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || { echo -e " -> Installation de paquets complémentaires..." - (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass) 2>/dev/null || true + (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true } # 4. Exécution des migrations SQLite et régénération Dialplan From 2db19ed2dd255a1c1162a257d7079bd773fe1f56 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 00:10:44 +0100 Subject: [PATCH 18/37] fix(cluster): use native sqlite3 .restore API to eliminate WAL/SHM desynchronization and corruption during live sync --- scripts/cluster/notify_backup.sh | 2 ++ scripts/cluster/notify_master.sh | 2 ++ src/ClusterManager.php | 36 +++++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index 10bb06a..f600b34 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -14,6 +14,8 @@ echo "[$TIMESTAMP] [CLUSTER] <<< Transition vers l'état BACKUP / STANDBY (En ve DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true + chown www-data:asterisk "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true + chmod 660 "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true fi # 2. Désenregistrement des Trunks PJSIP pour éviter les conflits d'appels entrants avec le Master diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 3a0bc68..56a855d 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -16,6 +16,8 @@ ASTDB_PATH="/var/lib/asterisk/astdb.sqlite3" if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='master_active', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true + chown www-data:asterisk "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true + chmod 660 "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true fi # 2. Vérification et réparation d'intégrité de la base SQLite AstDB (contacts téléphones) diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 7341e71..62b00cd 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -653,7 +653,7 @@ class ClusterManager 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 + // Sauvegarde d'urgence de la base actuelle avant synchronisation $dbPath = Database::getDatabasePath(); if (file_exists($dbPath)) { @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); @@ -666,16 +666,32 @@ 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 locaux et purger les résidus WAL/SHM pour éviter tout malformed - Database::resetConnection(); - @unlink($dbPath . '-wal'); - @unlink($dbPath . '-shm'); + // Restauration officielle en ligne via l'API SQLite .restore + // Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM, + // et n'invalide aucune lecture concurrente dans PHP-FPM. + if (file_exists($dbPath)) { + $restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1"; + $restoreOut = shell_exec($restoreCmd); + if (!empty($restoreOut)) { + error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut)); + } + } else { + @copy($extractedDb, $dbPath); + @chmod($dbPath, 0660); + @chown($dbPath, 'www-data'); + @chgrp($dbPath, 'asterisk'); + } - // Remplacement atomique de la base de données - rename($extractedDb, $dbPath); - @chmod($dbPath, 0660); - @chown($dbPath, 'www-data'); - @chgrp($dbPath, 'asterisk'); + @unlink($extractedDb); + + // Contrôle d'intégrité immédiat après synchronisation + $postCheck = trim(shell_exec("sqlite3 " . escapeshellarg($dbPath) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); + if ($postCheck !== 'ok') { + if (file_exists($dbPath . '.pre_cluster_sync.bak')) { + shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($dbPath . '.pre_cluster_sync.bak') . "\" 2>/dev/null || true"); + } + throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); + } // Extraction des sons personnalisés si présents if (is_dir('/var/lib/asterisk/sounds')) { From 1d2d6b1911d457c796dece9b156d4229fc17c465 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 00:23:35 +0100 Subject: [PATCH 19/37] fix(ha): auto-purge zombie channels on HA transition and enforce PJSIP rtp_timeout to fix stuck BLF keys --- scripts/cluster/notify_backup.sh | 5 ++++- scripts/cluster/notify_master.sh | 5 ++++- src/ConfigGenerator.php | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index f600b34..87ac026 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -18,12 +18,15 @@ if [ -f "$DB_PATH" ]; then chmod 660 "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" 2>/dev/null || true fi -# 2. Désenregistrement des Trunks PJSIP pour éviter les conflits d'appels entrants avec le Master +# 2. Raccrochage immédiat des canaux orphelins et désenregistrement des Trunks PJSIP +# Un nœud en Standby / Backup ne doit avoir aucun canal actif afin de libérer les BLF et éviter les conflits if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "channel request hangup all" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip send unregister *" >> "$LOG_FILE" 2>&1 || true for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do /usr/sbin/asterisk -rx "pjsip send unregister $reg" >> "$LOG_FILE" 2>&1 || true done + /usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true fi echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 56a855d..0eed385 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -33,8 +33,11 @@ if [ -f "$ASTDB_PATH" ]; then fi fi -# 3. Réactivation des enregistrements Trunks PJSIP et rechargement de la config +# 3. Purge préventive des canaux résiduels / fantômes et rechargement PJSIP/Dialplan +# Si le nœud a été mis en pause ou déconnecté, les canaux antérieurs sont morts. +# Leur raccrochage réinitialise immédiatement les hints à l'état Idle et éteint les BLF. if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "channel request hangup all" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index b895584..0f407b7 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -221,6 +221,19 @@ class ConfigGenerator $lines[] = "force_rport = yes"; $lines[] = "rewrite_contact = yes"; $lines[] = "direct_media = no"; + + // Détection automatique et purge des flux RTP fantômes / morts (ex: basculement HA, coupure réseau) + $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); + $rtpHoldTimeout = max(30, $rtpTimeout * 2); + $rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); + $lines[] = "rtp_timeout = {$rtpTimeout}"; + $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; + if ($rtpKeepalive > 0) { + $lines[] = "rtp_keepalive = {$rtpKeepalive}"; + } + $lines[] = "timers = yes"; + $lines[] = "timers_min_se = 90"; + $lines[] = "timers_sess_expires = 1800"; if ($wantsTls && $hasTlsCerts) { // Verrouillage strict du transport TLS : refuse le repli sur UDP $lines[] = "transport = transport-tls"; @@ -374,6 +387,10 @@ class ConfigGenerator $lines[] = "trust_id_inbound = {$trustInbound}"; $lines[] = "trust_id_outbound = {$trustOutbound}"; $lines[] = "timers = {$timers}"; + $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); + $rtpHoldTimeout = max(30, $rtpTimeout * 2); + $lines[] = "rtp_timeout = {$rtpTimeout}"; + $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; if ($rtpKeepalive > 0) { $lines[] = "rtp_keepalive = {$rtpKeepalive}"; } From c1c6eab03c226bfe7c6102ce1e4bb36fc2f31e5c Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 00:29:19 +0100 Subject: [PATCH 20/37] feat(pjsip): set default hold timeout to 600s (10 min) and add rtp_timeout_hold to settings schema --- src/ConfigGenerator.php | 4 ++-- src/Database.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 0f407b7..291a8df 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -224,7 +224,7 @@ class ConfigGenerator // Détection automatique et purge des flux RTP fantômes / morts (ex: basculement HA, coupure réseau) $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $rtpHoldTimeout = max(30, $rtpTimeout * 2); + $rtpHoldTimeout = max(60, (int)($settings['rtp_timeout_hold'] ?? 600)); // Par défaut 600s = 10 minutes d'attente max $rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; @@ -388,7 +388,7 @@ class ConfigGenerator $lines[] = "trust_id_outbound = {$trustOutbound}"; $lines[] = "timers = {$timers}"; $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $rtpHoldTimeout = max(30, $rtpTimeout * 2); + $rtpHoldTimeout = max(60, (int)($settings['rtp_timeout_hold'] ?? 600)); // Par défaut 600s = 10 minutes d'attente max $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; if ($rtpKeepalive > 0) { diff --git a/src/Database.php b/src/Database.php index 0bef49e..fbff0ab 100644 --- a/src/Database.php +++ b/src/Database.php @@ -89,6 +89,7 @@ class Database rtp_checksums INTEGER NOT NULL DEFAULT 1, rtp_strict INTEGER NOT NULL DEFAULT 1, rtp_timeout INTEGER NOT NULL DEFAULT 30, + rtp_timeout_hold INTEGER NOT NULL DEFAULT 600, rtp_keepalive INTEGER NOT NULL DEFAULT 15, allow_anonymous_sip INTEGER NOT NULL DEFAULT 0, last_applied_at DATETIME DEFAULT NULL, @@ -391,6 +392,7 @@ class Database 'rtp_checksums' => "INTEGER NOT NULL DEFAULT 1", 'rtp_strict' => "INTEGER NOT NULL DEFAULT 1", 'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30", + 'rtp_timeout_hold' => "INTEGER NOT NULL DEFAULT 600", 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", 'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0", 'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", From f14185d5974f15a3f28cb04ca3790258cf7fd52c Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 01:20:16 +0100 Subject: [PATCH 21/37] feat(cluster): add configurable BLF expiry interval in HA menu with 120s default and automatic check-sync notify on failover --- public/assets/app.js | 28 +++++++++++++++ public/index.php | 18 ++++++++++ scripts/cluster/notify_master.sh | 9 +++++ src/ClusterManager.php | 43 ++++++++++++++++------- src/ConfigGenerator.php | 58 +++++++++++++++++++++++++++++--- src/Database.php | 6 ++++ 6 files changed, 145 insertions(+), 17 deletions(-) diff --git a/public/assets/app.js b/public/assets/app.js index 7525941..a1cd727 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -6394,6 +6394,33 @@ async function refreshClusterStatus() { const secretInput = document.getElementById('cluster-secret'); if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret; + const blfExpiryContainer = document.getElementById('cluster-blf-expiry-container'); + const blfExpiryInput = document.getElementById('cluster-blf-expiry'); + if (blfExpiryContainer && blfExpiryInput) { + if (st.enabled) { + blfExpiryContainer.classList.remove('hidden'); + blfExpiryInput.value = st.blf_expiry || 120; + } else { + blfExpiryContainer.classList.add('hidden'); + blfExpiryInput.value = st.blf_expiry || 1800; + } + } + + if (chkEnabled && !chkEnabled.dataset.blfBound) { + chkEnabled.dataset.blfBound = '1'; + chkEnabled.addEventListener('change', () => { + const c = document.getElementById('cluster-blf-expiry-container'); + const inp = document.getElementById('cluster-blf-expiry'); + if (chkEnabled.checked) { + if (c) c.classList.remove('hidden'); + if (inp && (!inp.value || inp.value === '1800')) inp.value = 120; + } else { + if (c) c.classList.add('hidden'); + if (inp) inp.value = 1800; + } + }); + } + // Badges & Cartes const headerBadge = document.getElementById('cluster-header-state-badge'); const navBadge = document.getElementById('nav-badge-cluster'); @@ -6584,6 +6611,7 @@ async function saveClusterSettings(e) { peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443, cluster_secret: form.elements['cluster_secret'].value.trim(), vrid: parseInt(form.elements['vrid'].value, 10) || 51, + blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120, auto_sync: form.elements['auto_sync'].checked ? 1 : 0 }; diff --git a/public/index.php b/public/index.php index 37f905c..7abd29f 100644 --- a/public/index.php +++ b/public/index.php @@ -2939,6 +2939,24 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
+ + +
diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 0eed385..9f635d0 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -55,5 +55,14 @@ if [ -n "$VIP_CLEAN" ] && command -v arping >/dev/null 2>&1; then arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true fi +# 5. Envoi automatique du signal check-sync silencieux aux téléphones enregistrés +# Force Yealink, Fanvil, Snom à réémettre instantanément tous leurs SUBSCRIBE BLF sur ce serveur actif +if pidof asterisk >/dev/null 2>&1; then + echo "[$TIMESTAMP] [CLUSTER] Envoi check-sync de réveil aux postes pour réabonnement immédiat des BLF..." >> "$LOG_FILE" + for ep in $(/usr/sbin/asterisk -rx "pjsip show endpoints" 2>/dev/null | grep -o 'Endpoint: [0-9]*' | awk '{print $2}'); do + /usr/sbin/asterisk -rx "pjsip send notify check-sync endpoint $ep" >> "$LOG_FILE" 2>&1 || true + done +fi + echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" exit 0 diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 62b00cd..f238575 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -42,6 +42,7 @@ class ClusterManager 'cluster_secret' => bin2hex(random_bytes(16)), 'vrid' => 51, 'priority' => 100, + 'blf_expiry' => 120, 'sync_interval' => 15, 'auto_sync' => 1, 'last_sync_at' => null, @@ -52,6 +53,9 @@ class ClusterManager if (empty($row['peer_port'])) { $row['peer_port'] = 8443; } + if (empty($row['blf_expiry'])) { + $row['blf_expiry'] = 120; + } return $row; } @@ -70,6 +74,7 @@ class ClusterManager $clusterSecret = trim((string)($data['cluster_secret'] ?? '')); $vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); + $blfExpiry = max(30, min(3600, (int)($data['blf_expiry'] ?? 120))); $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); $autoSync = !empty($data['auto_sync']) ? 1 : 0; @@ -130,6 +135,7 @@ class ClusterManager cluster_secret = :secret, vrid = :vrid, priority = :prio, + blf_expiry = :blf_expiry, sync_interval = :sync_int, auto_sync = :auto_sync, updated_at = CURRENT_TIMESTAMP @@ -137,23 +143,34 @@ class ClusterManager '); $stmt->execute([ - ':enabled' => $enabled, - ':role' => $role, - ':vip' => $virtualIp, - ':vmask' => $virtualNetmask, - ':iface' => $interface, - ':peer' => $peerIp, - ':peer_port' => $peerPort, - ':secret' => $clusterSecret, - ':vrid' => $vrid, - ':prio' => $priority, - ':sync_int' => $syncInterval, - ':auto_sync' => $autoSync, + ':enabled' => $enabled, + ':role' => $role, + ':vip' => $virtualIp, + ':vmask' => $virtualNetmask, + ':iface' => $interface, + ':peer' => $peerIp, + ':peer_port' => $peerPort, + ':secret' => $clusterSecret, + ':vrid' => $vrid, + ':prio' => $priority, + ':blf_expiry' => $blfExpiry, + ':sync_int' => $syncInterval, + ':auto_sync' => $autoSync, ]); // Application automatique de la configuration Keepalived $applyResult = $this->applyKeepalivedConfig(); + // Régénération de la configuration PJSIP et rechargement Asterisk avec le nouveau délai d'abonnement + try { + $gen = new ConfigGenerator($this->db); + $gen->writeAllConfigs(); + shell_exec('asterisk -rx "pjsip reload" 2>/dev/null || true'); + shell_exec('asterisk -rx "dialplan reload" 2>/dev/null || true'); + } catch (\Throwable $e) { + error_log("[CLUSTER] Erreur régénération configs Asterisk : " . $e->getMessage()); + } + return [ 'success' => true, 'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.', @@ -723,6 +740,7 @@ class ClusterManager virtual_ip = :virtual_ip, virtual_netmask = :virtual_netmask, vrid = :vrid, + blf_expiry = :blf_expiry, last_sync_at = CURRENT_TIMESTAMP, last_sync_status = :status, last_sync_message = :msg @@ -737,6 +755,7 @@ class ClusterManager ':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '', ':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24', ':vrid' => $localClusterSettings['vrid'] ?? 51, + ':blf_expiry' => (int)($localClusterSettings['blf_expiry'] ?? 120), ':status' => 'synced', ':msg' => 'Base et sons synchronisés depuis le Master', ]); diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 291a8df..f923434 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -15,8 +15,9 @@ class ConfigGenerator public const MOH_CONF_PATH = '/etc/asterisk/musiconhold.custom.conf'; public const RTP_CONF_PATH = '/etc/asterisk/rtp.conf'; public const FEATURES_CONF_PATH = '/etc/asterisk/features.conf'; - public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.conf'; - public const BACKUP_DIR = __DIR__ . '/../storage/backups'; + public const VOICEMAIL_CONF_PATH = '/etc/asterisk/voicemail.custom.conf'; + public const PJSIP_NOTIFY_CONF_PATH = '/etc/asterisk/pjsip_notify.conf'; + public const BACKUP_DIR = __DIR__ . '/../storage/backups'; public function __construct(private PDO $db) {} @@ -187,6 +188,18 @@ class ConfigGenerator $lines[] = "; ==========================================================================="; $lines[] = "; POSTES TÉLÉPHONIQUES (EXTENSIONS)"; $lines[] = "; ==========================================================================="; + + // Détection de l'état du Cluster Haute Disponibilité (HA) + $clusterSt = []; + try { + $clusterSt = $this->db->query('SELECT enabled, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: []; + } catch (\Throwable $e) {} + $isHaActive = !empty($clusterSt['enabled']); + // En mode Haute Disponibilité, on plafonne l'expiration des AOR/souscriptions sur la valeur configurée (par défaut 120s) + // afin que les téléphones se réabonnent très vite en cas de bascule. Sans HA, on conserve le standard 1800s (30 min). + $regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 1800; + $subMinExpiry = $isHaActive ? 30 : 0; + foreach ($extensions as $ext) { $num = trim((string)$ext['extension']); $name = str_replace('"', '', trim((string)$ext['name'])); @@ -274,9 +287,9 @@ class ConfigGenerator $lines[] = "type = aor"; $lines[] = "max_contacts = {$maxContacts}"; $lines[] = "remove_existing = yes"; - $lines[] = "default_expiration = 120"; - $lines[] = "maximum_expiration = 300"; - $lines[] = "minimum_expiration = 60"; + $lines[] = "default_expiration = {$regExpiration}"; + $lines[] = "maximum_expiration = {$regExpiration}"; + $lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60); $lines[] = "qualify_frequency = 30"; if ($hasVm) { $lines[] = "mailboxes = {$num}@default"; @@ -1874,6 +1887,39 @@ class ConfigGenerator return implode("\n", $lines) . "\n"; } + /** + * Génère la configuration des événements SIP Notify (/etc/asterisk/pjsip_notify.conf) + * Profils check-sync pour Yealink, Fanvil, Snom, Polycom, etc. + */ + public function generatePjsipNotifyConf(): string + { + return <<check-sync\;reboot=false + +[yealink-check-cfg] +Event=>check-sync\;reboot=false + +[snom-check-cfg] +Event=>check-sync\;reboot=false + +[polycom-check-cfg] +Event=>check-sync + +[cisco-check-cfg] +Event=>check-sync + +[aastra-check-cfg] +Event=>check-sync + +EOF; + } + public function writeAllConfigs(): array { if (!is_dir(self::BACKUP_DIR)) { @@ -1886,6 +1932,7 @@ class ConfigGenerator $rtpContent = $this->generateRtpConf(); $featuresContent = $this->generateFeaturesConf(); $voicemailContent = $this->generateVoicemailConf(); + $notifyContent = $this->generatePjsipNotifyConf(); $this->writeAtomicWithPermissions(self::PJSIP_CONF_PATH, $pjsipContent); $this->writeAtomicWithPermissions(self::EXTENSIONS_CONF_PATH, $extensionsContent); @@ -1893,6 +1940,7 @@ class ConfigGenerator $this->writeAtomicWithPermissions(self::RTP_CONF_PATH, $rtpContent); $this->writeAtomicWithPermissions(self::FEATURES_CONF_PATH, $featuresContent); $this->writeAtomicWithPermissions(self::VOICEMAIL_CONF_PATH, $voicemailContent); + $this->writeAtomicWithPermissions(self::PJSIP_NOTIFY_CONF_PATH, $notifyContent); // S'assurer que pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf SystemChecker::ensureAsteriskIncludes(); diff --git a/src/Database.php b/src/Database.php index fbff0ab..37d1f40 100644 --- a/src/Database.php +++ b/src/Database.php @@ -329,6 +329,7 @@ class Database cluster_secret VARCHAR(64) NOT NULL DEFAULT '', vrid INTEGER NOT NULL DEFAULT 51, priority INTEGER NOT NULL DEFAULT 100, + blf_expiry INTEGER NOT NULL DEFAULT 120, sync_interval INTEGER NOT NULL DEFAULT 15, auto_sync INTEGER NOT NULL DEFAULT 1, last_sync_at DATETIME DEFAULT NULL, @@ -813,6 +814,11 @@ class Database $db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443"); } catch (\Throwable $e) {} + // Migration de la colonne blf_expiry si elle n'existe pas encore + try { + $db->exec("ALTER TABLE cluster_settings ADD COLUMN blf_expiry INTEGER NOT NULL DEFAULT 120"); + } catch (\Throwable $e) {} + $count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn(); if ($count === 0) { $defaultSecret = bin2hex(random_bytes(16)); From 767fea46b791d93c32b7375e350f64bcf0d0445b Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 01:21:03 +0100 Subject: [PATCH 22/37] fix(cluster): auto-ensure blf_expiry and peer_port columns exist in ClusterManager constructor --- src/ClusterManager.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ClusterManager.php b/src/ClusterManager.php index f238575..eb15d60 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -21,6 +21,20 @@ class ClusterManager public function __construct(?PDO $db = null) { $this->db = $db ?? Database::getConnection(); + $this->ensureColumns(); + } + + private function ensureColumns(): void + { + try { + $cols = $this->db->query("PRAGMA table_info(cluster_settings)")->fetchAll(PDO::FETCH_COLUMN, 1); + if (!in_array('blf_expiry', $cols, true)) { + $this->db->exec("ALTER TABLE cluster_settings ADD COLUMN blf_expiry INTEGER NOT NULL DEFAULT 120"); + } + if (!in_array('peer_port', $cols, true)) { + $this->db->exec("ALTER TABLE cluster_settings ADD COLUMN peer_port INTEGER NOT NULL DEFAULT 8443"); + } + } catch (\Throwable $e) {} } /** From c1805c90767a88386e0b9e9daa0a1c011cf811dd Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 01:40:31 +0100 Subject: [PATCH 23/37] feat(sip): make rtp_timeout_hold configurable in Web UI and default to 0 (unlimited hold) --- public/api.php | 9 ++++++--- public/assets/app.js | 6 ++++++ public/index.php | 8 +++++++- src/ConfigGenerator.php | 13 +++++++------ src/Database.php | 8 ++++---- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/public/api.php b/public/api.php index 763414b..b8a4a5b 100644 --- a/public/api.php +++ b/public/api.php @@ -2017,6 +2017,7 @@ try { $rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0; $rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0; $rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30)); + $rtpHoldTimeout = max(0, (int)($payload['rtp_timeout_hold'] ?? 0)); $rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15)); $allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0; @@ -2051,6 +2052,7 @@ try { rtp_checksums = :rtp_checksums, rtp_strict = :rtp_strict, rtp_timeout = :rtp_timeout, + rtp_timeout_hold = :rtp_timeout_hold, rtp_keepalive = :rtp_keepalive, allow_anonymous_sip = :anon, updated_at = CURRENT_TIMESTAMP @@ -2068,9 +2070,10 @@ try { ':rtp_start' => $rtpStart, ':rtp_end' => $rtpEnd, ':rtp_checksums' => $rtpChecksums, - ':rtp_strict' => $rtpStrict, - ':rtp_timeout' => $rtpTimeout, - ':rtp_keepalive' => $rtpKeepalive, + ':rtp_strict' => $rtpStrict, + ':rtp_timeout' => $rtpTimeout, + ':rtp_timeout_hold' => $rtpHoldTimeout, + ':rtp_keepalive' => $rtpKeepalive, ':anon' => $allowAnonymousSip, ]); diff --git a/public/assets/app.js b/public/assets/app.js index a1cd727..47e64fc 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -4152,6 +4152,7 @@ document.addEventListener('DOMContentLoaded', () => { rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1), rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15), rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30), + rtp_timeout_hold: Number(document.getElementById('sip-rtp-timeout-hold')?.value || 0), allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0), local_networks: localNets }; @@ -4613,6 +4614,11 @@ function renderSipSettings() { rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30; } + const rtpHoldTimeoutEl = document.getElementById('sip-rtp-timeout-hold'); + if (rtpHoldTimeoutEl && document.activeElement !== rtpHoldTimeoutEl) { + rtpHoldTimeoutEl.value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0; + } + const allowAnonEl = document.getElementById('sip-allow-anonymous'); if (allowAnonEl) { allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0'; diff --git a/public/index.php b/public/index.php index 7abd29f..2f22ce4 100644 --- a/public/index.php +++ b/public/index.php @@ -2554,7 +2554,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
-
+

Durée avant coupure si aucun flux voix n'est reçu (raccroché distant brutal).

+
+ + +

Délai max d'un appel en attente sans flux RTP (0 = illimité / désactivé).

+
diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index f923434..479a919 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -237,7 +237,7 @@ class ConfigGenerator // Détection automatique et purge des flux RTP fantômes / morts (ex: basculement HA, coupure réseau) $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $rtpHoldTimeout = max(60, (int)($settings['rtp_timeout_hold'] ?? 600)); // Par défaut 600s = 10 minutes d'attente max + $rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0)); $rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; @@ -401,7 +401,7 @@ class ConfigGenerator $lines[] = "trust_id_outbound = {$trustOutbound}"; $lines[] = "timers = {$timers}"; $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $rtpHoldTimeout = max(60, (int)($settings['rtp_timeout_hold'] ?? 600)); // Par défaut 600s = 10 minutes d'attente max + $rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0)); $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; if ($rtpKeepalive > 0) { @@ -1779,9 +1779,10 @@ class ConfigGenerator $rtpEnd = max($rtpStart + 4, min(65535, (int)($settings['rtp_end'] ?? 20000))); $checksums = !empty($settings['rtp_checksums']) ? 'yes' : 'no'; $strict = !empty($settings['rtp_strict']) ? 'yes' : 'no'; - $timeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); - $date = date('Y-m-d H:i:s'); + $timeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); + $holdTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0)); + $keepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); + $date = date('Y-m-d H:i:s'); $lines = []; $lines[] = "; ==========================================================================="; @@ -1794,7 +1795,7 @@ class ConfigGenerator $lines[] = "rtpchecksums = {$checksums}"; $lines[] = "strictrtp = {$strict}"; $lines[] = "rtptimeout = {$timeout}"; - $lines[] = "rtpholdtimeout = 300"; + $lines[] = "rtpholdtimeout = {$holdTimeout}"; if ($keepalive > 0) { $lines[] = "rtpkeepalive = {$keepalive}"; } diff --git a/src/Database.php b/src/Database.php index 37d1f40..0d8f0b7 100644 --- a/src/Database.php +++ b/src/Database.php @@ -46,9 +46,9 @@ class Database @chmod(self::DB_PATH, 0660); } else { // Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP - // ce qui sature SQLite de verrous exclusifs inutiles. On teste juste l'existence de la table principale. + // ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises. try { - self::$instance->query("SELECT 1 FROM settings LIMIT 1"); + self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1"); } catch (\Throwable $e) { self::initializeSchema(); } @@ -89,7 +89,7 @@ class Database rtp_checksums INTEGER NOT NULL DEFAULT 1, rtp_strict INTEGER NOT NULL DEFAULT 1, rtp_timeout INTEGER NOT NULL DEFAULT 30, - rtp_timeout_hold INTEGER NOT NULL DEFAULT 600, + rtp_timeout_hold INTEGER NOT NULL DEFAULT 0, rtp_keepalive INTEGER NOT NULL DEFAULT 15, allow_anonymous_sip INTEGER NOT NULL DEFAULT 0, last_applied_at DATETIME DEFAULT NULL, @@ -393,7 +393,7 @@ class Database 'rtp_checksums' => "INTEGER NOT NULL DEFAULT 1", 'rtp_strict' => "INTEGER NOT NULL DEFAULT 1", 'rtp_timeout' => "INTEGER NOT NULL DEFAULT 30", - 'rtp_timeout_hold' => "INTEGER NOT NULL DEFAULT 600", + 'rtp_timeout_hold' => "INTEGER NOT NULL DEFAULT 0", 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", 'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0", 'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", From fa8af27e46c3d95b40979afcc11e69ec66f71df7 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 01:47:02 +0100 Subject: [PATCH 24/37] feat(system): add RTP Timeout and RTP Hold Timeout directly in System Settings form --- public/api.php | 14 ++++++++++++++ public/assets/app.js | 6 ++++++ public/index.php | 12 ++++++++++++ 3 files changed, 32 insertions(+) diff --git a/public/api.php b/public/api.php index b8a4a5b..f0a04e1 100644 --- a/public/api.php +++ b/public/api.php @@ -1717,6 +1717,8 @@ try { $local_net = trim((string)($payload['local_net'] ?? '192.168.1.0/24')); $udp_port = max(1024, min(65535, (int)($payload['udp_port'] ?? 5060))); $tls_port = max(1024, min(65535, (int)($payload['tls_port'] ?? 5061))); + $rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30)); + $rtpHoldTimeout = max(0, (int)($payload['rtp_timeout_hold'] ?? 0)); $letsencrypt_domain = trim((string)($payload['letsencrypt_domain'] ?? '')); $ami_host = trim((string)($payload['ami_host'] ?? '127.0.0.1')); $ami_port = max(1, min(65535, (int)($payload['ami_port'] ?? 5038))); @@ -1738,6 +1740,8 @@ try { local_net = :local_net, udp_port = :udp_port, tls_port = :tls_port, + rtp_timeout = :rtp_timeout, + rtp_timeout_hold = :rtp_timeout_hold, letsencrypt_domain = :le_domain, ami_host = :ami_host, ami_port = :ami_port, @@ -1756,6 +1760,8 @@ try { ':local_net' => $local_net, ':udp_port' => $udp_port, ':tls_port' => $tls_port, + ':rtp_timeout' => $rtpTimeout, + ':rtp_timeout_hold' => $rtpHoldTimeout, ':le_domain' => $letsencrypt_domain, ':ami_host' => $ami_host, ':ami_port' => $ami_port, @@ -1767,6 +1773,14 @@ try { ]); SystemChecker::ensureAsteriskIncludes(); + $generator->writeAllConfigs(); + try { + $ami = AmiClient::fromDatabase($db); + $ami->reloadModule('res_pjsip.so'); + $ami->reloadModule('res_rtp_asterisk.so'); + } catch (\Throwable $e) { + // Ignore reload error if Asterisk is not running + } if (!empty($payload['new_admin_password'])) { $newPass = (string)$payload['new_admin_password']; diff --git a/public/assets/app.js b/public/assets/app.js index 47e64fc..e0f644d 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -3477,6 +3477,12 @@ function renderSettingsAndDiagnostics() { form.elements['external_ip'].value = s.external_ip || ''; form.elements['udp_port'].value = s.udp_port || 5060; form.elements['tls_port'].value = s.tls_port || 5061; + if (form.elements['rtp_timeout']) { + form.elements['rtp_timeout'].value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30; + } + if (form.elements['rtp_timeout_hold']) { + form.elements['rtp_timeout_hold'].value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0; + } form.elements['ami_host'].value = s.ami_host || '127.0.0.1'; form.elements['ami_port'].value = s.ami_port || 5038; form.elements['ami_user'].value = s.ami_user || 'pbxgui'; diff --git a/public/index.php b/public/index.php index 2f22ce4..b56d198 100644 --- a/public/index.php +++ b/public/index.php @@ -1702,6 +1702,18 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') +
+ + +

Coupure si aucun flux voix n'est reçu (défaut : 30s).

+
+
+ + +

Délai max d'un appel en attente sans RTP (0 = illimité / désactivé).

+
-
- - -

Coupure si aucun flux voix n'est reçu (défaut : 30s).

-
-
- - -

Délai max d'un appel en attente sans RTP (0 = illimité / désactivé).

-
db->query('SELECT enabled, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: []; } catch (\Throwable $e) {} $isHaActive = !empty($clusterSt['enabled']); - // En mode Haute Disponibilité, on plafonne l'expiration des AOR/souscriptions sur la valeur configurée (par défaut 120s) - // afin que les téléphones se réabonnent très vite en cas de bascule. Sans HA, on conserve le standard 1800s (30 min). + // En mode Haute Disponibilité, on plafonne l'expiration d'enregistrement des AOR (REGISTER) sur la valeur configurée (par défaut 120s) + // afin que les téléphones se réenregistrent très vite en cas de bascule HA. Sans HA, on conserve le standard 1800s (30 min). $regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 1800; $subMinExpiry = $isHaActive ? 30 : 0; From e54de571050cfc32a1cb0507fd8457ac28c52f9d Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 09:54:39 +0100 Subject: [PATCH 29/37] fix(cluster): strict SIP isolation on standby node and progressive BLF wake on master to eliminate stuck BLF lights --- scripts/cluster/notify_backup.sh | 13 ++++++++++++- scripts/cluster/notify_fault.sh | 7 +++++++ scripts/cluster/notify_master.sh | 26 ++++++++++++++++++++------ scripts/nexus-cluster | 2 ++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index 87ac026..71dc4d5 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -29,5 +29,16 @@ if pidof asterisk >/dev/null 2>&1; then /usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true fi -echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" +# 3. Isolation SIP stricte du nœud Standby / Backup via iptables (Architecture Télécom HA) +# Un nœud en veille ne doit JAMAIS répondre aux requêtes SIP des téléphones. +# Tout paquet SIP arrivant sur le Slave est immédiatement rejeté (ICMP port unreachable), +# ce qui force les téléphones (Yealink T46S, T54W, Fanvil, Snom...) à clore immédiatement +# leurs anciens dialogues orphelins et à réémettre leurs SUBSCRIBE BLF et REGISTER vers la VIP active. +iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || iptables -I INPUT -i lo -j ACCEPT +iptables -C INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || \ + iptables -A INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable +iptables -C INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || \ + iptables -A INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset + +echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif (Ports SIP isolés). En écoute des battements de cœur VRRP." >> "$LOG_FILE" exit 0 diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh index a0bf969..258bde7 100755 --- a/scripts/cluster/notify_fault.sh +++ b/scripts/cluster/notify_fault.sh @@ -15,4 +15,11 @@ if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='fault', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true fi +# Isolation des ports SIP en cas d'anomalie +iptables -C INPUT -i lo -j ACCEPT 2>/dev/null || iptables -I INPUT -i lo -j ACCEPT +iptables -C INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable 2>/dev/null || \ + iptables -A INPUT -p udp -m multiport --dports 5060,5061 -j REJECT --reject-with icmp-port-unreachable +iptables -C INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset 2>/dev/null || \ + iptables -A INPUT -p tcp -m multiport --dports 5060,5061 -j REJECT --reject-with tcp-reset + exit 0 diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 9f635d0..005049a 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -10,6 +10,10 @@ 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) +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 + # 1. Mise à jour de l'état en base de données locale si SQLite disponible DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" ASTDB_PATH="/var/lib/asterisk/astdb.sqlite3" @@ -55,13 +59,23 @@ if [ -n "$VIP_CLEAN" ] && command -v arping >/dev/null 2>&1; then arping -c 5 -U -I "$IFACE" "$VIP_CLEAN" >> "$LOG_FILE" 2>&1 || true fi -# 5. Envoi automatique du signal check-sync silencieux aux téléphones enregistrés -# Force Yealink, Fanvil, Snom à réémettre instantanément tous leurs SUBSCRIBE BLF sur ce serveur actif +# 5. Réveil progressif et réabonnement BLF des postes téléphoniques +# Dès que les postes se réenregistrent sur ce Master (via la VIP), un signal silencieux check-sync +# leur est transmis pour forcer la synchronisation immédiate de l'ensemble des touches BLF. if pidof asterisk >/dev/null 2>&1; then - echo "[$TIMESTAMP] [CLUSTER] Envoi check-sync de réveil aux postes pour réabonnement immédiat des BLF..." >> "$LOG_FILE" - for ep in $(/usr/sbin/asterisk -rx "pjsip show endpoints" 2>/dev/null | grep -o 'Endpoint: [0-9]*' | awk '{print $2}'); do - /usr/sbin/asterisk -rx "pjsip send notify check-sync endpoint $ep" >> "$LOG_FILE" 2>&1 || true - done + echo "[$TIMESTAMP] [CLUSTER] Lancement du réveil BLF progressif en arrière-plan..." >> "$LOG_FILE" + ( + for delay in 2 5 12 25; do + sleep "$delay" + if ! pidof asterisk >/dev/null 2>&1; then break; fi + # Cibler uniquement les postes actuellement joignables (Avail) + for ep in $(/usr/sbin/asterisk -rx "pjsip show contacts" 2>/dev/null | grep -E 'Contact:.*Avail' | awk '{print $2}' | cut -d'/' -f1 | sort -u); do + if [ -n "$ep" ]; then + /usr/sbin/asterisk -rx "pjsip send notify check-sync endpoint $ep" >> "$LOG_FILE" 2>&1 || true + fi + done + done + ) >/dev/null 2>&1 & fi echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index 619121e..b5a054e 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -53,6 +53,8 @@ case "$CMD" in stop) systemctl stop keepalived >/dev/null 2>&1 || true systemctl disable keepalived >/dev/null 2>&1 || 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 echo "KEEPALIVED_STOPPED_OK" ;; From 2ed38846aead66d71db03591f60ba700a31f9626 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 11:23:51 +0100 Subject: [PATCH 30/37] release(v2.0.0): bump version to 2.0.0 codename Pulsar --- public/index.php | 7 ++++--- version.json | 24 +++++++++++------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/public/index.php b/public/index.php index 603957d..a214264 100644 --- a/public/index.php +++ b/public/index.php @@ -28,7 +28,8 @@ $companyLogo = trim($branding['company_logo'] ?? ''); $hasBranding = !empty($companyName) || !empty($companyLogo); $appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : []; -$appVersion = $appVerData['version'] ?? '1.5.0'; +$appVersion = $appVerData['version'] ?? '2.0.0'; +$appCodename = $appVerData['codename'] ?? 'Pulsar'; // Détection de l'environnement de développement : présence de .dev local ou branche git dev $isDevEnv = file_exists(__DIR__ . '/../.dev') @@ -230,7 +231,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
NexusPBX
- v + v DEV @@ -1779,7 +1780,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')

Mises à Jour du Système NexusPBX

- v + v —

Vérifiez les nouvelles versions officielles et mettez à jour votre PBX en 1 clic sans coupure de service.

diff --git a/version.json b/version.json index 03e0047..8f579eb 100644 --- a/version.json +++ b/version.json @@ -1,18 +1,16 @@ { - "version": "1.6.5", - "codename": "Helios", - "release_date": "2026-11-05", + "version": "2.0.0", + "codename": "Pulsar", + "release_date": "2026-11-06", "min_php_version": "8.2", "changelog": [ - "Trunks SIP : dissociation stricte du CallerID sortant du Trunk (suppression de callerid dans l'endpoint PJSIP pour préserver l'affichage du numéro appelant réel sur les appels entrants)", - "Trunks SIP : suppression du forçage de from_user afin de préserver l'en-tête SIP From dynamique avec Nom d'appelant (CNAM) pour OVH / Orange", - "Trunks SIP & Routage Sortant : activation systématique de trust_id_outbound et formatage strict du Caller ID Name (CNAM) pour affichage garanti chez les opérateurs distants (OVH, Orange, etc.)", - "Sauvegarde & Restauration : versionnage dynamique automatique basé sur la version installée (résolution du tag figé v1.2.0)", - "Mises à jour système OTA : déclenchement garanti d'une sauvegarde Disaster Recovery complète pré-mise à jour (visible et restaurable dans l'interface)", - "Interface Sauvegardes : nouveau badge identifiant la source (Pré-MàJ, Auto, Manuel)", - "Identité visuelle officielle : intégration du nouveau logo NexusPBX 3D haute définition épuré et du favicon", - "Nouveau slogan télécom : « Supervision & Téléphonie IP • Powered by Asterisk »", - "Module Journal d'Appels & CDR avec filtres et export CSV", - "Support universel TLSv1.2/TLSv1.3 et suppression CertificateRequest pour postes Yealink" + "NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience", + "Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP", + "HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby éliminant tout blocage ou désynchronisation des touches BLF (Yealink, Fanvil, Snom)", + "HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master", + "HA Télécom : réplication atomique SQLite non-bloquante (VACUUM INTO) et synchronisation AstDB à chaud", + "Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée", + "Paramètres SIP : fiabilisation du rtp_timeout_hold exclusif aux flux audio RTP", + "Génération AOR PJSIP : plafonnement de réenregistrement automatique adapté au mode HA (120s recommandé)" ] } From 54fc495fa70001b5c3c3641a2ff112713484ebd2 Mon Sep 17 00:00:00 2001 From: sorcier Date: Fri, 6 Nov 2026 16:57:25 +0100 Subject: [PATCH 31/37] fix(cluster): bind PJSIP transports to VIP and announce VIP signaling address in HA mode --- src/ConfigGenerator.php | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 6c95c72..44645fb 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -61,6 +61,25 @@ class ConfigGenerator } } + // Détection de l'IP Virtuelle (VIP) du Cluster Haute Disponibilité (HA) + $clusterSt = []; + try { + $clusterSt = $this->db->query('SELECT enabled, virtual_ip, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(\PDO::FETCH_ASSOC) ?: []; + } catch (\Throwable) {} + $isHaActive = !empty($clusterSt['enabled']); + $haVip = ''; + if ($isHaActive && !empty($clusterSt['virtual_ip'])) { + $parts = explode('/', trim((string)$clusterSt['virtual_ip'])); + $candidateVip = trim($parts[0] ?? ''); + if (filter_var($candidateVip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $haVip = $candidateVip; + } + } + + // En mode HA, liaison obligatoire sur la VIP pour que tous les paquets SIP et contacts + // annoncent la VIP et non l'IP physique du serveur (élimine la perte des dialogues BLF au failover) + $sipBindHost = ($haVip !== '') ? $haVip : '0.0.0.0'; + $lines = []; $lines[] = "; ==========================================================================="; $lines[] = "; FICHIER GÉNÉRÉ AUTOMATIQUEMENT PAR NEXUSPBX ($date)"; @@ -73,7 +92,7 @@ class ConfigGenerator $lines[] = "[transport-udp]"; $lines[] = "type = transport"; $lines[] = "protocol = udp"; - $lines[] = "bind = 0.0.0.0:{$udpPort}"; + $lines[] = "bind = {$sipBindHost}:{$udpPort}"; $lines[] = "symmetric_transport = yes"; foreach ($localNetworks as $net) { $net = trim((string)$net); @@ -87,6 +106,9 @@ class ConfigGenerator if ($extUdpPort > 0) { $lines[] = "external_signaling_port = {$extUdpPort}"; } + } elseif ($haVip !== '') { + $lines[] = "external_media_address = {$haVip}"; + $lines[] = "external_signaling_address = {$haVip}"; } $lines[] = "allow_reload = no"; $lines[] = ""; @@ -96,7 +118,7 @@ class ConfigGenerator $lines[] = "[transport-tcp]"; $lines[] = "type = transport"; $lines[] = "protocol = tcp"; - $lines[] = "bind = 0.0.0.0:{$udpPort}"; + $lines[] = "bind = {$sipBindHost}:{$udpPort}"; $lines[] = "symmetric_transport = yes"; foreach ($localNetworks as $net) { $net = trim((string)$net); @@ -110,6 +132,9 @@ class ConfigGenerator if ($extUdpPort > 0) { $lines[] = "external_signaling_port = {$extUdpPort}"; } + } elseif ($haVip !== '') { + $lines[] = "external_media_address = {$haVip}"; + $lines[] = "external_signaling_address = {$haVip}"; } $lines[] = "allow_reload = no"; $lines[] = ""; @@ -153,7 +178,7 @@ class ConfigGenerator $lines[] = "[transport-tls]"; $lines[] = "type = transport"; $lines[] = "protocol = tls"; - $lines[] = "bind = 0.0.0.0:{$tlsPort}"; + $lines[] = "bind = {$sipBindHost}:{$tlsPort}"; $lines[] = "symmetric_transport = yes"; $lines[] = "cert_file = {$certPath}"; $lines[] = "priv_key_file = {$keyPath}"; @@ -180,6 +205,9 @@ class ConfigGenerator if ($extTlsPort > 0) { $lines[] = "external_signaling_port = {$extTlsPort}"; } + } elseif ($haVip !== '') { + $lines[] = "external_media_address = {$haVip}"; + $lines[] = "external_signaling_address = {$haVip}"; } $lines[] = "allow_reload = no"; $lines[] = ""; From d1d42f75b59b0bf02ff6de2cf91e314efb90e5c3 Mon Sep 17 00:00:00 2001 From: sorcier Date: Sat, 7 Nov 2026 01:26:02 +0100 Subject: [PATCH 32/37] feat(cluster): add voicemail & fail2ban sync and automatic failback re-sync reconciliation --- public/api.php | 21 +++ scripts/cluster/notify_master.sh | 9 +- scripts/nexus_cluster_sync.php | 9 ++ src/ClusterManager.php | 228 +++++++++++++++++++++++++++++++ src/ConfigGenerator.php | 4 + 5 files changed, 270 insertions(+), 1 deletion(-) diff --git a/public/api.php b/public/api.php index 2b0848e..9149093 100644 --- a/public/api.php +++ b/public/api.php @@ -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 { diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 005049a..307845c 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -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 diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php index 1f4d72b..8c6d8e5 100644 --- a/scripts/nexus_cluster_sync.php +++ b/scripts/nexus_cluster_sync.php @@ -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)) { diff --git a/src/ClusterManager.php b/src/ClusterManager.php index eb15d60..119133e 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -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(' diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 44645fb..2284395 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -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) { From 4bd0f8b0955844cbe1771b26a7c154669c9aaa4f Mon Sep 17 00:00:00 2001 From: sorcier Date: Sat, 7 Nov 2026 01:49:10 +0100 Subject: [PATCH 33/37] fix(cluster): purge subscription_persistence in AstDB replication to prevent dead BLF dialogues --- scripts/cluster/notify_master.sh | 1 + scripts/nexus-cluster | 9 +++++++-- src/ClusterManager.php | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 307845c..451fce9 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -49,6 +49,7 @@ fi # Leur raccrochage réinitialise immédiatement les hints à l'état Idle et éteint les BLF. if pidof asterisk >/dev/null 2>&1; then /usr/sbin/asterisk -rx "channel request hangup all" >> "$LOG_FILE" 2>&1 || true + /usr/sbin/asterisk -rx "database deltree subscription_persistence" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true /usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster index b5a054e..d2b7154 100755 --- a/scripts/nexus-cluster +++ b/scripts/nexus-cluster @@ -86,15 +86,20 @@ case "$CMD" in # 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 + # IMPORTANT : Ne jamais répliquer /subscription_persistence/ car les dialogues SIP BLF sont des objets en RAM + # propres à l'instance Asterisk d'origine. Les purger force le nouveau Master à envoyer 481, ce qui incite + # immédiatement les téléphones (Yealink T46S, T54W) à réémettre un dialogue BLF propre en 25ms. 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 \"INSERT OR REPLACE INTO astdb SELECT * FROM src.astdb WHERE key NOT LIKE '/registrar/%' AND key NOT LIKE '/subscription_persistence/%';\"" >/dev/null 2>&1 || true /usr/sbin/asterisk -rx "database query \"DETACH src;\"" >/dev/null 2>&1 || true + /usr/sbin/asterisk -rx "database deltree subscription_persistence" >/dev/null 2>&1 || true else - # Asterisk arrêté : copie directe intègre + # Asterisk arrêté : copie directe intègre en purgeant les souscriptions zombies cp -f "$SRC" "$DEST" + sqlite3 "$DEST" "DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';" 2>/dev/null || true chown asterisk:asterisk "$DEST" 2>/dev/null || true chmod 664 "$DEST" 2>/dev/null || true fi diff --git a/src/ClusterManager.php b/src/ClusterManager.php index 119133e..a59e879 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -527,6 +527,8 @@ class ClusterManager if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { $integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); if ($integrity === 'ok') { + // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) + shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); $hasAstdb = true; } else { @unlink($tmpAstdb); From c79faadd99198b92851609a7bad2bf805bb7221f Mon Sep 17 00:00:00 2001 From: sorcier Date: Sat, 7 Nov 2026 02:00:38 +0100 Subject: [PATCH 34/37] fix(cluster): mirror voicemails and custom sounds using rsync --delete to propagate deletions --- src/ClusterManager.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ClusterManager.php b/src/ClusterManager.php index a59e879..b43bb03 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -733,14 +733,17 @@ class ClusterManager throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); } - // 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"); + // Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) + $extractedCustom = $tmpExtractDir . '/custom'; + if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); + shell_exec("chown -R asterisk:asterisk /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"); + // Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) + $extractedVm = $tmpExtractDir . '/voicemail'; + if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { + shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); } From 3ef7ed00100a394d26eea980d4c46c2775eb8cf1 Mon Sep 17 00:00:00 2001 From: sorcier Date: Tue, 10 Nov 2026 11:14:38 +0100 Subject: [PATCH 35/37] perf(pjsip): optimize NAT/TLS keepalives and remote registration expiry for off-network phones --- src/ConfigGenerator.php | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 2284395..6ef1b0d 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -87,6 +87,21 @@ class ConfigGenerator $lines[] = "; ==========================================================================="; $lines[] = ""; + // 0. Paramètres globaux PJSIP (Keepalive applicatif TLS/TCP toutes les 30s pour préserver les sessions NAT hors réseau) + $lines[] = "; --- PARAMÈTRES GLOBAUX PJSIP (KEEPALIVE NAT / HORS RÉSEAU) ---"; + $lines[] = "[global]"; + $lines[] = "type = global"; + $lines[] = "keep_alive_interval = 30"; + $lines[] = "contact_expiration_check_interval = 15"; + $lines[] = ""; + + $tcpKeepaliveLines = [ + "tcp_keepalive_enable = yes", + "tcp_keepalive_idle_time = 20", + "tcp_keepalive_interval_time = 5", + "tcp_keepalive_probe_count = 3", + ]; + // 1. Transport UDP (Postes internes LAN & Trunks standards) $lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---"; $lines[] = "[transport-udp]"; @@ -136,6 +151,7 @@ class ConfigGenerator $lines[] = "external_media_address = {$haVip}"; $lines[] = "external_signaling_address = {$haVip}"; } + array_push($lines, ...$tcpKeepaliveLines); $lines[] = "allow_reload = no"; $lines[] = ""; @@ -209,6 +225,7 @@ class ConfigGenerator $lines[] = "external_media_address = {$haVip}"; $lines[] = "external_signaling_address = {$haVip}"; } + array_push($lines, ...$tcpKeepaliveLines); $lines[] = "allow_reload = no"; $lines[] = ""; @@ -223,10 +240,10 @@ class ConfigGenerator $clusterSt = $this->db->query('SELECT enabled, blf_expiry FROM cluster_settings WHERE id = 1')->fetch(PDO::FETCH_ASSOC) ?: []; } catch (\Throwable $e) {} $isHaActive = !empty($clusterSt['enabled']); - // En mode Haute Disponibilité, on plafonne l'expiration d'enregistrement des AOR (REGISTER) sur la valeur configurée (par défaut 120s) - // afin que les téléphones se réenregistrent très vite en cas de bascule HA. Sans HA, on conserve le standard 1800s (30 min). - $regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 1800; - $subMinExpiry = $isHaActive ? 30 : 0; + // En mode HA, on plafonne sur la valeur configurée (par défaut 120s). + // Sans HA, on garde 120s-300s afin de maintenir actives les tables NAT des postes hors réseau. + $regExpiration = $isHaActive ? max(30, min(3600, (int)($clusterSt['blf_expiry'] ?? 120))) : 120; + $regMaxExpiration = $isHaActive ? $regExpiration : 300; foreach ($extensions as $ext) { $num = trim((string)$ext['extension']); @@ -316,7 +333,7 @@ class ConfigGenerator $lines[] = "max_contacts = {$maxContacts}"; $lines[] = "remove_existing = yes"; $lines[] = "default_expiration = {$regExpiration}"; - $lines[] = "maximum_expiration = {$regExpiration}"; + $lines[] = "maximum_expiration = {$regMaxExpiration}"; $lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60); $lines[] = "qualify_frequency = 30"; if ($hasVm) { From d229aaf8168365cbfba69e0908d60555e33f3e37 Mon Sep 17 00:00:00 2001 From: sorcier Date: Wed, 11 Nov 2026 12:40:28 +0100 Subject: [PATCH 36/37] feat(ring-groups): allow direct voicemail redirection on no-answer failover Add vm- destination support in UI destination picker, API validation, and [app-voicemail] Asterisk dialplan context. Refs #2 --- public/api.php | 8 ++++++++ public/assets/app.js | 22 +++++++++++++++++++++- src/ConfigGenerator.php | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/public/api.php b/public/api.php index 9149093..a8be442 100644 --- a/public/api.php +++ b/public/api.php @@ -544,6 +544,14 @@ try { if ($name === '') { throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire."); } + if (str_starts_with($failover, 'vm-')) { + $vmTarget = substr($failover, 3); + $vmStmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1'); + $vmStmt->execute([':ext' => $vmTarget]); + if (!(int)$vmStmt->fetchColumn()) { + throw new InvalidArgumentException("Le répondeur du poste {$vmTarget} n'existe pas ou n'est pas activé."); + } + } if ($id) { $stmt = $db->prepare(' diff --git a/public/assets/app.js b/public/assets/app.js index 7ade917..8568ae4 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1234,6 +1234,9 @@ function renderGroupsTable() { const badges = members.length ? members.map(m => `${escapeHtml(m)}`).join(' ') : 'Aucun membre'; + const failoverLabel = String(grp.failover_dest || '').startsWith('vm-') + ? `📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}` + : `${escapeHtml(grp.failover_dest)}`; return ` @@ -1241,7 +1244,7 @@ function renderGroupsTable() { ${escapeHtml(grp.name)} ${escapeHtml(grp.strategy)}
${badges}
- ${Number(grp.ring_time)}s → ${escapeHtml(grp.failover_dest)} + ${Number(grp.ring_time)}s → ${failoverLabel} @@ -1893,6 +1896,21 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') { html += ``; } + const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1); + const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal); + if (vmExts.length > 0 || hasMissingVm) { + html += ``; + if (hasMissingVm) { + const missingExt = normVal.slice(3); + html += ``; + } + vmExts.forEach(e => { + const val = `vm-${e.extension}`; + html += ``; + }); + html += ``; + } + // Piège à Spammeurs / Musique d'attente en boucle continue (MoH) const mohSounds = sounds.filter(s => s.usage_type === 'moh'); html += ``; @@ -1948,6 +1966,8 @@ function styleDestinationSelect(selectEl) { selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold'; } else if (val.startsWith('grp-')) { selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold'; + } else if (val.startsWith('vm-')) { + selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-300 font-semibold'; } else { selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold'; } diff --git a/src/ConfigGenerator.php b/src/ConfigGenerator.php index 6ef1b0d..eae062a 100644 --- a/src/ConfigGenerator.php +++ b/src/ConfigGenerator.php @@ -1686,6 +1686,30 @@ class ConfigGenerator $lines[] = " same => n,Hangup()"; $lines[] = ""; + // 5b. Contexte dédié Dépôt Direct sur Boîte Vocale (Redirection sans sonnerie) + $lines[] = "; ==========================================================================="; + $lines[] = "; DÉPÔT DIRECT SUR BOÎTE VOCALE (REDIRECTION / DÉBORDEMENT)"; + $lines[] = "; ==========================================================================="; + $lines[] = "[app-voicemail]"; + foreach ($extensions as $ext) { + $num = trim((string)$ext['extension']); + if (empty($ext['voicemail_enabled'])) { + continue; + } + $lines[] = "exten => {$num},1,NoOp(Redirection directe vers messagerie vocale du poste {$num})"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Wait(0.5)"; + $lines[] = " same => n,VoiceMail({$num}@default,u)"; + $lines[] = " same => n,Hangup()"; + } + $lines[] = "exten => _X.,1,NoOp(Boite vocale inexistante ou desactivee pour \${EXTEN})"; + $lines[] = " same => n,Set(CHANNEL(language)={$sysLang})"; + $lines[] = " same => n,Answer()"; + $lines[] = " same => n,Playback(vm-incorrect-mailbox)"; + $lines[] = " same => n,Hangup()"; + $lines[] = ""; + return implode("\n", $lines) . "\n"; } @@ -2328,6 +2352,17 @@ EOF; if (preg_match('/^\d+$/', $dest)) { return "Goto(from-internal,{$dest},1)"; } + if (str_starts_with($dest, 'vm-')) { + $vmExt = preg_replace('/[^0-9A-Za-z_-]/', '', substr($dest, 3)); + if ($vmExt !== '') { + $stmt = $this->db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1'); + $stmt->execute([':ext' => $vmExt]); + if ($stmt->fetchColumn()) { + return "Goto(app-voicemail,{$vmExt},1)"; + } + } + return 'Goto(app-terminate,hangup,1)'; + } if (str_starts_with($dest, 'page-')) { $pageExt = substr($dest, 5); return "Goto(from-internal,{$pageExt},1)"; From 7922e338e38f42801e64d27290f5dd38c182d133 Mon Sep 17 00:00:00 2001 From: sorcier Date: Wed, 11 Nov 2026 13:04:21 +0100 Subject: [PATCH 37/37] docs(release): finalize v2.0.0 Pulsar changelog and release date --- version.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/version.json b/version.json index 8f579eb..d0d9a6c 100644 --- a/version.json +++ b/version.json @@ -1,16 +1,18 @@ { "version": "2.0.0", "codename": "Pulsar", - "release_date": "2026-11-06", + "release_date": "2026-11-11", "min_php_version": "8.2", "changelog": [ "NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience", "Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP", - "HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby éliminant tout blocage ou désynchronisation des touches BLF (Yealink, Fanvil, Snom)", + "HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média", + "HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover", "HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master", - "HA Télécom : réplication atomique SQLite non-bloquante (VACUUM INTO) et synchronisation AstDB à chaud", + "HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)", + "Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks", + "Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-) en débordement, SVI et routes entrantes", "Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée", - "Paramètres SIP : fiabilisation du rtp_timeout_hold exclusif aux flux audio RTP", - "Génération AOR PJSIP : plafonnement de réenregistrement automatique adapté au mode HA (120s recommandé)" + "Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs" ] }