fix(ucp,ha): auto-déploiement UCP, check SQLite PDO/SQLite3::backup HA, refresh comptes UCP (v2.5.1)
This commit is contained in:
1 parent
72e43f1a9a
commit
c96af67ac9
11 files changed
+201
-76
No files matched your search
+107
-50
@@ -495,8 +495,10 @@ class ClusterManager
|
||||
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
|
||||
$tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.sqlite';
|
||||
$tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite';
|
||||
@unlink($tmpDbDump);
|
||||
try {
|
||||
$this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
|
||||
@@ -509,26 +511,44 @@ class ClusterManager
|
||||
@copy($dbPath, $tmpDbDump);
|
||||
}
|
||||
|
||||
$dbIntegrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
|
||||
$dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump);
|
||||
if ($dbIntegrity !== 'ok') {
|
||||
@unlink($tmpDbDump);
|
||||
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';
|
||||
$tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3';
|
||||
$tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz';
|
||||
$tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3';
|
||||
$hasAstdb = false;
|
||||
|
||||
// Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3)
|
||||
$astdbPath = '/var/lib/asterisk/astdb.sqlite3';
|
||||
if (file_exists($astdbPath) && is_readable($astdbPath)) {
|
||||
@unlink($tmpAstdb);
|
||||
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
|
||||
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");
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
@copy($astdbPath, $tmpAstdb);
|
||||
}
|
||||
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') {
|
||||
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk)
|
||||
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true");
|
||||
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");
|
||||
}
|
||||
$hasAstdb = true;
|
||||
} else {
|
||||
@unlink($tmpAstdb);
|
||||
@@ -537,7 +557,7 @@ class ClusterManager
|
||||
}
|
||||
|
||||
// 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);
|
||||
rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
|
||||
|
||||
@@ -552,9 +572,9 @@ class ClusterManager
|
||||
$cmd .= " -C /var/www/nexus-pbx/public avatars";
|
||||
}
|
||||
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');
|
||||
@rmdir($tmpDbDir);
|
||||
@@ -562,9 +582,9 @@ class ClusterManager
|
||||
@unlink($tmpAstdb);
|
||||
}
|
||||
|
||||
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) {
|
||||
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) {
|
||||
@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);
|
||||
@@ -632,6 +652,9 @@ class ClusterManager
|
||||
$resJson = json_decode((string)$response, true);
|
||||
$detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : "");
|
||||
$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.");
|
||||
}
|
||||
|
||||
$uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4));
|
||||
$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);
|
||||
|
||||
// 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);
|
||||
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true");
|
||||
|
||||
$extractedDb = $tmpExtractDir . '/database.sqlite';
|
||||
if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
|
||||
@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.");
|
||||
}
|
||||
|
||||
// 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') {
|
||||
@unlink($extractedDb);
|
||||
@rmdir($tmpExtractDir);
|
||||
shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
|
||||
@unlink($tmpArchive);
|
||||
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);
|
||||
} 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,
|
||||
// et n'invalide aucune lecture concurrente dans PHP-FPM.
|
||||
if (file_exists($dbPath)) {
|
||||
$restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1";
|
||||
$restoreOut = shell_exec($restoreCmd);
|
||||
if (!empty($restoreOut)) {
|
||||
error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut));
|
||||
}
|
||||
$this->restoreSqliteDatabase($extractedDb, $dbPath);
|
||||
} else {
|
||||
@copy($extractedDb, $dbPath);
|
||||
@chmod($dbPath, 0660);
|
||||
@@ -728,11 +747,13 @@ class ClusterManager
|
||||
@unlink($extractedDb);
|
||||
|
||||
// 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 (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.");
|
||||
}
|
||||
|
||||
@@ -762,7 +783,13 @@ class ClusterManager
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1');
|
||||
@unlink($extractedAstdb);
|
||||
@@ -825,29 +852,11 @@ class ClusterManager
|
||||
|
||||
// Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués)
|
||||
try {
|
||||
$fw = FirewallManager::getSettings();
|
||||
FirewallManager::ensureUcpFail2banArtifacts();
|
||||
FirewallManager::applyNginxConfig(
|
||||
(int)($fw['admin_https_port'] ?? 8443),
|
||||
(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)
|
||||
);
|
||||
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
|
||||
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
|
||||
} else {
|
||||
FirewallManager::saveSettings(FirewallManager::getSettings());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 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
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
|
||||
+48
-4
@@ -673,18 +673,62 @@ EOF;
|
||||
public static function ensureUcpFail2banArtifacts(): void
|
||||
{
|
||||
$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) {
|
||||
@file_put_contents(self::UCP_FILTER_PATH, $filterContent);
|
||||
@chmod(self::UCP_FILTER_PATH, 0644);
|
||||
$isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
|
||||
|
||||
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);
|
||||
} 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)) {
|
||||
@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');
|
||||
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
|
||||
@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.
|
||||
*/
|
||||
|
||||
@@ -194,3 +194,4 @@ class UcpAuth
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user