984 lines
39 KiB
PHP
984 lines
39 KiB
PHP
<?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.6.2';
|
|
|
|
/**
|
|
* Retourne dynamiquement la version courante de NexusPBX lue depuis version.json
|
|
*/
|
|
public static function getAppVersion(): string
|
|
{
|
|
$vFile = dirname(__DIR__) . '/version.json';
|
|
if (file_exists($vFile)) {
|
|
$data = json_decode((string)file_get_contents($vFile), true);
|
|
if (!empty($data['version'])) {
|
|
return (string)$data['version'];
|
|
}
|
|
}
|
|
return self::VERSION;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
$currentVer = self::getAppVersion();
|
|
$filename = "nexus_backup_v{$currentVer}_{$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);
|
|
self::copyDirectoryRecursive($storageSounds, $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);
|
|
self::copyDirectoryRecursive($astSoundsCustom, $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);
|
|
self::copyDirectoryRecursive($voicemailSpool, $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);
|
|
self::copyDirectoryRecursive($storageCerts, $destCerts);
|
|
$manifestFiles['storage_certs'] = ['count' => count(glob($destCerts . '/*'))];
|
|
}
|
|
$astKeys = '/etc/asterisk/keys';
|
|
if (is_dir($astKeys)) {
|
|
$destAstKeys = $stageDir . '/asterisk_keys';
|
|
mkdir($destAstKeys, 0700, true);
|
|
self::copyDirectoryRecursive($astKeys, $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. Avatars et photos des contacts de l'annuaire (/var/www/nexus-pbx/public/avatars/)
|
|
$publicAvatars = '/var/www/nexus-pbx/public/avatars';
|
|
if (is_dir($publicAvatars)) {
|
|
$destAvatars = $stageDir . '/public_avatars';
|
|
mkdir($destAvatars, 0755, true);
|
|
self::copyDirectoryRecursive($publicAvatars, $destAvatars);
|
|
$manifestFiles['public_avatars'] = ['count' => count(glob($destAvatars . '/*'))];
|
|
}
|
|
|
|
// G. Création du Manifeste
|
|
$manifest = [
|
|
'nexus_version' => $currentVer,
|
|
'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);
|
|
|
|
$archiveVer = $manifest['nexus_version'] ?? null;
|
|
if (!$archiveVer && preg_match('/_v([0-9]+\.[0-9]+(?:\.[0-9]+)?)/', $name, $vm)) {
|
|
$archiveVer = $vm[1];
|
|
}
|
|
if (!$archiveVer) {
|
|
$archiveVer = self::getAppVersion();
|
|
}
|
|
|
|
$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' => $archiveVer,
|
|
'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';
|
|
self::copyDirectoryRecursive($stageDir . '/storage_sounds', $destSounds, true);
|
|
@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';
|
|
self::copyDirectoryRecursive($stageDir . '/asterisk_sounds_custom', $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';
|
|
self::copyDirectoryRecursive($stageDir . '/voicemail_default', $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';
|
|
self::copyDirectoryRecursive($stageDir . '/storage_certs', $destCerts);
|
|
exec("chown -R www-data:asterisk " . escapeshellarg($destCerts));
|
|
}
|
|
if (is_dir($stageDir . '/asterisk_keys')) {
|
|
$destKeys = '/etc/asterisk/keys';
|
|
self::copyDirectoryRecursive($stageDir . '/asterisk_keys', $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);
|
|
}
|
|
}
|
|
|
|
// 9b. Restauration des avatars et photos des contacts de l'annuaire
|
|
if (is_dir($stageDir . '/public_avatars')) {
|
|
$destAvatars = '/var/www/nexus-pbx/public/avatars';
|
|
if (!is_dir($destAvatars)) {
|
|
@mkdir($destAvatars, 0775, true);
|
|
}
|
|
self::copyDirectoryRecursive($stageDir . '/public_avatars', $destAvatars, true);
|
|
@chown($destAvatars, 'www-data');
|
|
@chgrp($destAvatars, 'asterisk');
|
|
exec("chown -R www-data:asterisk " . escapeshellarg($destAvatars));
|
|
exec("chmod -R 775 " . escapeshellarg($destAvatars));
|
|
}
|
|
|
|
// 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 isSshpassInstalled(): bool
|
|
{
|
|
return file_exists('/usr/bin/sshpass') || !empty(trim((string)shell_exec('which sshpass 2>/dev/null')));
|
|
}
|
|
|
|
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') {
|
|
if (!self::isSshpassInstalled()) {
|
|
return [
|
|
'exit_code' => 1,
|
|
'output' => '',
|
|
'error' => "Le paquet 'sshpass' n'est pas installé sur le système pour l'authentification SSH par mot de passe. Installez-le avec : apt install -y sshpass",
|
|
];
|
|
}
|
|
$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') {
|
|
if (!self::isSshpassInstalled()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "Le paquet 'sshpass' n'est pas installé sur le système pour l'authentification SSH par mot de passe. Installez-le avec : apt install -y sshpass",
|
|
];
|
|
}
|
|
$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') {
|
|
if (!self::isSshpassInstalled()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "Le paquet 'sshpass' n'est pas installé sur le système pour l'authentification SSH par mot de passe. Installez-le avec : apt install -y sshpass",
|
|
];
|
|
}
|
|
$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')) {
|
|
$remoteVer = null;
|
|
if (preg_match('/_v([0-9]+\.[0-9]+(?:\.[0-9]+)?)/', $name, $vm)) {
|
|
$remoteVer = $vm[1];
|
|
}
|
|
if (!$remoteVer) {
|
|
$remoteVer = self::getAppVersion();
|
|
}
|
|
$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' => $remoteVer,
|
|
'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 copyDirectoryRecursive(string $src, string $dest, bool $delete = false): void
|
|
{
|
|
if (!is_dir($src)) {
|
|
return;
|
|
}
|
|
@mkdir($dest, 0775, true);
|
|
|
|
// Si rsync est présent, l'utiliser en priorité
|
|
static $hasRsync = null;
|
|
if ($hasRsync === null) {
|
|
$hasRsync = !empty(shell_exec('which rsync 2>/dev/null'));
|
|
}
|
|
|
|
if ($hasRsync) {
|
|
$delFlag = $delete ? '--delete ' : '';
|
|
exec("rsync -a {$delFlag}" . escapeshellarg($src . '/') . " " . escapeshellarg($dest . '/'));
|
|
return;
|
|
}
|
|
|
|
// Sinon fallback cp -a natif Linux
|
|
if ($delete) {
|
|
foreach (glob($dest . '/*') as $f) {
|
|
if (is_dir($f)) {
|
|
self::removeDirRecursive($f);
|
|
} else {
|
|
@unlink($f);
|
|
}
|
|
}
|
|
}
|
|
exec("cp -a " . escapeshellarg($src . '/.') . " " . escapeshellarg($dest . '/'));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|