diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d89fa2..080cbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added +- SFTP remote storage with SSH key file authentication — key stored securely in database +- CLI restore options: --files-only, --db-only, --no-preserve-config, --password + ## [01.34.00] --- 2026-06-23 ## [01.34.00] --- 2026-06-23 diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php index 16f1473..2cc5ebe 100644 --- a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php @@ -124,6 +124,7 @@ class BackupsController extends ApiController // Strip sensitive credentials before serialization $sensitiveFields = [ 'ftp_password', 'ftp_username', + 'sftp_password', 'sftp_key_data', 'sftp_passphrase', 's3_access_key', 's3_secret_key', 'gdrive_client_secret', 'gdrive_refresh_token', 'encryption_password', 'ntfy_token', diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index d01599f..989d984 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -160,6 +160,7 @@ > + @@ -339,6 +340,70 @@ +
+ + + + + + + +
+
new FtpUploader($profile), + 'sftp' => new SftpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), diff --git a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php index 74faef4..ac62cd3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php @@ -278,6 +278,21 @@ class PreflightCheck break; + case 'sftp': + if (empty($profile->sftp_host)) { + $this->warnings[] = 'SFTP host is not configured — remote upload will fail'; + } + + if (empty($profile->sftp_username)) { + $this->warnings[] = 'SFTP username is not configured — remote upload will fail'; + } + + if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) { + $this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail'; + } + + break; + case 'google_drive': if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) { $this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail'; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php new file mode 100644 index 0000000..5911c06 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php @@ -0,0 +1,247 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * SFTP uploader using the system sftp/scp binary with SSH key authentication. + * + * The private key is stored in the database (profile column) and written + * to a temp file with 0600 permissions at upload time, then deleted. + * This avoids leaving key files on the filesystem permanently. + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class SftpUploader implements RemoteUploaderInterface +{ + private string $host; + private int $port; + private string $username; + private string $keyData; + private string $passphrase; + private string $password; + private string $remotePath; + + public function __construct(object $profile) + { + $this->host = $profile->sftp_host ?? ''; + $this->port = (int) ($profile->sftp_port ?? 22); + $this->username = $profile->sftp_username ?? ''; + $this->keyData = $profile->sftp_key_data ?? ''; + $this->passphrase = $profile->sftp_passphrase ?? ''; + $this->password = $profile->sftp_password ?? ''; + $this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/'); + } + + public function upload(string $localPath, string $remoteName): array + { + if (empty($this->host)) { + return ['success' => false, 'message' => 'SFTP host is not configured']; + } + + if (empty($this->username)) { + return ['success' => false, 'message' => 'SFTP username is not configured']; + } + + if (empty($this->keyData) && empty($this->password)) { + return ['success' => false, 'message' => 'SFTP requires either a private key or password']; + } + + $keyFile = null; + + try { + /* Write key to temp file if using key auth */ + if (!empty($this->keyData)) { + $keyFile = $this->writeTempKey(); + } + + /* Ensure remote directory exists */ + $this->ensureRemoteDir($keyFile); + + /* Upload via scp */ + $remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName; + $cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile); + + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $errorMsg = implode("\n", $output); + throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg); + } + + /* Verify upload by checking remote file size */ + $remoteFile = $this->remotePath . '/' . $remoteName; + $remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile); + $localSize = filesize($localPath); + + if ($remoteSize > 0 && $remoteSize !== $localSize) { + throw new \RuntimeException( + 'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize + ); + } + + return [ + 'success' => true, + 'message' => 'Uploaded via SFTP: ' . $remoteFile, + 'remote_path' => $remoteFile, + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()]; + } finally { + $this->cleanupTempKey($keyFile); + } + } + + public function testConnection(): array + { + if (empty($this->host)) { + return ['success' => false, 'message' => 'SFTP host is not configured']; + } + + $keyFile = null; + + try { + if (!empty($this->keyData)) { + $keyFile = $this->writeTempKey(); + } + + $cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile); + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)]; + } + + return [ + 'success' => true, + 'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output), + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()]; + } finally { + $this->cleanupTempKey($keyFile); + } + } + + /** + * Write the private key from the database to a temp file with 0600 permissions. + */ + private function writeTempKey(): string + { + $tmpDir = sys_get_temp_dir(); + $keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key'; + + if (file_put_contents($keyFile, $this->keyData) === false) { + throw new \RuntimeException('Cannot write temporary SSH key file'); + } + + chmod($keyFile, 0600); + + return $keyFile; + } + + /** + * Delete the temp key file. + */ + private function cleanupTempKey(?string $keyFile): void + { + if ($keyFile !== null && is_file($keyFile)) { + unlink($keyFile); + } + } + + /** + * Ensure the remote directory exists via ssh mkdir -p. + */ + private function ensureRemoteDir(?string $keyFile): void + { + $escapedPath = escapeshellarg($this->remotePath); + $cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile); + + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + /* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */ + if ($exitCode !== 0) { + throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output)); + } + } + + /** + * Get remote file size via ssh stat. + */ + private function getRemoteFileSize(string $remotePath, ?string $keyFile): int + { + $escapedPath = escapeshellarg($remotePath); + $cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile); + + $output = []; + exec($cmd . ' 2>&1', $output); + + $size = (int) trim(implode('', $output)); + + return $size > 0 ? $size : 0; + } + + /** + * Build an scp command string with proper SSH options. + */ + private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string + { + $parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes']; + + if ($this->port !== 22) { + $parts[] = '-P'; + $parts[] = (string) $this->port; + } + + if ($keyFile !== null) { + $parts[] = '-i'; + $parts[] = escapeshellarg($keyFile); + } + + if (!empty($this->passphrase)) { + /* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect. + For now, key files should be unencrypted or use ssh-agent. */ + } + + $parts[] = escapeshellarg($localPath); + $parts[] = escapeshellarg($remoteTarget); + + return implode(' ', $parts); + } + + /** + * Build an ssh command string for remote commands. + */ + private function buildSshCommand(string $remoteCmd, ?string $keyFile): string + { + $parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes']; + + if ($this->port !== 22) { + $parts[] = '-p'; + $parts[] = (string) $this->port; + } + + if ($keyFile !== null) { + $parts[] = '-i'; + $parts[] = escapeshellarg($keyFile); + } + + $parts[] = escapeshellarg($this->username . '@' . $this->host); + $parts[] = escapeshellarg($remoteCmd); + + return implode(' ', $parts); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index de44718..d268ac4 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -410,6 +410,7 @@ class SteppedBackupEngine $uploader = match ($session->remoteStorage) { 'ftp' => new FtpUploader($profile), + 'sftp' => new SftpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), diff --git a/source/packages/plg_console_mokosuitebackup/src/Command/RestoreCommand.php b/source/packages/plg_console_mokosuitebackup/src/Command/RestoreCommand.php index a6023e1..ce97a45 100644 --- a/source/packages/plg_console_mokosuitebackup/src/Command/RestoreCommand.php +++ b/source/packages/plg_console_mokosuitebackup/src/Command/RestoreCommand.php @@ -17,6 +17,7 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine; use Joomla\Console\Command\AbstractCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -28,6 +29,10 @@ class RestoreCommand extends AbstractCommand { $this->setDescription('Restore a backup by record ID'); $this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore'); + $this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)'); + $this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)'); + $this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php'); + $this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', ''); } protected function doExecute(InputInterface $input, OutputInterface $output): int @@ -85,8 +90,22 @@ class RestoreCommand extends AbstractCommand require_once $engineFile; } + $filesOnly = $input->getOption('files-only'); + $dbOnly = $input->getOption('db-only'); + $preserveConfig = !$input->getOption('no-preserve-config'); + $password = $input->getOption('password') ?: ''; + + $restoreFiles = !$dbOnly; + $restoreDb = !$filesOnly; + + if ($filesOnly) { + $io->note('Restoring files only (database will not be touched)'); + } elseif ($dbOnly) { + $io->note('Restoring database only (files will not be touched)'); + } + $engine = new RestoreEngine(); - $result = $engine->restore($recordId); + $result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password); if ($result['success']) { $io->success($result['message']);