From da0805fd5f7eedd30ad9f68d06d521da8c4b191a Mon Sep 17 00:00:00 2001 From: sorcier Date: Thu, 5 Nov 2026 19:22:16 +0100 Subject: [PATCH] fix(ha): orchestration Keepalived root, support unicast VRRP, preservation identite backup et synchro HTTPS --- install.sh | 14 +- scripts/cluster/check_asterisk.sh | 8 +- scripts/cluster/notify_backup.sh | 10 +- scripts/cluster/notify_fault.sh | 2 +- scripts/cluster/notify_master.sh | 10 +- scripts/nexus-cluster | 79 +++++++++ src/ClusterManager.php | 261 ++++++++++++++++++++++-------- 7 files changed, 302 insertions(+), 82 deletions(-) create mode 100755 scripts/nexus-cluster diff --git a/install.sh b/install.sh index 355fb34..fb07033 100755 --- a/install.sh +++ b/install.sh @@ -171,8 +171,12 @@ mkdir -p /var/lib/asterisk/sounds/custom mkdir -p /var/spool/asterisk/voicemail/default mkdir -p /var/log/asterisk /var/log/asterisk/cdr-csv mkdir -p /var/log/nexus-pbx -mkdir -p /etc/keepalived +mkdir -p /etc/keepalived/scripts mkdir -p "$TARGET_DIR/scripts/cluster" +touch /var/log/nexus_cluster.log +chmod 666 /var/log/nexus_cluster.log +sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true +echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true @@ -201,7 +205,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s # Droits sudoers pour l'interface web (www-data) cat << 'EOF' > /etc/sudoers.d/nexus-pbx # Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data -www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update +www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster EOF chmod 0440 /etc/sudoers.d/nexus-pbx @@ -213,7 +217,7 @@ for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php* cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf" [Service] ProtectSystem=false -ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt +ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt /etc/keepalived EOF_OVERRIDE fi done @@ -483,10 +487,12 @@ chmod 0644 /etc/cron.d/nexus-backup touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log # CLI symlinks -chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" +chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" "$TARGET_DIR/scripts/nexus-cluster" +chmod +x "$TARGET_DIR"/scripts/cluster/*.sh 2>/dev/null || true ln -sf "$TARGET_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner ln -sf "$TARGET_DIR/scripts/update.sh" /usr/local/bin/nexus-update +ln -sf "$TARGET_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster # Bannière interactive lors de chaque connexion terminal (SSH / Console) cat << 'EOF' > /etc/profile.d/nexus-motd.sh diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh index f6c9bd5..6966ff3 100755 --- a/scripts/cluster/check_asterisk.sh +++ b/scripts/cluster/check_asterisk.sh @@ -3,22 +3,22 @@ # NexusPBX HA - Script de surveillance de santé d'Asterisk pour Keepalived VRRP # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" # 1. Vérifie si le binaire et le démon asterisk tournent if ! pidof asterisk >/dev/null 2>&1; then exit 1 fi -# 2. Teste si Asterisk répond aux requêtes AMI/CLI (core ping) -PING_RESP=$(asterisk -rx "core ping" 2>/dev/null || true) +# 2. Teste si Asterisk répond aux requêtes CLI (core ping) +PING_RESP=$(/usr/sbin/asterisk -rx "core ping" 2>/dev/null || true) if echo "$PING_RESP" | grep -q "Pong"; then exit 0 fi # 3. Tentative alternative via core show version -if asterisk -rx "core show version" >/dev/null 2>&1; then +if /usr/sbin/asterisk -rx "core show version" >/dev/null 2>&1; then exit 0 fi exit 1 - diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh index a681889..10bb06a 100755 --- a/scripts/cluster/notify_backup.sh +++ b/scripts/cluster/notify_backup.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état BACKUP / STANDBY # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -15,6 +16,13 @@ if [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true fi +# 2. Désenregistrement des Trunks PJSIP pour éviter les conflits d'appels entrants avec le Master +if pidof asterisk >/dev/null 2>&1; then + /usr/sbin/asterisk -rx "pjsip send unregister *" >> "$LOG_FILE" 2>&1 || true + for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do + /usr/sbin/asterisk -rx "pjsip send unregister $reg" >> "$LOG_FILE" 2>&1 || true + done +fi + echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" exit 0 - diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh index 3406f29..a0bf969 100755 --- a/scripts/cluster/notify_fault.sh +++ b/scripts/cluster/notify_fault.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état FAULT / ERREUR # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -15,4 +16,3 @@ if [ -f "$DB_PATH" ]; then fi exit 0 - diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh index 84eb61f..12713c9 100755 --- a/scripts/cluster/notify_master.sh +++ b/scripts/cluster/notify_master.sh @@ -3,6 +3,7 @@ # NexusPBX HA - Notification Keepalived : Transition vers l'état MASTER # ============================================================================== set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" LOG_FILE="/var/log/nexus_cluster.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') @@ -17,8 +18,12 @@ fi # 2. Réactivation des enregistrements Trunks PJSIP et rechargement de la config if pidof asterisk >/dev/null 2>&1; then - asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true - asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true + /usr/sbin/asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true + /usr/sbin/asterisk -rx "pjsip send register *" >> "$LOG_FILE" 2>&1 || true + for reg in $(/usr/sbin/asterisk -rx "pjsip show registrations" 2>/dev/null | grep -o '[a-zA-Z0-9_\-]*-reg' | sort -u); do + /usr/sbin/asterisk -rx "pjsip send register $reg" >> "$LOG_FILE" 2>&1 || true + done + /usr/sbin/asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true fi # 3. Gratuitous ARP optionnel si arping est présent @@ -30,4 +35,3 @@ fi echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" exit 0 - diff --git a/scripts/nexus-cluster b/scripts/nexus-cluster new file mode 100755 index 0000000..86b398f --- /dev/null +++ b/scripts/nexus-cluster @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Script Helper Root pour l'orchestration Keepalived +# ============================================================================== +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" + +CMD="${1:-status}" + +case "$CMD" in + apply) + # 1. Activation kernel sysctl pour liaison IP non-locale (indispensable HA) + sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true + if [ ! -f /etc/sysctl.d/99-nexus-ha.conf ]; then + echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true + fi + + # 2. Vérification et installation éventuelle de Keepalived + if ! command -v keepalived >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq keepalived >/dev/null 2>&1 || true + fi + + # 3. Création et sécurisation stricte du dossier des scripts Keepalived (exigences strictes Keepalived 2.x) + mkdir -p /etc/keepalived/scripts + if [ -d "/var/www/nexus-pbx/scripts/cluster" ]; then + cp -f /var/www/nexus-pbx/scripts/cluster/*.sh /etc/keepalived/scripts/ 2>/dev/null || true + fi + chown -R root:root /etc/keepalived + chmod 755 /etc/keepalived + chmod 755 /etc/keepalived/scripts + chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true + + # 4. Déploiement de la configuration générée depuis /tmp + if [ -f "/tmp/keepalived.conf.tmp" ]; then + cp -f /tmp/keepalived.conf.tmp /etc/keepalived/keepalived.conf + chown root:root /etc/keepalived/keepalived.conf + chmod 644 /etc/keepalived/keepalived.conf + rm -f /tmp/keepalived.conf.tmp + fi + + # Fichier journal du cluster accessible en écriture + touch /var/log/nexus_cluster.log + chmod 666 /var/log/nexus_cluster.log + + # 5. Activation et démarrage du service + systemctl daemon-reload + systemctl unmask keepalived >/dev/null 2>&1 || true + systemctl enable keepalived >/dev/null 2>&1 || true + systemctl restart keepalived + echo "KEEPALIVED_APPLIED_OK" + ;; + + stop) + systemctl stop keepalived >/dev/null 2>&1 || true + systemctl disable keepalived >/dev/null 2>&1 || true + echo "KEEPALIVED_STOPPED_OK" + ;; + + status) + if systemctl is-active --quiet keepalived 2>/dev/null; then + echo "RUNNING" + else + echo "STOPPED" + fi + ;; + + check) + if pidof asterisk >/dev/null 2>&1 && /usr/sbin/asterisk -rx "core ping" 2>/dev/null | grep -q "Pong"; then + echo "HEALTHY" + else + echo "DEGRADED" + fi + ;; + + *) + echo "Usage: $0 {apply|stop|status|check}" + exit 1 + ;; +esac diff --git a/src/ClusterManager.php b/src/ClusterManager.php index a027564..070bb73 100644 --- a/src/ClusterManager.php +++ b/src/ClusterManager.php @@ -16,6 +16,7 @@ class ClusterManager private PDO $db; public const KEEPALIVED_CONF_PATH = '/etc/keepalived/keepalived.conf'; public const CLUSTER_LOG_PATH = '/var/log/nexus_cluster.log'; + public const CLUSTER_HELPER_PATH = '/var/www/nexus-pbx/scripts/nexus-cluster'; public function __construct(?PDO $db = null) { @@ -75,6 +76,22 @@ class ClusterManager throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA."); } + // Empêcher l'utilisateur de configurer l'IP locale de ce serveur comme IP du pair + if ($peerIp !== '') { + $localInterfaces = $this->detectNetworkInterfaces(); + foreach ($localInterfaces as $ifaceInfo) { + if (($ifaceInfo['primary'] ?? '') === $peerIp) { + throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud."); + } + foreach ($ifaceInfo['ips'] ?? [] as $cidr) { + $ipOnly = explode('/', $cidr)[0] ?? ''; + if ($ipOnly === $peerIp) { + throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud."); + } + } + } + } + $stmt = $this->db->prepare(' UPDATE cluster_settings SET enabled = :enabled, @@ -106,12 +123,12 @@ class ClusterManager ':auto_sync' => $autoSync, ]); - // Application automatique de la configuration Keepalived si demandé + // Application automatique de la configuration Keepalived $applyResult = $this->applyKeepalivedConfig(); return [ 'success' => true, - 'message' => 'Configuration Haute Disponibilité enregistrée.', + 'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.', 'apply_result' => $applyResult, ]; } @@ -129,14 +146,13 @@ class ClusterManager if ($line === '') { continue; } - // Format typique ip -o: "2: eth0 inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0\..." if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) { $iface = $matches[1]; $ip = $matches[2]; $mask = $matches[3]; if ($iface === 'lo') { - continue; // Ignorer loopback + continue; } if (!isset($interfaces[$iface])) { @@ -162,9 +178,13 @@ class ClusterManager if ($vip === '') { return false; } - $escapedVip = escapeshellarg($vip); - $output = shell_exec("ip addr show 2>/dev/null | grep -F " . $escapedVip) ?: ''; - return trim($output) !== ''; + $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; + foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { + if (preg_match('/\sinet\s+' . preg_quote($vip, '/') . '\//', $line)) { + return true; + } + } + return false; } /** @@ -177,19 +197,23 @@ class ClusterManager $peerIp = $settings['peer_ip'] ?? ''; $isVipActive = $this->isVirtualIpActive($vip); - // 1. Vérification de Keepalived + // 1. Statut Keepalived $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); $keepalivedRunning = false; if ($keepalivedInstalled) { - $psOut = shell_exec('pidof keepalived 2>/dev/null') ?: ''; - $keepalivedRunning = trim($psOut) !== ''; + $helperStatus = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' status 2>/dev/null')); + if ($helperStatus === 'RUNNING' || trim((string)shell_exec('pidof keepalived 2>/dev/null')) !== '') { + $keepalivedRunning = true; + } } - // 2. Vérification d'Asterisk + // 2. Statut Asterisk $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; $asteriskPing = false; if ($asteriskRunning) { - $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: ''; + $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") + ?: shell_exec("sudo -n /usr/sbin/asterisk -rx 'core ping' 2>/dev/null") + ?: ''; $asteriskPing = str_contains($pingOut, 'Pong'); } @@ -207,7 +231,7 @@ class ClusterManager } } - // Rôle réel effectif + // 4. Rôle effectif $effectiveState = 'STANDALONE'; if (!empty($settings['enabled'])) { if ($isVipActive) { @@ -217,7 +241,7 @@ class ClusterManager } } - // Dernières lignes du log cluster + // 5. Dernières lignes de journalisation HA $recentLogs = []; if (file_exists(self::CLUSTER_LOG_PATH)) { $lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; @@ -256,10 +280,20 @@ class ClusterManager $iface = $settings['interface'] ?? 'eth0'; $vip = $settings['virtual_ip'] ?? ''; $vmask = $settings['virtual_netmask'] ?? '24'; + $peerIp = trim((string)($settings['peer_ip'] ?? '')); $vrid = (int)($settings['vrid'] ?? 51); $prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90)); $secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass + // Détection de l'IP locale sur l'interface pour unicast VRRP + $localIp = ''; + foreach ($this->detectNetworkInterfaces() as $net) { + if ($net['name'] === $iface) { + $localIp = $net['primary'] ?? ''; + break; + } + } + $conf = []; $conf[] = "! =============================================================================="; $conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA"; @@ -273,7 +307,7 @@ class ClusterManager $conf[] = "}"; $conf[] = ""; $conf[] = "vrrp_script chk_asterisk {"; - $conf[] = " script \"/var/www/nexus-pbx/scripts/cluster/check_asterisk.sh\""; + $conf[] = " script \"/etc/keepalived/scripts/check_asterisk.sh\""; $conf[] = " interval 2"; $conf[] = " weight 2"; $conf[] = " fall 2"; @@ -287,6 +321,16 @@ class ClusterManager $conf[] = " priority {$prio}"; $conf[] = " advert_int 1"; $conf[] = ""; + + // Unicast VRRP si l'IP du nœud pair est configurée (indispensable en environnement virtualisé type Proxmox/VMware) + if ($peerIp !== '' && $localIp !== '') { + $conf[] = " unicast_src_ip {$localIp}"; + $conf[] = " unicast_peer {"; + $conf[] = " {$peerIp}"; + $conf[] = " }"; + $conf[] = ""; + } + $conf[] = " authentication {"; $conf[] = " auth_type PASS"; $conf[] = " auth_pass {$secret}"; @@ -300,9 +344,9 @@ class ClusterManager $conf[] = " chk_asterisk"; $conf[] = " }"; $conf[] = ""; - $conf[] = " notify_master \"/var/www/nexus-pbx/scripts/cluster/notify_master.sh\""; - $conf[] = " notify_backup \"/var/www/nexus-pbx/scripts/cluster/notify_backup.sh\""; - $conf[] = " notify_fault \"/var/www/nexus-pbx/scripts/cluster/notify_fault.sh\""; + $conf[] = " notify_master \"/etc/keepalived/scripts/notify_master.sh\""; + $conf[] = " notify_backup \"/etc/keepalived/scripts/notify_backup.sh\""; + $conf[] = " notify_fault \"/etc/keepalived/scripts/notify_fault.sh\""; $conf[] = "}"; $conf[] = ""; @@ -310,7 +354,7 @@ class ClusterManager } /** - * Applique la configuration Keepalived sur le système Linux + * Applique la configuration Keepalived sur le système Linux via le helper root */ public function applyKeepalivedConfig(): array { @@ -318,9 +362,7 @@ class ClusterManager $enabled = !empty($settings['enabled']); if (!$enabled) { - // Désactivation du service Keepalived si le cluster est désactivé - shell_exec('systemctl stop keepalived 2>/dev/null || true'); - shell_exec('systemctl disable keepalived 2>/dev/null || true'); + shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' stop 2>&1'); return [ 'status' => 'disabled', 'message' => 'Cluster HA désactivé (service Keepalived stoppé).', @@ -335,23 +377,21 @@ class ClusterManager } $confContent = $this->generateKeepalivedConfig(); - if (!is_dir('/etc/keepalived')) { - mkdir('/etc/keepalived', 0755, true); + file_put_contents('/tmp/keepalived.conf.tmp', $confContent); + chmod('/tmp/keepalived.conf.tmp', 0644); + + $out = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' apply 2>&1')); + + if (!str_contains($out, 'KEEPALIVED_APPLIED_OK')) { + return [ + 'status' => 'warning', + 'message' => 'Configuration transmise mais le démarrage a retourné : ' . ($out ?: 'Erreur inconnue'), + ]; } - file_put_contents(self::KEEPALIVED_CONF_PATH, $confContent); - chmod(self::KEEPALIVED_CONF_PATH, 0600); - - // S'assure que les scripts de cluster sont bien exécutables - shell_exec('chmod +x /var/www/nexus-pbx/scripts/cluster/*.sh 2>/dev/null || true'); - - // Activation et rechargement / démarrage de Keepalived - shell_exec('systemctl enable keepalived 2>/dev/null || true'); - shell_exec('systemctl restart keepalived 2>/dev/null || true'); - return [ 'status' => 'active', - 'message' => 'Configuration Keepalived générée et service redémarré avec succès.', + 'message' => 'Configuration Keepalived générée et service activé avec succès.', ]; } @@ -368,13 +408,24 @@ class ClusterManager throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); } - // Création du paquet de données en mémoire ou fichier temporaire + // Port d'administration HTTPS configuré + $adminPort = 8443; + try { + $st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + if (!empty($st['admin_https_port'])) { + $adminPort = (int)$st['admin_https_port']; + } + } catch (\Throwable $e) { + $adminPort = 8443; + } + + // Création de l'archive de réplication $dbPath = Database::getDatabasePath(); if (!file_exists($dbPath)) { throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); } - // Forcer le checkpoint SQLite WAL pour avoir un dump 100% à jour + // Forcer le checkpoint SQLite WAL pour un dump intègre $this->db->exec('PRAGMA wal_checkpoint(FULL);'); $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; @@ -393,39 +444,63 @@ class ClusterManager @unlink($tmpArchive); $payload = [ - 'timestamp' => time(), + 'timestamp' => time(), 'archive_b64' => base64_encode($archiveData), ]; - $signature = hash_hmac('sha256', json_encode($payload), $secret); + $jsonPayload = json_encode($payload); + $signature = hash_hmac('sha256', $jsonPayload, $secret); - $ch = curl_init("http://{$peerIp}/api.php?action=cluster_receive_sync"); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-Nexus-Cluster-Signature: ' . $signature, - ]); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); + // URLs d'accès candidates vers l'API du second nœud + $candidateUrls = [ + "https://{$peerIp}:{$adminPort}/api.php?action=cluster_receive_sync", + ]; + if ($adminPort !== 443) { + $candidateUrls[] = "https://{$peerIp}/api.php?action=cluster_receive_sync"; + } + if ($adminPort !== 8443) { + $candidateUrls[] = "https://{$peerIp}:8443/api.php?action=cluster_receive_sync"; + } + $candidateUrls[] = "http://{$peerIp}/api.php?action=cluster_receive_sync"; + $candidateUrls = array_values(array_unique($candidateUrls)); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $err = curl_error($ch); - curl_close($ch); + $lastError = ''; + $success = false; - if ($httpCode !== 200 || !empty($err)) { - $errorMsg = "Échec d'envoi vers le pair {$peerIp} (HTTP {$httpCode}) : {$err}"; - $this->updateSyncStatus('error', $errorMsg); - throw new RuntimeException($errorMsg); + foreach ($candidateUrls as $targetUrl) { + $ch = curl_init($targetUrl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Nexus-Cluster-Signature: ' . $signature, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($httpCode === 200 && !empty($response)) { + $resJson = json_decode((string)$response, true); + if (!empty($resJson['success'])) { + $success = true; + break; + } + $lastError = "Rejet par le pair ({$targetUrl}) : " . ($resJson['message'] ?? $resJson['error'] ?? 'Erreur inconnue'); + } else { + $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode})" . ($err ? " : {$err}" : ""); + } } - $resJson = json_decode((string)$response, true); - if (empty($resJson['success'])) { - $errorMsg = "Le pair a rejeté la réplication : " . ($resJson['error'] ?? 'Erreur inconnue'); - $this->updateSyncStatus('error', $errorMsg); - throw new RuntimeException($errorMsg); + if (!$success) { + $this->updateSyncStatus('error', $lastError); + throw new RuntimeException($lastError); } $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); @@ -463,13 +538,20 @@ class ClusterManager $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; file_put_contents($tmpArchive, $archiveRaw); - // Sauvegarde d'urgence de la DB actuelle avant écrasement + // Sauvegarde d'urgence de la base actuelle avant écrasement $dbPath = Database::getDatabasePath(); if (file_exists($dbPath)) { copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); } - // Fermeture de la connexion PDO pour libérer les verrous SQLite + // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master + $localClusterSettings = $settings; + $localSettingsRow = null; + try { + $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); + } catch (\Throwable $e) {} + + // Libérer les verrous PDO SQLite Database::resetConnection(); // Extraction de l'archive @@ -479,18 +561,60 @@ class ClusterManager } @unlink($tmpArchive); - // Réouverture de la connexion DB et régénération des configs Asterisk + // Réouverture de la connexion DB $newDb = Database::getConnection(); $this->db = $newDb; - // Préserver le rôle local configuré (pour ne pas qu'un master force l'esclave à se croire master) - $localRole = $settings['role'] ?? 'backup'; - $newDb->exec("UPDATE cluster_settings SET role = '{$localRole}', last_sync_at = CURRENT_TIMESTAMP, last_sync_status = 'synced', last_sync_message = 'Base et sons synchronisés depuis le Master' WHERE id = 1"); + // Restauration de l'identité cluster et réseau propre à CE serveur + $updateStmt = $newDb->prepare(' + UPDATE cluster_settings SET + role = :role, + interface = :iface, + peer_ip = :peer_ip, + priority = :priority, + virtual_ip = :virtual_ip, + virtual_netmask = :virtual_netmask, + vrid = :vrid, + last_sync_at = CURRENT_TIMESTAMP, + last_sync_status = :status, + last_sync_message = :msg + WHERE id = 1 + '); + $updateStmt->execute([ + ':role' => $localClusterSettings['role'] ?? 'backup', + ':iface' => $localClusterSettings['interface'] ?? 'eth0', + ':peer_ip' => $localClusterSettings['peer_ip'] ?? '', + ':priority' => $localClusterSettings['priority'] ?? 90, + ':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '', + ':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24', + ':vrid' => $localClusterSettings['vrid'] ?? 51, + ':status' => 'synced', + ':msg' => 'Base et sons synchronisés depuis le Master', + ]); + + if ($localSettingsRow) { + $newDb->prepare(' + UPDATE settings SET + local_net = :net, + local_networks_json = :nets, + admin_https_port = :port + WHERE id = 1 + ')->execute([ + ':net' => $localSettingsRow['local_net'] ?? '192.168.1.0/24', + ':nets' => $localSettingsRow['local_networks_json'] ?? '["192.168.1.0/24"]', + ':port' => $localSettingsRow['admin_https_port'] ?? 8443, + ]); + } // Régénérer les configurations PJSIP et Asterisk $generator = new ConfigGenerator($newDb); $generator->generateAndApply(); + // Si le cluster est actif, actualiser le service Keepalived + if (!empty($localClusterSettings['enabled'])) { + $this->applyKeepalivedConfig(); + } + return [ 'success' => true, 'message' => 'Base de données et fichiers audio appliqués avec succès.', @@ -525,4 +649,3 @@ class ClusterManager $stmt->execute([':status' => $status, ':msg' => $message]); } } -