mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-09 02:13:38 +00:00
[Web][Restic][Dovecot][Dockerapi] Add restic backups with restore in the UI
- 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
This commit is contained in:
66 files changed
+15214
-97
No files matched your search
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Backup\Service;
|
||||
|
||||
use Mailcow\Backup\Model\Settings;
|
||||
use Mailcow\Backup\Repository\SettingsRepository;
|
||||
use Mailcow\Backup\Service\SettingsService;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SettingsServiceTest extends TestCase
|
||||
{
|
||||
private SettingsRepository&MockObject $repo;
|
||||
/** @var array<string, string> what the repository holds */
|
||||
private array $stored = [];
|
||||
/** @var array[] values passed to save() */
|
||||
private array $saved = [];
|
||||
/** Key pair of the runner (sealed boxes) */
|
||||
private string $keypair;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->keypair = sodium_crypto_box_keypair();
|
||||
$this->stored = ['state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
|
||||
$this->repo = $this->createMock(SettingsRepository::class);
|
||||
$this->repo->method('load')->willReturnCallback(fn(): array => $this->stored);
|
||||
$this->repo->method('save')->willReturnCallback(function (array $values): void {
|
||||
$this->saved[] = $values;
|
||||
$this->stored = array_merge($this->stored, $values);
|
||||
});
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): SettingsService
|
||||
{
|
||||
return new SettingsService($this->repo, $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
private function expectUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError ' . json_encode($msg) . ' expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
/** A configured S3 target as the runner left it */
|
||||
private function s3(array $values = []): void
|
||||
{
|
||||
$this->stored = $values + ['target' => 's3', 's3_bucket' => 'backups', 's3_access_key' => 'AKIA1', 's3_secret_key' => 'SECRET',
|
||||
'password' => 'repo-password', 'password_confirmed' => '1', 'state_initialized' => '1', 'sftp_host_key' => 'ssh-ed25519 AAAA',
|
||||
'state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
|
||||
}
|
||||
|
||||
/** What the runner decrypts from a stored secret */
|
||||
private function open(string $stored): string|false
|
||||
{
|
||||
$this->assertStringStartsWith('sealed:v1:', $stored);
|
||||
return sodium_crypto_box_seal_open((string)base64_decode(substr($stored, strlen('sealed:v1:')), true), $this->keypair);
|
||||
}
|
||||
|
||||
public function testSecretsNeverLeaveTheApi(): void
|
||||
{
|
||||
$this->s3(['state_repo_stats' => '{"total_size": 5}', 'state_runner_seen' => '1700000000', 'unknown' => 'x']);
|
||||
|
||||
$settings = $this->service()->get()->toArray();
|
||||
|
||||
$this->assertSame([1, 1, 0], [$settings['password_set'], $settings['s3_secret_key_set'], $settings['rest_password_set']]);
|
||||
$this->assertArrayNotHasKey('password', $settings);
|
||||
$this->assertArrayNotHasKey('s3_secret_key', $settings);
|
||||
$this->assertArrayNotHasKey('unknown', $settings);
|
||||
$this->assertSame([7, 1700000000, null, ['total_size' => 5], 1], [$settings['keep_daily'], $settings['state_runner_seen'],
|
||||
$settings['state_repo_size'], $settings['state_repo_stats'], $settings['configured']]);
|
||||
$this->assertStringNotContainsString('SECRET', json_encode($settings));
|
||||
}
|
||||
|
||||
public function testValuesAreNormalized(): void
|
||||
{
|
||||
$settings = $this->service()->save([
|
||||
'target' => 'sftp', 'sftp_host' => 'u123.your-storagebox.de', 'sftp_port' => '23', 'sftp_user' => 'u123-sub1', 'sftp_path' => 'backups/mailcow/',
|
||||
's3_path' => '/mailcow/', 'schedule' => [['days' => [5, '1', 3, 1], 'time' => '3:05'], ['days' => [0], 'time' => '14:00'], ['days' => [1, 3, 5], 'time' => '03:05']], 'max_unused' => '5', 'notify_email' => "a@example.org; b@example.org\nA@example.org",
|
||||
'enabled' => ['0', '1'], 'include_fts' => false, 'keep_yearly' => 3, 'password' => 'long enough',
|
||||
]);
|
||||
|
||||
$this->assertSame(['sftp', 'backups/mailcow', 'mailcow', '[{"days":[1,3,5],"time":"03:05"},{"days":[0],"time":"14:00"}]', '5%',
|
||||
'a@example.org,b@example.org,A@example.org', '1', '0', '3'],
|
||||
array_map(fn(string $name): string => $settings->get($name),
|
||||
['target', 'sftp_path', 's3_path', 'schedule', 'max_unused', 'notify_email', 'enabled', 'include_fts', 'keep_yearly']));
|
||||
$this->assertSame([[0, 14, 0], [1, 3, 5], [3, 3, 5], [5, 3, 5]], $settings->scheduleSlots());
|
||||
$this->assertSame([['days' => [1, 3, 5], 'time' => '03:05'], ['days' => [0], 'time' => '14:00']], $settings->toArray()['schedule']);
|
||||
// as JSON as well
|
||||
$this->assertSame('[{"days":[2],"time":"23:59"}]', $this->service()->save(['schedule' => '[{"days":[2],"time":"23:59"}]'])->get('schedule'));
|
||||
// secrets are stored sealed for the runner
|
||||
$this->assertSame('long enough', $this->open($this->stored['password']));
|
||||
// secrets as typed, spaces at the ends included
|
||||
$this->service()->save(['password' => ' spaced password ', 's3_secret_key' => ' key ']);
|
||||
$this->assertSame([' spaced password ', ' key '], [$this->open($this->stored['password']), $this->open($this->stored['s3_secret_key'])]);
|
||||
// only changed values are written
|
||||
$this->assertArrayNotHasKey('keep_daily', $this->saved[0]);
|
||||
}
|
||||
|
||||
public static function invalidInput(): array
|
||||
{
|
||||
return [
|
||||
'target' => [['target' => 'ftp'], 'target'],
|
||||
'endpoint without scheme' => [['s3_endpoint' => 's3.example.org'], 's3_endpoint'],
|
||||
'endpoint with credentials' => [['s3_endpoint' => 'https://key:secret@s3.example.org'], 's3_endpoint'],
|
||||
'endpoint ftp' => [['s3_endpoint' => 'ftp://s3.example.org'], 's3_endpoint'],
|
||||
'endpoint with a path' => [['s3_endpoint' => 'https://s3.example.org/bucket'], 's3_endpoint'],
|
||||
'rest url with credentials' => [['rest_url' => 'https://user:pw@backup.example.org:8000/mailcow'], 'rest_url'],
|
||||
'rest url with query' => [['rest_url' => 'https://backup.example.org/?x=1'], 'rest_url'],
|
||||
'bucket' => [['s3_bucket' => 'my bucket'], 's3_bucket'],
|
||||
'bucket too long' => [['s3_bucket' => str_repeat('b', 64)], 's3_bucket'],
|
||||
's3 path up' => [['s3_path' => 'a/../b'], 's3_path'],
|
||||
'region' => [['s3_region' => 'eu central'], 's3_region'],
|
||||
'access key with space' => [['s3_access_key' => 'AK IA'], 's3_access_key'],
|
||||
'sftp host option' => [['sftp_host' => '-oProxyCommand=x'], 'sftp_host'],
|
||||
'sftp host with user' => [['sftp_host' => 'u1@host.example.org'], 'sftp_host'],
|
||||
'sftp port' => [['sftp_port' => '70000'], 'sftp_port'],
|
||||
'sftp user option' => [['sftp_user' => '-l'], 'sftp_user'],
|
||||
// the runner's character set
|
||||
'sftp user with @' => [['sftp_user' => 'u1@host'], 'sftp_user'],
|
||||
'sftp user with +' => [['sftp_user' => 'u1+x'], 'sftp_user'],
|
||||
'local path with ~' => [['local_path' => 'mailcow/~old'], 'local_path'],
|
||||
'local path with space' => [['local_path' => 'mail cow'], 'local_path'],
|
||||
'sftp path up' => [['sftp_path' => '/srv/../etc'], 'sftp_path'],
|
||||
'sftp path root' => [['sftp_path' => '/'], 'sftp_path'],
|
||||
'rest user with colon' => [['rest_user' => 'a:b'], 'rest_user'],
|
||||
'local path absolute' => [['local_path' => '/etc'], 'local_path'],
|
||||
'local path up' => [['local_path' => '../mailcow'], 'local_path'],
|
||||
'local path empty' => [['local_path' => ''], 'local_path'],
|
||||
'schedule time' => [['schedule' => [['days' => [1], 'time' => '24:00']]], 'schedule'],
|
||||
'schedule without time' => [['schedule' => [['days' => [1]]]], 'schedule'],
|
||||
'schedule day' => [['schedule' => [['days' => [1, 7], 'time' => '02:30']]], 'schedule'],
|
||||
'schedule without day' => [['schedule' => [['days' => [], 'time' => '02:30']]], 'schedule'],
|
||||
'schedule days as text' => [['schedule' => [['days' => '1,2', 'time' => '02:30']]], 'schedule'],
|
||||
'schedule nested day' => [['schedule' => [['days' => [[1]], 'time' => '02:30']]], 'schedule'],
|
||||
'schedule not a list' => [['schedule' => ['a' => ['days' => [1], 'time' => '02:30']]], 'schedule'],
|
||||
'schedule broken json' => [['schedule' => '[{"days":'], 'schedule'],
|
||||
'too many schedule entries' => [['schedule' => array_map(fn(int $h): array => ['days' => [1], 'time' => sprintf('%02d:00', $h % 24) . ''], range(0, 24))], 'schedule'],
|
||||
'keep' => [['keep_daily' => '1001'], 'keep_daily'],
|
||||
'keep hourly' => [['keep_hourly' => '1001'], 'keep_hourly'],
|
||||
'keep negative' => [['keep_weekly' => '-1'], 'keep_weekly'],
|
||||
'weekday' => [['prune_weekday' => '7'], 'prune_weekday'],
|
||||
'max unused' => [['max_unused' => '101%'], 'max_unused'],
|
||||
'max unused size' => [['max_unused' => '5G'], 'max_unused'],
|
||||
'check subset' => [['check_subset' => '366'], 'check_subset'],
|
||||
'limit upload' => [['limit_upload' => '-5'], 'limit_upload'],
|
||||
'notify' => [['notify_email' => 'a@example.org, nobody'], 'notify_email'],
|
||||
'flag' => [['enabled' => 'maybe'], 'enabled'],
|
||||
'list as text' => [['sftp_host' => ['a', 'b']], 'sftp_host'],
|
||||
'control character' => [['s3_secret_key' => "abc\ndef"], 's3_secret_key'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInput')]
|
||||
public function testInvalidInput(array $input, string $name): void
|
||||
{
|
||||
$this->expectUserError(['backup_setting_invalid', $name], fn() => $this->service()->save($input));
|
||||
$this->assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testPruneCanBeOff(): void
|
||||
{
|
||||
$settings = $this->service()->save(['prune_weekday' => '']);
|
||||
|
||||
$this->assertSame(['', ''], [$settings->get('prune_weekday'), $settings->toArray()['prune_weekday']]);
|
||||
$this->assertSame(3, $this->service()->save(['prune_weekday' => '3'])->toArray()['prune_weekday']);
|
||||
$this->assertSame(0, (new Settings())->toArray()['prune_weekday']);
|
||||
}
|
||||
|
||||
public function testSecretsNeedTheKeyOfTheRunner(): void
|
||||
{
|
||||
$this->stored = [];
|
||||
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['keep_daily' => '9', 'password' => 'long enough']));
|
||||
$this->assertSame([], $this->saved);
|
||||
// everything else can be saved before the backup container ran
|
||||
$this->assertSame('9', $this->service()->save(['keep_daily' => '9', 'password' => ''])->get('keep_daily'));
|
||||
$this->stored['state_secrets_pubkey'] = base64_encode('too short');
|
||||
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['rest_password' => 'x']));
|
||||
}
|
||||
|
||||
public function testEverySecretIsSealedAnew(): void
|
||||
{
|
||||
$this->s3();
|
||||
|
||||
$this->service()->save(['s3_secret_key' => 'NEW-SECRET', 'rest_password' => 'rest pw']);
|
||||
|
||||
$this->assertSame(['NEW-SECRET', 'rest pw'], [$this->open($this->stored['s3_secret_key']), $this->open($this->stored['rest_password'])]);
|
||||
$this->assertStringNotContainsString('NEW-SECRET', json_encode($this->saved));
|
||||
$this->assertSame([1, 1], [$this->service()->get()->toArray()['s3_secret_key_set'], $this->service()->get()->toArray()['rest_password_set']]);
|
||||
}
|
||||
|
||||
public function testATargetChangeIsReportedToTheAddressesOfBefore(): void
|
||||
{
|
||||
$this->s3(['notify_email' => 'old@example.org,boss@example.org', 's3_endpoint' => 'https://s3.example.org']);
|
||||
|
||||
$this->service()->save(['target' => 'local', 'local_path' => 'elsewhere', 'notify_email' => 'new@example.org']);
|
||||
|
||||
$change = json_decode($this->stored['state_target_changed'], true);
|
||||
$this->assertSame(['target' => 's3', 's3_endpoint' => 'https://s3.example.org', 's3_bucket' => 'backups', 's3_path' => '', 's3_region' => '',
|
||||
's3_access_key' => 'AKIA1', 's3_path_style' => '0'], $change['old_target']);
|
||||
$this->assertSame([['old@example.org', 'boss@example.org'], ['target', 'notify_email'], 'admin'],
|
||||
[$change['old_addresses'], $change['changed'], $change['changed_by']]);
|
||||
$this->assertIsInt($change['time']);
|
||||
$this->assertSame($change, $this->service()->get()->toArray()['state_target_changed']);
|
||||
// a second change before the runner mailed the first keeps the older state
|
||||
$this->service()->save(['local_path' => 'again']);
|
||||
$this->assertSame($change, json_decode($this->stored['state_target_changed'], true));
|
||||
}
|
||||
|
||||
public function testNewAddressesAreReportedToTheOldOnes(): void
|
||||
{
|
||||
$this->s3(['notify_email' => 'old@example.org']);
|
||||
|
||||
// new addresses first, then a new target: the original addresses hear about both
|
||||
$this->service()->save(['notify_email' => 'attacker@example.net']);
|
||||
$first = json_decode($this->stored['state_target_changed'], true);
|
||||
$this->service()->save(['target' => 'local']);
|
||||
$change = json_decode($this->stored['state_target_changed'], true);
|
||||
|
||||
$this->assertSame([['old@example.org'], ['notify_email']], [$first['old_addresses'], $first['changed']]);
|
||||
$this->assertSame([['old@example.org'], ['notify_email', 'target'], 's3', $first['time']],
|
||||
[$change['old_addresses'], $change['changed'], $change['old_target']['target'], $change['time']]);
|
||||
}
|
||||
|
||||
public function testWhatIsNoTargetChange(): void
|
||||
{
|
||||
// other settings, a value of another target
|
||||
$this->s3(['notify_email' => 'old@example.org']);
|
||||
$this->service()->save(['keep_daily' => '3', 'rest_url' => 'https://r.example.org', 'notify_email' => 'old@example.org']);
|
||||
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
|
||||
// a new secret of the target is a change
|
||||
$this->service()->save(['s3_secret_key' => 'NEW']);
|
||||
$this->assertSame([['old@example.org'], ['target']], array_values(array_intersect_key(json_decode($this->stored['state_target_changed'], true),
|
||||
['old_addresses' => 1, 'changed' => 1])));
|
||||
// the first addresses of a backup
|
||||
$this->s3();
|
||||
$this->service()->save(['notify_email' => 'first@example.org']);
|
||||
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
|
||||
// a target that was not configured, or nobody to tell
|
||||
$this->stored = ['state_secrets_pubkey' => $this->stored['state_secrets_pubkey'], 'notify_email' => 'old@example.org'];
|
||||
$this->service()->save(['target' => 'local']);
|
||||
$this->s3();
|
||||
$this->service()->save(['target' => 'local']);
|
||||
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
|
||||
// the runner's key cannot be set through the API
|
||||
$this->service()->save(['state_target_changed' => '', 'state_secrets_pubkey' => 'x']);
|
||||
$this->assertNotSame('x', $this->stored['state_secrets_pubkey']);
|
||||
}
|
||||
|
||||
public function testShortRepositoryPassword(): void
|
||||
{
|
||||
$this->expectUserError(['backup_password_too_short', 8], fn() => $this->service()->save(['password' => 'short']));
|
||||
}
|
||||
|
||||
public function testEmptySecretsAndRunnerValuesKeepTheStoredOnes(): void
|
||||
{
|
||||
$this->s3();
|
||||
|
||||
$settings = $this->service()->save(['password' => '', 's3_secret_key' => '', 'state_initialized' => '0', 'sftp_public_key' => 'evil',
|
||||
'password_set' => 0, 'keep_daily' => '10']);
|
||||
|
||||
$this->assertSame(['repo-password', 'SECRET', '1', 'ssh-ed25519 AAAA', '', '10'], array_map(fn(string $name): string => $settings->get($name),
|
||||
['password', 's3_secret_key', 'state_initialized', 'sftp_host_key', 'sftp_public_key', 'keep_daily']));
|
||||
$this->assertSame([['keep_daily' => '10']], $this->saved);
|
||||
}
|
||||
|
||||
public function testANewConnectionMustBeTestedAgain(): void
|
||||
{
|
||||
$this->s3();
|
||||
// values of another target do not touch the connection
|
||||
$this->assertSame('1', $this->service()->save(['rest_url' => 'https://backup.example.org:8000/mailcow'])->get('state_initialized'));
|
||||
$this->assertSame('0', $this->service()->save(['s3_secret_key' => 'NEW'])->get('state_initialized'));
|
||||
$this->s3();
|
||||
$this->assertSame('0', $this->service()->save(['s3_path_style' => '1'])->get('state_initialized'));
|
||||
$this->s3();
|
||||
$this->assertSame('0', $this->service()->save(['target' => 'local'])->get('state_initialized'));
|
||||
}
|
||||
|
||||
public function testANewSftpHostDropsTheKnownHostKey(): void
|
||||
{
|
||||
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
|
||||
$this->assertSame('ssh-ed25519 AAAA', $this->service()->save(['sftp_path' => 'repo2'])->get('sftp_host_key'));
|
||||
$this->assertSame('', $this->service()->save(['sftp_port' => '23'])->get('sftp_host_key'));
|
||||
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
|
||||
$this->assertSame('', $this->service()->save(['sftp_host' => '192.0.2.10'])->get('sftp_host_key'));
|
||||
}
|
||||
|
||||
public function testANewRepositoryPasswordNeedsANewConfirmation(): void
|
||||
{
|
||||
$this->s3();
|
||||
$settings = $this->service()->save(['password' => 'another password']);
|
||||
$this->assertSame(['0', '0'], [$settings->get('password_confirmed'), $settings->get('state_initialized')]);
|
||||
|
||||
$this->s3();
|
||||
$this->assertSame('1', $this->service()->save(['password' => 'another password', 'password_confirmed' => '1'])->get('password_confirmed'));
|
||||
}
|
||||
|
||||
public function testTheScheduleNeedsAConfiguredTarget(): void
|
||||
{
|
||||
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['enabled' => '1']));
|
||||
$this->s3();
|
||||
$this->assertSame('1', $this->service()->save(['enabled' => '1'])->get('enabled'));
|
||||
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['s3_bucket' => '']));
|
||||
// and at least one time
|
||||
$this->expectUserError(['backup_setting_invalid', 'schedule'], fn() => $this->service()->save(['schedule' => []]));
|
||||
$this->assertSame('[]', $this->service()->save(['enabled' => '0', 'schedule' => []])->get('schedule'));
|
||||
}
|
||||
|
||||
public function testOnlyAdminsReadAndOnlyWritingAdminsSave(): void
|
||||
{
|
||||
$this->s3();
|
||||
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||
|
||||
$this->assertNotNull($this->service($readOnly)->get());
|
||||
$this->assertNull($this->service(FakeContext::domainAdmin([], ['firma.de']))->get());
|
||||
$this->assertNull($this->service(FakeContext::user('anna@firma.de'))->get());
|
||||
foreach ([$readOnly, FakeContext::domainAdmin([], ['firma.de']), FakeContext::user('anna@firma.de'), Context::system()] as $context) {
|
||||
$this->expectUserError('access_denied', fn() => $this->service($context)->save(['keep_daily' => '1']));
|
||||
}
|
||||
$this->assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testConfiguredPerTarget(): void
|
||||
{
|
||||
$settings = Settings::fromStored(['password' => 'x', 'target' => 'local']);
|
||||
$this->assertTrue($settings->configured());
|
||||
$this->assertFalse(Settings::fromStored(['target' => 'local'])->configured());
|
||||
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'rest'])->configured());
|
||||
$this->assertTrue(Settings::fromStored(['password' => 'x', 'target' => 'rest', 'rest_url' => 'https://r.example.org'])->configured());
|
||||
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'sftp', 'sftp_host' => 'h', 'sftp_user' => 'u'])->configured());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user