Backup. One answer at the top - backed up, one being made, the last one * failed, none yet - with the buttons that take one. Under it the backups on this server, * then the schedule and how long backups are kept. Beside them: where backups stand at a * glance, the key they are encrypted with, how one is restored, and the credential vault's * master key. * * Nothing is built here. Back up now records a Pending row and asks the dispatcher for a * run (backupQueue()), and cron/backup.php builds it - so while one is on its way the page * reloads itself (js/auto_reload.js), as Maintenance > Update does. The handlers are * admin/post/backup.php; the schedule's own switch and time are the Backup job's, edited * in the Cron page's window (modals/cron/cron_edit.php), which comes back here. */ // The list: newest first until a heading is clicked $sort = "backup_created_at"; $order = "DESC"; require_once "includes/inc_all_admin.php"; $backup_key = backupEncryptionKey(); $backup_dir = backupStorageDir(); $csrf = $_SESSION['csrf_token']; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at, config_backup_retention_days, config_backup_retention_count, config_backup_cron_type FROM settings WHERE company_id = 1")); $config_enable_cron = intval($row['config_enable_cron']); $cron_last_dispatch_at = $row['config_cron_last_dispatch_at']; $config_backup_retention_days = intval($row['config_backup_retention_days']); $config_backup_retention_count = intval($row['config_backup_retention_count']); $config_backup_cron_type = in_array($row['config_backup_cron_type'], backupUnattendedTypes(), true) ? $row['config_backup_cron_type'] : BACKUP_TYPE_FULL; // Same heartbeat rule as Maintenance > Cron - backups are built by the dispatcher, so // without it the buttons below queue work that never runs $cron_heartbeat = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300; $cron_is_running = $cron_heartbeat && $config_enable_cron === 1; $backup_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_id, cron_job_enabled, cron_job_daily_at, cron_job_last_status, cron_job_last_run_at FROM cron_jobs WHERE cron_job_name = 'backup'")); $schedule_on = !empty($backup_job) && intval($backup_job['cron_job_enabled']) === 1; $schedule_time = substr((string) ($backup_job['cron_job_daily_at'] ?? ''), 0, 5); $backup_job_id = intval($backup_job['cron_job_id'] ?? 0); // The newest finished backup of each kind cron can make - the vault's key isn't a backup $latest = []; foreach (backupUnattendedTypes() as $type) { $latest[$type] = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT backup_type, backup_size, backup_source, backup_created_by, backup_created_at FROM backups WHERE backup_status = 'Complete' AND backup_type = '$type' ORDER BY backup_created_at DESC, backup_id DESC LIMIT 1")); } $last_good = null; foreach (array_filter($latest) as $candidate) { if (!$last_good || strtotime($candidate['backup_created_at']) > strtotime($last_good['backup_created_at'])) { $last_good = $candidate; } } $last_full = $latest[BACKUP_TYPE_FULL]; // A failure matters while nothing has worked since $last_failed = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT backup_type, backup_error, backup_created_at FROM backups WHERE backup_status = 'Failed' AND backup_type <> '" . BACKUP_TYPE_MASTER_KEY . "' ORDER BY backup_created_at DESC, backup_id DESC LIMIT 1")); if ($last_failed && $last_good && strtotime($last_failed['backup_created_at']) <= strtotime($last_good['backup_created_at'])) { $last_failed = null; } // What is queued or being built $queue = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS backups, COALESCE(SUM(backup_status = 'Running'), 0) AS started, MIN(backup_created_at) AS since, MIN(backup_type) AS type FROM backups WHERE backup_status IN ('Pending', 'Running')")); $queued = intval($queue['backups']); $started = intval($queue['started']) > 0; // Waiting longer than cron takes to come round, with the Backup job not at work, is stuck - // cron stopped, or the job died part way. The nightly job tidies it; no need to wait for that $job_at_work = ($backup_job['cron_job_last_status'] ?? '') === 'Running' && !empty($backup_job['cron_job_last_run_at']) && strtotime($backup_job['cron_job_last_run_at']) > strtotime('-6 hours'); $stuck = $queued > 0 && strtotime($queue['since']) < strtotime('-10 minutes') && !$job_at_work; $kept = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS backups, COALESCE(SUM(backup_size), 0) AS bytes FROM backups WHERE backup_status = 'Complete'")); $free_bytes = @disk_free_space($backup_dir); /* * The one thing to say at the top. In this order, because each hides the ones after it: * no key to encrypt with, one on its way, a failure nothing has made up for, none at all, * one too old to count on, then all is well. */ $stale_days = 7; // the age Debug's backup check starts warning at if ($backup_key === '') { $state = 'nokey'; } elseif ($queued > 0) { $state = $stuck ? 'stuck' : 'working'; } elseif ($last_failed) { $state = 'failed'; } elseif (!$last_good) { $state = 'none'; } elseif (strtotime($last_good['backup_created_at']) < strtotime("-$stale_days days")) { $state = 'stale'; } else { $state = 'current'; } $working_title = $queued === 1 ? 'Making a ' . backupTypeLabel((string) $queue['type']) . ($queue['type'] === BACKUP_TYPE_FULL ? '' : ' backup') : "Making $queued backups"; $hero = [ 'nokey' => ['icon' => 'fa-key', 'tone' => 'danger', 'title' => "Backups can't be made yet"], 'working' => ['icon' => 'fa-circle-notch fa-spin', 'tone' => 'primary', 'title' => $working_title], 'stuck' => ['icon' => 'fa-hourglass-half', 'tone' => 'warning', 'title' => $started ? 'A backup stopped part way' : ($queued === 1 ? 'A backup is waiting to be made' : "$queued backups are waiting to be made")], 'failed' => ['icon' => 'fa-exclamation-triangle', 'tone' => 'danger', 'title' => "The last backup didn't finish"], 'none' => ['icon' => 'fa-life-ring', 'tone' => 'warning', 'title' => 'No backup yet'], 'stale' => ['icon' => 'fa-history', 'tone' => 'warning', 'title' => 'The last backup is ' . ($last_good ? floor((time() - strtotime($last_good['backup_created_at'])) / 86400) : 0) . ' days old'], 'current' => ['icon' => 'fa-check', 'tone' => 'success', 'title' => 'Backed up ' . ($last_good ? timeAgo($last_good['backup_created_at']) : '')], ][$state]; // Shown once, straight after an export, then dropped - it must not survive a reload $master_key_reveal = ''; if (!empty($_SESSION['backup_master_key_reveal'])) { $master_key_reveal = $_SESSION['backup_master_key_reveal']; unset($_SESSION['backup_master_key_reveal']); } // The page reloads while a backup is on its way - but never out from under a key shown once $reload = $state === 'working' && $master_key_reveal === ''; // A time as "3 hours ago", with the date and time behind it $ago = function ($datetime) { return '' . escapeHtml(timeAgo($datetime)) . ''; }; // Who a backup is down to, from its row $made_by = function ($backup) { if ($backup['backup_source'] === 'Cron') { return 'Scheduled'; } if ($backup['backup_source'] === 'Adopted') { return 'Found on this server'; } return (string) $backup['backup_created_by'] !== '' ? 'By ' . $backup['backup_created_by'] : 'By hand'; }; $schedule_text = $schedule_on ? "Every night at $schedule_time" : 'Off'; // A type as it reads in a sentence - "a Full Backup", "a Database Only backup" $type_noun = function ($type) { return backupTypeLabel((string) $type) . ($type === BACKUP_TYPE_FULL ? '' : ($type === BACKUP_TYPE_MASTER_KEY ? ' export' : ' backup')); }; // The restore command, as this server runs it - the user that owns the files, in this folder $install_root = dirname(__DIR__); $file_owner = ''; if (function_exists('posix_getpwuid')) { $file_owner = (string) (posix_getpwuid((int) @fileowner($install_root . '/scripts/restore_cli.php'))['name'] ?? ''); } $shell_word = function ($word) { return preg_match('/^[A-Za-z0-9_\/.@%+=:,-]+$/', $word) ? $word : escapeshellarg($word); }; $cli_command = "cd " . $shell_word($install_root) . "\n" . ($file_owner !== '' ? "sudo -u " . $shell_word($file_owner) . " " : '') . "php scripts/restore_cli.php \\\n --file=/path/to/backup.zip"; // The list - only these sort; an unknown column would take the page down $sortable_columns = [ 'backup_created_at' => "backup_created_at $order, backup_id $order", 'backup_type' => "backup_type $order, backup_created_at DESC, backup_id DESC", 'backup_size' => "backup_size $order, backup_created_at DESC, backup_id DESC", 'backup_status' => "backup_status $order, backup_created_at DESC, backup_id DESC", ]; if (!isset($sortable_columns[$sort])) { $sort = 'backup_created_at'; } $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS backup_id, backup_type, backup_file_name, backup_size, backup_sha256, backup_status, backup_error, backup_source, backup_created_by, backup_created_at, backup_downloaded_at FROM backups ORDER BY {$sortable_columns[$sort]} LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { return '' . $label . ($sort === $column ? " $order_icon" : '') . ''; }; $type_looks = [ BACKUP_TYPE_FULL => ['icon' => 'fa-box-open', 'tone' => 'itflow-asset-tone-server'], BACKUP_TYPE_DATABASE => ['icon' => 'fa-database', 'tone' => 'itflow-asset-tone-network'], BACKUP_TYPE_MASTER_KEY => ['icon' => 'fa-key', 'tone' => 'itflow-asset-tone-phone'], ]; $status_looks = [ 'Complete' => ['label' => 'Complete', 'tone' => 'success'], 'Pending' => ['label' => 'Queued', 'tone' => 'secondary'], 'Running' => ['label' => 'Being made', 'tone' => 'primary'], 'Failed' => ['label' => 'Failed', 'tone' => 'danger'], 'Missing' => ['label' => 'Missing', 'tone' => 'warning'], ]; ?>
Shown this once - it is gone when the page reloads. It opens every saved credential, so keep it the way you'd keep those.
Every backup is encrypted, and ITFlow couldn't write an encryption key to config.php. Make config.php writable by the web server's user and reload this page, or add this line to it yourself:
$config_backup_key = '= escapeHtml(randomString(32)) ?>';
= $started ? 'Started' : 'Asked for' ?> = $ago($queue['since']) ?>. Cron builds = $queued === 1 ? 'it' : 'them' ?> in the background - a minute or two for most installs, longer with a lot uploaded. You can leave this page; you'll get a notification as each is ready.
It started = $ago($queue['since']) ?> and never finished - the Backup job was stopped while it worked. Remove it in the list below and take another; Maintenance > Cron has what the job last said. Asked for = $ago($queue['since']) ?>, and the Backup job hasn't picked it up - Maintenance > Cron has what it last said. Cancel it in the list below and take another. Asked for = $ago($queue['since']) ?>. It is made as soon as cron runs again.
The = escapeHtml($type_noun($last_failed['backup_type'])) ?> started = $ago($last_failed['backup_created_at']) ?> stopped= (string) $last_failed['backup_error'] !== '' ? ':' : '.' ?>
= escapeHtml($last_failed['backup_error']) ?>
= $last_good ? 'The last one that worked was ' . $ago($last_good['backup_created_at']) . '.' : 'There is no backup that worked yet.' ?> Fix what stopped it and take another.
Take a Full Backup now - the database and everything uploaded, in one encrypted file. = $schedule_on ? "After that, one is made every night at $schedule_time." : 'Then turn the schedule on below, and one is made every night.' ?>
= escapeHtml(backupTypeLabel((string) $last_good['backup_type'])) ?>, = escapeHtml(backupFormatBytes($last_good['backup_size'])) ?> - = escapeHtml(lcfirst($made_by($last_good))) ?>. = $last_full ? 'The last Full Backup, with everything uploaded, was ' . $ago($last_full['backup_created_at']) . '.' : "There's no Full Backup yet - attachments and documents aren't in a Database Only one." ?> = $schedule_on ? "One is due every night at $schedule_time, so something is stopping it." : 'The schedule is off, so the next one is whenever you take it.' ?> = $schedule_on ? "The next scheduled one is at $schedule_time." : 'The schedule is off, so the next one is whenever you take it.' ?>
Back up now makes a Full Backup: the database and every uploaded file. Database only is smaller and quicker, without attachments or documents.
| = $sort_link('backup_type', 'Backup') ?> | = $sort_link('backup_created_at', 'Made') ?> | = $sort_link('backup_size', 'Size') ?> | = $sort_link('backup_status', 'Status') ?> | |
|---|---|---|---|---|
|
= escapeHtml($type_label) ?>
= escapeHtml($made_by($backup)) ?>
= escapeHtml($made_at) ?>
|
= escapeHtml($made_at) ?>
= escapeHtml(timeAgo($backup['backup_created_at'])) ?>
|
= $backup['backup_size'] > 0 ? escapeHtml(backupFormatBytes($backup['backup_size'])) : '—' ?> |
= escapeHtml($status_look['label']) ?>
= escapeHtml($backup['backup_error']) ?>
Its file is no longer on this server
Downloaded = escapeHtml(timeAgo($backup['backup_downloaded_at'])) ?>
|
Download
|
= escapeHtml($backup_dir) ?>
$config_backup_path in config.php keeps them somewhere else - another disk, or a mounted share.Every backup is encrypted with this key, and can't be opened or restored without it - not by you, not by anyone. Keep a copy somewhere other than this server.
There isn't one yet - config.php couldn't be written.
It is kept in config.php and never in the database, so a backup that gets out doesn't carry its own key.
A backup is an AES-256 encrypted zip. 7-Zip, WinZip, PeaZip and Keka open it with this key. The unzip command, Windows Explorer and the macOS Archive Utility can't - they don't do AES.
Restoring replaces the database - and, from a Full Backup, everything uploaded - with what's in the backup. It isn't done from this page, on purpose: a running ITFlow shouldn't drop its own tables from a browser.
From the command line
= escapeHtml($cli_command) ?>
Works for a backup of any size. Add --inspect to see what's in one without changing anything, and --key= for a backup from another server, which needs that server's key. A backup's menu copies its path.
On a new server
The setup wizard at /setup offers to restore a backup before the first user exists - up to = escapeHtml(backupFormatBytes(backupMaxUploadBytes())) ?> here, the most PHP takes in one upload.
The master key that opens every saved credential. A restore brings the vault back by itself, as long as one user still knows their password - this is for when none does.