diff --git a/install.sh b/install.sh index 9ca2b79..355fb34 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,9 @@ apt-get install -y --no-install-recommends \ sox \ sshpass +# Composants optionnels Haute Disponibilité (Cluster HA) +apt-get install -y --no-install-recommends keepalived 2>/dev/null || 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 +129,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 +145,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 +171,8 @@ 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 "$TARGET_DIR/scripts/cluster" 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 diff --git a/public/api.php b/public/api.php index c86c05e..bc49c6d 100644 --- a/public/api.php +++ b/public/api.php @@ -74,6 +74,16 @@ try { exit; } + // Action inter-serveurs réplication Cluster (authentifiée par signature HMAC) + if ($action === 'cluster_receive_sync') { + $rawBody = file_get_contents('php://input') ?: ''; + $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? ''; + $cm = new ClusterManager($db); + $res = $cm->receiveSyncPayload($rawBody, $signature); + echo json_encode($res); + exit; + } + // Toutes les autres actions requièrent une session authentifiée if (!Auth::isAuthenticated()) { http_response_code(401); @@ -2414,6 +2424,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..277a3e5 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -3523,7 +3523,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 +3575,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 +3599,11 @@ function switchTab(tabId) { loadFirewallStatus(); } + // Si onglet Cluster HA, charger les interfaces et le statut en direct + if (tabId === 'cluster') { + loadCluster(); + } + toggleMobileSidebar(false); } @@ -6318,3 +6324,329 @@ 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 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 secretInput = document.getElementById('cluster-secret'); + if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret; + + // 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(), + cluster_secret: form.elements['cluster_secret'].value.trim(), + vrid: parseInt(form.elements['vrid'].value, 10) || 51, + auto_sync: form.elements['auto_sync'].checked ? 1 : 0 + }; + + 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 e909960..908a6e5 100644 --- a/public/index.php +++ b/public/index.php @@ -454,6 +454,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') • Pare-feu & Fail2ban 0 + @@ -2755,6 +2760,236 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev') + + + + + diff --git a/scripts/cluster/check_asterisk.sh b/scripts/cluster/check_asterisk.sh new file mode 100755 index 0000000..c155db0 --- /dev/null +++ b/scripts/cluster/check_asterisk.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Script de surveillance de santé d'Asterisk pour Keepalived VRRP +# ============================================================================== +set -euo pipefail + +# 1. Vérifie si le binaire et le démon asterisk tournent +if ! pidof asterisk >/dev/null 2>&1; then + exit 1 +fi + +# 2. Teste si Asterisk répond aux requêtes AMI/CLI (core ping) +PING_RESP=$(asterisk -rx "core ping" 2>/dev/null || true) +if echo "$PING_RESP" | grep -q "Pong"; then + exit 0 +fi + +# 3. Tentative alternative via core show version +if asterisk -rx "core show version" >/dev/null 2>&1; then + exit 0 +fi + +exit 1 diff --git a/scripts/cluster/notify_backup.sh b/scripts/cluster/notify_backup.sh new file mode 100755 index 0000000..e38b639 --- /dev/null +++ b/scripts/cluster/notify_backup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état BACKUP / STANDBY +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] <<< Transition vers l'état BACKUP / STANDBY (En veille) >>>" >> "$LOG_FILE" + +# 1. Mise à jour de l'état en base locale +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='standby_idle', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +echo "[$TIMESTAMP] [CLUSTER] Mode Standby actif. En écoute des battements de cœur VRRP." >> "$LOG_FILE" +exit 0 diff --git a/scripts/cluster/notify_fault.sh b/scripts/cluster/notify_fault.sh new file mode 100755 index 0000000..80d19d4 --- /dev/null +++ b/scripts/cluster/notify_fault.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état FAULT / ERREUR +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] [ALERTE] ÉTAT FAULT DÉTECTÉ sur le nœud !" >> "$LOG_FILE" + +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='fault', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +exit 0 diff --git a/scripts/cluster/notify_master.sh b/scripts/cluster/notify_master.sh new file mode 100755 index 0000000..c5a70ec --- /dev/null +++ b/scripts/cluster/notify_master.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# ============================================================================== +# NexusPBX HA - Notification Keepalived : Transition vers l'état MASTER +# ============================================================================== +set -euo pipefail + +LOG_FILE="/var/log/nexus_cluster.log" +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +echo "[$TIMESTAMP] [CLUSTER] >>> Transition vers l'état MASTER (VIP active) <<<" >> "$LOG_FILE" + +# 1. Mise à jour de l'état en base de données locale si SQLite disponible +DB_PATH="/var/www/nexus-pbx/storage/database.sqlite" +if [ -f "$DB_PATH" ]; then + sqlite3 "$DB_PATH" "UPDATE cluster_settings SET last_sync_status='master_active', updated_at=CURRENT_TIMESTAMP WHERE id=1;" 2>/dev/null || true +fi + +# 2. Réactivation des enregistrements Trunks PJSIP et rechargement de la config +if pidof asterisk >/dev/null 2>&1; then + asterisk -rx "pjsip reload" >> "$LOG_FILE" 2>&1 || true + asterisk -rx "dialplan reload" >> "$LOG_FILE" 2>&1 || true +fi + +# 3. Gratuitous ARP optionnel si arping est présent +VIP=$(sqlite3 "$DB_PATH" "SELECT virtual_ip FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "") +IFACE=$(sqlite3 "$DB_PATH" "SELECT interface FROM cluster_settings WHERE id=1;" 2>/dev/null || echo "eth0") +if [ -n "$VIP" ] && command -v arping >/dev/null 2>&1; then + arping -c 3 -U -I "$IFACE" "$VIP" >> "$LOG_FILE" 2>&1 || true +fi + +echo "[$TIMESTAMP] [CLUSTER] Prise de relais MASTER terminée avec succès." >> "$LOG_FILE" +exit 0 diff --git a/src/ClusterManager.php b/src/ClusterManager.php new file mode 100644 index 0000000..9dbe372 --- /dev/null +++ b/src/ClusterManager.php @@ -0,0 +1,527 @@ +db = $db ?? Database::getConnection(); + } + + /** + * Récupère la configuration actuelle du Cluster + */ + public function getSettings(): array + { + $stmt = $this->db->query('SELECT * FROM cluster_settings WHERE id = 1'); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return [ + 'enabled' => 0, + 'role' => 'master', + 'virtual_ip' => '', + 'virtual_netmask' => '24', + 'interface' => 'eth0', + 'peer_ip' => '', + 'cluster_secret' => bin2hex(random_bytes(16)), + 'vrid' => 51, + 'priority' => 100, + 'sync_interval' => 15, + 'auto_sync' => 1, + 'last_sync_at' => null, + 'last_sync_status' => 'none', + 'last_sync_message' => '', + ]; + } + return $row; + } + + /** + * Sauvegarde la configuration du cluster + */ + public function saveSettings(array $data): array + { + $enabled = !empty($data['enabled']) ? 1 : 0; + $role = in_array($data['role'] ?? '', ['master', 'backup'], true) ? $data['role'] : 'master'; + $virtualIp = trim((string)($data['virtual_ip'] ?? '')); + $virtualNetmask = preg_replace('/[^0-9]/', '', (string)($data['virtual_netmask'] ?? '24')) ?: '24'; + $interface = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', trim((string)($data['interface'] ?? 'eth0'))) ?: 'eth0'; + $peerIp = trim((string)($data['peer_ip'] ?? '')); + $clusterSecret = trim((string)($data['cluster_secret'] ?? '')); + $vrid = max(1, min(255, (int)($data['vrid'] ?? 51))); + $priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90)))); + $syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15))); + $autoSync = !empty($data['auto_sync']) ? 1 : 0; + + if ($clusterSecret === '') { + $clusterSecret = bin2hex(random_bytes(16)); + } + + if ($enabled && $virtualIp === '') { + throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA."); + } + + $stmt = $this->db->prepare(' + UPDATE cluster_settings SET + enabled = :enabled, + role = :role, + virtual_ip = :vip, + virtual_netmask = :vmask, + interface = :iface, + peer_ip = :peer, + cluster_secret = :secret, + vrid = :vrid, + priority = :prio, + sync_interval = :sync_int, + auto_sync = :auto_sync, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + '); + + $stmt->execute([ + ':enabled' => $enabled, + ':role' => $role, + ':vip' => $virtualIp, + ':vmask' => $virtualNetmask, + ':iface' => $interface, + ':peer' => $peerIp, + ':secret' => $clusterSecret, + ':vrid' => $vrid, + ':prio' => $priority, + ':sync_int' => $syncInterval, + ':auto_sync' => $autoSync, + ]); + + // Application automatique de la configuration Keepalived si demandé + $applyResult = $this->applyKeepalivedConfig(); + + return [ + 'success' => true, + 'message' => 'Configuration Haute Disponibilité enregistrée.', + 'apply_result' => $applyResult, + ]; + } + + /** + * Détecte les interfaces réseau physiques et virtuelles locales avec leurs IP + */ + public function detectNetworkInterfaces(): array + { + $interfaces = []; + $output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: ''; + + foreach (preg_split('/\r?\n/', $output) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + // Format typique ip -o: "2: eth0 inet 10.10.0.4/16 brd 10.10.255.255 scope global eth0\..." + if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) { + $iface = $matches[1]; + $ip = $matches[2]; + $mask = $matches[3]; + + if ($iface === 'lo') { + continue; // Ignorer loopback + } + + if (!isset($interfaces[$iface])) { + $interfaces[$iface] = [ + 'name' => $iface, + 'ips' => [], + 'primary' => $ip, + 'netmask' => $mask, + ]; + } + $interfaces[$iface]['ips'][] = "{$ip}/{$mask}"; + } + } + + return array_values($interfaces); + } + + /** + * Vérifie si l'adresse IP Virtuelle (VIP) est actuellement active sur cette machine locale + */ + public function isVirtualIpActive(string $vip): bool + { + if ($vip === '') { + return false; + } + $escapedVip = escapeshellarg($vip); + $output = shell_exec("ip addr show 2>/dev/null | grep -F " . $escapedVip) ?: ''; + return trim($output) !== ''; + } + + /** + * Retourne l'état complet en temps réel du Cluster + */ + public function getClusterStatus(): array + { + $settings = $this->getSettings(); + $vip = $settings['virtual_ip'] ?? ''; + $peerIp = $settings['peer_ip'] ?? ''; + $isVipActive = $this->isVirtualIpActive($vip); + + // 1. Vérification de Keepalived + $keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null')); + $keepalivedRunning = false; + if ($keepalivedInstalled) { + $psOut = shell_exec('pidof keepalived 2>/dev/null') ?: ''; + $keepalivedRunning = trim($psOut) !== ''; + } + + // 2. Vérification d'Asterisk + $asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== ''; + $asteriskPing = false; + if ($asteriskRunning) { + $pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null") ?: ''; + $asteriskPing = str_contains($pingOut, 'Pong'); + } + + // 3. Test de connectivité vers le nœud pair (Peer Ping) + $peerReachable = false; + $peerPingMs = null; + if ($peerIp !== '') { + $escapedPeer = escapeshellarg($peerIp); + $pingOut = shell_exec("ping -c 1 -W 1 {$escapedPeer} 2>&1") ?: ''; + if (preg_match('/time=([0-9.]+)\s*ms/', $pingOut, $pm)) { + $peerReachable = true; + $peerPingMs = (float)$pm[1]; + } elseif (str_contains($pingOut, '1 received') || str_contains($pingOut, '1 packets received')) { + $peerReachable = true; + } + } + + // Rôle réel effectif + $effectiveState = 'STANDALONE'; + if (!empty($settings['enabled'])) { + if ($isVipActive) { + $effectiveState = 'MASTER'; + } else { + $effectiveState = 'BACKUP'; + } + } + + // Dernières lignes du log cluster + $recentLogs = []; + if (file_exists(self::CLUSTER_LOG_PATH)) { + $lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $recentLogs = array_slice($lines, -15); + } + + return [ + 'enabled' => (bool)$settings['enabled'], + 'configured_role' => $settings['role'], + 'effective_state' => $effectiveState, + 'virtual_ip' => $vip, + 'virtual_ip_active' => $isVipActive, + 'interface' => $settings['interface'], + 'peer_ip' => $peerIp, + 'peer_reachable' => $peerReachable, + 'peer_ping_ms' => $peerPingMs, + 'keepalived_installed' => $keepalivedInstalled, + 'keepalived_running' => $keepalivedRunning, + 'asterisk_running' => $asteriskRunning, + 'asterisk_healthy' => $asteriskPing, + 'last_sync_at' => $settings['last_sync_at'], + 'last_sync_status' => $settings['last_sync_status'], + 'last_sync_message' => $settings['last_sync_message'], + 'cluster_secret' => $settings['cluster_secret'], + 'recent_logs' => array_reverse($recentLogs), + ]; + } + + /** + * Génère la configuration officielle /etc/keepalived/keepalived.conf + */ + public function generateKeepalivedConfig(): string + { + $settings = $this->getSettings(); + $role = strtoupper($settings['role'] ?? 'MASTER'); + $iface = $settings['interface'] ?? 'eth0'; + $vip = $settings['virtual_ip'] ?? ''; + $vmask = $settings['virtual_netmask'] ?? '24'; + $vrid = (int)($settings['vrid'] ?? 51); + $prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90)); + $secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass + + $conf = []; + $conf[] = "! =============================================================================="; + $conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA"; + $conf[] = "! Date de génération : " . date('Y-m-d H:i:s'); + $conf[] = "! =============================================================================="; + $conf[] = ""; + $conf[] = "global_defs {"; + $conf[] = " router_id NEXUS_PBX_" . ($role === 'MASTER' ? '1' : '2'); + $conf[] = " enable_script_security"; + $conf[] = " script_user root"; + $conf[] = "}"; + $conf[] = ""; + $conf[] = "vrrp_script chk_asterisk {"; + $conf[] = " script \"/var/www/nexus-pbx/scripts/cluster/check_asterisk.sh\""; + $conf[] = " interval 2"; + $conf[] = " weight 2"; + $conf[] = " fall 2"; + $conf[] = " rise 2"; + $conf[] = "}"; + $conf[] = ""; + $conf[] = "vrrp_instance VI_NEXUS {"; + $conf[] = " state {$role}"; + $conf[] = " interface {$iface}"; + $conf[] = " virtual_router_id {$vrid}"; + $conf[] = " priority {$prio}"; + $conf[] = " advert_int 1"; + $conf[] = ""; + $conf[] = " authentication {"; + $conf[] = " auth_type PASS"; + $conf[] = " auth_pass {$secret}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " virtual_ipaddress {"; + $conf[] = " {$vip}/{$vmask} dev {$iface}"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " track_script {"; + $conf[] = " chk_asterisk"; + $conf[] = " }"; + $conf[] = ""; + $conf[] = " notify_master \"/var/www/nexus-pbx/scripts/cluster/notify_master.sh\""; + $conf[] = " notify_backup \"/var/www/nexus-pbx/scripts/cluster/notify_backup.sh\""; + $conf[] = " notify_fault \"/var/www/nexus-pbx/scripts/cluster/notify_fault.sh\""; + $conf[] = "}"; + $conf[] = ""; + + return implode("\n", $conf); + } + + /** + * Applique la configuration Keepalived sur le système Linux + */ + public function applyKeepalivedConfig(): array + { + $settings = $this->getSettings(); + $enabled = !empty($settings['enabled']); + + if (!$enabled) { + // Désactivation du service Keepalived si le cluster est désactivé + shell_exec('systemctl stop keepalived 2>/dev/null || true'); + shell_exec('systemctl disable keepalived 2>/dev/null || true'); + return [ + 'status' => 'disabled', + 'message' => 'Cluster HA désactivé (service Keepalived stoppé).', + ]; + } + + if (empty($settings['virtual_ip'])) { + return [ + 'status' => 'error', + 'message' => 'IP Virtuelle (VIP) non renseignée.', + ]; + } + + $confContent = $this->generateKeepalivedConfig(); + if (!is_dir('/etc/keepalived')) { + mkdir('/etc/keepalived', 0755, true); + } + + file_put_contents(self::KEEPALIVED_CONF_PATH, $confContent); + chmod(self::KEEPALIVED_CONF_PATH, 0600); + + // S'assure que les scripts de cluster sont bien exécutables + shell_exec('chmod +x /var/www/nexus-pbx/scripts/cluster/*.sh 2>/dev/null || true'); + + // Activation et rechargement / démarrage de Keepalived + shell_exec('systemctl enable keepalived 2>/dev/null || true'); + shell_exec('systemctl restart keepalived 2>/dev/null || true'); + + return [ + 'status' => 'active', + 'message' => 'Configuration Keepalived générée et service redémarré avec succès.', + ]; + } + + /** + * Pousse la synchronisation complète (SQLite + Custom Sounds) vers le Nœud pair + */ + public function pushSyncToPeer(): array + { + $settings = $this->getSettings(); + $peerIp = trim((string)($settings['peer_ip'] ?? '')); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + + if ($peerIp === '') { + throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée."); + } + + // Création du paquet de données en mémoire ou fichier temporaire + $dbPath = Database::getDatabasePath(); + if (!file_exists($dbPath)) { + throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); + } + + // Forcer le checkpoint SQLite WAL pour avoir un dump 100% à jour + $this->db->exec('PRAGMA wal_checkpoint(FULL);'); + + $tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; + $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite"; + if (is_dir('/var/lib/asterisk/sounds/custom')) { + $cmd .= " -C /var/lib/asterisk/sounds custom"; + } + shell_exec($cmd); + + if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { + @unlink($tmpArchive); + throw new RuntimeException("Échec de génération de l'archive de réplication."); + } + + $archiveData = file_get_contents($tmpArchive); + @unlink($tmpArchive); + + $payload = [ + 'timestamp' => time(), + 'archive_b64' => base64_encode($archiveData), + ]; + + $signature = hash_hmac('sha256', json_encode($payload), $secret); + + $ch = curl_init("http://{$peerIp}/api.php?action=cluster_receive_sync"); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-Nexus-Cluster-Signature: ' . $signature, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($httpCode !== 200 || !empty($err)) { + $errorMsg = "Échec d'envoi vers le pair {$peerIp} (HTTP {$httpCode}) : {$err}"; + $this->updateSyncStatus('error', $errorMsg); + throw new RuntimeException($errorMsg); + } + + $resJson = json_decode((string)$response, true); + if (empty($resJson['success'])) { + $errorMsg = "Le pair a rejeté la réplication : " . ($resJson['error'] ?? 'Erreur inconnue'); + $this->updateSyncStatus('error', $errorMsg); + throw new RuntimeException($errorMsg); + } + + $this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}"); + + return [ + 'success' => true, + 'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp} avec succès.", + ]; + } + + /** + * Réception et application du paquet de synchronisation envoyé par le pair + */ + public function receiveSyncPayload(string $rawBody, string $providedSignature): array + { + $settings = $this->getSettings(); + $secret = trim((string)($settings['cluster_secret'] ?? '')); + + if ($secret === '') { + throw new RuntimeException("Le secret de cluster n'est pas configuré sur ce serveur."); + } + + // Vérification signature HMAC + $expectedSignature = hash_hmac('sha256', $rawBody, $secret); + if (!hash_equals($expectedSignature, $providedSignature)) { + throw new RuntimeException("Signature de cluster invalide (accès refusé)."); + } + + $data = json_decode($rawBody, true); + if (empty($data['archive_b64'])) { + throw new RuntimeException("Paquet de réplication vide ou corrompu."); + } + + $archiveRaw = base64_decode($data['archive_b64']); + $tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; + file_put_contents($tmpArchive, $archiveRaw); + + // Sauvegarde d'urgence de la DB actuelle avant écrasement + $dbPath = Database::getDatabasePath(); + if (file_exists($dbPath)) { + copy($dbPath, $dbPath . '.pre_cluster_sync.bak'); + } + + // Fermeture de la connexion PDO pour libérer les verrous SQLite + Database::resetConnection(); + + // Extraction de l'archive + shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite 2>/dev/null || true"); + if (is_dir('/var/lib/asterisk/sounds')) { + shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true"); + } + @unlink($tmpArchive); + + // Réouverture de la connexion DB et régénération des configs Asterisk + $newDb = Database::getConnection(); + $this->db = $newDb; + + // Préserver le rôle local configuré (pour ne pas qu'un master force l'esclave à se croire master) + $localRole = $settings['role'] ?? 'backup'; + $newDb->exec("UPDATE cluster_settings SET role = '{$localRole}', last_sync_at = CURRENT_TIMESTAMP, last_sync_status = 'synced', last_sync_message = 'Base et sons synchronisés depuis le Master' WHERE id = 1"); + + // Régénérer les configurations PJSIP et Asterisk + $generator = new ConfigGenerator($newDb); + $generator->generateAndApply(); + + return [ + 'success' => true, + 'message' => 'Base de données et fichiers audio appliqués avec succès.', + ]; + } + + /** + * Bascule manuelle de rôle (Switchover) + */ + public function triggerSwitchover(): array + { + $settings = $this->getSettings(); + $currentRole = $settings['role'] ?? 'master'; + $newRole = ($currentRole === 'master') ? 'backup' : 'master'; + $newPrio = ($newRole === 'master') ? 100 : 90; + + $settings['role'] = $newRole; + $settings['priority'] = $newPrio; + + return $this->saveSettings($settings); + } + + private function updateSyncStatus(string $status, string $message): void + { + $stmt = $this->db->prepare(' + UPDATE cluster_settings SET + last_sync_at = CURRENT_TIMESTAMP, + last_sync_status = :status, + last_sync_message = :msg + WHERE id = 1 + '); + $stmt->execute([':status' => $status, ':msg' => $message]); + } +} diff --git a/src/Database.php b/src/Database.php index ff74a2e..a9d5b8d 100644 --- a/src/Database.php +++ b/src/Database.php @@ -307,6 +307,27 @@ class Database created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + -- 15. Table cluster_settings : Haute Disponibilité (Cluster HA Actif/Passif Keepalived/VRRP) + CREATE TABLE IF NOT EXISTS cluster_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + role VARCHAR(16) NOT NULL DEFAULT 'master', + virtual_ip VARCHAR(45) NOT NULL DEFAULT '', + virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', + interface VARCHAR(32) NOT NULL DEFAULT 'eth0', + peer_ip VARCHAR(45) NOT NULL DEFAULT '', + cluster_secret VARCHAR(64) NOT NULL DEFAULT '', + vrid INTEGER NOT NULL DEFAULT 51, + priority INTEGER NOT NULL DEFAULT 100, + sync_interval INTEGER NOT NULL DEFAULT 15, + auto_sync INTEGER NOT NULL DEFAULT 1, + last_sync_at DATETIME DEFAULT NULL, + last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); SQL; $db->exec($schema); @@ -322,6 +343,7 @@ class Database self::migrateFeatureCodesTable($db); self::migratePhonebookContactsTable($db); self::migratePagingGroupsTable($db); + self::migrateClusterSettingsTable($db); // Création du compte admin par défaut (admin / admin123) s'il n'existe aucun utilisateur $userCount = (int)$db->query('SELECT COUNT(*) FROM users')->fetchColumn(); @@ -749,8 +771,48 @@ class Database updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); "); + } - // Aucun groupe de paging par défaut pour conserver une base propre + private static function migrateClusterSettingsTable(PDO $db): void + { + $db->exec(" + CREATE TABLE IF NOT EXISTS cluster_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + role VARCHAR(16) NOT NULL DEFAULT 'master', + virtual_ip VARCHAR(45) NOT NULL DEFAULT '', + virtual_netmask VARCHAR(16) NOT NULL DEFAULT '24', + interface VARCHAR(32) NOT NULL DEFAULT 'eth0', + peer_ip VARCHAR(45) NOT NULL DEFAULT '', + cluster_secret VARCHAR(64) NOT NULL DEFAULT '', + vrid INTEGER NOT NULL DEFAULT 51, + priority INTEGER NOT NULL DEFAULT 100, + sync_interval INTEGER NOT NULL DEFAULT 15, + auto_sync INTEGER NOT NULL DEFAULT 1, + last_sync_at DATETIME DEFAULT NULL, + last_sync_status VARCHAR(32) NOT NULL DEFAULT 'none', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "); + + $count = (int)$db->query('SELECT COUNT(*) FROM cluster_settings')->fetchColumn(); + if ($count === 0) { + $defaultSecret = bin2hex(random_bytes(16)); + $stmt = $db->prepare(' + INSERT INTO cluster_settings (id, enabled, role, virtual_ip, virtual_netmask, interface, peer_ip, cluster_secret, vrid, priority, sync_interval, auto_sync) + VALUES (1, 0, :role, :vip, :vmask, :iface, :peer, :secret, 51, 100, 15, 1) + '); + $stmt->execute([ + ':role' => 'master', + ':vip' => '', + ':vmask' => '24', + ':iface' => 'eth0', + ':peer' => '', + ':secret' => $defaultSecret, + ]); + } } }