feat(cluster): module Haute Disponibilité (Cluster HA Actif/Passif Keepalived VRRP & Réplication temps réel)

This commit is contained in:
sorcier committed 2026-11-05 14:32:35 +01:00
1 parent bee2055b62
commit 521f21dc9c
10 files changed
+1310 -7

No files matched your search

+527
View File
@@ -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
View File
@@ -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,
]);
}
}
}