feat(cluster): module Haute Disponibilité (Cluster HA Actif/Passif Keepalived VRRP & Réplication temps réel)
This commit is contained in:
1 parent
bee2055b62
commit
521f21dc9c
10 files changed
+1310
-7
No files matched your search
+16
-4
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
+334
-2
@@ -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 `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
|
||||
}).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 `<div class="${color}">${escapeHtml(line)}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
} 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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +454,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
<span class="truncate">• Pare-feu & Fail2ban</span>
|
||||
<span id="nav-badge-firewall" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-tab="cluster" onclick="switchTab('cluster')"
|
||||
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left">
|
||||
<span class="truncate">• Haute Disponibilité (HA)</span>
|
||||
<span id="nav-badge-cluster" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-cyan-300 border border-anthracite-700 font-bold">HA</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2755,6 +2760,236 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================================= -->
|
||||
<!-- ONGLET 16 : HAUTE DISPONIBILITÉ & CLUSTER HA (ACTIF / PASSIF KEEPALIVED) -->
|
||||
<!-- ========================================================================= -->
|
||||
<section id="tab-cluster" class="tab-panel hidden space-y-6">
|
||||
<!-- EN-TÊTE DE LA SECTION -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">CLUSTER HA</span>
|
||||
<span id="cluster-header-state-badge" class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300">DÉSACTIVÉ</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-black text-white tracking-wide">Haute Disponibilité (Cluster HA Actif / Passif)</h2>
|
||||
<p class="text-xs text-slate-300 mt-1">Bascule automatique en < 3 secondes par IP Virtuelle Flottante (VRRP / Keepalived) et synchronisation temps réel.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="refreshClusterStatus()"
|
||||
class="px-3.5 py-2 rounded-xl text-xs font-bold text-slate-300 bg-anthracite-900 border border-anthracite-700 hover:text-white hover:border-slate-500 transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="triggerClusterSwitchover()" id="btn-cluster-switchover"
|
||||
class="px-3.5 py-2 rounded-xl text-xs font-bold text-amber-300 bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
|
||||
<span>Bascule Manuelle (Switchover)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CARTES STATISTIQUES / ÉTAT EN TEMPS RÉEL -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- CARTE 1 : ÉTAT NŒUD LOCAL -->
|
||||
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Nœud Local</span>
|
||||
<span id="cluster-card-role-badge" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">MASTER</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span id="cluster-card-state" class="text-lg font-bold text-white tracking-wide">STANDALONE</span>
|
||||
<span id="cluster-card-vip-indicator" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400">VIP: Inactive</span>
|
||||
</div>
|
||||
<p id="cluster-card-iface" class="text-[11px] text-slate-400 font-mono">Interface : ens18</p>
|
||||
</div>
|
||||
|
||||
<!-- CARTE 2 : ADRESSE IP VIRTUELLE (VIP) -->
|
||||
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>IP Virtuelle (VIP)</span>
|
||||
<span class="text-[10px] font-bold text-cyan-400">VRRP ID: <span id="cluster-card-vrid">51</span></span>
|
||||
</div>
|
||||
<div class="text-lg font-bold font-mono text-cyan-300 tracking-wide" id="cluster-card-vip">
|
||||
Non configurée
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400">Point de connexion des Postes & Trunks</p>
|
||||
</div>
|
||||
|
||||
<!-- CARTE 3 : NŒUD DISTANT (PEER) -->
|
||||
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Nœud Distant (Pair)</span>
|
||||
<span id="cluster-card-peer-status" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">INCONNU</span>
|
||||
</div>
|
||||
<div class="text-base font-bold font-mono text-white tracking-wide truncate" id="cluster-card-peer-ip">
|
||||
Non configuré
|
||||
</div>
|
||||
<p id="cluster-card-peer-ping" class="text-[11px] text-slate-400 font-mono">Latence ping : —</p>
|
||||
</div>
|
||||
|
||||
<!-- CARTE 4 : RÉPLICATION DES DONNÉES -->
|
||||
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Synchronisation</span>
|
||||
<button type="button" onclick="triggerClusterSyncNow()" class="text-[10px] text-indigo-400 hover:text-indigo-300 font-bold underline">Sync Now</button>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-white truncate" id="cluster-card-sync-status">
|
||||
Jamais synchronisé
|
||||
</div>
|
||||
<p id="cluster-card-sync-time" class="text-[11px] text-slate-400 font-mono">Dernier échange : —</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENU PRINCIPAL : FORMULAIRE & ACTIONS -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- FORMULAIRE DE CONFIGURATION DU CLUSTER (2/3) -->
|
||||
<div class="lg:col-span-2 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-5">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>
|
||||
<span>Paramétrage de la Haute Disponibilité (Keepalived VRRP)</span>
|
||||
</h3>
|
||||
|
||||
<form id="form-cluster-settings" onsubmit="saveClusterSettings(event)" class="space-y-4">
|
||||
<!-- SWITCH ACTIVATION -->
|
||||
<div class="flex items-center justify-between p-3.5 rounded-xl bg-anthracite-900/80 border border-anthracite-700">
|
||||
<div>
|
||||
<label for="cluster-enabled" class="text-sm font-bold text-white cursor-pointer">Activer le Cluster Haute Disponibilité</label>
|
||||
<p class="text-xs text-slate-400">Active Keepalived pour surveiller Asterisk et partager l'IP flottante.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="cluster-enabled" name="enabled" value="1" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- RÔLE DU SERVEUR -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-bold text-slate-300 uppercase">Rôle initial de ce serveur</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
|
||||
<input type="radio" name="role" value="master" class="text-cyan-500 focus:ring-cyan-400" checked>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-white">Nœud 1 : Master (Primaire)</span>
|
||||
<p class="text-[10px] text-slate-400">Priorité VRRP 100 • Porte la VIP au démarrage.</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
|
||||
<input type="radio" name="role" value="backup" class="text-cyan-500 focus:ring-cyan-400">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-white">Nœud 2 : Standby (Secondaire)</span>
|
||||
<p class="text-[10px] text-slate-400">Priorité VRRP 90 • Prend le relais si le Master tombe.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INTERFACE RÉSEAU & IP VIRTUELLE -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="sm:col-span-1">
|
||||
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Carte Réseau</label>
|
||||
<select id="cluster-interface" name="interface" required
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||
<option value="eth0">eth0</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP Virtuelle Flottante (VIP)</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="cluster-virtual-ip" name="virtual_ip" placeholder="ex: 10.10.0.100"
|
||||
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||
<span class="flex items-center text-slate-400 font-mono text-sm">/</span>
|
||||
<input type="text" id="cluster-virtual-netmask" name="virtual_netmask" value="24" placeholder="24"
|
||||
class="w-16 px-2 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP DU PAIR & JETON SECRET -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP du Nœud Pair (Peer IP)</label>
|
||||
<input type="text" id="cluster-peer-ip" name="peer_ip" placeholder="ex: 10.10.0.5"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Jeton de Sécurité Cluster (Secret)</label>
|
||||
<div class="flex gap-1.5">
|
||||
<input type="text" id="cluster-secret" name="cluster_secret" required
|
||||
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
|
||||
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
|
||||
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button>
|
||||
<button type="button" onclick="copyClusterSecret()" title="Copier le token"
|
||||
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONS AVANCÉES (VRID, AUTO-SYNC) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-anthracite-700/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="cluster-auto-sync" name="auto_sync" value="1" checked
|
||||
class="rounded bg-anthracite-900 border-anthracite-700 text-cyan-500 focus:ring-cyan-400">
|
||||
<label for="cluster-auto-sync" class="text-xs text-slate-300 cursor-pointer">Synchroniser automatiquement la base et les sons vers le pair</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 text-xs text-slate-400 font-mono">
|
||||
<span>Virtual Router ID (VRID) :</span>
|
||||
<input type="number" id="cluster-vrid" name="vrid" value="51" min="1" max="255"
|
||||
class="w-16 px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-white text-center text-xs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 rounded-xl font-bold text-xs bg-cyan-500 hover:bg-cyan-400 text-slate-950 transition flex items-center gap-2 shadow-lg shadow-cyan-500/20">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span>Enregistrer & Déployer le Cluster Keepalived</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- PANNEAU DROIT : LOGS & DIAGNOSTIC EN DIRECT (1/3) -->
|
||||
<div class="bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-4 flex flex-col justify-between">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span>Services & Santé Système</span>
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||
<span class="text-slate-300">Démon Keepalived</span>
|
||||
<span id="cluster-svc-keepalived" class="font-mono text-slate-400">Vérification...</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||
<span class="text-slate-300">Processus Asterisk</span>
|
||||
<span id="cluster-svc-asterisk" class="font-mono text-slate-400">Vérification...</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
|
||||
<span class="text-slate-300">Ping Nœud Pair</span>
|
||||
<span id="cluster-svc-peer" class="font-mono text-slate-400">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<h4 class="text-xs font-bold text-slate-300 uppercase mb-1.5 flex items-center justify-between">
|
||||
<span>Journal d'Événements Cluster</span>
|
||||
<span class="text-[10px] text-slate-500 font-mono">/var/log/nexus_cluster.log</span>
|
||||
</h4>
|
||||
<div id="cluster-logs-container"
|
||||
class="h-44 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-700/80 font-mono text-[11px] text-slate-300 space-y-1">
|
||||
<p class="text-slate-500">Aucun événement enregistré.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
|
||||
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Executable
+23
@@ -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
|
||||
Executable
+19
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+32
@@ -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
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* ClusterManager - Gestionnaire de Haute Disponibilité (Cluster HA Actif/Passif)
|
||||
* Orchestration Keepalived (VRRP), IP Flottante (VIP) et réplication temps réel.
|
||||
*/
|
||||
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 function __construct(?PDO $db = null)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user