fix(ucp,ha): auto-déploiement UCP, check SQLite PDO/SQLite3::backup HA, refresh comptes UCP (v2.5.1)

This commit is contained in:
sorcier committed 2026-11-11 19:31:32 +01:00
1 parent 72e43f1a9a
commit c96af67ac9
11 files changed
+191 -66

No files matched your search

+16 -1
View File
@@ -133,6 +133,9 @@ try {
switch ($action) { switch ($action) {
case 'get_state': case 'get_state':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch(); $settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
@@ -1920,6 +1923,11 @@ try {
$ami = AmiClient::fromDatabase($db); $ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan(); $reloadInfo = $ami->reloadPjsipAndDialplan();
// S'assurer que le VHost UCP & Fail2ban UCP sont déployés même après un git pull manuel
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
// Actualisation de l'horodatage de modification en base pour la réconciliation cluster // Actualisation de l'horodatage de modification en base pour la réconciliation cluster
try { try {
$db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1"); $db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
@@ -2454,9 +2462,14 @@ try {
break; break;
case 'get_ucp_users': case 'get_ucp_users':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$users = UcpManager::listUsers($db);
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'users' => UcpManager::listUsers($db), 'users' => $users,
'ucp_users' => $users,
]); ]);
break; break;
@@ -2465,6 +2478,7 @@ try {
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'users' => $users, 'users' => $users,
'ucp_users' => $users,
'message' => 'Compte utilisateur UCP enregistré avec succès.', 'message' => 'Compte utilisateur UCP enregistré avec succès.',
]); ]);
break; break;
@@ -2475,6 +2489,7 @@ try {
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'users' => $users, 'users' => $users,
'ucp_users' => $users,
'message' => 'Compte utilisateur UCP supprimé.', 'message' => 'Compte utilisateur UCP supprimé.',
]); ]);
break; break;
+2 -2
View File
@@ -7017,7 +7017,7 @@ async function saveUcpUser(event) {
try { try {
const res = await apiCall('save_ucp_user', payload); const res = await apiCall('save_ucp_user', payload);
appState.ucp_users = res.ucp_users || []; appState.ucp_users = res.ucp_users || res.users || [];
renderUcpTab(); renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp'); const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length; if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
@@ -7036,7 +7036,7 @@ async function deleteUcpUser(userId, username) {
} }
try { try {
const res = await apiCall('delete_ucp_user', { id: Number(userId) }); const res = await apiCall('delete_ucp_user', { id: Number(userId) });
appState.ucp_users = res.ucp_users || []; appState.ucp_users = res.ucp_users || res.users || [];
renderUcpTab(); renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp'); const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length; if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
+10 -1
View File
@@ -74,8 +74,17 @@ switch ($cmd) {
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n"; echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n";
break; break;
case 'init-ucp':
case 'sync-ucp':
case 'apply':
$settings = FirewallManager::getSettings();
$res = FirewallManager::saveSettings($settings);
@unlink('/tmp/nexus_fw_status_cache.json');
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . ($res['message'] ?? '') . "\n";
break;
default: default:
echo "Usage: nexus-firewall [status|on|off|ban <ip>|unban <ip>]\n"; echo "Usage: nexus-firewall [status|on|off|init-ucp|ban <ip>|unban <ip>]\n";
exit(1); exit(1);
} }
+2 -2
View File
@@ -158,9 +158,9 @@ fi
# 3. Vérification des dépendances Debian # 3. Vérification des dépendances Debian
echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}" echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}"
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || { which sqlite3 >/dev/null 2>&1 && which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || {
echo -e " -> Installation de paquets complémentaires..." echo -e " -> Installation de paquets complémentaires..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends sqlite3 acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
} }
# 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP # 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP
+105 -48
View File
@@ -495,8 +495,10 @@ class ClusterManager
throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}");
} }
$uniq = getmypid() . '_' . bin2hex(random_bytes(4));
// Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite // Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite
$tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.sqlite'; $tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite';
@unlink($tmpDbDump); @unlink($tmpDbDump);
try { try {
$this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump)); $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
@@ -509,26 +511,44 @@ class ClusterManager
@copy($dbPath, $tmpDbDump); @copy($dbPath, $tmpDbDump);
} }
$dbIntegrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump);
if ($dbIntegrity !== 'ok') { if ($dbIntegrity !== 'ok') {
@unlink($tmpDbDump); @unlink($tmpDbDump);
throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}");
} }
$tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; $tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz';
$tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3'; $tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3';
$hasAstdb = false; $hasAstdb = false;
// Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3)
$astdbPath = '/var/lib/asterisk/astdb.sqlite3'; $astdbPath = '/var/lib/asterisk/astdb.sqlite3';
if (file_exists($astdbPath) && is_readable($astdbPath)) { if (file_exists($astdbPath) && is_readable($astdbPath)) {
@unlink($tmpAstdb); @unlink($tmpAstdb);
try {
if (class_exists(\SQLite3::class)) {
$srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY);
$dstAst = new \SQLite3($tmpAstdb);
$srcAst->backup($dstAst);
$srcAst->close();
$dstAst->close();
} else {
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true"); shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
}
} catch (\Throwable) {
@copy($astdbPath, $tmpAstdb);
}
if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) {
$integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $integrity = $this->checkSqliteIntegrity($tmpAstdb);
if ($integrity === 'ok') { if ($integrity === 'ok') {
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) // Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk)
try {
$astPdo = new PDO('sqlite:' . $tmpAstdb);
$astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'");
$astPdo = null;
} catch (\Throwable) {
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true");
}
$hasAstdb = true; $hasAstdb = true;
} else { } else {
@unlink($tmpAstdb); @unlink($tmpAstdb);
@@ -537,7 +557,7 @@ class ClusterManager
} }
// Préparation du dossier temporaire de tarage pour isoler database.sqlite // Préparation du dossier temporaire de tarage pour isoler database.sqlite
$tmpDbDir = '/tmp/nexus_cluster_tar_' . time(); $tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq;
@mkdir($tmpDbDir, 0755, true); @mkdir($tmpDbDir, 0755, true);
rename($tmpDbDump, $tmpDbDir . '/database.sqlite'); rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
@@ -552,9 +572,9 @@ class ClusterManager
$cmd .= " -C /var/www/nexus-pbx/public avatars"; $cmd .= " -C /var/www/nexus-pbx/public avatars";
} }
if ($hasAstdb) { if ($hasAstdb) {
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; $cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb));
} }
shell_exec($cmd); exec($cmd . " 2>&1", $tarOut, $tarCode);
@unlink($tmpDbDir . '/database.sqlite'); @unlink($tmpDbDir . '/database.sqlite');
@rmdir($tmpDbDir); @rmdir($tmpDbDir);
@@ -562,9 +582,9 @@ class ClusterManager
@unlink($tmpAstdb); @unlink($tmpAstdb);
} }
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) { if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) {
@unlink($tmpArchive); @unlink($tmpArchive);
throw new RuntimeException("Échec de génération de l'archive de réplication."); throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode}).");
} }
$archiveData = file_get_contents($tmpArchive); $archiveData = file_get_contents($tmpArchive);
@@ -632,6 +652,9 @@ class ClusterManager
$resJson = json_decode((string)$response, true); $resJson = json_decode((string)$response, true);
$detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : ""); $detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : "");
$lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}"; $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}";
if (in_array($httpCode, [400, 401, 403, 422], true) && !empty($resJson['message'])) {
break;
}
} }
} }
@@ -671,27 +694,27 @@ class ClusterManager
throw new RuntimeException("Paquet de réplication vide ou corrompu."); throw new RuntimeException("Paquet de réplication vide ou corrompu.");
} }
$uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4));
$archiveRaw = base64_decode($data['archive_b64']); $archiveRaw = base64_decode($data['archive_b64']);
$tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; $tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz';
file_put_contents($tmpArchive, $archiveRaw); file_put_contents($tmpArchive, $archiveRaw);
// Extraction sécurisée dans un dossier temporaire pour validation préalable // Extraction sécurisée dans un dossier temporaire pour validation préalable
$tmpExtractDir = '/tmp/nexus_cluster_extract_' . time(); $tmpExtractDir = '/tmp/nexus_cluster_extract_' . $uniqRecv;
@mkdir($tmpExtractDir, 0755, true); @mkdir($tmpExtractDir, 0755, true);
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true"); shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true");
$extractedDb = $tmpExtractDir . '/database.sqlite'; $extractedDb = $tmpExtractDir . '/database.sqlite';
if (!file_exists($extractedDb) || filesize($extractedDb) === 0) { if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
@unlink($tmpArchive); @unlink($tmpArchive);
@rmdir($tmpExtractDir); shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide."); throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide.");
} }
// Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local
$check = trim(shell_exec("sqlite3 " . escapeshellarg($extractedDb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $check = $this->checkSqliteIntegrity($extractedDb);
if ($check !== 'ok') { if ($check !== 'ok') {
@unlink($extractedDb); shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
@rmdir($tmpExtractDir);
@unlink($tmpArchive); @unlink($tmpArchive);
throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application."); throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application.");
} }
@@ -709,15 +732,11 @@ class ClusterManager
$localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); $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) {} } catch (\Throwable $e) {}
// Restauration officielle en ligne via l'API SQLite .restore // Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore)
// Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM, // Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM,
// et n'invalide aucune lecture concurrente dans PHP-FPM. // et n'invalide aucune lecture concurrente dans PHP-FPM.
if (file_exists($dbPath)) { if (file_exists($dbPath)) {
$restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1"; $this->restoreSqliteDatabase($extractedDb, $dbPath);
$restoreOut = shell_exec($restoreCmd);
if (!empty($restoreOut)) {
error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut));
}
} else { } else {
@copy($extractedDb, $dbPath); @copy($extractedDb, $dbPath);
@chmod($dbPath, 0660); @chmod($dbPath, 0660);
@@ -728,11 +747,13 @@ class ClusterManager
@unlink($extractedDb); @unlink($extractedDb);
// Contrôle d'intégrité immédiat après synchronisation // Contrôle d'intégrité immédiat après synchronisation
$postCheck = trim(shell_exec("sqlite3 " . escapeshellarg($dbPath) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $postCheck = $this->checkSqliteIntegrity($dbPath);
if ($postCheck !== 'ok') { if ($postCheck !== 'ok') {
if (file_exists($dbPath . '.pre_cluster_sync.bak')) { if (file_exists($dbPath . '.pre_cluster_sync.bak')) {
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($dbPath . '.pre_cluster_sync.bak') . "\" 2>/dev/null || true"); $this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath);
} }
shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
@unlink($tmpArchive);
throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée."); throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée.");
} }
@@ -762,7 +783,13 @@ class ClusterManager
} }
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root // Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3'; $extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync_' . $uniqRecv . '.sqlite3';
if (!file_exists($extractedAstdb)) {
$candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: [];
if (!empty($candidates)) {
$extractedAstdb = $candidates[0];
}
}
if (file_exists($extractedAstdb)) { if (file_exists($extractedAstdb)) {
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1');
@unlink($extractedAstdb); @unlink($extractedAstdb);
@@ -825,29 +852,11 @@ class ClusterManager
// Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués) // Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués)
try { try {
$fw = FirewallManager::getSettings(); if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
FirewallManager::ensureUcpFail2banArtifacts(); @exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
FirewallManager::applyNginxConfig( } else {
(int)($fw['admin_https_port'] ?? 8443), FirewallManager::saveSettings(FirewallManager::getSettings());
(int)($fw['certbot_strict_port80'] ?? 0), }
(int)($fw['ucp_enabled'] ?? 1),
(int)($fw['ucp_https_port'] ?? 8444),
(int)($fw['ucp_http_port'] ?? 8084)
);
FirewallManager::generateJailLocal(
(string)($fw['fail2ban_whitelist_ips'] ?? ''),
(int)($fw['fail2ban_sip_maxretry'] ?? 3),
(int)($fw['fail2ban_sip_findtime'] ?? 600),
(int)($fw['fail2ban_sip_bantime'] ?? 3600),
(int)($fw['fail2ban_web_maxretry'] ?? 3),
(int)($fw['fail2ban_web_bantime'] ?? 3600),
(int)($fw['admin_https_port'] ?? 8443),
(int)($fw['ucp_enabled'] ?? 1),
(int)($fw['ucp_https_port'] ?? 8444),
(int)($fw['ucp_http_port'] ?? 8084),
(int)($fw['fail2ban_ucp_maxretry'] ?? 3),
(int)($fw['fail2ban_ucp_bantime'] ?? 3600)
);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Non-bloquant lors de la synchronisation HA // Non-bloquant lors de la synchronisation HA
} }
@@ -1110,6 +1119,54 @@ class ClusterManager
]; ];
} }
private function checkSqliteIntegrity(string $filePath): string
{
if (!file_exists($filePath) || filesize($filePath) === 0) {
return 'fichier manquant ou vide';
}
try {
$pdo = new PDO('sqlite:' . $filePath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$res = trim((string)$pdo->query('PRAGMA integrity_check;')->fetchColumn());
$pdo = null;
if ($res !== '') {
return $res;
}
} catch (\Throwable $e) {
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
return $cliOut !== '' ? $cliOut : $e->getMessage();
}
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
return $cliOut !== '' ? $cliOut : 'réponse integrity_check vide';
}
private function restoreSqliteDatabase(string $sourceFile, string $targetFile): void
{
if (class_exists(\SQLite3::class)) {
try {
$src = new \SQLite3($sourceFile, SQLITE3_OPEN_READONLY);
$dst = new \SQLite3($targetFile);
$dst->busyTimeout(5000);
$ok = $src->backup($dst);
$src->close();
$dst->close();
if ($ok) {
return;
}
} catch (\Throwable $e) {
error_log('[CLUSTER] Avertissement SQLite3::backup : ' . $e->getMessage());
}
}
$restoreCmd = 'sqlite3 ' . escapeshellarg($targetFile) . ' ".restore ' . escapeshellarg($sourceFile) . '" 2>&1';
$restoreOut = shell_exec($restoreCmd);
if (!empty($restoreOut)) {
error_log('[CLUSTER] Avertissement sqlite3 .restore : ' . trim((string)$restoreOut));
}
}
private function updateSyncStatus(string $status, string $message): void private function updateSyncStatus(string $status, string $message): void
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
+47 -3
View File
@@ -673,18 +673,62 @@ EOF;
public static function ensureUcpFail2banArtifacts(): void public static function ensureUcpFail2banArtifacts(): void
{ {
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n"; $filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
if (!file_exists(self::UCP_FILTER_PATH) || file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) { $isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
@file_put_contents(self::UCP_FILTER_PATH, $filterContent);
if (!file_exists(self::UCP_FILTER_PATH) || @file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
if (@file_put_contents(self::UCP_FILTER_PATH, $filterContent) !== false) {
@chmod(self::UCP_FILTER_PATH, 0644); @chmod(self::UCP_FILTER_PATH, 0644);
} elseif ($isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
} }
if (!file_exists(self::UCP_SECURITY_LOG)) { if (!file_exists(self::UCP_SECURITY_LOG)) {
@touch(self::UCP_SECURITY_LOG); if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
} }
@chown(self::UCP_SECURITY_LOG, 'www-data'); @chown(self::UCP_SECURITY_LOG, 'www-data');
@chgrp(self::UCP_SECURITY_LOG, 'asterisk'); @chgrp(self::UCP_SECURITY_LOG, 'asterisk');
@chmod(self::UCP_SECURITY_LOG, 0664); @chmod(self::UCP_SECURITY_LOG, 0664);
} }
/**
* Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull).
*/
public static function ensureUcpDeployed(): void
{
$st = self::getSettings();
if (empty($st['ucp_enabled'])) {
return;
}
$httpsPort = (int)($st['ucp_https_port'] ?? 8444);
$httpPort = (int)($st['ucp_http_port'] ?? 8084);
if ($httpsPort <= 0 && $httpPort <= 0) {
return;
}
$nginxFile = '/etc/nginx/sites-available/nexus-pbx';
$nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : '';
$hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR);
$hasFilter = file_exists(self::UCP_FILTER_PATH);
$hasLog = file_exists(self::UCP_SECURITY_LOG);
if ($hasNginxUcp && $hasFilter && $hasLog) {
return;
}
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code);
if ($code === 0) {
return;
}
}
self::saveSettings($st);
}
/** /**
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe. * Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
*/ */
+1
View File
@@ -194,3 +194,4 @@ class UcpAuth
]); ]);
} }
} }
+1
View File
@@ -340,3 +340,4 @@ try {
echo json_encode(['success' => false, 'message' => $e->getMessage()]); echo json_encode(['success' => false, 'message' => $e->getMessage()]);
} }
} }
+1
View File
@@ -829,3 +829,4 @@ if (window.UCP_IS_AUTH) {
} }
}, 15000); }, 15000);
} }
+1
View File
@@ -412,3 +412,4 @@ $csrfToken = UcpAuth::getCsrfToken();
<script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script> <script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script>
</body> </body>
</html> </html>
+5 -9
View File
@@ -1,16 +1,12 @@
{ {
"version": "2.5.0", "version": "2.5.1",
"codename": "Pulsar UCP", "codename": "Pulsar UCP",
"release_date": "2026-11-11", "release_date": "2026-11-11",
"min_php_version": "8.2", "min_php_version": "8.2",
"changelog": [ "changelog": [
"NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)", "Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel",
"Sécurité & Isolation Nginx : VirtualHosts UCP dédiés (/var/www/nexus-pbx/ucp) séparés physiquement de l'Administration", "Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp",
"Ports UCP configurables depuis l'interface Pare-feu : HTTPS (8444) pour accès direct et HTTP (8084) pour Reverse Proxy, avec interrupteur Activer/Désactiver", "Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page",
"Fail2ban dédié Portail Utilisateur : nouvelle jail [nexus-ucp], filtre nexus-ucp.conf et journal isolé /var/log/nexus_ucp_security.log (résolution X-Real-IP derrière Proxy)", "NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)"
"Gestion Admin des Comptes UCP : création/édition/désactivation d'utilisateurs avec liaison d'une ou plusieurs extensions SIP",
"Portail Employé Mode Clair : suivi des postes temps réel, activation NPD (DND) + mise à jour BLF, renvois internes (CF/CFU) et vers répondeur (vm-<poste>) sans numéro externe",
"Messagerie Vocale Web : écoute HTML5 (Accept-Ranges), téléchargement WAV, marquage Lu/Non lu (synchro MWI), suppression et modification PIN/email",
"Journal d'appels personnel filtré par extensions liées, annuaire complet avec édition photo (avatar) et email pour ses propres postes, et synchro HA des avatars"
] ]
} }