693 lines
27 KiB
PHP
693 lines
27 KiB
PHP
<?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 const CLUSTER_HELPER_PATH = '/var/www/nexus-pbx/scripts/nexus-cluster';
|
|
|
|
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' => '',
|
|
'peer_port' => 8443,
|
|
'cluster_secret' => bin2hex(random_bytes(16)),
|
|
'vrid' => 51,
|
|
'priority' => 100,
|
|
'sync_interval' => 15,
|
|
'auto_sync' => 1,
|
|
'last_sync_at' => null,
|
|
'last_sync_status' => 'none',
|
|
'last_sync_message' => '',
|
|
];
|
|
}
|
|
if (empty($row['peer_port'])) {
|
|
$row['peer_port'] = 8443;
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/**
|
|
* Sauvegarde la configuration du cluster
|
|
*/
|
|
public function saveSettings(array $data): array
|
|
{
|
|
$enabled = !empty($data['enabled']) ? 1 : 0;
|
|
$role = in_array($data['role'] ?? '', ['master', 'backup'], true) ? $data['role'] : 'master';
|
|
$virtualIp = trim((string)($data['virtual_ip'] ?? ''));
|
|
$virtualNetmask = preg_replace('/[^0-9]/', '', (string)($data['virtual_netmask'] ?? '24')) ?: '24';
|
|
$interface = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', trim((string)($data['interface'] ?? 'eth0'))) ?: 'eth0';
|
|
$peerIpRaw = trim((string)($data['peer_ip'] ?? ''));
|
|
$peerPort = (int)($data['peer_port'] ?? 0);
|
|
$clusterSecret = trim((string)($data['cluster_secret'] ?? ''));
|
|
$vrid = max(1, min(255, (int)($data['vrid'] ?? 51)));
|
|
$priority = max(1, min(254, (int)($data['priority'] ?? ($role === 'master' ? 100 : 90))));
|
|
$syncInterval = max(5, min(3600, (int)($data['sync_interval'] ?? 15)));
|
|
$autoSync = !empty($data['auto_sync']) ? 1 : 0;
|
|
|
|
// Détection et parsing si le port est saisi dans l'IP (ex: 10.10.11.197:2356)
|
|
if (str_contains($peerIpRaw, ':')) {
|
|
$parts = explode(':', $peerIpRaw, 2);
|
|
$peerIp = trim($parts[0]);
|
|
$parsedPort = (int)trim($parts[1]);
|
|
if ($parsedPort > 0 && $parsedPort <= 65535) {
|
|
$peerPort = $parsedPort;
|
|
}
|
|
} else {
|
|
$peerIp = $peerIpRaw;
|
|
}
|
|
|
|
if ($peerPort <= 0 || $peerPort > 65535) {
|
|
try {
|
|
$st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
|
$peerPort = (int)($st['admin_https_port'] ?? 8443);
|
|
} catch (\Throwable $e) {
|
|
$peerPort = 8443;
|
|
}
|
|
}
|
|
|
|
if ($clusterSecret === '') {
|
|
$clusterSecret = bin2hex(random_bytes(16));
|
|
}
|
|
|
|
if ($enabled && $virtualIp === '') {
|
|
throw new InvalidArgumentException("L'adresse IP Virtuelle partagée (VIP) est requise pour activer le cluster HA.");
|
|
}
|
|
|
|
// Empêcher l'utilisateur de configurer l'IP locale de ce serveur comme IP du pair
|
|
if ($peerIp !== '') {
|
|
$localInterfaces = $this->detectNetworkInterfaces();
|
|
foreach ($localInterfaces as $ifaceInfo) {
|
|
if (($ifaceInfo['primary'] ?? '') === $peerIp) {
|
|
throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud.");
|
|
}
|
|
foreach ($ifaceInfo['ips'] ?? [] as $cidr) {
|
|
$ipOnly = explode('/', $cidr)[0] ?? '';
|
|
if ($ipOnly === $peerIp) {
|
|
throw new InvalidArgumentException("L'adresse IP du pair ({$peerIp}) correspond à l'adresse locale de cette machine ! Veuillez renseigner l'IP du second nœud.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$stmt = $this->db->prepare('
|
|
UPDATE cluster_settings SET
|
|
enabled = :enabled,
|
|
role = :role,
|
|
virtual_ip = :vip,
|
|
virtual_netmask = :vmask,
|
|
interface = :iface,
|
|
peer_ip = :peer,
|
|
peer_port = :peer_port,
|
|
cluster_secret = :secret,
|
|
vrid = :vrid,
|
|
priority = :prio,
|
|
sync_interval = :sync_int,
|
|
auto_sync = :auto_sync,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
');
|
|
|
|
$stmt->execute([
|
|
':enabled' => $enabled,
|
|
':role' => $role,
|
|
':vip' => $virtualIp,
|
|
':vmask' => $virtualNetmask,
|
|
':iface' => $interface,
|
|
':peer' => $peerIp,
|
|
':peer_port' => $peerPort,
|
|
':secret' => $clusterSecret,
|
|
':vrid' => $vrid,
|
|
':prio' => $priority,
|
|
':sync_int' => $syncInterval,
|
|
':auto_sync' => $autoSync,
|
|
]);
|
|
|
|
// Application automatique de la configuration Keepalived
|
|
$applyResult = $this->applyKeepalivedConfig();
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Configuration Haute Disponibilité enregistrée et appliquée.',
|
|
'apply_result' => $applyResult,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Détecte les interfaces réseau physiques et virtuelles locales avec leurs IP
|
|
*/
|
|
public function detectNetworkInterfaces(): array
|
|
{
|
|
$interfaces = [];
|
|
$output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: '';
|
|
|
|
foreach (preg_split('/\r?\n/', $output) ?: [] as $line) {
|
|
$line = trim($line);
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
if (preg_match('/^\d+:\s+([a-zA-Z0-9_\-\.]+)\s+inet\s+([0-9.]+)\/(\d+)/', $line, $matches)) {
|
|
$iface = $matches[1];
|
|
$ip = $matches[2];
|
|
$mask = $matches[3];
|
|
|
|
if ($iface === 'lo') {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($interfaces[$iface])) {
|
|
$interfaces[$iface] = [
|
|
'name' => $iface,
|
|
'ips' => [],
|
|
'primary' => $ip,
|
|
'netmask' => $mask,
|
|
];
|
|
}
|
|
$interfaces[$iface]['ips'][] = "{$ip}/{$mask}";
|
|
}
|
|
}
|
|
|
|
return array_values($interfaces);
|
|
}
|
|
|
|
/**
|
|
* Vérifie si l'adresse IP Virtuelle (VIP) est actuellement active sur cette machine locale
|
|
*/
|
|
public function isVirtualIpActive(string $vip): bool
|
|
{
|
|
if ($vip === '') {
|
|
return false;
|
|
}
|
|
$output = shell_exec('ip -o -4 addr show 2>/dev/null') ?: '';
|
|
foreach (preg_split('/\r?\n/', $output) ?: [] as $line) {
|
|
if (preg_match('/\sinet\s+' . preg_quote($vip, '/') . '\//', $line)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Retourne l'état complet en temps réel du Cluster
|
|
*/
|
|
public function getClusterStatus(): array
|
|
{
|
|
$settings = $this->getSettings();
|
|
$vip = $settings['virtual_ip'] ?? '';
|
|
$peerIp = $settings['peer_ip'] ?? '';
|
|
$isVipActive = $this->isVirtualIpActive($vip);
|
|
|
|
// 1. Statut Keepalived
|
|
$keepalivedInstalled = !empty(shell_exec('command -v keepalived 2>/dev/null'));
|
|
$keepalivedRunning = false;
|
|
if ($keepalivedInstalled) {
|
|
$helperStatus = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' status 2>/dev/null'));
|
|
if ($helperStatus === 'RUNNING' || trim((string)shell_exec('pidof keepalived 2>/dev/null')) !== '') {
|
|
$keepalivedRunning = true;
|
|
}
|
|
}
|
|
|
|
// 2. Statut Asterisk
|
|
$asteriskRunning = trim((string)shell_exec('pidof asterisk 2>/dev/null')) !== '';
|
|
$asteriskPing = false;
|
|
if ($asteriskRunning) {
|
|
$pingOut = shell_exec("asterisk -rx 'core ping' 2>/dev/null")
|
|
?: shell_exec("sudo -n /usr/sbin/asterisk -rx 'core ping' 2>/dev/null")
|
|
?: '';
|
|
$asteriskPing = str_contains($pingOut, 'Pong');
|
|
}
|
|
|
|
// 3. Test de connectivité vers le nœud pair (Peer Ping)
|
|
$peerReachable = false;
|
|
$peerPingMs = null;
|
|
if ($peerIp !== '') {
|
|
$escapedPeer = escapeshellarg($peerIp);
|
|
$pingOut = shell_exec("ping -c 1 -W 1 {$escapedPeer} 2>&1") ?: '';
|
|
if (preg_match('/time=([0-9.]+)\s*ms/', $pingOut, $pm)) {
|
|
$peerReachable = true;
|
|
$peerPingMs = (float)$pm[1];
|
|
} elseif (str_contains($pingOut, '1 received') || str_contains($pingOut, '1 packets received')) {
|
|
$peerReachable = true;
|
|
}
|
|
}
|
|
|
|
// 4. Rôle effectif
|
|
$effectiveState = 'STANDALONE';
|
|
if (!empty($settings['enabled'])) {
|
|
if ($isVipActive) {
|
|
$effectiveState = 'MASTER';
|
|
} else {
|
|
$effectiveState = 'BACKUP';
|
|
}
|
|
}
|
|
|
|
// 5. Dernières lignes de journalisation HA
|
|
$recentLogs = [];
|
|
if (file_exists(self::CLUSTER_LOG_PATH)) {
|
|
$lines = file(self::CLUSTER_LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
|
$recentLogs = array_slice($lines, -15);
|
|
}
|
|
|
|
return [
|
|
'enabled' => (bool)$settings['enabled'],
|
|
'configured_role' => $settings['role'],
|
|
'effective_state' => $effectiveState,
|
|
'virtual_ip' => $vip,
|
|
'virtual_netmask' => $settings['virtual_netmask'] ?? '24',
|
|
'vrid' => (int)($settings['vrid'] ?? 51),
|
|
'priority' => (int)($settings['priority'] ?? 100),
|
|
'peer_port' => (int)($settings['peer_port'] ?? 8443),
|
|
'auto_sync' => (bool)($settings['auto_sync'] ?? 1),
|
|
'virtual_ip_active' => $isVipActive,
|
|
'interface' => $settings['interface'],
|
|
'peer_ip' => $peerIp,
|
|
'peer_reachable' => $peerReachable,
|
|
'peer_ping_ms' => $peerPingMs,
|
|
'keepalived_installed' => $keepalivedInstalled,
|
|
'keepalived_running' => $keepalivedRunning,
|
|
'asterisk_running' => $asteriskRunning,
|
|
'asterisk_healthy' => $asteriskPing,
|
|
'last_sync_at' => $settings['last_sync_at'],
|
|
'last_sync_status' => $settings['last_sync_status'],
|
|
'last_sync_message' => $settings['last_sync_message'],
|
|
'cluster_secret' => $settings['cluster_secret'],
|
|
'recent_logs' => array_reverse($recentLogs),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Génère la configuration officielle /etc/keepalived/keepalived.conf
|
|
*/
|
|
public function generateKeepalivedConfig(): string
|
|
{
|
|
$settings = $this->getSettings();
|
|
$role = strtoupper($settings['role'] ?? 'MASTER');
|
|
$iface = $settings['interface'] ?? 'eth0';
|
|
$vip = $settings['virtual_ip'] ?? '';
|
|
$vmask = $settings['virtual_netmask'] ?? '24';
|
|
$peerIp = trim((string)($settings['peer_ip'] ?? ''));
|
|
$vrid = (int)($settings['vrid'] ?? 51);
|
|
$prio = (int)($settings['priority'] ?? ($role === 'MASTER' ? 100 : 90));
|
|
$secret = substr($settings['cluster_secret'] ?? 'NexusPBX', 0, 8); // Keepalived max 8 chars auth_pass
|
|
|
|
// Détection de l'IP locale sur l'interface pour unicast VRRP
|
|
$localIp = '';
|
|
foreach ($this->detectNetworkInterfaces() as $net) {
|
|
if ($net['name'] === $iface) {
|
|
$localIp = $net['primary'] ?? '';
|
|
break;
|
|
}
|
|
}
|
|
|
|
$conf = [];
|
|
$conf[] = "! ==============================================================================";
|
|
$conf[] = "! Configuration Keepalived générée automatiquement par NexusPBX HA";
|
|
$conf[] = "! Date de génération : " . date('Y-m-d H:i:s');
|
|
$conf[] = "! ==============================================================================";
|
|
$conf[] = "";
|
|
$conf[] = "global_defs {";
|
|
$conf[] = " router_id NEXUS_PBX_" . ($role === 'MASTER' ? '1' : '2');
|
|
$conf[] = " enable_script_security";
|
|
$conf[] = " script_user root";
|
|
$conf[] = "}";
|
|
$conf[] = "";
|
|
$conf[] = "vrrp_script chk_asterisk {";
|
|
$conf[] = " script \"/etc/keepalived/scripts/check_asterisk.sh\"";
|
|
$conf[] = " interval 2";
|
|
$conf[] = " weight 2";
|
|
$conf[] = " fall 2";
|
|
$conf[] = " rise 2";
|
|
$conf[] = "}";
|
|
$conf[] = "";
|
|
$conf[] = "vrrp_instance VI_NEXUS {";
|
|
$conf[] = " state {$role}";
|
|
$conf[] = " interface {$iface}";
|
|
$conf[] = " virtual_router_id {$vrid}";
|
|
$conf[] = " priority {$prio}";
|
|
$conf[] = " advert_int 1";
|
|
$conf[] = "";
|
|
|
|
// Unicast VRRP si l'IP du nœud pair est configurée (indispensable en environnement virtualisé type Proxmox/VMware)
|
|
if ($peerIp !== '' && $localIp !== '') {
|
|
$conf[] = " unicast_src_ip {$localIp}";
|
|
$conf[] = " unicast_peer {";
|
|
$conf[] = " {$peerIp}";
|
|
$conf[] = " }";
|
|
$conf[] = "";
|
|
}
|
|
|
|
$conf[] = " authentication {";
|
|
$conf[] = " auth_type PASS";
|
|
$conf[] = " auth_pass {$secret}";
|
|
$conf[] = " }";
|
|
$conf[] = "";
|
|
$conf[] = " virtual_ipaddress {";
|
|
$conf[] = " {$vip}/{$vmask} dev {$iface}";
|
|
$conf[] = " }";
|
|
$conf[] = "";
|
|
$conf[] = " track_script {";
|
|
$conf[] = " chk_asterisk";
|
|
$conf[] = " }";
|
|
$conf[] = "";
|
|
$conf[] = " notify_master \"/etc/keepalived/scripts/notify_master.sh\"";
|
|
$conf[] = " notify_backup \"/etc/keepalived/scripts/notify_backup.sh\"";
|
|
$conf[] = " notify_fault \"/etc/keepalived/scripts/notify_fault.sh\"";
|
|
$conf[] = "}";
|
|
$conf[] = "";
|
|
|
|
return implode("\n", $conf);
|
|
}
|
|
|
|
/**
|
|
* Applique la configuration Keepalived sur le système Linux via le helper root
|
|
*/
|
|
public function applyKeepalivedConfig(): array
|
|
{
|
|
$settings = $this->getSettings();
|
|
$enabled = !empty($settings['enabled']);
|
|
|
|
if (!$enabled) {
|
|
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' stop 2>&1');
|
|
return [
|
|
'status' => 'disabled',
|
|
'message' => 'Cluster HA désactivé (service Keepalived stoppé).',
|
|
];
|
|
}
|
|
|
|
if (empty($settings['virtual_ip'])) {
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'IP Virtuelle (VIP) non renseignée.',
|
|
];
|
|
}
|
|
|
|
$confContent = $this->generateKeepalivedConfig();
|
|
file_put_contents('/tmp/keepalived.conf.tmp', $confContent);
|
|
chmod('/tmp/keepalived.conf.tmp', 0644);
|
|
|
|
$out = trim((string)shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' apply 2>&1'));
|
|
|
|
if (!str_contains($out, 'KEEPALIVED_APPLIED_OK')) {
|
|
return [
|
|
'status' => 'warning',
|
|
'message' => 'Configuration transmise mais le démarrage a retourné : ' . ($out ?: 'Erreur inconnue'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'status' => 'active',
|
|
'message' => 'Configuration Keepalived générée et service activé avec succès.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Pousse la synchronisation complète (SQLite + Custom Sounds) vers le Nœud pair
|
|
*/
|
|
public function pushSyncToPeer(): array
|
|
{
|
|
$settings = $this->getSettings();
|
|
$peerIp = trim((string)($settings['peer_ip'] ?? ''));
|
|
$peerPort = (int)($settings['peer_port'] ?? 8443);
|
|
if ($peerPort <= 0 || $peerPort > 65535) {
|
|
$peerPort = 8443;
|
|
}
|
|
$secret = trim((string)($settings['cluster_secret'] ?? ''));
|
|
|
|
if ($peerIp === '') {
|
|
throw new RuntimeException("L'adresse IP du Nœud pair (Peer IP) n'est pas configurée.");
|
|
}
|
|
|
|
// Port d'administration HTTPS local configuré pour éventuel fallback
|
|
$localAdminPort = 8443;
|
|
try {
|
|
$st = $this->db->query("SELECT admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
|
if (!empty($st['admin_https_port'])) {
|
|
$localAdminPort = (int)$st['admin_https_port'];
|
|
}
|
|
} catch (\Throwable $e) {}
|
|
|
|
// Création de l'archive de réplication
|
|
$dbPath = Database::getDatabasePath();
|
|
if (!file_exists($dbPath)) {
|
|
throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}");
|
|
}
|
|
|
|
// Forcer le checkpoint SQLite WAL pour un dump intègre
|
|
$this->db->exec('PRAGMA wal_checkpoint(FULL);');
|
|
|
|
$tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz';
|
|
$cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite";
|
|
if (is_dir('/var/lib/asterisk/sounds/custom')) {
|
|
$cmd .= " -C /var/lib/asterisk/sounds custom";
|
|
}
|
|
shell_exec($cmd);
|
|
|
|
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) {
|
|
@unlink($tmpArchive);
|
|
throw new RuntimeException("Échec de génération de l'archive de réplication.");
|
|
}
|
|
|
|
$archiveData = file_get_contents($tmpArchive);
|
|
@unlink($tmpArchive);
|
|
|
|
$payload = [
|
|
'timestamp' => time(),
|
|
'archive_b64' => base64_encode($archiveData),
|
|
];
|
|
|
|
$jsonPayload = json_encode($payload);
|
|
$signature = hash_hmac('sha256', $jsonPayload, $secret);
|
|
|
|
// URLs d'accès candidates vers l'API du second nœud (priorité au port configuré)
|
|
$candidateUrls = [
|
|
"https://{$peerIp}:{$peerPort}/api.php?action=cluster_receive_sync",
|
|
];
|
|
if ($peerPort !== $localAdminPort) {
|
|
$candidateUrls[] = "https://{$peerIp}:{$localAdminPort}/api.php?action=cluster_receive_sync";
|
|
}
|
|
if ($peerPort !== 8443 && $localAdminPort !== 8443) {
|
|
$candidateUrls[] = "https://{$peerIp}:8443/api.php?action=cluster_receive_sync";
|
|
}
|
|
if ($peerPort !== 443 && $localAdminPort !== 443) {
|
|
$candidateUrls[] = "https://{$peerIp}/api.php?action=cluster_receive_sync";
|
|
}
|
|
$candidateUrls[] = "http://{$peerIp}/api.php?action=cluster_receive_sync";
|
|
$candidateUrls = array_values(array_unique($candidateUrls));
|
|
|
|
$lastError = '';
|
|
$success = false;
|
|
|
|
foreach ($candidateUrls as $targetUrl) {
|
|
$ch = curl_init($targetUrl);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Content-Type: application/json',
|
|
'X-Nexus-Cluster-Signature: ' . $signature,
|
|
]);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
|
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200 && !empty($response)) {
|
|
$resJson = json_decode((string)$response, true);
|
|
if (!empty($resJson['success'])) {
|
|
$success = true;
|
|
break;
|
|
}
|
|
$lastError = "Rejet par le pair ({$targetUrl}) : " . ($resJson['message'] ?? $resJson['error'] ?? 'Erreur inconnue');
|
|
} else {
|
|
$resJson = json_decode((string)$response, true);
|
|
$detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : "");
|
|
$lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}";
|
|
}
|
|
}
|
|
|
|
if (!$success) {
|
|
$this->updateSyncStatus('error', $lastError);
|
|
throw new RuntimeException($lastError);
|
|
}
|
|
|
|
$this->updateSyncStatus('success', "Synchronisation réussie avec le nœud {$peerIp}:{$peerPort}");
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "Réplication transmise et appliquée sur le nœud pair {$peerIp}:{$peerPort} avec succès.",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Réception et application du paquet de synchronisation envoyé par le pair
|
|
*/
|
|
public function receiveSyncPayload(string $rawBody, string $providedSignature): array
|
|
{
|
|
$settings = $this->getSettings();
|
|
$secret = trim((string)($settings['cluster_secret'] ?? ''));
|
|
|
|
if ($secret === '') {
|
|
throw new RuntimeException("Le secret de cluster n'est pas configuré sur ce serveur.");
|
|
}
|
|
|
|
// Vérification signature HMAC
|
|
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
|
|
if (!hash_equals($expectedSignature, $providedSignature)) {
|
|
throw new RuntimeException("Signature de cluster invalide (accès refusé).");
|
|
}
|
|
|
|
$data = json_decode($rawBody, true);
|
|
if (empty($data['archive_b64'])) {
|
|
throw new RuntimeException("Paquet de réplication vide ou corrompu.");
|
|
}
|
|
|
|
$archiveRaw = base64_decode($data['archive_b64']);
|
|
$tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz';
|
|
file_put_contents($tmpArchive, $archiveRaw);
|
|
|
|
// Sauvegarde d'urgence de la base actuelle avant écrasement
|
|
$dbPath = Database::getDatabasePath();
|
|
if (file_exists($dbPath)) {
|
|
copy($dbPath, $dbPath . '.pre_cluster_sync.bak');
|
|
}
|
|
|
|
// Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master
|
|
$localClusterSettings = $settings;
|
|
$localSettingsRow = null;
|
|
try {
|
|
$localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
|
} catch (\Throwable $e) {}
|
|
|
|
// Libérer les verrous PDO SQLite
|
|
Database::resetConnection();
|
|
|
|
// Extraction de l'archive
|
|
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/www/nexus-pbx/storage database.sqlite 2>/dev/null || true");
|
|
if (is_dir('/var/lib/asterisk/sounds')) {
|
|
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C /var/lib/asterisk/sounds custom 2>/dev/null || true");
|
|
}
|
|
@unlink($tmpArchive);
|
|
|
|
// Réouverture de la connexion DB
|
|
$newDb = Database::getConnection();
|
|
$this->db = $newDb;
|
|
|
|
// Restauration de l'identité cluster et réseau propre à CE serveur
|
|
$updateStmt = $newDb->prepare('
|
|
UPDATE cluster_settings SET
|
|
role = :role,
|
|
interface = :iface,
|
|
peer_ip = :peer_ip,
|
|
peer_port = :peer_port,
|
|
priority = :priority,
|
|
virtual_ip = :virtual_ip,
|
|
virtual_netmask = :virtual_netmask,
|
|
vrid = :vrid,
|
|
last_sync_at = CURRENT_TIMESTAMP,
|
|
last_sync_status = :status,
|
|
last_sync_message = :msg
|
|
WHERE id = 1
|
|
');
|
|
$updateStmt->execute([
|
|
':role' => $localClusterSettings['role'] ?? 'backup',
|
|
':iface' => $localClusterSettings['interface'] ?? 'eth0',
|
|
':peer_ip' => $localClusterSettings['peer_ip'] ?? '',
|
|
':peer_port' => (int)($localClusterSettings['peer_port'] ?? 8443),
|
|
':priority' => $localClusterSettings['priority'] ?? 90,
|
|
':virtual_ip' => $localClusterSettings['virtual_ip'] ?? '',
|
|
':virtual_netmask' => $localClusterSettings['virtual_netmask'] ?? '24',
|
|
':vrid' => $localClusterSettings['vrid'] ?? 51,
|
|
':status' => 'synced',
|
|
':msg' => 'Base et sons synchronisés depuis le Master',
|
|
]);
|
|
|
|
if ($localSettingsRow) {
|
|
$newDb->prepare('
|
|
UPDATE settings SET
|
|
local_net = :net,
|
|
local_networks_json = :nets,
|
|
admin_https_port = :port
|
|
WHERE id = 1
|
|
')->execute([
|
|
':net' => $localSettingsRow['local_net'] ?? '192.168.1.0/24',
|
|
':nets' => $localSettingsRow['local_networks_json'] ?? '["192.168.1.0/24"]',
|
|
':port' => $localSettingsRow['admin_https_port'] ?? 8443,
|
|
]);
|
|
}
|
|
|
|
// Régénérer les configurations PJSIP et Asterisk
|
|
ConfigGenerator::generateAndApply($newDb);
|
|
|
|
// Si le cluster est actif, actualiser le service Keepalived
|
|
if (!empty($localClusterSettings['enabled'])) {
|
|
$this->applyKeepalivedConfig();
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Base de données et fichiers audio appliqués avec succès.',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Bascule manuelle de rôle (Switchover)
|
|
*/
|
|
public function triggerSwitchover(): array
|
|
{
|
|
$settings = $this->getSettings();
|
|
$currentRole = $settings['role'] ?? 'master';
|
|
$newRole = ($currentRole === 'master') ? 'backup' : 'master';
|
|
$newPrio = ($newRole === 'master') ? 100 : 90;
|
|
|
|
$settings['role'] = $newRole;
|
|
$settings['priority'] = $newPrio;
|
|
|
|
return $this->saveSettings($settings);
|
|
}
|
|
|
|
private function updateSyncStatus(string $status, string $message): void
|
|
{
|
|
$stmt = $this->db->prepare('
|
|
UPDATE cluster_settings SET
|
|
last_sync_at = CURRENT_TIMESTAMP,
|
|
last_sync_status = :status,
|
|
last_sync_message = :msg
|
|
WHERE id = 1
|
|
');
|
|
$stmt->execute([':status' => $status, ':msg' => $message]);
|
|
}
|
|
}
|