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' => '', 'peer_port' => 8443, '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' => '', ]; } if (empty($row['peer_port'])) { $row['peer_port'] = 8443; } 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)))); $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, 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, ':sync_int' => $syncInterval, ':auto_sync' => $autoSync, ]); // Application automatique de la configuration Keepalived $applyResult = $this->applyKeepalivedConfig(); 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 { if ($vip === '') { return false; } $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { if (preg_match('/\sinet\s+' . preg_quote($vip, '/') . '\//', $line)) { 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) { $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: shell_exec("sudo -n /usr/sbin/asterisk -rx 'core ping' 2>/dev/null") ?: ''; $asteriskPing = str_contains($pingOut, 'Pong'); } // 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 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[] = ""; // 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}"); } // Forcer le checkpoint SQLite WAL pour un dump intègre $this->db->exec('PRAGMA wal_checkpoint(FULL);'); $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; $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), ]; $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', 'X-Nexus-Cluster-Signature: ' . $signature, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($httpCode === 200 && !empty($response)) { $resJson = json_decode((string)$response, true); if (!empty($resJson['success'])) { $success = true; break; } $lastError = "Rejet par le pair ({$targetUrl}) : " . ($resJson['message'] ?? $resJson['error'] ?? 'Erreur inconnue'); } else { $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); // Sauvegarde d'urgence de la base actuelle avant écrasement $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) {} // Libérer les verrous PDO 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 $newDb = Database::getConnection(); $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, 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, ':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); // Si le cluster est actif, actualiser le service Keepalived if (!empty($localClusterSettings['enabled'])) { $this->applyKeepalivedConfig(); } return [ 'success' => true, 'message' => 'Base de données et fichiers audio appliqués avec succès.', ]; } /** * 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]); } }