202 lines
6.9 KiB
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);
|
|
}
|
|
}
|