[Web][Restic][Dovecot] Backup: safer restic.sh restores, access check first, own aliases and sync sources kept, runner fixes

This commit is contained in:
FreddleSpl0it committed 2026-11-09 15:22:34 +01:00
1 parent 6cf0390d58
commit b881e18b8e
12 files changed
+169 -24

No files matched your search

+11 -1
View File
@@ -192,7 +192,7 @@ class JobServiceTest extends TestCase
// domain admins create mailboxes in their domains, never domains
$da = self::domainAdmin();
$this->assertSame(['dora@firma.de'], $this->service($da)->restore(self::restore(['mailboxes' => ['dora@firma.de']]))->params['create_mailboxes']);
$this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de'])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de'])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@alt.de']])));
}
@@ -253,6 +253,10 @@ class JobServiceTest extends TestCase
// mailboxes and domains of others
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['carl@tochter.de']])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'tochter.de'])));
// unknown ones look the same
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['nobody@tochter.de']])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@nirgends.de']])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'nirgends.de'])));
}
public function testUsersAndReadOnlyKeysCannotRestore(): void
@@ -494,8 +498,14 @@ class JobServiceTest extends TestCase
$view['result']['mailboxes']['anna@firma.de']['aliases']['errors']);
$this->assertSame('restic: Fatal: unable to open repository at …', $view['error']);
$this->assertSame("09:00 restore from … by …\n09:01 anna@firma.de done", $view['log']);
// and not the account of the admin who requested it
$job->requestedBy = 'superuser';
$job->log = "09:00 restore job started (requested by superuser)";
$view = $this->service(self::domainAdmin())->view($job, true);
$this->assertSame(['', '09:00 restore job started (requested by an administrator)'], [$view['requested_by'], $view['log']]);
// admins see everything
$this->assertSame($job->error, $this->service()->view($job, true)['error']);
$this->assertSame('superuser', $this->service()->view($job, true)['requested_by']);
}
public function testADomainAdminRestoresADomainOnceAtATime(): void
@@ -588,6 +588,31 @@ class RestoreServiceTest extends TestCase
$this->assertSame([], $this->groupUpdates);
}
public function testTheOwnAliasOfAnotherMailboxIsLeftAlone(): void
{
// bob forwarded to anna when the backup was made; info2@ was an alias then and is a mailbox now
$this->job(['aliases']);
$this->repo->tables['mailbox'][] = self::mailbox('info2@firma.de');
$this->repo->tables['alias'][] = ['id' => 5, 'address' => 'info2@firma.de', 'goto' => 'info2@firma.de', 'domain' => 'firma.de', 'active' => 1];
$result = $this->merge(self::backup(['aliases' => [
['address' => 'bob@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de'],
['address' => 'info2@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
]]))['aliases'];
$this->assertSame(['bob@firma.de', 'info2@firma.de'], [$this->alias('bob@firma.de')['goto'], $this->alias('info2@firma.de')['goto']]);
$this->assertSame(['added' => 0, 'skipped' => 2], array_slice($result, 0, 2));
}
public function testASyncJobUsesItsSourceOnlyWhileTheMailboxMayUseIt(): void
{
// source 2 no longer covers firma.de since the backup
$this->job(['syncjobs']);
$this->repo->tables['imapsync_source_domain'] = [];
$result = $this->merge(self::backup(['imapsync' => [['user1' => 'anna@legacy.de', 'source_id' => 2, 'source_host1' => 'imap.legacy.de',
'source_port1' => 143, 'mins_interval' => 60]]]))['syncjobs'];
$this->assertSame([], $this->repo->rows('imapsync', ['user1' => 'anna@legacy.de']));
$this->assertSame(['anna@legacy.de (imap.legacy.de:143): sync source does not exist'], $result['errors']);
}
public function testAliasLimitAndAddressesInUse(): void
{
$this->job(['aliases']);
+19
View File
@@ -108,6 +108,25 @@ class Space(Patch):
rr.check_local_space(types.SimpleNamespace(log=logs.append), dict(rr.defaults, target='local'))
return logs
def staging(self, free, need, imported, same_disk=True):
usage = types.SimpleNamespace(total=100 * GiB, free=free)
disk_usage, stat = rr.shutil.disk_usage, os.stat
rr.shutil.disk_usage = lambda path: usage
os.stat = lambda path, *args, **kwargs: (types.SimpleNamespace(st_dev=1 if same_disk or path != '/var/vmail' else 2)
if path in ('/restore', '/var/vmail') else stat(path, *args, **kwargs))
self.addCleanup(setattr, rr.shutil, 'disk_usage', disk_usage)
self.addCleanup(setattr, os, 'stat', stat)
rr.free_space(None, '/restore', need, 'the files', imported)
def test_staging_keeps_room_for_the_import(self):
# 10 GiB staged and imported on one disk: 10 + 11 + 5 (5 % of 100 GiB) GiB
self.staging(26 * GiB, 10 * GiB, 10 * GiB)
with self.assertRaisesRegex(rr.JobError, 'the files: 26624.0 MiB needed, 25600.0 MiB free'):
self.staging(25 * GiB, 10 * GiB, 10 * GiB)
with self.assertRaisesRegex(rr.JobError, 'import of the mails: 16384.0 MiB needed'):
self.staging(15 * GiB, 10 * GiB, 10 * GiB, same_disk=False)
self.staging(GiB, GiB // 2, 0)
def test_check(self):
self.assertIn('50.0 GiB free, about 1.0 GiB needed plus 5.0 GiB reserve', self.check(50 * GiB, 100 * GiB, added=1)[0])
with self.assertRaisesRegex(rr.JobError, 'not enough free space for the local repository: 10.0 GiB free'):
@@ -56,6 +56,15 @@ class Acl(unittest.TestCase):
self.assertEqual(acl.shown('user=a@x.de'), 'user=a@x.de')
self.assertTrue(rr.AclRestore(('Shared/',), None).in_scope('a@y.de'))
def test_masks(self):
# doveadm -u reads * and ? as wildcards: such owners are skipped before doveadm runs
self.assertTrue(rr.masked('a*@x.de'))
self.assertTrue(rr.masked('a?b@x.de'))
self.assertFalse(rr.masked('a.b+c@x.de'))
result = rr.component()
rr.AclRestore(('Shared/',), None).add('a*@x.de', 'INBOX', 'user=b@x.de', ['lookup'], result)
self.assertEqual(result['skipped'], 1)
class Messages(unittest.TestCase):
def test_message_key(self):