Files
NexusPBX/src/BackupManager.php
T

929 lines
37 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.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);
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' => 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';
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 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 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);
}
}