mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-10 02:27:10 +00:00
[Web][Restic][Dovecot] Backup: safer restic.sh restores, access check first, own aliases and sync sources kept, runner fixes
This commit is contained in:
12 files changed
+169
-24
No files matched your search
@@ -168,6 +168,9 @@ def own_folders(user, prefixes):
|
||||
guids[row['mailbox']] = row['guid']
|
||||
return guids
|
||||
|
||||
def masked(user):
|
||||
return '*' in user or '?' in user
|
||||
|
||||
def free_name(base, existing):
|
||||
name, count = base, 1
|
||||
while name in existing or any(folder.startswith(name + '/') for folder in existing):
|
||||
@@ -451,7 +454,7 @@ class AclRestore:
|
||||
return identifier
|
||||
|
||||
def add(self, owner, folder, identifier, rights, result):
|
||||
if not self.in_scope(owner):
|
||||
if not self.in_scope(owner) or masked(owner):
|
||||
result['skipped'] += 1
|
||||
return
|
||||
try:
|
||||
@@ -549,6 +552,10 @@ def count_errors(result):
|
||||
def restore_mailbox(job_id, user, components, mode, staging, snapshot_time, prefixes, acl, granted, check_quota, result):
|
||||
local, _, domain = user.rpartition('@')
|
||||
home = '%s/var/vmail/%s/%s' % (staging, domain, local)
|
||||
if masked(user):
|
||||
for key in components & {'mails', 'imap_acl', 'filters'}:
|
||||
merge(result, key, component(['username with * or ?, doveadm reads it as a mask']))
|
||||
return
|
||||
location = mail_location(user)
|
||||
if not location:
|
||||
for key in components & {'mails', 'imap_acl', 'filters'}:
|
||||
@@ -654,6 +661,9 @@ def run_batch(job):
|
||||
|
||||
def main():
|
||||
global conn
|
||||
if os.geteuid() == 0:
|
||||
# as vmail like the ofelia job: a run as root would leave a lock file the runs as vmail cannot open
|
||||
os.execv('/usr/local/bin/gosu', ['gosu', 'vmail', sys.executable, os.path.abspath(__file__)] + sys.argv[1:])
|
||||
os.umask(0o077)
|
||||
# released by the kernel when the process ends, a crashed run leaves no stale lock
|
||||
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
|
||||
@@ -863,7 +863,7 @@ def install_info():
|
||||
info = {}
|
||||
|
||||
def git(*args):
|
||||
result = subprocess.run(['git', '-c', 'safe.directory=*', '--no-optional-locks', '-C', install_dir] + list(args),
|
||||
result = subprocess.run(['git', '-c', 'safe.directory=*', '-c', 'core.fsmonitor=false', '--no-optional-locks', '-C', install_dir] + list(args),
|
||||
capture_output=True, text=True, timeout=300)
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
@@ -1128,10 +1128,19 @@ def make_dir(path, uid=0, gid=0, mode=0o711):
|
||||
def component_error(text):
|
||||
return {'added': 0, 'skipped': 0, 'errors': [text]}
|
||||
|
||||
def free_space(job, path, need, what):
|
||||
free = shutil.disk_usage(path).free
|
||||
if need + space_reserve > free:
|
||||
raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(need + space_reserve), mib(free)))
|
||||
# imported: what the dovecot runner imports into /var/vmail afterwards, keeping disk_reserve() free like import_space_ok()
|
||||
def free_space(job, path, need, what, imported=0):
|
||||
usage = shutil.disk_usage(path)
|
||||
required = need + space_reserve
|
||||
if imported:
|
||||
vmail = shutil.disk_usage('/var/vmail')
|
||||
if os.stat(path).st_dev == os.stat('/var/vmail').st_dev:
|
||||
required = need + imported * 11 // 10 + disk_reserve(usage.total)
|
||||
elif imported * 11 // 10 + disk_reserve(vmail.total) > vmail.free:
|
||||
raise JobError('not enough free disk space for the import of the mails: %s needed, %s free' % (
|
||||
mib(imported * 11 // 10 + disk_reserve(vmail.total)), mib(vmail.free)))
|
||||
if required > usage.free:
|
||||
raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(required), mib(usage.free)))
|
||||
|
||||
# Sizes of the files below the given directories of a snapshot (bytes per directory); listed directories that do not
|
||||
# exist are missing in the result
|
||||
@@ -1180,8 +1189,8 @@ def snapshot_names(job, repo, snap_id, folder):
|
||||
raise RepoError('the snapshot could not be listed: ' + restic_error(rc, err))
|
||||
return names
|
||||
|
||||
def restic_restore(job, repo, snap_id, target, includes, need, what):
|
||||
free_space(job, restore_dir, need, what)
|
||||
def restic_restore(job, repo, snap_id, target, includes, need, what, imported=0):
|
||||
free_space(job, restore_dir, need, what, imported)
|
||||
args = []
|
||||
for include in includes:
|
||||
args += ['--include', include]
|
||||
@@ -1274,8 +1283,11 @@ def restore_settings(job, batch, sections, components, results):
|
||||
try:
|
||||
for component, value in post({'action': 'restore', 'job': job.id, 'username': username, 'data': section}).items():
|
||||
result[component] = value
|
||||
job.log('%s: %s' % (username, 'created and settings restored' if (result.get('mailbox') or {}).get('created')
|
||||
else 'settings restored'))
|
||||
parts = ['created'] if (result.get('mailbox') or {}).get('created') else []
|
||||
restored = [component for component in sorted(components) if component in result]
|
||||
if restored:
|
||||
parts.append(', '.join(restored) + ' restored')
|
||||
job.log('%s: %s' % (username, '; '.join(parts) or 'nothing to restore'))
|
||||
except Exception as e:
|
||||
for component in components:
|
||||
result[component] = component_error('the restore request failed: %s' % http_error(e))
|
||||
@@ -1464,11 +1476,12 @@ def job_restore(job, s, repo):
|
||||
job.save()
|
||||
batch_dir = '%s/b%d' % (target, number)
|
||||
includes, need = batch_includes(ready, homes, components)
|
||||
# also without files: the dovecot runner then reports "no mails" instead of missing files
|
||||
make_dir(batch_dir)
|
||||
if includes:
|
||||
make_dir(batch_dir)
|
||||
job.step('files')
|
||||
started = time.monotonic()
|
||||
restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore')
|
||||
restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore', imported=need)
|
||||
job.log('files of %d mailboxes restored in %d s (batch %d of %d)' % (len(ready), time.monotonic() - started, number,
|
||||
len(batches)))
|
||||
detached = False
|
||||
|
||||
@@ -102,6 +102,13 @@ class JobService
|
||||
$entry[$key] = self::restricted($entry[$key], $domains);
|
||||
}
|
||||
}
|
||||
// the account of an admin who requested the restore stays hidden, the UI shows "Administrator"
|
||||
if (($job->params['requested_role'] ?? '') !== 'domainadmin') {
|
||||
$entry['requested_by'] = '';
|
||||
if (isset($entry['log'])) {
|
||||
$entry['log'] = str_replace('(requested by ' . $job->requestedBy . ')', '(requested by an administrator)', $entry['log']);
|
||||
}
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
@@ -550,6 +557,9 @@ class JobService
|
||||
private function domainScope(Snapshot $snapshot, mixed $value): array
|
||||
{
|
||||
$domain = strtolower((string)Input::text($value));
|
||||
if ($domain !== '' && !$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$exists = $domain !== '' && $this->jobs->isDomain($domain);
|
||||
// a deleted domain is created from the snapshot's export: only when the content lists it
|
||||
if (!$exists && !($this->context->hasAdminRights() && $snapshot->mailboxesOf($domain) !== null)) {
|
||||
@@ -580,6 +590,9 @@ class JobService
|
||||
}
|
||||
$domain = strtolower((string)Input::text($domainValue));
|
||||
foreach ($mailboxes as $username) {
|
||||
if (str_contains($username, '@') && !$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($username))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
// a deleted mailbox can be restored when the snapshot knows it
|
||||
$mailboxDomain = $this->jobs->mailboxDomain($username) ?? (isset($snapshot->mailboxesOf(self::domainOf($username))[$username]) ? self::domainOf($username) : null);
|
||||
if ($mailboxDomain === null) {
|
||||
|
||||
@@ -330,6 +330,11 @@ class RestoreService
|
||||
continue;
|
||||
}
|
||||
$current = $this->repo->rows('alias', ['address' => $address])[0] ?? null;
|
||||
// the own alias of another mailbox is its forwarding, it stays as its owner set it
|
||||
if ($current !== null && $this->mailbox($address) !== null) {
|
||||
$run->skipped('aliases');
|
||||
continue;
|
||||
}
|
||||
if ($current !== null) {
|
||||
$this->aliasGoto($run, $current, $gotos);
|
||||
continue;
|
||||
@@ -676,9 +681,9 @@ class RestoreService
|
||||
$run->skipped('syncjobs');
|
||||
continue;
|
||||
}
|
||||
$source = $this->repo->rows('imapsync_source', ['id' => (int)($row['source_id'] ?? 0)])[0] ?? null;
|
||||
$sourceId = ($source !== null && strtolower((string)$source['host1']) === strtolower($host) && (int)$source['port1'] === $port)
|
||||
? (int)$source['id'] : ($this->repo->syncSourcesFor($host, $port, $run->username, $run->domain)[0] ?? null);
|
||||
// the backup's source when the mailbox may still use it, else another one it may use
|
||||
$usable = $this->repo->syncSourcesFor($host, $port, $run->username, $run->domain);
|
||||
$sourceId = in_array((int)($row['source_id'] ?? 0), $usable, true) ? (int)$row['source_id'] : ($usable[0] ?? null);
|
||||
if ($sourceId === null || $user1 === '') {
|
||||
$run->error('syncjobs', "$user1 ($host:$port): sync source does not exist");
|
||||
continue;
|
||||
|
||||
@@ -155,6 +155,8 @@ var mailcowBackup = (function($) {
|
||||
return job.state === 'queued' || job.state === 'running';
|
||||
}
|
||||
function isCancellable(job) {
|
||||
// domain admins cancel only restores domain admins requested (JobService::cancel)
|
||||
if (typeof role !== 'undefined' && role === 'domainadmin' && (job.params || {}).requested_role !== 'domainadmin') return false;
|
||||
return job.state === 'queued' || (job.state === 'running' && !(job.type === 'restore' && DOVECOT_STEPS.indexOf(job.step) !== -1));
|
||||
}
|
||||
function stepText(job) {
|
||||
|
||||
@@ -880,6 +880,7 @@
|
||||
"backup_password_not_confirmed": "Bitte zuerst bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt wird",
|
||||
"backup_password_too_short": "Das Repository-Passwort muss mindestens %s Zeichen lang sein",
|
||||
"backup_restore_active": "Eine Wiederherstellung von %s ist bereits eingereiht oder läuft",
|
||||
"backup_restore_domain_active": "In %s ist bereits eine Wiederherstellung eingereiht oder läuft, Domain-Administratoren können je Domain eine Wiederherstellung gleichzeitig starten",
|
||||
"backup_scope_invalid": "Ungültiger Umfang der Wiederherstellung (Mailbox oder Domain)",
|
||||
"backup_secrets_key_missing": "Der Backup-Container hat seinen Schlüssel noch nicht bereitgestellt: Bitte den Container (restic-mailcow) starten und erneut speichern",
|
||||
"backup_setting_invalid": "Ungültiger Wert für %s",
|
||||
|
||||
@@ -871,6 +871,7 @@
|
||||
"backup_password_not_confirmed": "Confirm first that the repository password is stored outside of mailcow",
|
||||
"backup_password_too_short": "The repository password must have at least %s characters",
|
||||
"backup_restore_active": "A restore of %s is already queued or running",
|
||||
"backup_restore_domain_active": "A restore in %s is already queued or running, domain administrators can run one restore per domain at a time",
|
||||
"backup_scope_invalid": "Invalid restore scope (mailbox or domain)",
|
||||
"backup_secrets_key_missing": "The backup container has not provided its key yet: start it (restic-mailcow) and save again",
|
||||
"backup_setting_invalid": "Invalid value for %s",
|
||||
|
||||
Reference in new issue
Block a user