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'], ]; ?>
The credential vault's master key

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 = '';

. Cron builds 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 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 , 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 . It is made as soon as cron runs again.

The started stopped

Fix what stopped it and take another.

Take a Full Backup now - the database and everything uploaded, in one encrypted file.

, - .

Back up now makes a Full Backup: the database and every uploaded file. Database only is smaller and quicker, without attachments or documents.

Cron is switched off, so no backup is made - scheduled or started here - until it's back on in Maintenance > Cron. Cron hasn't run in the last five minutes. Backups are built by cron, not by your browser, so one started here waits until it runs. Check Maintenance > Cron.
0) { // with none at all, the card above has said so ?>

Backups

'fa-file-archive', 'tone' => 'itflow-asset-tone-other']; $status_look = $status_looks[$status] ?? ['label' => $status, 'tone' => 'secondary']; $made_at = substr((string) $backup['backup_created_at'], 0, 16); $backup_path = $status === 'Complete' ? backupResolvePath((string) $backup['backup_file_name']) : false; // A finished one is deleted, file and all; one still waiting is taken off the queue; the rest are only rows $remove = [ 'Complete' => ['Delete', 'Delete this backup?', "The $type_as_noun from $made_at is deleted from this server for good. A copy you've downloaded isn't touched."], 'Pending' => ['Take off the queue', 'Take it off the queue?', "The $type_as_noun asked for at $made_at won't be made."], 'Running' => ['Remove', 'Remove this backup?', "It's taken off this list, along with anything written so far."], ][$status] ?? ['Remove', 'Remove it from the list?', "The $type_as_noun from $made_at is taken off this list. There is no file left to delete."]; ?>
0 ? escapeHtml(backupFormatBytes($backup['backup_size'])) : '—' ?>
Its file is no longer on this server
Downloaded
Download

Schedule and retention

A is made every night at
It's the Backup job in Maintenance > Cron.
Scheduled backups are off
Backups are only made when someone starts one here. Turn the schedule on and one is made every night.
Turn on
Database Only leaves out attachments and documents.
days
0 keeps them whatever their age.
of each type
The newest of each type is never removed.
Older backups are removed by the nightly job, not by a backup itself - so one that fails never costs you the one before it.

At a glance

  • Last Full Backup None yet' ?>
  • Last Database Only
  • Schedule
  • Kept 0 ? $config_backup_retention_days . ' day' . ($config_backup_retention_days === 1 ? '' : 's') . ', ' : '' ?> of each type
  • On this server backup 0 ? ', ' . escapeHtml(backupFormatBytes($kept['bytes'])) : '' ?>
  • Free space
  • Cron
  • Kept in
    Inside ITFlow's own folder. $config_backup_path in config.php keeps them somewhere else - another disk, or a mounted share.

Encryption key

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.

Opening a backup by hand

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

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

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 here, the most PHP takes in one upload.

Credential vault key

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.