mirror of
https://github.com/itflow-org/itflow
synced 2026-08-16 12:35:11 +00:00
Encrypted backups with types, scheduling and CLI restore
Backups are now AES-256 encrypted zips in three types (full, database only, master key), catalogued in a new backups table, built by cron rather than the web request, and kept under uploads/backups with retention in the nightly job. The encryption key is one value per install held in config.php, never in the database and never in the file name. Restore is shared by the setup wizard and the new scripts/restore_cli.php, which is the only path without an upload size limit. It verifies the key and unpacks the archive before dropping anything, and dumps the current database first so a failed import is rolled back. A backup dumps, zips and encrypts for minutes without issuing a query, so on a server with a short wait_timeout the connection is closed underneath it and the UPDATE marking the backup complete is what fails - long after the archive was written correctly. The connection is now held open for the job and re-established before any write that follows long file work, including the database phase of a restore. Retention recovers rows a dropped connection left behind: still Running after six hours becomes Complete if the archive is on disk, Failed if it is not. cron.php's own failure path is hardened to match. It recorded job failures through the same connection the failing job had just killed, so an uncaught exception ended the dispatch and no trace of the original error survived. Failures now also echo to stdout, so cron mails something useful when the database is unreachable. Security: the setup wizard's restore step is now closed on any install that has users, whatever config.php says. $config_enable_setup defaulted to enabled when the flag was absent, and the flag is only written at the end of a successful install, so an install abandoned partway left an unauthenticated endpoint that would drop every table, import an attacker-supplied archive, and overwrite uploads/ including the .htaccess that stops PHP running there. Affects 26.07 and earlier. Restoring over a live install is now CLI only.
This commit is contained in:
70
cron/backup.php
Normal file
70
cron/backup.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
// Set working directory to the directory this cron script lives at.
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
// Ensure we're running from command line
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
// Prevent overlapping runs of this script
|
||||
$cron_lock_script = __FILE__;
|
||||
require_once "includes/cron_lock.php";
|
||||
|
||||
require_once "../config.php";
|
||||
|
||||
// Set Timezone
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
|
||||
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE settings.company_id = 1");
|
||||
$row = mysqli_fetch_assoc($sql_settings);
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
$config_backup_cron_type = $row['config_backup_cron_type'] ?? 'full';
|
||||
|
||||
if ($config_enable_cron == 0) {
|
||||
cronJobStop("Cron: is not enabled\n");
|
||||
}
|
||||
|
||||
/*
|
||||
* Anything an administrator started from Settings > Backup is built first. Those are
|
||||
* explicit requests and somebody is waiting on the notification.
|
||||
*/
|
||||
$queued = backupRunQueued($mysqli);
|
||||
|
||||
if ($queued > 0) {
|
||||
echo "Built $queued queued backup(s)\n";
|
||||
}
|
||||
|
||||
/*
|
||||
* Then the scheduled one. The dispatcher only calls this script when the schedule says so,
|
||||
* so reaching here means a scheduled backup is due.
|
||||
*
|
||||
* Skipped if a backup of the same type already completed today - the day-match trap from
|
||||
* CONTRIBUTING's cron rules. A second dispatch in the same day (a manual Run Now, a catch-up
|
||||
* after downtime) must not produce a second scheduled archive.
|
||||
*/
|
||||
$type = in_array($config_backup_cron_type, backupUnattendedTypes(), true) ? $config_backup_cron_type : BACKUP_TYPE_FULL;
|
||||
$type_esc = escapeSql($type);
|
||||
|
||||
$already = mysqli_num_rows(mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_source = 'Cron' AND backup_type = '$type_esc' AND backup_status = 'Complete' AND backup_completed_at >= CURDATE()"));
|
||||
|
||||
if ($already > 0) {
|
||||
echo "Scheduled backup already ran today\n";
|
||||
} else {
|
||||
$error = null;
|
||||
$backup_id = backupCreate($mysqli, $type, 'Cron', 'Cron', $error);
|
||||
|
||||
if ($backup_id > 0) {
|
||||
echo "Scheduled " . backupTypeLabel($type) . " complete\n";
|
||||
appNotify("Backup", "Scheduled " . backupTypeLabel($type) . " is ready to download", "/admin/backup.php");
|
||||
logAudit("Backup", "Create", "Scheduled " . backupTypeLabel($type) . " completed");
|
||||
} else {
|
||||
echo "Scheduled backup FAILED: $error\n";
|
||||
appNotify("Backup", "Scheduled backup failed: $error", "/admin/backup.php");
|
||||
logAudit("Backup", "Create", "Scheduled backup failed: $error");
|
||||
logApp("Backup", "error", "Scheduled backup failed: $error");
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,11 @@ function cronJobClaim($mysqli, array $job): bool
|
||||
$default_interval = intval($job['interval_minutes'] ?? 1);
|
||||
$default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL';
|
||||
|
||||
$default_enabled = isset($job['enabled']) ? intval($job['enabled']) : 1;
|
||||
|
||||
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET
|
||||
cron_job_name = '$name',
|
||||
cron_job_enabled = $default_enabled,
|
||||
cron_job_schedule = '$default_schedule',
|
||||
cron_job_interval_minutes = $default_interval,
|
||||
cron_job_daily_at = $default_daily_at");
|
||||
@@ -156,12 +159,24 @@ function cronJobFinished($mysqli, string $job_name, string $status, ?float $dura
|
||||
$error_sql = ", cron_job_last_error = '$error_text', cron_job_last_error_at = '$finished_at'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_finished_at = '$finished_at',
|
||||
cron_job_last_status = '$status',
|
||||
cron_job_last_duration = $duration_sql
|
||||
$error_sql
|
||||
WHERE cron_job_name = '$name'");
|
||||
// This is the failure path. A job that killed the database connection - a long backup
|
||||
// whose idle connection was closed, a server restart mid-cycle - must not have its
|
||||
// bookkeeping throw on top, or an uncaught mysqli_sql_exception ends the dispatch and
|
||||
// no record of the original failure survives anywhere.
|
||||
if (function_exists('backupDbEnsure')) {
|
||||
$mysqli = backupDbEnsure($mysqli);
|
||||
}
|
||||
|
||||
try {
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_finished_at = '$finished_at',
|
||||
cron_job_last_status = '$status',
|
||||
cron_job_last_duration = $duration_sql
|
||||
$error_sql
|
||||
WHERE cron_job_name = '$name'");
|
||||
} catch (Throwable $e) {
|
||||
echo "Cron: could not record the outcome of '$job_name' - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell
|
||||
@@ -218,7 +233,12 @@ foreach (cronJobRegistry() as $cron_dispatch_job) {
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason", microtime(true) - $cron_dispatch_started);
|
||||
} catch (Throwable $e) {
|
||||
// One job throwing is not a reason to skip the rest of the cycle
|
||||
logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
|
||||
echo "Cron: job '{$cron_dispatch_job['name']}' failed - " . $e->getMessage() . "\n";
|
||||
try {
|
||||
logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
|
||||
} catch (Throwable $log_e) {
|
||||
// Logging the failure must never become a second, fatal failure
|
||||
}
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed', microtime(true) - $cron_dispatch_started, $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,11 @@ mysqli_query($mysqli, "DELETE FROM email_queue WHERE email_queued_at < CURDATE()
|
||||
// Clean-up old remember me tokens
|
||||
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_created_at < CURDATE() - INTERVAL $config_login_remember_me_expire DAY");
|
||||
|
||||
// Cleanup old backups, and reconcile rows whose file is gone against files with no row.
|
||||
// Retention lives here rather than in cron/backup.php so a failed backup run can never
|
||||
// delete the archive it was supposed to replace.
|
||||
backupRunRetention($mysqli);
|
||||
|
||||
// Cleanup old audit logs
|
||||
mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user