diff --git a/config/workkit.php b/config/workkit.php new file mode 100644 index 0000000..729801d --- /dev/null +++ b/config/workkit.php @@ -0,0 +1,20 @@ + [ + 'path' => env('WORKKIT_BACKUP_PATH'), // null → storage_path('backups') + 'retention_days' => (int) env('WORKKIT_BACKUP_RETENTION_DAYS', 30), + ], +]; diff --git a/src/Commands/Database/BackupCommand.php b/src/Commands/Database/BackupCommand.php new file mode 100644 index 0000000..5024655 --- /dev/null +++ b/src/Commands/Database/BackupCommand.php @@ -0,0 +1,119 @@ +_.sql.xz.enc + * + * The DB password is passed to mysqldump via the MYSQL_PWD env var, not + * on the CLI — process listings (`ps auxf`) don't expose it that way. + */ +class BackupCommand extends Command +{ + protected $signature = 'workkit:db:backup + {--connection= : DB connection to back up (defaults to config(database.default))} + {--out= : Custom output path (overrides storage/backups default)}'; + + protected $description = 'Create a compressed + encrypted backup of the configured MySQL database.'; + + public function handle(): int + { + $connection = $this->option('connection') ?: config('database.default'); + $cfg = config("database.connections.{$connection}"); + + if (! $cfg) { + $this->error("Unknown database connection: {$connection}"); + return self::FAILURE; + } + if (($cfg['driver'] ?? null) !== 'mysql') { + $this->error("workkit:db:backup currently supports only MySQL connections (got: {$cfg['driver']})."); + return self::FAILURE; + } + + try { + BackupService::requireBinary('mysqldump'); + } catch (RuntimeException $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + $stamp = date('Y-m-d_H-i-s'); + $base = BackupService::backupDirectory(); + $sqlPath = $this->option('out') + ?: "{$base}/db_{$connection}_{$stamp}.sql"; + + $this->info("Dumping `{$cfg['database']}` from {$cfg['host']} → {$sqlPath}"); + + try { + $this->dump($cfg, $sqlPath); + + $this->info('Compressing with xz…'); + $xzPath = BackupService::compressFile($sqlPath); + @unlink($sqlPath); + + $this->info('Encrypting with APP_KEY…'); + $encPath = BackupService::encryptFile($xzPath); + @unlink($xzPath); + } catch (RuntimeException $e) { + // Clean up any half-written intermediate files so a failed + // backup doesn't leave SQL with secrets sitting unencrypted + // on disk. + foreach ([$sqlPath, $sqlPath . '.xz'] as $leftover) { + if (is_file($leftover)) { + @unlink($leftover); + } + } + $this->error($e->getMessage()); + return self::FAILURE; + } + + $size = filesize($encPath); + $this->info(sprintf('Backup complete: %s (%s)', $encPath, self::humanBytes((int) $size))); + return self::SUCCESS; + } + + /** + * Run mysqldump with credentials passed via env vars (MYSQL_PWD) so + * the password never appears in the process listing or shell history. + */ + private function dump(array $cfg, string $sqlPath): void + { + $args = [ + '--single-transaction', // consistent dump on InnoDB without FLUSH TABLES locking + '--quick', // stream rows instead of buffering + '--skip-lock-tables', + '--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')), + '--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')), + '--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)), + escapeshellarg((string) $cfg['database']), + ]; + + $cmd = 'mysqldump ' . implode(' ', $args) . ' > ' . escapeshellarg($sqlPath); + BackupService::run($cmd, [ + 'MYSQL_PWD' => (string) ($cfg['password'] ?? ''), + ]); + + if (! file_exists($sqlPath) || filesize($sqlPath) === 0) { + throw new RuntimeException("mysqldump produced no output at {$sqlPath}"); + } + } + + private static function humanBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + return sprintf('%.2f %s', $bytes, $units[$i]); + } +} diff --git a/src/Commands/Database/PruneBackupsCommand.php b/src/Commands/Database/PruneBackupsCommand.php new file mode 100644 index 0000000..7c1ed15 --- /dev/null +++ b/src/Commands/Database/PruneBackupsCommand.php @@ -0,0 +1,68 @@ +daily(); + */ +class PruneBackupsCommand extends Command +{ + protected $signature = 'workkit:db:prune-backups + {--days= : Retention window in days (default: workkit.backup.retention_days, falling back to 30)} + {--dry-run : Show what would be deleted without removing anything}'; + + protected $description = 'Delete backup files older than the retention window.'; + + public function handle(): int + { + $days = (int) ($this->option('days') ?: config('workkit.backup.retention_days', 30)); + if ($days < 1) { + $this->error('--days must be >= 1'); + return self::FAILURE; + } + + $base = BackupService::backupDirectory(); + $files = glob($base . '/*') ?: []; + + $cutoff = time() - $days * 86400; + $removed = 0; + $kept = 0; + + foreach ($files as $f) { + if (! is_file($f)) { + continue; + } + if (filemtime($f) < $cutoff) { + if ($this->option('dry-run')) { + $this->line("would remove: {$f}"); + } else { + @unlink($f); + $this->line("removed: {$f}"); + } + $removed++; + } else { + $kept++; + } + } + + $this->info(sprintf( + 'Backups: %d kept, %d %s (cutoff: %d days).', + $kept, + $removed, + $this->option('dry-run') ? 'would-remove' : 'removed', + $days, + )); + + return self::SUCCESS; + } +} diff --git a/src/Commands/Database/RestoreCommand.php b/src/Commands/Database/RestoreCommand.php new file mode 100644 index 0000000..cdb5ab7 --- /dev/null +++ b/src/Commands/Database/RestoreCommand.php @@ -0,0 +1,185 @@ +option('connection') ?: config('database.default'); + $cfg = config("database.connections.{$connection}"); + + if (! $cfg) { + $this->error("Unknown database connection: {$connection}"); + return self::FAILURE; + } + if (($cfg['driver'] ?? null) !== 'mysql') { + $this->error("workkit:db:restore currently supports only MySQL connections (got: {$cfg['driver']})."); + return self::FAILURE; + } + + try { + BackupService::requireBinary('mysql'); + } catch (RuntimeException $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + $file = $this->resolveFile(); + if (! $file) { + $this->error('No backup found.'); + return self::FAILURE; + } + if (! file_exists($file)) { + $this->error("Backup file not found: {$file}"); + return self::FAILURE; + } + + $this->warn(sprintf( + 'About to restore `%s`@%s from: %s', + $cfg['database'], + $cfg['host'], + $file, + )); + $this->warn('This will OVERWRITE any data that conflicts with the dump.'); + + if (! $this->option('force') && ! $this->confirm('Proceed?', false)) { + $this->info('Aborted.'); + return self::SUCCESS; + } + + $tmpSql = null; + try { + $tmpSql = $this->prepare($file); + $this->info("Restoring → {$cfg['database']}"); + $this->importInto($cfg, $tmpSql); + $this->info('Restore complete.'); + return self::SUCCESS; + } catch (DecryptException $e) { + // Almost always means the file was encrypted with a different + // APP_KEY than the current one. Spell that out so the operator + // doesn't have to recognise the cryptographer's stack trace. + $this->error('Decryption failed — likely an APP_KEY mismatch between backup and current environment.'); + $this->line("Underlying error: {$e->getMessage()}"); + return self::FAILURE; + } catch (RuntimeException $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } finally { + if ($tmpSql && is_file($tmpSql)) { + @unlink($tmpSql); + } + } + } + + /** + * Resolve the file argument: + * - --file=path/to/X.sql.xz.enc → use as-is if it exists + * - --file=basename → look up inside storage/backups + * - omitted → pick newest in storage/backups + */ + private function resolveFile(): ?string + { + $base = BackupService::backupDirectory(); + $opt = $this->option('file'); + + if ($opt) { + if (is_file($opt)) { + return $opt; + } + $candidate = $base . '/' . ltrim((string) $opt, '/'); + return is_file($candidate) ? $candidate : null; + } + + $files = glob($base . '/*'); + if (! $files) { + return null; + } + usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); + return $files[0]; + } + + /** + * Walk the file through decrypt + decompress as needed and write the + * resulting SQL to a temp path. Returns the path of the .sql file. + */ + private function prepare(string $file): string + { + $current = $file; + $intermediate = []; + + if (str_ends_with($current, '.enc')) { + $this->info('Decrypting…'); + $current = BackupService::decryptFile($current, sys_get_temp_dir() . '/workkit_restore_' . uniqid() . '.dec'); + $intermediate[] = $current; + } + + if (str_ends_with($current, '.xz')) { + $this->info('Decompressing…'); + $next = BackupService::decompressFile($current, sys_get_temp_dir() . '/workkit_restore_' . uniqid() . '.sql'); + // Drop the .xz intermediate now that we have the .sql. + foreach ($intermediate as $f) { + if (is_file($f)) { + @unlink($f); + } + } + $intermediate = []; + $current = $next; + $intermediate[] = $current; + } + + // Sanity check: a real SQL dump always has at least one + // CREATE/INSERT/USE statement near the top. Catches the case + // where APP_KEY didn't match (Crypt::decryptString throws on + // bad key, but if someone hand-renamed a non-encrypted file to + // .enc this still helps). + $head = (string) @file_get_contents($current, false, null, 0, 8192); + if (! preg_match('/(INSERT\s+INTO|CREATE\s+TABLE|USE\s+`)/i', $head)) { + throw new RuntimeException('Decoded file does not look like a SQL dump (no CREATE/INSERT/USE found in header).'); + } + + return $current; + } + + /** + * Pipe the .sql file into the mysql CLI, password via MYSQL_PWD. + */ + private function importInto(array $cfg, string $sqlPath): void + { + $args = [ + '--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')), + '--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')), + '--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)), + escapeshellarg((string) $cfg['database']), + ]; + + $cmd = 'mysql ' . implode(' ', $args) . ' < ' . escapeshellarg($sqlPath); + BackupService::run($cmd, [ + 'MYSQL_PWD' => (string) ($cfg['password'] ?? ''), + ]); + } +} diff --git a/src/Services/BackupService.php b/src/Services/BackupService.php new file mode 100644 index 0000000..96403be --- /dev/null +++ b/src/Services/BackupService.php @@ -0,0 +1,159 @@ + %s', escapeshellarg($in), escapeshellarg($out))); + if (! file_exists($out)) { + throw new RuntimeException("xz compression failed; output not found at {$out}"); + } + return $out; + } + + /** + * Decompress an .xz file. If $out is null, strips the `.xz` suffix + * (or generates a uniquely-named sibling if there is none). + */ + public static function decompressFile(string $in, ?string $out = null): string + { + self::requireBinary('xz'); + if ($out === null) { + $out = str_ends_with($in, '.xz') + ? substr($in, 0, -3) + : $in . '.decompressed'; + } + self::run(sprintf('xz -d -T0 -c %s > %s', escapeshellarg($in), escapeshellarg($out))); + if (! file_exists($out)) { + throw new RuntimeException("xz decompression failed; output not found at {$out}"); + } + return $out; + } + + /** + * Encrypt $in with Crypt:: (APP_KEY-derived) and return the path + * of the .enc output. + */ + public static function encryptFile(string $in, ?string $out = null): string + { + $out ??= $in . '.enc'; + $payload = Crypt::encryptString(file_get_contents($in)); + file_put_contents($out, $payload); + return $out; + } + + /** + * Decrypt $in (Crypt:: payload) and write to $out. If $out is null, + * strips the `.enc` suffix. + */ + public static function decryptFile(string $in, ?string $out = null): string + { + if ($out === null) { + $out = str_ends_with($in, '.enc') + ? substr($in, 0, -4) + : $in . '.decrypted'; + } + $payload = file_get_contents($in); + file_put_contents($out, Crypt::decryptString($payload)); + return $out; + } + + /** + * Path of the host's backup directory, created if missing. + * Defaults to storage/backups; the host can override via the + * `workkit.backup.path` config (published by the package). + */ + public static function backupDirectory(): string + { + $path = config('workkit.backup.path') ?: storage_path('backups'); + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + return rtrim($path, '/'); + } + + /** + * Bail loudly if a required system binary isn't on $PATH. We do + * this early in each command so users get one clear message + * instead of a cryptic exec failure halfway through. + */ + public static function requireBinary(string $bin): void + { + $found = trim((string) @shell_exec('command -v ' . escapeshellarg($bin))); + if ($found === '') { + throw new RuntimeException("Required binary `{$bin}` not found on PATH."); + } + } + + /** + * Run a shell command and throw on non-zero exit. + */ + public static function run(string $command, array $env = []): void + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $envForProc = $env === [] ? null : array_merge($_ENV, $env); + $proc = proc_open($command, $descriptors, $pipes, null, $envForProc); + if (! is_resource($proc)) { + throw new RuntimeException("Failed to start process: {$command}"); + } + + fclose($pipes[0]); + // We don't need stdout — most of these commands write to files. + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($proc); + + if ($exit !== 0) { + throw new RuntimeException(sprintf( + "Command failed (exit %d): %s\nstderr:\n%s", + $exit, + self::redactCommand($command), + trim((string) $stderr) ?: '(empty)' + )); + } + } + + /** + * Hide credential-looking flags in error messages so we don't dump + * passwords to logs. Crude but enough for the legacy CLI flag form; + * the commands themselves prefer MYSQL_PWD env vars. + */ + private static function redactCommand(string $command): string + { + return preg_replace('/(--password=)[^\s]+/', '$1***', $command); + } +} diff --git a/src/WorkkitServiceProvider.php b/src/WorkkitServiceProvider.php index 9678319..8565f06 100644 --- a/src/WorkkitServiceProvider.php +++ b/src/WorkkitServiceProvider.php @@ -2,6 +2,9 @@ namespace Blax\Workkit; +use Blax\Workkit\Commands\Database\BackupCommand; +use Blax\Workkit\Commands\Database\PruneBackupsCommand; +use Blax\Workkit\Commands\Database\RestoreCommand; use Blax\Workkit\Commands\PlugNPrayCommand; class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider @@ -13,7 +16,7 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider */ public function register() { - // + $this->mergeConfigFrom(__DIR__ . '/../config/workkit.php', 'workkit'); } /** @@ -26,7 +29,16 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider if ($this->app->runningInConsole()) { $this->commands([ PlugNPrayCommand::class, + BackupCommand::class, + RestoreCommand::class, + PruneBackupsCommand::class, ]); + + // Hosts that want to override path / retention publish the + // config; otherwise mergeConfigFrom() above provides defaults. + $this->publishes([ + __DIR__ . '/../config/workkit.php' => $this->app->configPath('workkit.php'), + ], 'workkit-config'); } } }