99b844e04a
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Wiki Update Reminder (pull_request) Successful in 2s
Universal: PR Check / Require Docs Update (pull_request) Failing after 8s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 13s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 28s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 46s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 50s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Security (P0): - #169 mask FTP password in AjaxController maskSecrets()/mergeExistingSecrets() - #187 stop leaking absolute_path in API item view (JsonapiView) - #182 SftpUploader StrictHostKeyChecking=no -> accept-new (MITM) Data integrity / correctness (P1): - #179 DatabaseDumper: real newline after DROP TABLE (was literal backslash-n) - #180 profile-picker forms ORDER BY id (the 'ordering' column is dropped on upgraded sites, breaking the query) — run_profile.xml + plg_content xml - #181 uninstall.mysql.sql: add missing DROP for snapshots table - #184 CLI snapshot delete uses data_file column (file_path does not exist, so snapshot files were never deleted) - #186 remove duplicate pre-update backup: content plugin no longer subscribes to onExtensionBeforeUpdate (owned by the system plugin); keeps pre-install - #188 DatabaseImporter now collects non-fatal statement errors (getErrors()/hasErrors()) so restores are no longer silent and callers can surface a warning status Also folds in the .mokogitea/CLAUDE.md mokoplatform -> mokocli fix (stale repo name after the rename). Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
256 lines
7.0 KiB
PHP
256 lines
7.0 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @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';
|
|
|
|
/* Key is stored base64-encoded in the database — decode before writing */
|
|
$keyContent = base64_decode($this->keyData, true);
|
|
|
|
if ($keyContent === false) {
|
|
/* Fallback: might be raw PEM (legacy or paste) */
|
|
$keyContent = $this->keyData;
|
|
}
|
|
|
|
if (file_put_contents($keyFile, $keyContent) === 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=accept-new', '-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=accept-new', '-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);
|
|
}
|
|
}
|