Initial commit - NexusPBX IP Telephony Platform
This commit is contained in:
commit
c5de8d0c71
25 files changed
+18123
No files matched your search
@@ -0,0 +1,879 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class BackupManager
|
||||
{
|
||||
public const BACKUP_DIR = '/var/www/nexus-pbx/storage/system_backups';
|
||||
public const VERSION = '1.2.0';
|
||||
|
||||
/**
|
||||
* Initialise le répertoire des sauvegardes.
|
||||
*/
|
||||
public static function initDirectory(): void
|
||||
{
|
||||
if (!is_dir(self::BACKUP_DIR)) {
|
||||
@mkdir(self::BACKUP_DIR, 0775, true);
|
||||
}
|
||||
@chown(self::BACKUP_DIR, 'www-data');
|
||||
@chgrp(self::BACKUP_DIR, 'asterisk');
|
||||
@chmod(self::BACKUP_DIR, 02775);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les paramètres de sauvegarde depuis SQLite.
|
||||
*/
|
||||
public static function getBackupSettings(): array
|
||||
{
|
||||
$db = Database::getConnection();
|
||||
$stmt = $db->query("SELECT
|
||||
backup_auto_enabled, backup_frequency, backup_retention, backup_storage_type,
|
||||
backup_ssh_host, backup_ssh_port, backup_ssh_user, backup_ssh_auth_type,
|
||||
backup_ssh_password, backup_ssh_remote_path, backup_last_run, backup_last_status
|
||||
FROM settings WHERE id = 1");
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
return [
|
||||
'backup_auto_enabled' => 0,
|
||||
'backup_frequency' => 'daily',
|
||||
'backup_retention' => 7,
|
||||
'backup_storage_type' => 'local',
|
||||
'backup_ssh_host' => '',
|
||||
'backup_ssh_port' => 22,
|
||||
'backup_ssh_user' => '',
|
||||
'backup_ssh_auth_type' => 'password',
|
||||
'backup_ssh_password' => '',
|
||||
'backup_ssh_remote_path' => '/backups/nexus',
|
||||
'backup_last_run' => null,
|
||||
'backup_last_status' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'backup_auto_enabled' => (int)($row['backup_auto_enabled'] ?? 0),
|
||||
'backup_frequency' => (string)($row['backup_frequency'] ?? 'daily'),
|
||||
'backup_retention' => (int)($row['backup_retention'] ?? 7),
|
||||
'backup_storage_type' => (string)($row['backup_storage_type'] ?? 'local'),
|
||||
'backup_ssh_host' => (string)($row['backup_ssh_host'] ?? ''),
|
||||
'backup_ssh_port' => (int)($row['backup_ssh_port'] ?? 22),
|
||||
'backup_ssh_user' => (string)($row['backup_ssh_user'] ?? ''),
|
||||
'backup_ssh_auth_type' => (string)($row['backup_ssh_auth_type'] ?? 'password'),
|
||||
'backup_ssh_password' => (string)($row['backup_ssh_password'] ?? ''),
|
||||
'backup_ssh_remote_path' => (string)($row['backup_ssh_remote_path'] ?? '/backups/nexus'),
|
||||
'backup_last_run' => $row['backup_last_run'] ?? null,
|
||||
'backup_last_status' => (string)($row['backup_last_status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les paramètres de configuration des backups.
|
||||
*/
|
||||
public static function saveBackupSettings(array $data): void
|
||||
{
|
||||
$db = Database::getConnection();
|
||||
|
||||
$autoEnabled = !empty($data['backup_auto_enabled']) ? 1 : 0;
|
||||
$frequency = in_array($data['backup_frequency'] ?? '', ['hourly', '6h', '12h', 'daily', 'weekly'], true)
|
||||
? $data['backup_frequency']
|
||||
: 'daily';
|
||||
$retention = max(1, min(60, (int)($data['backup_retention'] ?? 7)));
|
||||
$storageType = ($data['backup_storage_type'] ?? 'local') === 'ssh' ? 'ssh' : 'local';
|
||||
$sshHost = trim((string)($data['backup_ssh_host'] ?? ''));
|
||||
$sshPort = max(1, min(65535, (int)($data['backup_ssh_port'] ?? 22)));
|
||||
$sshUser = trim((string)($data['backup_ssh_user'] ?? ''));
|
||||
$sshAuthType = ($data['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password';
|
||||
$sshPassword = (string)($data['backup_ssh_password'] ?? '');
|
||||
$sshRemotePath = trim((string)($data['backup_ssh_remote_path'] ?? '/backups/nexus'));
|
||||
if ($sshRemotePath === '') {
|
||||
$sshRemotePath = '/backups/nexus';
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE settings SET
|
||||
backup_auto_enabled = :auto,
|
||||
backup_frequency = :freq,
|
||||
backup_retention = :ret,
|
||||
backup_storage_type = :stype,
|
||||
backup_ssh_host = :shost,
|
||||
backup_ssh_port = :sport,
|
||||
backup_ssh_user = :suser,
|
||||
backup_ssh_auth_type = :sauth,
|
||||
backup_ssh_password = :spass,
|
||||
backup_ssh_remote_path = :spath,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1");
|
||||
|
||||
$stmt->execute([
|
||||
':auto' => $autoEnabled,
|
||||
':freq' => $frequency,
|
||||
':ret' => $retention,
|
||||
':stype' => $storageType,
|
||||
':shost' => $sshHost,
|
||||
':sport' => $sshPort,
|
||||
':suser' => $sshUser,
|
||||
':sauth' => $sshAuthType,
|
||||
':spass' => $sshPassword,
|
||||
':spath' => $sshRemotePath,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion SSH avec les identifiants fournis.
|
||||
*/
|
||||
public static function testSshConnection(array $cfg): array
|
||||
{
|
||||
$host = trim((string)($cfg['backup_ssh_host'] ?? ''));
|
||||
$port = (int)($cfg['backup_ssh_port'] ?? 22);
|
||||
$user = trim((string)($cfg['backup_ssh_user'] ?? ''));
|
||||
$authType = ($cfg['backup_ssh_auth_type'] ?? 'password') === 'key' ? 'key' : 'password';
|
||||
$password = (string)($cfg['backup_ssh_password'] ?? '');
|
||||
$remotePath = trim((string)($cfg['backup_ssh_remote_path'] ?? '/backups/nexus'));
|
||||
|
||||
if ($host === '' || $user === '') {
|
||||
throw new InvalidArgumentException("L'hôte SSH et l'utilisateur distant sont obligatoires.");
|
||||
}
|
||||
|
||||
if ($authType === 'password' && $password === '') {
|
||||
throw new InvalidArgumentException("Le mot de passe SSH est obligatoire.");
|
||||
}
|
||||
|
||||
$remoteDirEsc = escapeshellarg($remotePath);
|
||||
$testCmd = "mkdir -p {$remoteDirEsc} && touch {$remoteDirEsc}/.nexus_test && rm -f {$remoteDirEsc}/.nexus_test && echo 'NEXUS_SSH_OK'";
|
||||
|
||||
$result = self::runSshCommand($host, $port, $user, $authType, $password, $testCmd);
|
||||
|
||||
if ($result['exit_code'] !== 0 || !str_contains($result['output'], 'NEXUS_SSH_OK')) {
|
||||
$err = trim($result['error'] ?: $result['output']);
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Échec de connexion SSH ou dossier distant non accessible : " . ($err ?: 'Erreur inconnue (code ' . $result['exit_code'] . ')'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Connexion SSH réussie ! Dossier distant '{$remotePath}' accessible et prêt pour les sauvegardes.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une archive de sauvegarde complète (Base de données SQLite, sons, messageries, certs, confs).
|
||||
*
|
||||
* @param string $source 'manual' ou 'cron'
|
||||
* @return array
|
||||
*/
|
||||
public static function createBackup(string $source = 'manual'): array
|
||||
{
|
||||
self::initDirectory();
|
||||
$settings = self::getBackupSettings();
|
||||
$db = Database::getConnection();
|
||||
|
||||
// 1. Checkpoint WAL SQLite pour garantir l'intégrité absolue de la base
|
||||
try {
|
||||
$db->exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch (Exception $e) {
|
||||
// Ignorer si échec temporaire
|
||||
}
|
||||
|
||||
$timestamp = date('Ymd_His');
|
||||
$filename = "nexus_backup_v" . self::VERSION . "_{$timestamp}.tar.gz";
|
||||
$localArchivePath = self::BACKUP_DIR . '/' . $filename;
|
||||
|
||||
// 2. Préparation du dossier de staging
|
||||
$stageDir = '/tmp/nexus_stage_' . uniqid() . '_' . $timestamp;
|
||||
if (!mkdir($stageDir, 0700, true)) {
|
||||
throw new RuntimeException("Impossible de créer le répertoire temporaire de staging : {$stageDir}");
|
||||
}
|
||||
|
||||
$manifestFiles = [];
|
||||
|
||||
try {
|
||||
// A. Copie de la base SQLite
|
||||
$dbSource = Database::getDatabasePath();
|
||||
if (file_exists($dbSource)) {
|
||||
$dbDest = $stageDir . '/database.sqlite';
|
||||
copy($dbSource, $dbDest);
|
||||
$manifestFiles['database.sqlite'] = [
|
||||
'size' => filesize($dbDest),
|
||||
'md5' => md5_file($dbDest),
|
||||
];
|
||||
}
|
||||
|
||||
// B. Copie des fichiers sons d'accueil et musiques personnalisées
|
||||
// /var/www/nexus-pbx/storage/sounds/
|
||||
$storageSounds = '/var/www/nexus-pbx/storage/sounds';
|
||||
if (is_dir($storageSounds)) {
|
||||
$destSounds = $stageDir . '/storage_sounds';
|
||||
mkdir($destSounds, 0755, true);
|
||||
exec("rsync -a " . escapeshellarg($storageSounds . '/') . " " . escapeshellarg($destSounds . '/'));
|
||||
$manifestFiles['storage_sounds'] = ['count' => count(glob($destSounds . '/*'))];
|
||||
}
|
||||
|
||||
// /var/lib/asterisk/sounds/custom/
|
||||
$astSoundsCustom = '/var/lib/asterisk/sounds/custom';
|
||||
if (is_dir($astSoundsCustom)) {
|
||||
$destAstSounds = $stageDir . '/asterisk_sounds_custom';
|
||||
mkdir($destAstSounds, 0755, true);
|
||||
exec("rsync -a " . escapeshellarg($astSoundsCustom . '/') . " " . escapeshellarg($destAstSounds . '/'));
|
||||
$manifestFiles['asterisk_sounds_custom'] = ['count' => count(glob($destAstSounds . '/*'))];
|
||||
}
|
||||
|
||||
// C. Spool Voicemail (Messages vocaux, salutations personnalisées, secrets/pins)
|
||||
$voicemailSpool = '/var/spool/asterisk/voicemail/default';
|
||||
if (is_dir($voicemailSpool)) {
|
||||
$destVm = $stageDir . '/voicemail_default';
|
||||
mkdir($destVm, 0755, true);
|
||||
exec("rsync -a " . escapeshellarg($voicemailSpool . '/') . " " . escapeshellarg($destVm . '/'));
|
||||
$manifestFiles['voicemail_default'] = ['count' => count(glob($destVm . '/*'))];
|
||||
}
|
||||
|
||||
// D. Certificats SSL / TLS personnalisés
|
||||
$storageCerts = '/var/www/nexus-pbx/storage/certs';
|
||||
if (is_dir($storageCerts)) {
|
||||
$destCerts = $stageDir . '/storage_certs';
|
||||
mkdir($destCerts, 0700, true);
|
||||
exec("rsync -a " . escapeshellarg($storageCerts . '/') . " " . escapeshellarg($destCerts . '/'));
|
||||
$manifestFiles['storage_certs'] = ['count' => count(glob($destCerts . '/*'))];
|
||||
}
|
||||
$astKeys = '/etc/asterisk/keys';
|
||||
if (is_dir($astKeys)) {
|
||||
$destAstKeys = $stageDir . '/asterisk_keys';
|
||||
mkdir($destAstKeys, 0700, true);
|
||||
exec("rsync -a " . escapeshellarg($astKeys . '/') . " " . escapeshellarg($destAstKeys . '/'));
|
||||
$manifestFiles['asterisk_keys'] = ['count' => count(glob($destAstKeys . '/*'))];
|
||||
}
|
||||
|
||||
// E. Fichiers Asterisk Custom (.conf)
|
||||
$destConfs = $stageDir . '/asterisk_custom_conf';
|
||||
mkdir($destConfs, 0755, true);
|
||||
foreach (glob('/etc/asterisk/*.custom.conf') as $customConf) {
|
||||
copy($customConf, $destConfs . '/' . basename($customConf));
|
||||
}
|
||||
$manifestFiles['asterisk_custom_conf'] = ['count' => count(glob($destConfs . '/*'))];
|
||||
|
||||
// F. Création du Manifeste
|
||||
$manifest = [
|
||||
'nexus_version' => self::VERSION,
|
||||
'created_at' => date('c'),
|
||||
'timestamp' => time(),
|
||||
'filename' => $filename,
|
||||
'source' => $source,
|
||||
'storage_type' => $settings['backup_storage_type'],
|
||||
'hostname' => php_uname('n'),
|
||||
'os' => php_uname('s') . ' ' . php_uname('r'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'components' => $manifestFiles,
|
||||
];
|
||||
file_put_contents($stageDir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// G. Compression en .tar.gz
|
||||
$tarCmd = "tar -czf " . escapeshellarg($localArchivePath) . " -C " . escapeshellarg($stageDir) . " .";
|
||||
exec($tarCmd, $tarOut, $tarCode);
|
||||
if ($tarCode !== 0 || !file_exists($localArchivePath)) {
|
||||
throw new RuntimeException("Échec de la création de l'archive tar.gz (code {$tarCode}).");
|
||||
}
|
||||
|
||||
@chown($localArchivePath, 'www-data');
|
||||
@chgrp($localArchivePath, 'asterisk');
|
||||
@chmod($localArchivePath, 0664);
|
||||
|
||||
$fileSize = filesize($localArchivePath);
|
||||
|
||||
// 3. Gestion du stockage distant SSH si configuré
|
||||
$storageType = $settings['backup_storage_type'];
|
||||
$remoteSuccess = false;
|
||||
|
||||
if ($storageType === 'ssh') {
|
||||
$remotePath = rtrim($settings['backup_ssh_remote_path'], '/');
|
||||
$transferResult = self::transferToSsh($localArchivePath, $filename, $settings);
|
||||
if (!$transferResult['success']) {
|
||||
throw new RuntimeException("Échec du transfert distant SSH : " . $transferResult['message']);
|
||||
}
|
||||
$remoteSuccess = true;
|
||||
|
||||
// L'utilisateur demande que la sauvegarde distante désactive la sauvegarde locale
|
||||
// On supprime donc l'archive locale pour ne pas encombrer le disque local
|
||||
@unlink($localArchivePath);
|
||||
|
||||
// Pruning des sauvegardes distantes
|
||||
self::pruneRemoteBackups($settings);
|
||||
} else {
|
||||
// Pruning des sauvegardes locales selon la rétention
|
||||
self::pruneLocalBackups($settings['backup_retention']);
|
||||
}
|
||||
|
||||
// 4. Mettre à jour le statut dans la DB
|
||||
$db->prepare("UPDATE settings SET
|
||||
backup_last_run = :now,
|
||||
backup_last_status = :status
|
||||
WHERE id = 1")->execute([
|
||||
':now' => date('Y-m-d H:i:s'),
|
||||
':status' => 'Succès (' . ($storageType === 'ssh' ? 'Distant SSH' : 'Local') . ' - ' . self::formatBytes($fileSize) . ')',
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'filename' => $filename,
|
||||
'storage_type' => $storageType,
|
||||
'size' => $fileSize,
|
||||
'size_formatted' => self::formatBytes($fileSize),
|
||||
'created_at' => date('d/m/Y H:i:s'),
|
||||
'manifest' => $manifest,
|
||||
];
|
||||
} finally {
|
||||
// Nettoyage impératif du dossier staging
|
||||
self::removeDirRecursive($stageDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sauvegardes disponibles (locales ou distantes).
|
||||
*/
|
||||
public static function listBackups(): array
|
||||
{
|
||||
self::initDirectory();
|
||||
$settings = self::getBackupSettings();
|
||||
$backups = [];
|
||||
|
||||
// 1. Sauvegardes locales
|
||||
$localFiles = glob(self::BACKUP_DIR . '/*.tar.gz') ?: [];
|
||||
foreach ($localFiles as $file) {
|
||||
$name = basename($file);
|
||||
$size = filesize($file);
|
||||
$mtime = filemtime($file);
|
||||
|
||||
// Tenter de lire le manifest depuis l'archive sans l'extraire entièrement
|
||||
$manifest = self::readManifestFromTar($file);
|
||||
|
||||
$backups[] = [
|
||||
'filename' => $name,
|
||||
'storage_type' => 'local',
|
||||
'size' => $size,
|
||||
'size_formatted' => self::formatBytes($size),
|
||||
'timestamp' => $mtime,
|
||||
'date_formatted' => date('d/m/Y H:i:s', $mtime),
|
||||
'version' => $manifest['nexus_version'] ?? self::VERSION,
|
||||
'source' => $manifest['source'] ?? 'local',
|
||||
'is_local' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Sauvegardes distantes SSH (si stockage distant SSH configuré)
|
||||
if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) {
|
||||
$remoteBackups = self::listRemoteBackups($settings);
|
||||
foreach ($remoteBackups as $rb) {
|
||||
$backups[] = $rb;
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par date décroissante (plus récent au plus ancien)
|
||||
usort($backups, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une sauvegarde (locale ou distante).
|
||||
*/
|
||||
public static function deleteBackup(string $filename): bool
|
||||
{
|
||||
$filename = basename($filename);
|
||||
if ($filename === '' || !str_ends_with($filename, '.tar.gz')) {
|
||||
throw new InvalidArgumentException("Nom de fichier de sauvegarde invalide.");
|
||||
}
|
||||
|
||||
$localPath = self::BACKUP_DIR . '/' . $filename;
|
||||
if (file_exists($localPath)) {
|
||||
return @unlink($localPath);
|
||||
}
|
||||
|
||||
// Si non présent en local, vérifier distant SSH
|
||||
$settings = self::getBackupSettings();
|
||||
if ($settings['backup_storage_type'] === 'ssh' && !empty($settings['backup_ssh_host'])) {
|
||||
$remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename;
|
||||
$res = self::runSshCommand(
|
||||
$settings['backup_ssh_host'],
|
||||
$settings['backup_ssh_port'],
|
||||
$settings['backup_ssh_user'],
|
||||
$settings['backup_ssh_auth_type'],
|
||||
$settings['backup_ssh_password'],
|
||||
"rm -f " . escapeshellarg($remoteFile)
|
||||
);
|
||||
return $res['exit_code'] === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge une sauvegarde (si distante, la rapatrie temporairement pour la streamer).
|
||||
*/
|
||||
public static function downloadBackup(string $filename): void
|
||||
{
|
||||
$filename = basename($filename);
|
||||
if ($filename === '' || !str_ends_with($filename, '.tar.gz')) {
|
||||
http_response_code(400);
|
||||
die("Nom de sauvegarde invalide.");
|
||||
}
|
||||
|
||||
$localPath = self::BACKUP_DIR . '/' . $filename;
|
||||
$tempDownload = false;
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
// Rapatriement depuis SSH
|
||||
$settings = self::getBackupSettings();
|
||||
if ($settings['backup_storage_type'] !== 'ssh' || empty($settings['backup_ssh_host'])) {
|
||||
http_response_code(404);
|
||||
die("Sauvegarde introuvable.");
|
||||
}
|
||||
|
||||
$localPath = '/tmp/' . uniqid('dl_') . '_' . $filename;
|
||||
$remoteFile = rtrim($settings['backup_ssh_remote_path'], '/') . '/' . $filename;
|
||||
$fetchRes = self::fetchFromSsh($remoteFile, $localPath, $settings);
|
||||
if (!$fetchRes['success'] || !file_exists($localPath)) {
|
||||
http_response_code(502);
|
||||
die("Impossible de récupérer la sauvegarde distante : " . $fetchRes['message']);
|
||||
}
|
||||
$tempDownload = true;
|
||||
}
|
||||
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
header('Content-Description: File Transfer');
|
||||
header('Content-Type: application/gzip');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Expires: 0');
|
||||
header('Cache-Control: must-revalidate');
|
||||
header('Pragma: public');
|
||||
header('Content-Length: ' . filesize($localPath));
|
||||
readfile($localPath);
|
||||
|
||||
if ($tempDownload) {
|
||||
@unlink($localPath);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure une sauvegarde complète du système PBX en un seul clic !
|
||||
*
|
||||
* @param string $archivePath Chemin absolu du fichier .tar.gz (déjà présent ou uploadé)
|
||||
* @return array
|
||||
*/
|
||||
public static function restoreBackup(string $archivePath): array
|
||||
{
|
||||
if (!file_exists($archivePath) || !is_readable($archivePath)) {
|
||||
throw new RuntimeException("Fichier d'archive introuvable ou inaccessible.");
|
||||
}
|
||||
|
||||
// 1. Vérifier la validité de l'archive tar.gz
|
||||
exec("tar -tzf " . escapeshellarg($archivePath) . " 2>&1", $tarList, $testCode);
|
||||
if ($testCode !== 0) {
|
||||
throw new RuntimeException("L'archive fournie est corrompue ou n'est pas un fichier tar.gz valide.");
|
||||
}
|
||||
|
||||
$stageDir = '/tmp/nexus_restore_' . uniqid();
|
||||
if (!mkdir($stageDir, 0700, true)) {
|
||||
throw new RuntimeException("Impossible de créer le dossier de décompression.");
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Extraction complète dans le dossier de staging
|
||||
exec("tar -xzf " . escapeshellarg($archivePath) . " -C " . escapeshellarg($stageDir), $extOut, $extCode);
|
||||
if ($extCode !== 0) {
|
||||
throw new RuntimeException("Échec de décompression de l'archive de sauvegarde.");
|
||||
}
|
||||
|
||||
// 3. Vérification du manifest et de la base de données SQLite
|
||||
$manifestPath = $stageDir . '/manifest.json';
|
||||
$dbPath = $stageDir . '/database.sqlite';
|
||||
|
||||
if (!file_exists($dbPath)) {
|
||||
throw new RuntimeException("L'archive ne contient pas de base de données NexusPBX (database.sqlite). Restauration annulée.");
|
||||
}
|
||||
|
||||
$manifest = [];
|
||||
if (file_exists($manifestPath)) {
|
||||
$manifest = json_decode((string)file_get_contents($manifestPath), true) ?: [];
|
||||
}
|
||||
|
||||
// 4. Sécurité : Création d'un backup de précaution de la base actuelle
|
||||
$currentDb = Database::getDatabasePath();
|
||||
if (file_exists($currentDb)) {
|
||||
@copy($currentDb, $currentDb . '.prerestore.' . date('Ymd_His'));
|
||||
}
|
||||
|
||||
// 5. Restauration de la Base de données SQLite
|
||||
// Remplacer la base SQLite
|
||||
copy($dbPath, $currentDb);
|
||||
@chown($currentDb, 'www-data');
|
||||
@chgrp($currentDb, 'asterisk');
|
||||
@chmod($currentDb, 0660);
|
||||
|
||||
// Supprimer d'éventuels fichiers WAL/SHM résiduels de l'ancienne base
|
||||
if (file_exists($currentDb . '-wal')) {
|
||||
@unlink($currentDb . '-wal');
|
||||
}
|
||||
if (file_exists($currentDb . '-shm')) {
|
||||
@unlink($currentDb . '-shm');
|
||||
}
|
||||
|
||||
// 6. Restauration des fichiers sons (NexusPBX & Asterisk)
|
||||
if (is_dir($stageDir . '/storage_sounds')) {
|
||||
$destSounds = '/var/www/nexus-pbx/storage/sounds';
|
||||
@mkdir($destSounds, 0775, true);
|
||||
exec("rsync -a --delete " . escapeshellarg($stageDir . '/storage_sounds/') . " " . escapeshellarg($destSounds . '/'));
|
||||
@chown($destSounds, 'www-data');
|
||||
@chgrp($destSounds, 'asterisk');
|
||||
exec("chown -R www-data:asterisk " . escapeshellarg($destSounds));
|
||||
exec("chmod -R 775 " . escapeshellarg($destSounds));
|
||||
}
|
||||
|
||||
if (is_dir($stageDir . '/asterisk_sounds_custom')) {
|
||||
$destAstSounds = '/var/lib/asterisk/sounds/custom';
|
||||
@mkdir($destAstSounds, 0775, true);
|
||||
exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_sounds_custom/') . " " . escapeshellarg($destAstSounds . '/'));
|
||||
exec("chown -R asterisk:asterisk " . escapeshellarg($destAstSounds));
|
||||
exec("chmod -R 775 " . escapeshellarg($destAstSounds));
|
||||
}
|
||||
|
||||
// 7. Restauration de la boîte vocale (Voicemail Spool & Greetings)
|
||||
if (is_dir($stageDir . '/voicemail_default')) {
|
||||
$destVm = '/var/spool/asterisk/voicemail/default';
|
||||
@mkdir($destVm, 0775, true);
|
||||
exec("rsync -a " . escapeshellarg($stageDir . '/voicemail_default/') . " " . escapeshellarg($destVm . '/'));
|
||||
exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail");
|
||||
exec("chmod -R g+rwX /var/spool/asterisk/voicemail");
|
||||
}
|
||||
|
||||
// 8. Restauration des certificats TLS
|
||||
if (is_dir($stageDir . '/storage_certs')) {
|
||||
$destCerts = '/var/www/nexus-pbx/storage/certs';
|
||||
@mkdir($destCerts, 0750, true);
|
||||
exec("rsync -a " . escapeshellarg($stageDir . '/storage_certs/') . " " . escapeshellarg($destCerts . '/'));
|
||||
exec("chown -R www-data:asterisk " . escapeshellarg($destCerts));
|
||||
}
|
||||
if (is_dir($stageDir . '/asterisk_keys')) {
|
||||
$destKeys = '/etc/asterisk/keys';
|
||||
@mkdir($destKeys, 0750, true);
|
||||
exec("rsync -a " . escapeshellarg($stageDir . '/asterisk_keys/') . " " . escapeshellarg($destKeys . '/'));
|
||||
exec("chown -R asterisk:asterisk " . escapeshellarg($destKeys));
|
||||
}
|
||||
|
||||
// 9. Restauration des fichiers custom Asterisk (.conf)
|
||||
if (is_dir($stageDir . '/asterisk_custom_conf')) {
|
||||
foreach (glob($stageDir . '/asterisk_custom_conf/*.custom.conf') as $cf) {
|
||||
copy($cf, '/etc/asterisk/' . basename($cf));
|
||||
@chown('/etc/asterisk/' . basename($cf), 'asterisk');
|
||||
@chgrp('/etc/asterisk/' . basename($cf), 'asterisk');
|
||||
@chmod('/etc/asterisk/' . basename($cf), 0664);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Régénération intégrale des configurations Asterisk avec la nouvelle base restaurée
|
||||
Database::resetConnection();
|
||||
$dbFresh = Database::getConnection(true);
|
||||
$generator = new ConfigGenerator($dbFresh);
|
||||
$generator->writeAllConfigs();
|
||||
|
||||
// 11. Rechargement du moteur Asterisk via AMI
|
||||
try {
|
||||
$ami = AmiClient::fromDatabase($dbFresh);
|
||||
$reloadRes = $ami->reloadPjsipAndDialplan();
|
||||
} catch (Exception $e) {
|
||||
$reloadRes = ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Restauration complète effectuée avec succès ! Base de données réintégrée, fichiers audio, messageries vocales et configurations Asterisk rechargées.',
|
||||
'reload_success' => $reloadRes['success'] ?? false,
|
||||
'manifest' => $manifest,
|
||||
];
|
||||
} finally {
|
||||
self::removeDirRecursive($stageDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute le script cron de sauvegarde automatique périodique.
|
||||
*/
|
||||
public static function runAutoBackupCron(): array
|
||||
{
|
||||
$settings = self::getBackupSettings();
|
||||
if (empty($settings['backup_auto_enabled'])) {
|
||||
return ['status' => 'skipped', 'message' => 'Sauvegardes automatiques désactivées.'];
|
||||
}
|
||||
|
||||
$freq = $settings['backup_frequency'];
|
||||
$lastRun = $settings['backup_last_run'] ? strtotime($settings['backup_last_run']) : 0;
|
||||
$now = time();
|
||||
|
||||
$intervals = [
|
||||
'hourly' => 3600,
|
||||
'6h' => 6 * 3600,
|
||||
'12h' => 12 * 3600,
|
||||
'daily' => 24 * 3600,
|
||||
'weekly' => 7 * 24 * 3600,
|
||||
];
|
||||
|
||||
$intervalSeconds = $intervals[$freq] ?? 86400;
|
||||
|
||||
// Marge de tolérance de 5 minutes
|
||||
if (($now - $lastRun) < ($intervalSeconds - 300)) {
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'message' => "Sauvegarde automatique non requise actuellement (dernière exécutée il y a " . round(($now - $lastRun) / 60) . " min, fréquence: {$freq}).",
|
||||
];
|
||||
}
|
||||
|
||||
return self::createBackup('cron');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MÉTHODES PRIVÉES / UTILITAIRES
|
||||
// =========================================================================
|
||||
|
||||
private static function runSshCommand(string $host, int $port, string $user, string $authType, string $password, string $cmd): array
|
||||
{
|
||||
$sshOptions = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 -p " . (int)$port;
|
||||
$target = escapeshellarg("{$user}@{$host}");
|
||||
|
||||
if ($authType === 'password') {
|
||||
$fullCmd = "sshpass -p " . escapeshellarg($password) . " ssh {$sshOptions} {$target} " . escapeshellarg($cmd);
|
||||
} else {
|
||||
$fullCmd = "ssh {$sshOptions} {$target} " . escapeshellarg($cmd);
|
||||
}
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$proc = proc_open($fullCmd, $descriptors, $pipes);
|
||||
if (!is_resource($proc)) {
|
||||
return ['exit_code' => 1, 'output' => '', 'error' => "Impossible de lancer le processus SSH."];
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$exitCode = proc_close($proc);
|
||||
|
||||
return [
|
||||
'exit_code' => $exitCode,
|
||||
'output' => (string)$output,
|
||||
'error' => (string)$error,
|
||||
];
|
||||
}
|
||||
|
||||
private static function transferToSsh(string $localFile, string $filename, array $cfg): array
|
||||
{
|
||||
$host = $cfg['backup_ssh_host'];
|
||||
$port = (int)$cfg['backup_ssh_port'];
|
||||
$user = $cfg['backup_ssh_user'];
|
||||
$authType = $cfg['backup_ssh_auth_type'];
|
||||
$password = $cfg['backup_ssh_password'];
|
||||
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
|
||||
|
||||
// S'assurer que le dossier distant existe
|
||||
self::runSshCommand($host, $port, $user, $authType, $password, "mkdir -p " . escapeshellarg($remotePath));
|
||||
|
||||
$scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10";
|
||||
$dest = escapeshellarg("{$user}@{$host}:{$remotePath}/{$filename}");
|
||||
$src = escapeshellarg($localFile);
|
||||
|
||||
if ($authType === 'password') {
|
||||
$scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}";
|
||||
} else {
|
||||
$scpCmd = "scp {$scpOptions} {$src} {$dest}";
|
||||
}
|
||||
|
||||
exec($scpCmd . " 2>&1", $output, $code);
|
||||
if ($code !== 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => implode("\n", $output) ?: "Erreur SCP (code {$code})",
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private static function fetchFromSsh(string $remoteFile, string $localDest, array $cfg): array
|
||||
{
|
||||
$host = $cfg['backup_ssh_host'];
|
||||
$port = (int)$cfg['backup_ssh_port'];
|
||||
$user = $cfg['backup_ssh_user'];
|
||||
$authType = $cfg['backup_ssh_auth_type'];
|
||||
$password = $cfg['backup_ssh_password'];
|
||||
|
||||
$scpOptions = "-P " . (int)$port . " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10";
|
||||
$src = escapeshellarg("{$user}@{$host}:{$remoteFile}");
|
||||
$dest = escapeshellarg($localDest);
|
||||
|
||||
if ($authType === 'password') {
|
||||
$scpCmd = "sshpass -p " . escapeshellarg($password) . " scp {$scpOptions} {$src} {$dest}";
|
||||
} else {
|
||||
$scpCmd = "scp {$scpOptions} {$src} {$dest}";
|
||||
}
|
||||
|
||||
exec($scpCmd . " 2>&1", $output, $code);
|
||||
if ($code !== 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => implode("\n", $output) ?: "Erreur récupération SCP (code {$code})",
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private static function listRemoteBackups(array $cfg): array
|
||||
{
|
||||
$host = $cfg['backup_ssh_host'];
|
||||
$port = (int)$cfg['backup_ssh_port'];
|
||||
$user = $cfg['backup_ssh_user'];
|
||||
$authType = $cfg['backup_ssh_auth_type'];
|
||||
$password = $cfg['backup_ssh_password'];
|
||||
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
|
||||
|
||||
$listCmd = "ls -l --time-style=+%s " . escapeshellarg($remotePath) . "/*.tar.gz 2>/dev/null";
|
||||
$res = self::runSshCommand($host, $port, $user, $authType, $password, $listCmd);
|
||||
|
||||
$results = [];
|
||||
if ($res['exit_code'] === 0 && !empty($res['output'])) {
|
||||
$lines = explode("\n", trim($res['output']));
|
||||
foreach ($lines as $line) {
|
||||
// Format attendu: -rw-r--r-- 1 user group 12345 1698888888 /path/nexus_backup_v1.2.0_20261101_180000.tar.gz
|
||||
$parts = preg_split('/\s+/', trim($line));
|
||||
if (count($parts) >= 7) {
|
||||
$size = (int)$parts[4];
|
||||
$timestamp = (int)$parts[5];
|
||||
$fullPath = $parts[6];
|
||||
$name = basename($fullPath);
|
||||
|
||||
if (str_ends_with($name, '.tar.gz')) {
|
||||
$results[] = [
|
||||
'filename' => $name,
|
||||
'storage_type' => 'ssh',
|
||||
'size' => $size,
|
||||
'size_formatted' => self::formatBytes($size),
|
||||
'timestamp' => $timestamp > 0 ? $timestamp : time(),
|
||||
'date_formatted' => date('d/m/Y H:i:s', $timestamp > 0 ? $timestamp : time()),
|
||||
'version' => self::VERSION,
|
||||
'source' => 'distant (SSH)',
|
||||
'is_local' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private static function pruneLocalBackups(int $retention): void
|
||||
{
|
||||
if ($retention <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob(self::BACKUP_DIR . '/*.tar.gz') ?: [];
|
||||
if (count($files) <= $retention) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Trier par date décroissante
|
||||
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||
|
||||
// Supprimer ceux au-delà du quota de rétention
|
||||
$toDelete = array_slice($files, $retention);
|
||||
foreach ($toDelete as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
private static function pruneRemoteBackups(array $cfg): void
|
||||
{
|
||||
$retention = (int)($cfg['backup_retention'] ?? 7);
|
||||
if ($retention <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$remotePath = rtrim($cfg['backup_ssh_remote_path'], '/');
|
||||
// Script shell distant pour supprimer les fichiers les plus anciens au-delà de N
|
||||
$pruneCmd = "cd " . escapeshellarg($remotePath) . " && ls -t *.tar.gz 2>/dev/null | tail -n +" . ($retention + 1) . " | xargs -r rm -f";
|
||||
self::runSshCommand(
|
||||
$cfg['backup_ssh_host'],
|
||||
$cfg['backup_ssh_port'],
|
||||
$cfg['backup_ssh_user'],
|
||||
$cfg['backup_ssh_auth_type'],
|
||||
$cfg['backup_ssh_password'],
|
||||
$pruneCmd
|
||||
);
|
||||
}
|
||||
|
||||
private static function readManifestFromTar(string $tarGzPath): ?array
|
||||
{
|
||||
// Lire manifest.json sans tout décompresser
|
||||
$cmd = "tar -xzOf " . escapeshellarg($tarGzPath) . " ./manifest.json 2>/dev/null || tar -xzOf " . escapeshellarg($tarGzPath) . " manifest.json 2>/dev/null";
|
||||
$json = shell_exec($cmd);
|
||||
if ($json) {
|
||||
$parsed = json_decode($json, true);
|
||||
if (is_array($parsed)) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' Go';
|
||||
}
|
||||
if ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' Mo';
|
||||
}
|
||||
if ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 1) . ' Ko';
|
||||
}
|
||||
return $bytes . ' B';
|
||||
}
|
||||
|
||||
private static function removeDirRecursive(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $item;
|
||||
if (is_dir($path)) {
|
||||
self::removeDirRecursive($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user