diff --git a/.gitignore b/.gitignore index 2dbd9da..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 @@ -38,3 +40,8 @@ Thumbs.db .idea/ .vscode/ +# Environnement local de développement (exclu de Git) +/.dev +/.env +/.env.local + diff --git a/install.sh b/install.sh index 9ca2b79..e13201c 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,11 @@ apt-get install -y --no-install-recommends \ sox \ sshpass +# Composants optionnels Haute Disponibilité (Cluster HA) +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 + # Vérification et installation d'Asterisk echo -e "${C_GREEN} -> Installation du moteur Asterisk PBX...${C_RESET}" if ! apt-cache policy asterisk 2>/dev/null | grep -q "Candidate: [0-9]"; then @@ -126,11 +131,14 @@ fi echo -e "${C_GREEN}[2/8] Préparation et déploiement des fichiers NexusPBX...${C_RESET}" TARGET_DIR="/var/www/nexus-pbx" +INSTALL_BRANCH="${1:-${INSTALL_BRANCH:-dev}}" + if [ -d "$TARGET_DIR/.git" ]; then - echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour..." + echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour (branche: $INSTALL_BRANCH)..." cd "$TARGET_DIR" git config --global --add safe.directory "$TARGET_DIR" 2>/dev/null || true - git pull origin main 2>/dev/null || true + git checkout "$INSTALL_BRANCH" 2>/dev/null || true + git pull origin "$INSTALL_BRANCH" 2>/dev/null || true elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then echo " -> Exécution depuis le dossier source, synchronisation vers $TARGET_DIR..." if [ "$PWD" != "$TARGET_DIR" ]; then @@ -139,13 +147,17 @@ elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then cd "$TARGET_DIR" fi else - echo " -> Clonage du dépôt public officiel depuis https://git.sorcier-research.com..." + echo " -> Clonage du dépôt public officiel (branche: $INSTALL_BRANCH)..." rm -rf "$TARGET_DIR" - git clone https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR" + git clone -b "$INSTALL_BRANCH" https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR" git config --system --add safe.directory "$TARGET_DIR" 2>/dev/null || true cd "$TARGET_DIR" fi +if [ "$INSTALL_BRANCH" = "dev" ]; then + touch "$TARGET_DIR/.dev" +fi + # 4. Configuration des utilisateurs, groupes et arborescence de stockage echo -e "${C_GREEN}[3/8] Configuration des permissions et de l'arborescence...${C_RESET}" usermod -aG asterisk www-data 2>/dev/null || true @@ -161,6 +173,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/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 @@ -189,7 +207,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 @@ -201,7 +219,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 @@ -470,11 +488,28 @@ 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 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 +*/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 + # 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 + +# 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 @@ -489,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 c86c05e..a8be442 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,13 +35,13 @@ 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'); try { $db = Database::getConnection(); - Auth::startSession(); $action = trim((string)($_GET['action'] ?? '')); $rawBody = file_get_contents('php://input'); @@ -39,6 +50,39 @@ 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; + } + + 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 + // 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'] ?? '')); @@ -74,6 +118,7 @@ try { exit; } + // Toutes les autres actions requièrent une session authentifiée if (!Auth::isAuthenticated()) { http_response_code(401); @@ -403,6 +448,87 @@ try { ]); break; + case 'check_extension_call_status': + $extension = trim((string)($payload['extension'] ?? '')); + if ($extension === '') { + throw new InvalidArgumentException("Extension requise."); + } + + $inCall = false; + $callDetails = ''; + try { + $ami = AmiClient::fromDatabase($db); + $rawChannels = $ami->runCommand("core show channels concise"); + $channelLines = array_filter(explode("\n", trim($rawChannels))); + foreach ($channelLines as $cline) { + $parts = explode('!', trim($cline)); + $chanName = $parts[0] ?? ''; + if (preg_match('#^PJSIP/' . preg_quote($extension, '#') . '[-/]#i', $chanName)) { + $inCall = true; + $state = $parts[4] ?? 'Up'; + $callerId = $parts[7] ?? ''; + $duration = $parts[10] ?? ''; + $callDetails = "Canal : {$chanName}"; + if ($callerId !== '') { + $callDetails .= " • Interlocuteur : {$callerId}"; + } + if ($duration !== '') { + $callDetails .= " • Durée : {$duration}"; + } + break; + } + } + + if (!$inCall) { + $rawHints = $ami->runCommand("core show hints {$extension}"); + if (preg_match('/' . preg_quote($extension, '/') . '@[^\n]*State:(InUse|Busy|Ringing)/i', $rawHints, $m)) { + $inCall = true; + $callDetails = "Ligne active : " . ($m[1] === 'Ringing' ? 'En sonnerie' : 'En communication'); + } + } + } catch (\Throwable $e) { + // AMI indisponible + } + + echo json_encode([ + 'success' => true, + 'extension' => $extension, + 'in_call' => $inCall, + 'call_details' => $callDetails, + ]); + break; + + case 'reboot_extension': + $extension = trim((string)($payload['extension'] ?? '')); + if ($extension === '') { + throw new InvalidArgumentException("Extension requise."); + } + + $stmt = $db->prepare('SELECT extension, name FROM extensions WHERE extension = :ext'); + $stmt->execute([':ext' => $extension]); + $extRow = $stmt->fetch(); + if (!$extRow) { + throw new InvalidArgumentException("Poste {$extension} introuvable."); + } + + $cleanExt = preg_replace('/[^a-zA-Z0-9_\-]/', '', $extension); + + try { + $ami = AmiClient::fromDatabase($db); + $cmdOutput = $ami->runCommand("pjsip send notify reboot endpoint {$cleanExt}"); + if (stripos($cmdOutput, 'Unable to') !== false) { + throw new RuntimeException("Échec Asterisk : " . trim($cmdOutput)); + } + echo json_encode([ + 'success' => true, + 'message' => "Ordre de redémarrage envoyé au poste {$extension} ({$extRow['name']}).", + 'details' => trim($cmdOutput), + ]); + } catch (\Throwable $e) { + throw new RuntimeException("Erreur lors de l'envoi de la commande de redémarrage : " . $e->getMessage()); + } + break; + case 'save_group': $id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null; $extension = trim((string)($payload['extension'] ?? '')); @@ -418,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(' @@ -1738,6 +1872,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']; @@ -1771,12 +1913,33 @@ 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 { + $cm = new ClusterManager($db); + $cSet = $cm->getSettings(); + $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'; + } + } 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; @@ -1972,6 +2135,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; @@ -2006,6 +2170,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 @@ -2023,9 +2188,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, ]); @@ -2414,6 +2580,40 @@ try { } break; + case 'cluster_status': + $cm = new ClusterManager($db); + echo json_encode([ + 'success' => true, + 'status' => $cm->getClusterStatus(), + ]); + break; + + case 'cluster_interfaces': + $cm = new ClusterManager($db); + echo json_encode([ + 'success' => true, + 'interfaces' => $cm->detectNetworkInterfaces(), + ]); + break; + + case 'cluster_save': + $cm = new ClusterManager($db); + $res = $cm->saveSettings($payload); + echo json_encode($res); + break; + + case 'cluster_sync_now': + $cm = new ClusterManager($db); + $res = $cm->pushSyncToPeer(); + echo json_encode($res); + break; + + case 'cluster_switchover': + $cm = new ClusterManager($db); + $res = $cm->triggerSwitchover(); + echo json_encode($res); + break; + default: http_response_code(400); echo json_encode([ diff --git a/public/assets/app.js b/public/assets/app.js index 93274ac..8568ae4 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -951,9 +951,10 @@ function renderExtensionsTable() { ${escapeHtml(ext.secret)} - - - + + + + `; @@ -1109,6 +1110,90 @@ async function deleteExtension(id, optNum = null) { } } +let pendingRebootExtension = null; + +async function promptRebootExtension(id) { + const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id)); + if (!ext) return; + + pendingRebootExtension = ext; + + document.getElementById('reboot-modal-ext-num').textContent = ext.extension; + document.getElementById('reboot-modal-ext-name').textContent = ext.name || ''; + + const checkingEl = document.getElementById('reboot-modal-checking'); + const warningEl = document.getElementById('reboot-modal-call-warning'); + const normalEl = document.getElementById('reboot-modal-normal-info'); + const btnAction = document.getElementById('btn-confirm-reboot-action'); + const btnLabel = document.getElementById('reboot-confirm-btn-label'); + + checkingEl.classList.remove('hidden'); + warningEl.classList.add('hidden'); + normalEl.classList.add('hidden'); + + btnAction.disabled = true; + btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2'; + btnLabel.textContent = 'Vérification en cours...'; + + openModal('modal-confirm-reboot-extension'); + + try { + const res = await apiCall('check_extension_call_status', { extension: ext.extension }); + checkingEl.classList.add('hidden'); + btnAction.disabled = false; + + if (res.in_call) { + warningEl.classList.remove('hidden'); + normalEl.classList.add('hidden'); + + const detailsEl = document.getElementById('reboot-modal-call-details'); + if (detailsEl) { + detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.'; + } + + btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2'; + btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)"; + } else { + warningEl.classList.add('hidden'); + normalEl.classList.remove('hidden'); + + btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2'; + btnLabel.textContent = 'Confirmer le redémarrage'; + } + } catch (err) { + checkingEl.classList.add('hidden'); + normalEl.classList.remove('hidden'); + btnAction.disabled = false; + btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2'; + btnLabel.textContent = 'Confirmer le redémarrage'; + } +} + +function closeRebootModal() { + pendingRebootExtension = null; + closeModal('modal-confirm-reboot-extension'); +} + +async function confirmRebootExtension() { + if (!pendingRebootExtension) return; + const ext = pendingRebootExtension; + const btnAction = document.getElementById('btn-confirm-reboot-action'); + const btnLabel = document.getElementById('reboot-confirm-btn-label'); + + if (btnAction) btnAction.disabled = true; + if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...'; + + try { + const res = await apiCall('reboot_extension', { extension: ext.extension }); + closeRebootModal(); + showToast(res.message, 'success'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + if (btnAction) btnAction.disabled = false; + } +} + function syncEncryptionToggle(transport, currentVal = null) { const sel = document.getElementById('ext-encryption'); if (!sel) return; @@ -1149,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 ` @@ -1156,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} @@ -1808,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 += ``; @@ -1863,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'; } @@ -3523,7 +3628,8 @@ const PAGE_TITLES = { certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)', system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf', backups: 'Sauvegardes & Restauration Système (Disaster Recovery)', - firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)' + firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)', + cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)' }; function switchTab(tabId) { @@ -3574,7 +3680,7 @@ function switchTab(tabId) { } // Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié - if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') { + if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall' || tabId === 'cluster') { const submenu = document.getElementById('submenu-system'); const arrow = document.getElementById('arrow-menu-system'); if (submenu && submenu.classList.contains('hidden')) { @@ -3598,6 +3704,11 @@ function switchTab(tabId) { loadFirewallStatus(); } + // Si onglet Cluster HA, charger les interfaces et le statut en direct + if (tabId === 'cluster') { + loadCluster(); + } + toggleMobileSidebar(false); } @@ -4146,6 +4257,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 }; @@ -4607,6 +4719,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'; @@ -6318,3 +6435,367 @@ async function confirmClearCdr() { } } +/* ============================================================================== + MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED) + ============================================================================== */ + +let currentClusterData = null; + +async function loadCluster() { + await loadClusterInterfaces(); + await refreshClusterStatus(); +} + +async function loadClusterInterfaces() { + try { + const res = await apiCall('cluster_interfaces'); + const select = document.getElementById('cluster-interface'); + if (!select || !res.interfaces) return; + + const currentVal = select.value; + select.innerHTML = res.interfaces.map(iface => { + const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)'; + return ``; + }).join(''); + + if (currentVal && res.interfaces.some(i => i.name === currentVal)) { + select.value = currentVal; + } + } catch (e) { + console.error("Erreur lors du chargement des interfaces réseau :", e); + } +} + +async function refreshClusterStatus() { + try { + const res = await apiCall('cluster_status'); + if (!res.success || !res.status) return; + + const st = res.status; + currentClusterData = st; + + // Formulaire - Mise à jour des valeurs + const chkEnabled = document.getElementById('cluster-enabled'); + if (chkEnabled) chkEnabled.checked = Boolean(st.enabled); + + const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`); + if (radioRole) radioRole.checked = true; + + const ifaceSelect = document.getElementById('cluster-interface'); + if (ifaceSelect && st.interface) ifaceSelect.value = st.interface; + + 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; + + 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'); + const cardRoleBadge = document.getElementById('cluster-card-role-badge'); + const cardState = document.getElementById('cluster-card-state'); + const cardVipInd = document.getElementById('cluster-card-vip-indicator'); + const cardIface = document.getElementById('cluster-card-iface'); + const cardVip = document.getElementById('cluster-card-vip'); + const cardVrid = document.getElementById('cluster-card-vrid'); + const cardPeerStatus = document.getElementById('cluster-card-peer-status'); + const cardPeerIp = document.getElementById('cluster-card-peer-ip'); + const cardPeerPing = document.getElementById('cluster-card-peer-ping'); + const cardSyncStatus = document.getElementById('cluster-card-sync-status'); + const cardSyncTime = document.getElementById('cluster-card-sync-time'); + + if (cardVrid) cardVrid.textContent = st.vrid || '51'; + if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`; + if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée'; + + if (cardRoleBadge) { + cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase(); + } + + if (st.enabled) { + if (st.effective_state === 'MASTER') { + if (headerBadge) { + headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'; + headerBadge.textContent = 'ACTIF (MASTER)'; + } + if (navBadge) { + navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold'; + navBadge.textContent = 'MASTER'; + } + if (cardState) { + cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide'; + cardState.textContent = 'MASTER (ACTIF)'; + } + if (cardVipInd) { + cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800'; + cardVipInd.textContent = 'VIP: Portée'; + } + } else { + if (headerBadge) { + headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30'; + headerBadge.textContent = 'STANDBY (EN VEILLE)'; + } + if (navBadge) { + navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold'; + navBadge.textContent = 'STANDBY'; + } + if (cardState) { + cardState.className = 'text-lg font-bold text-blue-400 tracking-wide'; + cardState.textContent = 'STANDBY (VEILLE)'; + } + if (cardVipInd) { + cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400'; + cardVipInd.textContent = 'VIP: Écoute'; + } + } + } else { + if (headerBadge) { + headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300'; + headerBadge.textContent = 'DÉSACTIVÉ'; + } + if (navBadge) { + navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold'; + navBadge.textContent = 'OFF'; + } + if (cardState) { + cardState.className = 'text-lg font-bold text-slate-400 tracking-wide'; + cardState.textContent = 'STANDALONE'; + } + if (cardVipInd) { + cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400'; + cardVipInd.textContent = 'VIP: Inactive'; + } + } + + // Nœud Distant (Peer) + if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré'; + if (cardPeerStatus) { + if (!st.peer_ip) { + cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300'; + cardPeerStatus.textContent = 'NON DÉFINI'; + } else if (st.peer_reachable) { + cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'; + cardPeerStatus.textContent = 'JOIGNABLE'; + } else { + cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30'; + cardPeerStatus.textContent = 'INJOIGNABLE'; + } + } + if (cardPeerPing) { + cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible'; + } + + // Synchronisation + if (cardSyncStatus) { + if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') { + cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate'; + cardSyncStatus.textContent = 'Synchronisation OK'; + } else if (st.last_sync_status === 'error') { + cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate'; + cardSyncStatus.textContent = 'Erreur réplication'; + } else { + cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate'; + cardSyncStatus.textContent = 'En attente'; + } + } + if (cardSyncTime) { + cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais'; + } + + // Services Système (Diagnostic) + const svcKeepalived = document.getElementById('cluster-svc-keepalived'); + if (svcKeepalived) { + if (!st.keepalived_installed) { + svcKeepalived.className = 'font-mono text-amber-400 font-bold'; + svcKeepalived.textContent = '⚠️ Non installé'; + } else if (st.keepalived_running) { + svcKeepalived.className = 'font-mono text-emerald-400 font-bold'; + svcKeepalived.textContent = '🟢 Actif (En cours)'; + } else { + svcKeepalived.className = 'font-mono text-slate-400'; + svcKeepalived.textContent = '⚪ Arrêté'; + } + } + + const svcAsterisk = document.getElementById('cluster-svc-asterisk'); + if (svcAsterisk) { + if (st.asterisk_healthy) { + svcAsterisk.className = 'font-mono text-emerald-400 font-bold'; + svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)'; + } else if (st.asterisk_running) { + svcAsterisk.className = 'font-mono text-amber-400 font-bold'; + svcAsterisk.textContent = '🟡 En cours (Ping en attente)'; + } else { + svcAsterisk.className = 'font-mono text-red-400 font-bold'; + svcAsterisk.textContent = '🔴 Arrêté'; + } + } + + const svcPeer = document.getElementById('cluster-svc-peer'); + if (svcPeer) { + if (!st.peer_ip) { + svcPeer.textContent = 'Non configuré'; + svcPeer.className = 'font-mono text-slate-500'; + } else if (st.peer_reachable) { + svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`; + svcPeer.className = 'font-mono text-emerald-400 font-bold'; + } else { + svcPeer.textContent = '🔴 Injoignable (Timeout)'; + svcPeer.className = 'font-mono text-red-400 font-bold'; + } + } + + // Journal de logs + const logsContainer = document.getElementById('cluster-logs-container'); + if (logsContainer && st.recent_logs) { + if (st.recent_logs.length > 0) { + logsContainer.innerHTML = st.recent_logs.map(line => { + let color = 'text-slate-300'; + if (line.includes('MASTER')) color = 'text-emerald-300 font-bold'; + if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300'; + if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold'; + return `
${escapeHtml(line)}
`; + }).join(''); + } else { + logsContainer.innerHTML = '

Aucun événement enregistré.

'; + } + } + + } catch (e) { + console.error("Erreur lors du rafraîchissement du cluster :", e); + } +} + +async function saveClusterSettings(e) { + e.preventDefault(); + const form = e.target; + const payload = { + enabled: form.elements['enabled'].checked ? 1 : 0, + role: form.elements['role'].value, + interface: form.elements['interface'].value, + 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, + blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120, + auto_sync: form.elements['auto_sync'].checked ? 1 : 0 + }; + + if (payload.enabled && !payload.virtual_ip) { + showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error'); + return; + } + + try { + const res = await apiCall('cluster_save', payload); + showToast(res.message || "Paramètres du cluster enregistrés.", 'success'); + refreshClusterStatus(); + } catch (err) { + showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error'); + } +} + +async function triggerClusterSyncNow() { + if (!currentClusterData || !currentClusterData.peer_ip) { + showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning'); + return; + } + showToast("Déclenchement de la réplication vers le pair...", 'info'); + try { + const res = await apiCall('cluster_sync_now', {}); + showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success'); + refreshClusterStatus(); + } catch (e) { + showToast(e.message || "Erreur lors de la synchronisation.", 'error'); + } +} + +async function triggerClusterSwitchover() { + if (!currentClusterData || !currentClusterData.enabled) { + showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning'); + return; + } + const currentRole = currentClusterData.configured_role || 'master'; + const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER'; + + if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) { + return; + } + + try { + const res = await apiCall('cluster_switchover', {}); + showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success'); + refreshClusterStatus(); + } catch (e) { + showToast(e.message || "Erreur lors de la bascule.", 'error'); + } +} + +function regenerateClusterSecret() { + const secretInput = document.getElementById('cluster-secret'); + if (!secretInput) return; + const array = new Uint8Array(16); + window.crypto.getRandomValues(array); + const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + secretInput.value = newSecret; + showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info'); +} + +function copyClusterSecret() { + const secretInput = document.getElementById('cluster-secret'); + if (!secretInput || !secretInput.value) return; + navigator.clipboard.writeText(secretInput.value).then(() => { + showToast("Jeton secret copié dans le presse-papier !", 'success'); + }).catch(() => { + secretInput.select(); + document.execCommand('copy'); + showToast("Jeton secret copié !", 'success'); + }); +} + + diff --git a/public/index.php b/public/index.php index 6f22497..a214264 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; @@ -18,7 +28,12 @@ $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') + || (trim((string)@shell_exec('git -C ' . escapeshellarg(__DIR__) . ' rev-parse --abbrev-ref HEAD 2>/dev/null')) === 'dev'); ?> @@ -26,7 +41,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 +177,12 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
NexusPBX Logo
-

NexusPBX

+

+ NexusPBX + + DEV + +

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

@@ -211,7 +231,10 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
NexusPBX
- v + v + + DEV +
Powered by Asterisk
@@ -442,6 +465,11 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; • Pare-feu & Fail2ban 0 + @@ -494,6 +522,13 @@ $appVersion = $appVerData['version'] ?? '1.5.0';
+ +
+ + DEV +
+ +

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

@@ -2520,7 +2555,7 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; -
+

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é).

+
@@ -2736,6 +2777,265 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; + + + + + @@ -5074,6 +5374,90 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; + + + + + diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh new file mode 100755 index 0000000..86ac16a --- /dev/null +++ b/scripts/cluster/check_asterisk.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# ============================================================================== +# 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 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 + +# 3. Tentative alternative via core show version +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 new file mode 100755 index 0000000..71dc4d5 --- /dev/null +++ b/scripts/cluster/notify_backup.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# ============================================================================== +# 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') + +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 + 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. 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 + +# 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 new file mode 100755 index 0000000..258bde7 --- /dev/null +++ b/scripts/cluster/notify_fault.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# ============================================================================== +# 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') + +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 + +# 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 new file mode 100755 index 0000000..451fce9 --- /dev/null +++ b/scripts/cluster/notify_master.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# ============================================================================== +# 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') + +echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE" + +# 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 + +# 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 + 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) +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. 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 "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 + /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 + +# 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") +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 + +# 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] 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" +exit 0 diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster new file mode 100755 index 0000000..d2b7154 --- /dev/null +++ b/scripts/nexus-cluster @@ -0,0 +1,142 @@ +#!/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 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) + 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 + 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" + ;; + + status) + if systemctl is-active --quiet keepalived 2>/dev/null; then + echo "RUNNING" + else + echo "STOPPED" + 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 + + # 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" + rm -f "$SRC" + exit 1 + 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 + # 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/%' 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 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 + + 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" + else + echo "DEGRADED" + fi + ;; + + *) + echo "Usage: $0 {apply|stop|status|check}" + exit 1 + ;; +esac diff --git a/scripts/nexus_cluster_sync.php b/scripts/nexus_cluster_sync.php new file mode 100644 index 0000000..8c6d8e5 --- /dev/null +++ b/scripts/nexus_cluster_sync.php @@ -0,0 +1,59 @@ +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)) { + exit(0); + } + + $db = Database::getConnection(); + $cm = new ClusterManager($db); + $st = $cm->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. 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'] ?? ''; + if (empty($vip) || !$cm->isVirtualIpActive($vip)) { + 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); +} + diff --git a/scripts/update.sh b/scripts/update.sh index 1a48da7..bbea215 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -102,26 +102,65 @@ 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 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 # 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 diff --git a/src/ClusterManager.php b/src/ClusterManager.php new file mode 100644 index 0000000..b43bb03 --- /dev/null +++ b/src/ClusterManager.php @@ -0,0 +1,1081 @@ +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) {} + } + + /** + * 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' => '', + 'peer_port' => 8443, + 'cluster_secret' => bin2hex(random_bytes(16)), + 'vrid' => 51, + 'priority' => 100, + 'blf_expiry' => 120, + 'sync_interval' => 15, + 'auto_sync' => 1, + 'last_sync_at' => null, + 'last_sync_status' => 'none', + 'last_sync_message' => '', + ]; + } + if (empty($row['peer_port'])) { + $row['peer_port'] = 8443; + } + if (empty($row['blf_expiry'])) { + $row['blf_expiry'] = 120; + } + 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'; + $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)))); + $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; + + // 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)); + } + + if ($enabled && $virtualIp === '') { + 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, + role = :role, + virtual_ip = :vip, + virtual_netmask = :vmask, + interface = :iface, + peer_ip = :peer, + peer_port = :peer_port, + cluster_secret = :secret, + vrid = :vrid, + priority = :prio, + blf_expiry = :blf_expiry, + 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, + ':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.', + '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; + } + 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; + } + + 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 + { + $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($cleanVip, '/') . '(\/|\s)/', $line)) { + return true; + } + } + return false; + } + + /** + * 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. Statut Keepalived + $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); + $keepalivedRunning = false; + if ($keepalivedInstalled) { + $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. Statut Asterisk + $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; + $asteriskPing = false; + if ($asteriskRunning) { + $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) + $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; + } + } + + // 4. Rôle effectif + $effectiveState = 'STANDALONE'; + if (!empty($settings['enabled'])) { + if ($isVipActive) { + $effectiveState = 'MASTER'; + } else { + $effectiveState = 'BACKUP'; + } + } + + // 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) ?: []; + $recentLogs = array_slice($lines, -15); + } + + return [ + 'enabled' => (bool)$settings['enabled'], + '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, + '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'; + $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"; + $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 \"/etc/keepalived/scripts/check_asterisk.sh\""; + $conf[] = " interval 2"; + $conf[] = " weight -20"; + $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[] = " 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) + 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}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " virtual_ipaddress {"; + $conf[] = " {$vip}/{$vmask} dev {$iface}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " track_script {"; + $conf[] = " chk_asterisk"; + $conf[] = " }"; + $conf[] = ""; + $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[] = ""; + + return implode("\n", $conf); + } + + /** + * Applique la configuration Keepalived sur le système Linux via le helper root + */ + public function applyKeepalivedConfig(): array + { + $settings = $this->getSettings(); + $enabled = !empty($settings['enabled']); + + if (!$enabled) { + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' stop 2>&1'); + 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(); + 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'), + ]; + } + + return [ + 'status' => 'active', + 'message' => 'Configuration Keepalived générée et service activé 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'] ?? '')); + $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 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'])) { + $localAdminPort = (int)$st['admin_https_port']; + } + } catch (\Throwable $e) {} + + // 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}"); + } + + // 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'; + $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) { + $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); + } + } + } + + // 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"; + } + if (is_dir('/var/spool/asterisk/voicemail')) { + $cmd .= " -C /var/spool/asterisk voicemail"; + } + if ($hasAstdb) { + $cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; + } + shell_exec($cmd); + + @unlink($tmpDbDir . '/database.sqlite'); + @rmdir($tmpDbDir); + if ($hasAstdb) { + @unlink($tmpAstdb); + } + + 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); + + // 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); + $signature = hash_hmac('sha256', $jsonPayload, $secret); + + // URLs d'accès candidates vers l'API du second nœud (priorité au port configuré) + $candidateUrls = [ + "https://{$peerIp}:{$peerPort}/api.php?action=cluster_receive_sync", + ]; + if ($peerPort !== $localAdminPort) { + $candidateUrls[] = "https://{$peerIp}:{$localAdminPort}/api.php?action=cluster_receive_sync"; + } + 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)); + + $lastError = ''; + $success = false; + + 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', + '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, 3); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + + $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 { + $resJson = json_decode((string)$response, true); + $detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : ""); + $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}"; + } + } + + if (!$success) { + $this->updateSyncStatus('error', $lastError); + throw new RuntimeException($lastError); + } + + $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}:{$peerPort} 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); + + // 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 synchronisation + $dbPath = Database::getDatabasePath(); + if (file_exists($dbPath)) { + @copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + } + + // 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) {} + + // 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'); + } + + @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."); + } + + // 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"); + } + + // 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"); + } + + // 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 fraîche + $newDb = Database::getConnection(true); + $this->db = $newDb; + + // 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, + peer_port = :peer_port, + priority = :priority, + 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 + WHERE id = 1 + '); + $updateStmt->execute([ + ':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', + ':vrid' => $localClusterSettings['vrid'] ?? 51, + ':blf_expiry' => (int)($localClusterSettings['blf_expiry'] ?? 120), + ':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 + 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'); + } + + // 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'); + } + + // Si le cluster est activé mais que Keepalived n'est pas encore démarré, le démarrer + if (!empty($localClusterSettings['enabled'])) { + $keepalivedRunning = trim((string)shell_exec('pidof keepalived 2>/dev/null')) !== ''; + if (!$keepalivedRunning) { + $this->applyKeepalivedConfig(); + } + } + + 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); + } + + /** + * 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(' + 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/ConfigGenerator.php b/src/ConfigGenerator.php index e37eedf..eae062a 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) {} @@ -60,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)"; @@ -67,9 +87,13 @@ class ConfigGenerator $lines[] = "; ==========================================================================="; $lines[] = ""; - $rtpTimeout = max(5, (int)($settings['rtp_timeout'] ?? 30)); - $rtpHoldTimeout = max(0, (int)($settings['rtp_timeout_hold'] ?? 0)); - $rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); + // 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", @@ -78,20 +102,12 @@ class ConfigGenerator "tcp_keepalive_probe_count = 3", ]; - // 0. Paramètres globaux PJSIP (Keepalive applicatif TLS/TCP toutes les 30s pour postes hors réseau / NAT) - $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[] = ""; - // 1. Transport UDP (Postes internes LAN & Trunks standards) $lines[] = "; --- TRANSPORT UDP (LAN / INTERNE) ---"; $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); @@ -105,6 +121,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[] = ""; @@ -114,7 +133,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); @@ -128,6 +147,9 @@ class ConfigGenerator if ($extUdpPort > 0) { $lines[] = "external_signaling_port = {$extUdpPort}"; } + } elseif ($haVip !== '') { + $lines[] = "external_media_address = {$haVip}"; + $lines[] = "external_signaling_address = {$haVip}"; } array_push($lines, ...$tcpKeepaliveLines); $lines[] = "allow_reload = no"; @@ -172,7 +194,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}"; @@ -199,6 +221,9 @@ class ConfigGenerator if ($extTlsPort > 0) { $lines[] = "external_signaling_port = {$extTlsPort}"; } + } elseif ($haVip !== '') { + $lines[] = "external_media_address = {$haVip}"; + $lines[] = "external_signaling_address = {$haVip}"; } array_push($lines, ...$tcpKeepaliveLines); $lines[] = "allow_reload = no"; @@ -208,6 +233,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 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']); $name = str_replace('"', '', trim((string)$ext['name'])); @@ -242,7 +279,11 @@ 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 (coupure réseau, NAT hors réseau) + + // 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(0, (int)($settings['rtp_timeout_hold'] ?? 0)); + $rtpKeepalive = max(0, (int)($settings['rtp_keepalive'] ?? 15)); $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; if ($rtpKeepalive > 0) { @@ -291,9 +332,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 = {$regMaxExpiration}"; + $lines[] = "minimum_expiration = " . ($isHaActive ? 30 : 60); $lines[] = "qualify_frequency = 30"; if ($hasVm) { $lines[] = "mailboxes = {$num}@default"; @@ -404,6 +445,8 @@ 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(0, (int)($settings['rtp_timeout_hold'] ?? 0)); $lines[] = "rtp_timeout = {$rtpTimeout}"; $lines[] = "rtp_timeout_hold = {$rtpHoldTimeout}"; if ($rtpKeepalive > 0) { @@ -1643,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"; } @@ -1781,9 +1848,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[] = "; ==========================================================================="; @@ -1796,7 +1864,7 @@ class ConfigGenerator $lines[] = "rtpchecksums = {$checksums}"; $lines[] = "strictrtp = {$strict}"; $lines[] = "rtptimeout = {$timeout}"; - $lines[] = "rtpholdtimeout = 300"; + $lines[] = "rtpholdtimeout = {$holdTimeout}"; if ($keepalive > 0) { $lines[] = "rtpkeepalive = {$keepalive}"; } @@ -1847,6 +1915,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) { @@ -1889,6 +1961,48 @@ 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 + +[reboot] +Event=>check-sync\;reboot=true + +[yealink-check-cfg] +Event=>check-sync\;reboot=false + +[yealink-reboot] +Event=>check-sync\;reboot=true + +[snom-check-cfg] +Event=>check-sync\;reboot=false + +[snom-reboot] +Event=>check-sync\;reboot=true + +[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)) { @@ -1901,6 +2015,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); @@ -1908,6 +2023,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(); @@ -2236,6 +2352,17 @@ class ConfigGenerator 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)"; diff --git a/src/Database.php b/src/Database.php index ff74a2e..0d8f0b7 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 s'assure des colonnes requises. + try { + self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1"); + } catch (\Throwable $e) { + self::initializeSchema(); + } } } @@ -82,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 0, rtp_keepalive INTEGER NOT NULL DEFAULT 15, allow_anonymous_sip INTEGER NOT NULL DEFAULT 0, last_applied_at DATETIME DEFAULT NULL, @@ -307,6 +315,29 @@ 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 '', + 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, + 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, + 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 +353,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(); @@ -361,6 +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 0", 'rtp_keepalive' => "INTEGER NOT NULL DEFAULT 15", 'allow_anonymous_sip' => "INTEGER NOT NULL DEFAULT 0", 'system_language' => "VARCHAR(16) NOT NULL DEFAULT 'fr'", @@ -749,8 +782,59 @@ 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 '', + 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, + 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 + ); + "); + + // 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) {} + + // 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)); + $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, + ]); + } } } diff --git a/version.json b/version.json index e4c8f1b..d0d9a6c 100644 --- a/version.json +++ b/version.json @@ -1,20 +1,18 @@ { - "version": "1.6.6", - "codename": "Helios", - "release_date": "2026-11-10", + "version": "2.0.0", + "codename": "Pulsar", + "release_date": "2026-11-11", "min_php_version": "8.2", "changelog": [ - "PJSIP Endpoints & Trunks : injection effective de rtp_timeout, rtp_timeout_hold, rtp_keepalive et SIP Session Timers (RFC 4028) sur chaque endpoint PJSIP pour raccrocher instantanément les canaux fantômes", - "PJSIP Global & Transports TLS/TCP : keepalive applicatif réduit à 30s (keep_alive_interval = 30), vérification expiration contacts toutes les 15s et TCP keepalives noyau (20s/5s/3) pour fiabiliser les BLF et postes hors réseau derrière NAT", - "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 : 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 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 et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs" ] }