db = $db ?? Database::getConnection(); } /** * Récupère la configuration actuelle du Cluster */ public function getSettings(): array { $stmt = $this->db->query('SELECT * FROM cluster_settings WHERE id = 1'); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return [ 'enabled' => 0, 'role' => 'master', 'virtual_ip' => '', 'virtual_netmask' => '24', 'interface' => 'eth0', 'peer_ip' => '', 'cluster_secret' => bin2hex(random_bytes(16)), 'vrid' => 51, 'priority' => 100, 'sync_interval' => 15, 'auto_sync' => 1, 'last_sync_at' => null, 'last_sync_status' => 'none', 'last_sync_message' => '', ]; } return $row; } /** * Sauvegarde la configuration du cluster */ public function saveSettings(array $data): array { $enabled = !empty($data['enabled']) ? 1 : 0; $role = in_array($data['role'] ?? '', ['master', 'backup'], true) ? $data['role'] : 'master'; $virtualIp = trim((string)($data['virtual_ip'] ?? '')); $virtualNetmask = preg_replace('/[^0-9]/', '', (string)($data['virtual_netmask'] ?? '24')) ?: '24'; $interface = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', trim((string)($data['interface'] ?? 'eth0'))) ?: 'eth0'; $peerIp = trim((string)($data['peer_ip'] ?? '')); $clusterSecret = trim((string)($data['cluster_secret'] ?? '')); $vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); $autoSync = !empty($data['auto_sync']) ? 1 : 0; if ($clusterSecret === '') { $clusterSecret = bin2hex(random_bytes(16)); } if ($enabled && $virtualIp === '') { throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA."); } $stmt = $this->db->prepare(' UPDATE cluster_settings SET enabled = :enabled, role = :role, virtual_ip = :vip, virtual_netmask = :vmask, interface = :iface, peer_ip = :peer, cluster_secret = :secret, vrid = :vrid, priority = :prio, sync_interval = :sync_int, auto_sync = :auto_sync, updated_at = CURRENT_TIMESTAMP WHERE id = 1 '); $stmt->execute([ ':enabled' => $enabled, ':role' => $role, ':vip' => $virtualIp, ':vmask' => $virtualNetmask, ':iface' => $interface, ':peer' => $peerIp, ':secret' => $clusterSecret, ':vrid' => $vrid, ':prio' => $priority, ':sync_int' => $syncInterval, ':auto_sync' => $autoSync, ]); // Application automatique de la configuration Keepalived si demandé $applyResult = $this->applyKeepalivedConfig(); return [ 'success' => true, 'message' => 'Configuration Haute Disponibilité enregistrée.', 'apply_result' => $applyResult, ]; } /** * Détecte les interfaces réseau physiques et virtuelles locales avec leurs IP */ public function detectNetworkInterfaces(): array { $interfaces = []; $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { $line = trim($line); if ($line === '') { continue; } // Format typique ip -o: "2: eth0 inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0\..." if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) { $iface = $matches[1]; $ip = $matches[2]; $mask = $matches[3]; if ($iface === 'lo') { continue; // Ignorer loopback } if (!isset($interfaces[$iface])) { $interfaces[$iface] = [ 'name' => $iface, 'ips' => [], 'primary' => $ip, 'netmask' => $mask, ]; } $interfaces[$iface]['ips'][] = "{$ip}/{$mask}"; } } return array_values($interfaces); } /** * Vérifie si l'adresse IP Virtuelle (VIP) est actuellement active sur cette machine locale */ public function isVirtualIpActive(string $vip): bool { if ($vip === '') { return false; } $escapedVip = escapeshellarg($vip); $output = shell_exec("ip addr show 2>/dev/null | grep -F " . $escapedVip) ?: ''; return trim($output) !== ''; } /** * Retourne l'état complet en temps réel du Cluster */ public function getClusterStatus(): array { $settings = $this->getSettings(); $vip = $settings['virtual_ip'] ?? ''; $peerIp = $settings['peer_ip'] ?? ''; $isVipActive = $this->isVirtualIpActive($vip); // 1. Vérification de Keepalived $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); $keepalivedRunning = false; if ($keepalivedInstalled) { $psOut = shell_exec('pidof keepalived 2>/dev/null') ?: ''; $keepalivedRunning = trim($psOut) !== ''; } // 2. Vérification d'Asterisk $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; $asteriskPing = false; if ($asteriskRunning) { $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: ''; $asteriskPing = str_contains($pingOut, 'Pong'); } // 3. Test de connectivité vers le nœud pair (Peer Ping) $peerReachable = false; $peerPingMs = null; if ($peerIp !== '') { $escapedPeer = escapeshellarg($peerIp); $pingOut = shell_exec("ping -c 1 -W 1 {$escapedPeer} 2>&1") ?: ''; if (preg_match('/time=([0-9.]+)\s*ms/', $pingOut, $pm)) { $peerReachable = true; $peerPingMs = (float)$pm[1]; } elseif (str_contains($pingOut, '1 received') || str_contains($pingOut, '1 packets received')) { $peerReachable = true; } } // Rôle réel effectif $effectiveState = 'STANDALONE'; if (!empty($settings['enabled'])) { if ($isVipActive) { $effectiveState = 'MASTER'; } else { $effectiveState = 'BACKUP'; } } // Dernières lignes du log cluster $recentLogs = []; if (file_exists(self::CLUSTER_LOG_PATH)) { $lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $recentLogs = array_slice($lines, -15); } return [ 'enabled' => (bool)$settings['enabled'], 'configured_role' => $settings['role'], 'effective_state' => $effectiveState, 'virtual_ip' => $vip, 'virtual_ip_active' => $isVipActive, 'interface' => $settings['interface'], 'peer_ip' => $peerIp, 'peer_reachable' => $peerReachable, 'peer_ping_ms' => $peerPingMs, 'keepalived_installed' => $keepalivedInstalled, 'keepalived_running' => $keepalivedRunning, 'asterisk_running' => $asteriskRunning, 'asterisk_healthy' => $asteriskPing, 'last_sync_at' => $settings['last_sync_at'], 'last_sync_status' => $settings['last_sync_status'], 'last_sync_message' => $settings['last_sync_message'], 'cluster_secret' => $settings['cluster_secret'], 'recent_logs' => array_reverse($recentLogs), ]; } /** * Génère la configuration officielle /etc/keepalived/keepalived.conf */ public function generateKeepalivedConfig(): string { $settings = $this->getSettings(); $role = strtoupper($settings['role'] ?? 'MASTER'); $iface = $settings['interface'] ?? 'eth0'; $vip = $settings['virtual_ip'] ?? ''; $vmask = $settings['virtual_netmask'] ?? '24'; $vrid = (int)($settings['vrid'] ?? 51); $prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90)); $secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass $conf = []; $conf[] = "! =============================================================================="; $conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA"; $conf[] = "! Date de génération : " . date('Y-m-d H:i:s'); $conf[] = "! =============================================================================="; $conf[] = ""; $conf[] = "global_defs {"; $conf[] = " router_id NEXUS_PBX_" . ($role === 'MASTER' ? '1' : '2'); $conf[] = " enable_script_security"; $conf[] = " script_user root"; $conf[] = "}"; $conf[] = ""; $conf[] = "vrrp_script chk_asterisk {"; $conf[] = " script \"/var/www/nexus-pbx/scripts/cluster/check_asterisk.sh\""; $conf[] = " interval 2"; $conf[] = " weight 2"; $conf[] = " fall 2"; $conf[] = " rise 2"; $conf[] = "}"; $conf[] = ""; $conf[] = "vrrp_instance VI_NEXUS {"; $conf[] = " state {$role}"; $conf[] = " interface {$iface}"; $conf[] = " virtual_router_id {$vrid}"; $conf[] = " priority {$prio}"; $conf[] = " advert_int 1"; $conf[] = ""; $conf[] = " authentication {"; $conf[] = " auth_type PASS"; $conf[] = " auth_pass {$secret}"; $conf[] = " }"; $conf[] = ""; $conf[] = " virtual_ipaddress {"; $conf[] = " {$vip}/{$vmask} dev {$iface}"; $conf[] = " }"; $conf[] = ""; $conf[] = " track_script {"; $conf[] = " chk_asterisk"; $conf[] = " }"; $conf[] = ""; $conf[] = " notify_master \"/var/www/nexus-pbx/scripts/cluster/notify_master.sh\""; $conf[] = " notify_backup \"/var/www/nexus-pbx/scripts/cluster/notify_backup.sh\""; $conf[] = " notify_fault \"/var/www/nexus-pbx/scripts/cluster/notify_fault.sh\""; $conf[] = "}"; $conf[] = ""; return implode("\n", $conf); } /** * Applique la configuration Keepalived sur le système Linux */ public function applyKeepalivedConfig(): array { $settings = $this->getSettings(); $enabled = !empty($settings['enabled']); if (!$enabled) { // Désactivation du service Keepalived si le cluster est désactivé shell_exec('systemctl stop keepalived 2>/dev/null || true'); shell_exec('systemctl disable keepalived 2>/dev/null || true'); return [ 'status' => 'disabled', 'message' => 'Cluster HA désactivé (service Keepalived stoppé).', ]; } if (empty($settings['virtual_ip'])) { return [ 'status' => 'error', 'message' => 'IP Virtuelle (VIP) non renseignée.', ]; } $confContent = $this->generateKeepalivedConfig(); if (!is_dir('/etc/keepalived')) { mkdir('/etc/keepalived', 0755, true); } file_put_contents(self::KEEPALIVED_CONF_PATH, $confContent); chmod(self::KEEPALIVED_CONF_PATH, 0600); // S'assure que les scripts de cluster sont bien exécutables shell_exec('chmod +x /var/www/nexus-pbx/scripts/cluster/*.sh 2>/dev/null || true'); // Activation et rechargement / démarrage de Keepalived shell_exec('systemctl enable keepalived 2>/dev/null || true'); shell_exec('systemctl restart keepalived 2>/dev/null || true'); return [ 'status' => 'active', 'message' => 'Configuration Keepalived générée et service redémarré avec succès.', ]; } /** * Pousse la synchronisation complète (SQLite + Custom Sounds) vers le Nœud pair */ public function pushSyncToPeer(): array { $settings = $this->getSettings(); $peerIp = trim((string)($settings['peer_ip'] ?? '')); $secret = trim((string)($settings['cluster_secret'] ?? '')); if ($peerIp === '') { throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); } // Création du paquet de données en mémoire ou fichier temporaire $dbPath = Database::getDatabasePath(); if (!file_exists($dbPath)) { throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } // Forcer le checkpoint SQLite WAL pour avoir un dump 100% à jour $this->db->exec('PRAGMA wal_checkpoint(FULL);'); $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite"; if (is_dir('/var/lib/asterisk/sounds/custom')) { $cmd .= " -C /var/lib/asterisk/sounds custom"; } shell_exec($cmd); if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { @unlink($tmpArchive); throw new RuntimeException("Échec de génération de l'archive de réplication."); } $archiveData = file_get_contents($tmpArchive); @unlink($tmpArchive); $payload = [ 'timestamp' => time(), 'archive_b64' => base64_encode($archiveData), ]; $signature = hash_hmac('sha256', json_encode($payload), $secret); $ch = curl_init("http://{$peerIp}/api.php?action=cluster_receive_sync"); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-Nexus-Cluster-Signature: ' . $signature, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($httpCode !== 200 || !empty($err)) { $errorMsg = "Échec d'envoi vers le pair {$peerIp} (HTTP {$httpCode}) : {$err}"; $this->updateSyncStatus('error', $errorMsg); throw new RuntimeException($errorMsg); } $resJson = json_decode((string)$response, true); if (empty($resJson['success'])) { $errorMsg = "Le pair a rejeté la réplication : " . ($resJson['error'] ?? 'Erreur inconnue'); $this->updateSyncStatus('error', $errorMsg); throw new RuntimeException($errorMsg); } $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); return [ 'success' => true, 'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp} avec succès.", ]; } /** * Réception et application du paquet de synchronisation envoyé par le pair */ public function receiveSyncPayload(string $rawBody, string $providedSignature): array { $settings = $this->getSettings(); $secret = trim((string)($settings['cluster_secret'] ?? '')); if ($secret === '') { throw new RuntimeException("Le secret de cluster n'est pas configuré sur ce serveur."); } // Vérification signature HMAC $expectedSignature = hash_hmac('sha256', $rawBody, $secret); if (!hash_equals($expectedSignature, $providedSignature)) { throw new RuntimeException("Signature de cluster invalide (accès refusé)."); } $data = json_decode($rawBody, true); if (empty($data['archive_b64'])) { throw new RuntimeException("Paquet de réplication vide ou corrompu."); } $archiveRaw = base64_decode($data['archive_b64']); $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; file_put_contents($tmpArchive, $archiveRaw); // Sauvegarde d'urgence de la DB actuelle avant écrasement $dbPath = Database::getDatabasePath(); if (file_exists($dbPath)) { copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); } // Fermeture de la connexion PDO pour libérer les verrous SQLite Database::resetConnection(); // Extraction de l'archive shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite 2>/dev/null || true"); if (is_dir('/var/lib/asterisk/sounds')) { shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true"); } @unlink($tmpArchive); // Réouverture de la connexion DB et régénération des configs Asterisk $newDb = Database::getConnection(); $this->db = $newDb; // Préserver le rôle local configuré (pour ne pas qu'un master force l'esclave à se croire master) $localRole = $settings['role'] ?? 'backup'; $newDb->exec("UPDATE cluster_settings SET role = '{$localRole}', last_sync_at = CURRENT_TIMESTAMP, last_sync_status = 'synced', last_sync_message = 'Base et sons synchronisés depuis le Master' WHERE id = 1"); // Régénérer les configurations PJSIP et Asterisk $generator = new ConfigGenerator($newDb); $generator->generateAndApply(); return [ 'success' => true, 'message' => 'Base de données et fichiers audio appliqués avec succès.', ]; } /** * Bascule manuelle de rôle (Switchover) */ public function triggerSwitchover(): array { $settings = $this->getSettings(); $currentRole = $settings['role'] ?? 'master'; $newRole = ($currentRole === 'master') ? 'backup' : 'master'; $newPrio = ($newRole === 'master') ? 100 : 90; $settings['role'] = $newRole; $settings['priority'] = $newPrio; return $this->saveSettings($settings); } private function updateSyncStatus(string $status, string $message): void { $stmt = $this->db->prepare(' UPDATE cluster_settings SET last_sync_at = CURRENT_TIMESTAMP, last_sync_status = :status, last_sync_message = :msg WHERE id = 1 '); $stmt->execute([':status' => $status, ':msg' => $message]); } }