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); } }