mailwolt/app/Console/Commands/BackupRun.php

202 lines
6.9 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\Setting;
use Illuminate\Console\Command;
class BackupRun extends Command
{
protected $signature = 'backup:run {jobId : ID des BackupJob-Eintrags}';
protected $description = 'Führt einen Backup-Job aus und aktualisiert den Status';
public function handle(): int
{
$job = BackupJob::with('policy')->findOrFail($this->argument('jobId'));
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
$log = [];
$exitCode = 0;
// ── 1. External backup script (takes full control if present) ──────
$script = '/usr/local/sbin/mailwolt-backup';
if (file_exists($script)) {
$output = [];
exec("bash " . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
$artifact = null;
foreach ($output as $line) {
if (str_starts_with($line, 'ARTIFACT:')) {
$artifact = trim(substr($line, 9));
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
// ── 2. Built-in backup ────────────────────────────────────────────
// Database
if ($policy?->include_db ?? true) {
[$ok, $msg, $dumpFile] = $this->dumpDatabase($tmpDir);
if ($ok) {
$sources[] = $dumpFile;
$log[] = '✓ Datenbank gesichert';
} else {
$log[] = '✗ Datenbank: ' . $msg;
$exitCode = 1;
}
}
// Mail directories
if ($policy?->include_maildirs ?? true) {
$mailDir = (string) Setting::get('backup_mail_dir', '');
if (empty($mailDir)) {
foreach (['/var/vmail', '/var/mail/vhosts', '/home/vmail'] as $c) {
if (is_dir($c)) { $mailDir = $c; break; }
}
}
if ($mailDir && is_dir($mailDir)) {
$sources[] = $mailDir;
$log[] = '✓ Maildirs: ' . $mailDir;
} else {
$log[] = '✗ Maildir nicht gefunden (konfiguriere den Pfad in den Einstellungen)';
}
}
// Config files + SSL
if ($policy?->include_configs ?? true) {
foreach (['/etc/postfix', '/etc/dovecot', '/etc/opendkim', '/etc/rspamd', '/etc/letsencrypt'] as $dir) {
if (is_dir($dir)) {
$sources[] = $dir;
$log[] = '✓ ' . $dir;
}
}
if (file_exists(base_path('.env'))) {
$sources[] = base_path('.env');
$log[] = '✓ .env';
}
}
if (empty($sources)) {
$msg = 'Keine Quellen zum Sichern gefunden.';
$job->update(['status' => 'failed', 'finished_at' => now(), 'error' => $msg]);
$this->cleanTmp($tmpDir);
return self::FAILURE;
}
// Build archive — use storage/app/backups so www-data always has write access
$outDir = storage_path('app/backups');
if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) {
// Final fallback: /tmp (always writable)
$outDir = sys_get_temp_dir() . '/mailwolt_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];
$tarExit = 0;
exec("tar --ignore-failed-read -czf " . escapeshellarg($outFile) . " {$srcArgs} 2>&1", $tarOutput, $tarExit);
$this->cleanTmp($tmpDir);
// tar exit 1 = some files unreadable but archive was written — treat as ok
if ($tarExit <= 1 && file_exists($outFile)) {
$tarExit = 0;
}
if ($tarExit !== 0) {
$exitCode = $tarExit;
}
$fullLog = implode("\n", $log) . "\n\n" . implode("\n", array_slice($tarOutput, -20));
$this->finalize($job, $exitCode, $fullLog, $tarExit === 0 ? $outFile : null);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
private function dumpDatabase(string $tmpDir): array
{
$conn = config('database.connections.' . config('database.default'));
if (($conn['driver'] ?? '') !== 'mysql') {
return [false, 'Nur MySQL/MariaDB wird unterstützt.', null];
}
$host = $conn['host'] ?? '127.0.0.1';
$port = (string)($conn['port'] ?? '3306');
$username = $conn['username'] ?? '';
$password = $conn['password'] ?? '';
$database = $conn['database'] ?? '';
$dumpFile = "{$tmpDir}/database.sql";
$cmd = 'MYSQL_PWD=' . escapeshellarg($password)
. ' mysqldump'
. ' -h ' . escapeshellarg($host)
. ' -P ' . escapeshellarg($port)
. ' -u ' . escapeshellarg($username)
. ' --single-transaction --routines --triggers'
. ' ' . escapeshellarg($database)
. ' > ' . escapeshellarg($dumpFile)
. ' 2>&1';
$output = [];
$exit = 0;
exec($cmd, $output, $exit);
if ($exit !== 0 || !file_exists($dumpFile)) {
return [false, implode('; ', $output), null];
}
return [true, '', $dumpFile];
}
private function finalize(BackupJob $job, int $exitCode, string $log, ?string $artifact): void
{
$sizeBytes = ($artifact && file_exists($artifact)) ? filesize($artifact) : 0;
if ($exitCode === 0) {
$job->update([
'status' => 'ok',
'finished_at' => now(),
'log_excerpt' => $log,
'artifact_path' => $artifact,
'size_bytes' => $sizeBytes,
]);
$job->policy?->update([
'last_run_at' => now(),
'last_status' => 'ok',
'last_size_bytes' => $sizeBytes,
]);
} else {
$job->update([
'status' => 'failed',
'finished_at' => now(),
'error' => $log,
]);
$job->policy?->update(['last_status' => 'failed']);
}
}
private function cleanTmp(string $dir): void
{
if (!is_dir($dir)) return;
foreach (glob("{$dir}/*") ?: [] as $f) {
is_file($f) ? unlink($f) : null;
}
@rmdir($dir);
}
}