mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-09 18:26:20 +00:00
- Scheduled, encrypted backups to S3, SFTP, REST or a local folder (new restic-mailcow container) - Restore of mailboxes and whole domains in the UI, also deleted ones, for admins and domain admins - Scope set by RESTIC_BACKUP_MODE in mailcow.conf (all, data, config) - Optional backup before updates (update.sh), helper-scripts/restic.sh for a full restore
190 lines
7.8 KiB
PHP
190 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Mailcow\Backup\Model;
|
|
|
|
/**
|
|
* Backup settings (table `backup_setting`, name/value pairs) over their defaults. Secrets are stored sealed for the
|
|
* runner (`sealed:v1:<base64>`) and never leave toArray(): the API gets `<key>_set` instead. The state_* values, the
|
|
* SSH public key and the SFTP host key are written by the runner in the restic container and cannot be set through
|
|
* the API (PHP writes state_target_changed when the target changes).
|
|
*/
|
|
class Settings
|
|
{
|
|
public const DEFAULTS = [
|
|
'enabled' => '0',
|
|
'target' => 's3',
|
|
's3_endpoint' => 'https://s3.amazonaws.com',
|
|
's3_bucket' => '',
|
|
's3_path' => '',
|
|
's3_region' => '',
|
|
's3_access_key' => '',
|
|
's3_secret_key' => '',
|
|
's3_path_style' => '0',
|
|
'sftp_host' => '',
|
|
'sftp_port' => '22',
|
|
'sftp_user' => '',
|
|
'sftp_path' => '',
|
|
'sftp_public_key' => '',
|
|
'sftp_host_key' => '',
|
|
'rest_url' => '',
|
|
'rest_user' => '',
|
|
'rest_password' => '',
|
|
'local_path' => 'mailcow',
|
|
'password' => '',
|
|
'password_confirmed' => '0',
|
|
// JSON list of the scheduled backups: {"days": [weekdays, 0 = Sunday], "time": "HH:MM"}
|
|
'schedule' => '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]',
|
|
'keep_hourly' => '0',
|
|
'keep_daily' => '7',
|
|
'keep_weekly' => '4',
|
|
'keep_monthly' => '12',
|
|
'keep_yearly' => '0',
|
|
'prune_weekday' => '0',
|
|
'max_unused' => '10%',
|
|
'check_subset' => '30',
|
|
'include_index' => '1',
|
|
'include_fts' => '1',
|
|
'limit_upload' => '0',
|
|
'notify_email' => '',
|
|
'state_initialized' => '0',
|
|
'state_runner_seen' => '',
|
|
'state_last_backup' => '',
|
|
'state_last_success' => '',
|
|
'state_repo_size' => '',
|
|
'state_repo_stats' => '',
|
|
'state_schedule_since' => '',
|
|
'state_schedule' => '',
|
|
// public key (base64, X25519) the secrets are sealed with; the private key stays in the restic container
|
|
'state_secrets_pubkey' => '',
|
|
'state_secrets_error' => '',
|
|
// JSON {old_target, old_addresses, changed, changed_by, time}: the runner mails the old addresses and clears it
|
|
'state_target_changed' => '',
|
|
// RESTIC_BACKUP_MODE of mailcow.conf as the runner sees it
|
|
'state_backup_mode' => '',
|
|
];
|
|
public const SECRETS = ['password', 's3_secret_key', 'rest_password'];
|
|
/** Written by the runner only */
|
|
public const RUNNER = ['sftp_public_key', 'sftp_host_key', 'state_initialized', 'state_runner_seen', 'state_last_backup',
|
|
'state_last_success', 'state_repo_size', 'state_repo_stats', 'state_schedule_since', 'state_schedule', 'state_secrets_pubkey',
|
|
'state_secrets_error', 'state_target_changed', 'state_backup_mode'];
|
|
/** Prefix of a sealed secret (sodium_crypto_box_seal with state_secrets_pubkey) */
|
|
public const SEALED = 'sealed:v1:';
|
|
public const TARGETS = ['s3', 'sftp', 'rest', 'local'];
|
|
/** The connection of each target: a change (or another target or repository password) resets state_initialized */
|
|
public const CONNECTION = [
|
|
's3' => ['s3_endpoint', 's3_bucket', 's3_path', 's3_region', 's3_access_key', 's3_secret_key', 's3_path_style'],
|
|
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
|
|
'rest' => ['rest_url', 'rest_user', 'rest_password'],
|
|
'local' => ['local_path'],
|
|
];
|
|
/** Values a target cannot work without (besides the repository password) */
|
|
public const REQUIRED = [
|
|
's3' => ['s3_endpoint', 's3_bucket', 's3_access_key', 's3_secret_key'],
|
|
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
|
|
'rest' => ['rest_url'],
|
|
'local' => ['local_path'],
|
|
];
|
|
/** Returned as numbers by the API ('' = not known yet: null; prune_weekday '' = never stays '') */
|
|
private const NUMBERS = ['enabled', 's3_path_style', 'sftp_port', 'password_confirmed', 'keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly',
|
|
'keep_yearly', 'prune_weekday', 'check_subset', 'include_index', 'include_fts', 'limit_upload', 'state_initialized',
|
|
'state_runner_seen', 'state_last_backup', 'state_last_success', 'state_repo_size', 'state_schedule_since'];
|
|
private const JSON = ['schedule', 'state_repo_stats', 'state_target_changed'];
|
|
|
|
/** @param array<string, string> $values every key of DEFAULTS */
|
|
public function __construct(public array $values = self::DEFAULTS)
|
|
{
|
|
}
|
|
|
|
/** Stored values (name => value) over the defaults; unknown names are dropped */
|
|
public static function fromStored(array $stored): self
|
|
{
|
|
$values = self::DEFAULTS;
|
|
foreach ($stored as $name => $value) {
|
|
if (array_key_exists($name, $values)) {
|
|
$values[$name] = (string)$value;
|
|
}
|
|
}
|
|
return new self($values);
|
|
}
|
|
|
|
public function get(string $name): string
|
|
{
|
|
return $this->values[$name] ?? '';
|
|
}
|
|
|
|
/** The target has its required values and the repository password is set */
|
|
public function configured(): bool
|
|
{
|
|
$required = self::REQUIRED[$this->get('target')] ?? null;
|
|
if ($required === null || $this->get('password') === '') {
|
|
return false;
|
|
}
|
|
foreach ($required as $name) {
|
|
if ($this->get($name) === '') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The schedule as [weekday (0 = Sunday), hour, minute] slots, sorted and unique
|
|
*
|
|
* @return array<int, array{int, int, int}>
|
|
*/
|
|
public function scheduleSlots(): array
|
|
{
|
|
$entries = json_decode($this->get('schedule'), true);
|
|
$slots = [];
|
|
foreach (is_array($entries) ? $entries : [] as $entry) {
|
|
if (!is_array($entry) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', (string)($entry['time'] ?? ''), $time) || !is_array($entry['days'] ?? null)) {
|
|
continue;
|
|
}
|
|
foreach ($entry['days'] as $day) {
|
|
if (is_int($day) && $day >= 0 && $day <= 6) {
|
|
$slots[] = $day * 1440 + (int)$time[1] * 60 + (int)$time[2];
|
|
}
|
|
}
|
|
}
|
|
$slots = array_unique($slots);
|
|
sort($slots);
|
|
return array_map(fn(int $slot): array => [intdiv($slot, 1440), intdiv($slot % 1440, 60), $slot % 60], $slots);
|
|
}
|
|
|
|
/** The longest time between two scheduled backups of a week in seconds, null without a schedule */
|
|
public function scheduleGap(): ?int
|
|
{
|
|
$slots = array_map(fn(array $slot): int => $slot[0] * 86400 + $slot[1] * 3600 + $slot[2] * 60, $this->scheduleSlots());
|
|
if (empty($slots)) {
|
|
return null;
|
|
}
|
|
$gaps = [$slots[0] + 7 * 86400 - end($slots)];
|
|
for ($i = 1; $i < count($slots); $i++) {
|
|
$gaps[] = $slots[$i] - $slots[$i - 1];
|
|
}
|
|
return max($gaps);
|
|
}
|
|
|
|
/** Entry of get/backup-settings: every key, secrets as <key>_set, plus `configured` */
|
|
public function toArray(): array
|
|
{
|
|
$settings = [];
|
|
foreach ($this->values as $name => $value) {
|
|
if (in_array($name, self::SECRETS, true)) {
|
|
$settings[$name . '_set'] = (int)($value !== '');
|
|
} elseif (in_array($name, self::JSON, true)) {
|
|
$decoded = json_decode($value, true);
|
|
$settings[$name] = is_array($decoded) ? $decoded : null;
|
|
} elseif (in_array($name, self::NUMBERS, true)) {
|
|
$settings[$name] = ($value === '') ? (($name === 'prune_weekday') ? '' : null) : (int)$value;
|
|
} else {
|
|
$settings[$name] = $value;
|
|
}
|
|
}
|
|
$settings['configured'] = (int)$this->configured();
|
|
return $settings;
|
|
}
|
|
}
|