mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-09 18:26:20 +00:00
713 lines
30 KiB
Python
713 lines
30 KiB
Python
#!/usr/bin/python3
|
|
# Mail part of restores (ofelia, dovecot container, as vmail): the restic container restores a batch of mailboxes
|
|
# into /restore/<job id>/b<n> and sets the job to step 'mails'. Per mailbox this imports the mails folder by folder
|
|
# (into "Restored <snapshot time>" or only the missing ones into the original folders), adds IMAP ACLs and missing
|
|
# Sieve scripts, merges the results into the job and sets step 'mails_done'; the restic runner then removes the staging
|
|
# and goes on with the next batch. Nothing that exists is changed or deleted.
|
|
|
|
import base64
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import smtplib
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from email.message import EmailMessage
|
|
from email.utils import formatdate, make_msgid
|
|
import MySQLdb
|
|
import MySQLdb.cursors
|
|
|
|
lock_file = '/tmp/restore_runner.lock'
|
|
restore_dir = '/restore'
|
|
max_log = 1000000
|
|
# import, moves and the removal of the temporary folder ignore the quota (a restore may need more than is free),
|
|
# the quota is recalculated afterwards
|
|
no_quota = ['-o', 'plugin/quota=']
|
|
# uid ranges per import (one argument stays far below the 128 KiB limit), folders per fetch and status
|
|
uid_ranges = 2000
|
|
folder_batch = 200
|
|
# the restic runner waits for the batch while this heartbeat is fresh
|
|
heartbeat_interval = 30
|
|
# seconds to wait for the next batch of a restore
|
|
batch_wait = 120
|
|
rights_by_letter = {'l': 'lookup', 'r': 'read', 'w': 'write', 's': 'write-seen', 't': 'write-deleted', 'i': 'insert',
|
|
'p': 'post', 'e': 'expunge', 'k': 'create', 'x': 'delete', 'a': 'admin'}
|
|
components_here = {'mails', 'imap_acl', 'filters', 'sieve_activate'}
|
|
|
|
conn = None
|
|
|
|
|
|
def connect():
|
|
return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'],
|
|
passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True)
|
|
|
|
# Reconnect when the server dropped the connection (imports can outlast wait_timeout)
|
|
def query(sql, args=None):
|
|
global conn
|
|
try:
|
|
conn.ping()
|
|
except (AttributeError, MySQLdb.Error):
|
|
conn = connect()
|
|
cur = conn.cursor(MySQLdb.cursors.DictCursor)
|
|
cur.execute(sql, args)
|
|
return cur
|
|
|
|
# state_restore_seen while a batch runs, in its own connection
|
|
class Heartbeat(threading.Thread):
|
|
def __init__(self):
|
|
super().__init__(daemon=True)
|
|
self.done = threading.Event()
|
|
|
|
def run(self):
|
|
db = None
|
|
while not self.done.is_set():
|
|
try:
|
|
if db is None:
|
|
db = connect()
|
|
db.cursor().execute("""INSERT INTO backup_setting (name, value) VALUES ('state_restore_seen', %s)
|
|
ON DUPLICATE KEY UPDATE value = VALUES(value)""", (str(int(time.time())),))
|
|
except MySQLdb.Error:
|
|
db = None
|
|
self.done.wait(heartbeat_interval)
|
|
|
|
def log(job_id, text):
|
|
print('restore job %d: %s' % (job_id, text), flush=True)
|
|
line = '%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), text)
|
|
query("UPDATE backup_job SET log = RIGHT(CONCAT(IFNULL(log, ''), %s), %s) WHERE id = %s", (line, max_log, job_id))
|
|
|
|
def save(job_id, result):
|
|
query("UPDATE backup_job SET result = %s WHERE id = %s", (json.dumps(result), job_id))
|
|
|
|
def doveadm(*args, data=None, check=True, partial=False):
|
|
result = subprocess.run(['doveadm', *args], input=data, stdin=None if data is not None else subprocess.DEVNULL,
|
|
capture_output=True, timeout=86400)
|
|
# mailbox status fails for folders that cannot be opened (implicit parents); the others are listed
|
|
if check and result.returncode != 0 and not (partial and result.stdout.strip()):
|
|
# mail_log writes an info line per message, the errors are the other lines
|
|
lines = [l for l in result.stderr.decode('utf-8', 'replace').splitlines() if ': Info: ' not in l]
|
|
raise RuntimeError('\n'.join(lines)[-500:] or 'doveadm exit code %d' % result.returncode)
|
|
return result.stdout.decode('utf-8', 'replace')
|
|
|
|
def doveadm_json(*args, partial=False):
|
|
out = doveadm('-f', 'json', *args, partial=partial)
|
|
return json.loads(out) if out.strip() else []
|
|
|
|
# free disk space that must stay after the import: max(2 GiB, 5 % of the filesystem), a full disk stops mail
|
|
# (disk_reserve, mib and count_errors as in restic_runner.py of the backup image; tests/python/backup compares them)
|
|
def disk_reserve(total):
|
|
return max(2 << 30, total // 20)
|
|
|
|
def import_space_ok(need, free, total):
|
|
return need * 11 // 10 + disk_reserve(total) <= free
|
|
|
|
def mib(size):
|
|
return '%.1f MiB' % (size / 1048576)
|
|
|
|
def component(errors=None):
|
|
return {'added': 0, 'skipped': 0, 'errors': errors or []}
|
|
|
|
# Folder names on disk: modified UTF-7, listescape writes . / \ as \xx
|
|
def mutf7_decode(text):
|
|
def utf16(m):
|
|
if not m.group(1):
|
|
return '&'
|
|
data = m.group(1).replace(',', '/')
|
|
return base64.b64decode(data + '=' * (-len(data) % 4)).decode('utf-16-be')
|
|
return re.sub(r'&([A-Za-z0-9+,]*)-', utf16, text)
|
|
|
|
def unescape(part):
|
|
return mutf7_decode(re.sub(r'\\([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), part))
|
|
|
|
# Maildir++: the maildir is INBOX, ".A.B" is A/B
|
|
def folder_name(entry):
|
|
return '/'.join(unescape(part) for part in entry[1:].split('.')) if entry else 'INBOX'
|
|
|
|
def is_dir(path):
|
|
try:
|
|
return stat.S_ISDIR(os.lstat(path).st_mode)
|
|
except OSError:
|
|
return False
|
|
|
|
def read_file(path):
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
with os.fdopen(fd, 'rb') as f:
|
|
return f.read()
|
|
|
|
# Prefixes of the shared and public namespaces, their folders belong to others
|
|
def shared_prefixes():
|
|
prefixes, namespace, depth = {'Shared/'}, None, 0
|
|
# as vmail doveconf cannot read every included file (mail_crypt keys) and fails, but prints the namespaces
|
|
for line in subprocess.run(['doveconf', '-n'], capture_output=True, text=True).stdout.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.endswith('{'):
|
|
depth += 1
|
|
if depth == 1 and stripped.startswith('namespace'):
|
|
namespace = {}
|
|
elif stripped == '}':
|
|
if depth == 1 and namespace is not None:
|
|
if namespace.get('type') in ('shared', 'public') and namespace.get('prefix'):
|
|
prefixes.add(namespace['prefix'].split('%')[0])
|
|
namespace = None
|
|
depth = max(depth - 1, 0)
|
|
elif depth == 1 and namespace is not None and '=' in stripped:
|
|
key, value = (part.strip() for part in stripped.split('=', 1))
|
|
namespace[key] = value
|
|
return tuple(prefix for prefix in prefixes if prefix)
|
|
|
|
# name => guid of the user's own folders (implicit parents have none); the names are looked up literally
|
|
def own_folders(user, prefixes):
|
|
names = [n for n in doveadm('mailbox', 'list', '-u', user).splitlines() if n and not n.startswith(prefixes)]
|
|
wanted, guids = set(names), {}
|
|
for i in range(0, len(names), folder_batch):
|
|
for row in doveadm_json('mailbox', 'status', '-u', user, 'guid', '--', *names[i:i + folder_batch], partial=True):
|
|
if row.get('mailbox') in wanted and re.fullmatch(r'[0-9a-f]{32}', row.get('guid') or ''):
|
|
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):
|
|
count += 1
|
|
name = '%s (%d)' % (base, count)
|
|
return name
|
|
|
|
# Path of the maildir now (/var/vmail/<domain>/<local>/Maildir), None for unknown or inactive mailboxes
|
|
def mail_location(user):
|
|
result = subprocess.run(['doveadm', 'user', '-f', 'mail', '--', user], capture_output=True, text=True)
|
|
m = re.match(r'maildir:([^:]+)', result.stdout.strip()) if result.returncode == 0 else None
|
|
return m.group(1).rstrip('/') if m else None
|
|
|
|
# The restored maildir of a mailbox (may hold only ACL files), None if the snapshot has nothing of it
|
|
def staged_maildir(staging, home, location):
|
|
for path in (staging + location, home + '/' + os.environ.get('MAILDIR_SUB', 'Maildir'), home):
|
|
if is_dir(path):
|
|
return path
|
|
return None
|
|
|
|
def uid_chunks(uids):
|
|
ranges, start, prev = [], None, None
|
|
for uid in sorted(uids):
|
|
if start is None:
|
|
start = prev = uid
|
|
elif uid == prev + 1:
|
|
prev = uid
|
|
else:
|
|
ranges.append('%d:%d' % (start, prev) if prev != start else str(start))
|
|
start = prev = uid
|
|
if start is not None:
|
|
ranges.append('%d:%d' % (start, prev) if prev != start else str(start))
|
|
return [','.join(ranges[i:i + uid_ranges]) for i in range(0, len(ranges), uid_ranges)]
|
|
|
|
# Same message: same Message-ID, without one the same Date and size
|
|
def message_key(row):
|
|
message_id = re.sub(r'\s+', '', row.get('hdr.message-id') or '').strip('<>')
|
|
if message_id:
|
|
return 'id:' + message_id
|
|
return 'date:%s:%s' % (row.get('date.sent'), row.get('size.physical'))
|
|
|
|
def present_keys(user, guids):
|
|
keys = set()
|
|
guids = list(guids)
|
|
for i in range(0, len(guids), folder_batch):
|
|
search = []
|
|
for guid in guids[i:i + folder_batch]:
|
|
search += (['OR'] if search else []) + ['mailbox-guid', guid]
|
|
keys.update(message_key(row) for row in doveadm_json('fetch', '-u', user, 'hdr.message-id date.sent size.physical',
|
|
*search))
|
|
return keys
|
|
|
|
def location(path):
|
|
if ':' in path:
|
|
raise RuntimeError('unsupported path')
|
|
return 'maildir:%s:INDEX=MEMORY' % path
|
|
|
|
# The folders of a restored maildir as standalone maildirs (their messages are the INBOX there): the subfolders are
|
|
# moved out of the maildir, so every folder is imported without the name rules of the source (listescape)
|
|
def split_folders(maildir, home):
|
|
folders, failed = [('INBOX', maildir)], []
|
|
out = home + '/_restore_folders'
|
|
for entry in sorted(os.listdir(maildir)):
|
|
path = os.path.join(maildir, entry)
|
|
if not entry.startswith('.') or entry in ('.', '..') or not is_dir(path) or not is_dir(path + '/cur'):
|
|
continue
|
|
try:
|
|
name = folder_name(entry)
|
|
except (ValueError, UnicodeDecodeError):
|
|
failed.append(entry)
|
|
continue
|
|
if not os.path.isdir(out):
|
|
os.mkdir(out, 0o700)
|
|
target = '%s/f%d' % (out, len(folders))
|
|
os.rename(path, target)
|
|
folders.append((name, target))
|
|
return folders, failed
|
|
|
|
def source_messages(user, root):
|
|
return doveadm_json('-o', 'mail_location=' + location(root), 'fetch', '-u', user,
|
|
'uid hdr.message-id date.sent size.physical', 'mailbox', 'INBOX')
|
|
|
|
def remove_tree(user, name):
|
|
doveadm(*no_quota, 'mailbox', 'delete', '-u', user, '-r', '-s', '--', name, check=False)
|
|
|
|
def folder_guid(user, name):
|
|
rows = doveadm_json('mailbox', 'status', '-u', user, 'guid', '--', name)
|
|
for row in rows:
|
|
if row.get('mailbox') == name:
|
|
return row['guid']
|
|
raise RuntimeError('folder not found after the import')
|
|
|
|
def quota_room(user):
|
|
# free bytes of the storage quota, None without a limit
|
|
for row in doveadm_json('quota', 'get', '-u', user):
|
|
if row.get('type') == 'STORAGE' and str(row.get('limit', '-')).isdigit():
|
|
return (int(row['limit']) - int(row.get('value') or 0)) * 1024
|
|
return None
|
|
|
|
# Subscriptions of the backup (subscriptions file: "V\t2", then names with \t between the levels)
|
|
def backup_subscriptions(maildir):
|
|
try:
|
|
lines = read_file(maildir + '/subscriptions').decode('utf-8', 'replace').splitlines()
|
|
except OSError:
|
|
return []
|
|
if lines and lines[0].startswith('V\t'):
|
|
return ['/'.join(unescape(part) for part in line.split('\t')) for line in lines[1:] if line.strip()]
|
|
# version 1: storage names with the Maildir++ separator
|
|
return [folder_name('.' + line.strip()) for line in lines if line.strip()]
|
|
|
|
def restore_subscriptions(user, wanted, prefixes):
|
|
if not wanted:
|
|
return 0
|
|
existing = set(own_folders(user, prefixes)) | {'INBOX'}
|
|
subscribed = set(doveadm('mailbox', 'list', '-u', user, '-s').splitlines())
|
|
missing = [name for name in dict.fromkeys(wanted) if name in existing and name not in subscribed]
|
|
if missing:
|
|
doveadm('mailbox', 'subscribe', '-u', user, '--', *missing)
|
|
return len(missing)
|
|
|
|
# Mails folder by folder: original = only messages missing anywhere in the mailbox, into their original folders;
|
|
# folder = all messages below "Restored <snapshot time>"
|
|
def restore_mails(job_id, user, home, maildir, mode, snapshot_time, prefixes, check_quota):
|
|
result = component()
|
|
result.update(folders=0, folders_failed=0)
|
|
if not maildir or not is_dir(maildir + '/cur'):
|
|
# a key the UI translates
|
|
result['note'] = 'no_mails'
|
|
return result
|
|
tmp = 'Restoring %d' % job_id
|
|
# a leftover of an interrupted run goes first, it is never counted as present
|
|
remove_tree(user, tmp)
|
|
subscriptions = backup_subscriptions(maildir)
|
|
folders, broken = split_folders(maildir, home)
|
|
for entry in broken:
|
|
result['errors'].append('folder %s: name not readable' % entry)
|
|
result['folders_failed'] += 1
|
|
live = own_folders(user, prefixes)
|
|
present = present_keys(user, live.values()) if mode == 'original' else set()
|
|
plan, need = [], 0
|
|
for name, root in folders:
|
|
try:
|
|
rows = source_messages(user, root)
|
|
except RuntimeError as e:
|
|
result['errors'].append('%s: %s' % (name, e))
|
|
result['folders_failed'] += 1
|
|
continue
|
|
wanted = [row for row in rows if mode != 'original' or message_key(row) not in present]
|
|
result['skipped'] += len(rows) - len(wanted)
|
|
if wanted:
|
|
size = sum(int(row.get('size.physical') or 0) for row in wanted)
|
|
plan.append((name, root, [int(row['uid']) for row in wanted], size))
|
|
need += size
|
|
if not plan:
|
|
if mode == 'original':
|
|
result['subscribed'] = restore_subscriptions(user, subscriptions, prefixes)
|
|
return result
|
|
vfs = os.statvfs('/var/vmail')
|
|
free, total = vfs.f_bavail * vfs.f_frsize, vfs.f_blocks * vfs.f_frsize
|
|
if not import_space_ok(need, free, total):
|
|
raise RuntimeError('not enough free disk space for the mails: %s needed plus %s reserve, %s free' % (
|
|
mib(need * 11 // 10), mib(disk_reserve(total)), mib(free)))
|
|
if check_quota:
|
|
room = quota_room(user)
|
|
if room is not None and need > room:
|
|
raise RuntimeError('the mails do not fit into the quota of the mailbox: %s needed, %s free' % (
|
|
mib(need), mib(max(room, 0))))
|
|
names = set(live) | {'INBOX'}
|
|
dest = None
|
|
if mode != 'original':
|
|
dest = free_name('Restored ' + snapshot_time, names)
|
|
doveadm('mailbox', 'create', '-u', user, '-s', '--', dest)
|
|
names.add(dest)
|
|
result['folder'] = dest
|
|
created = []
|
|
try:
|
|
for number, (name, root, uids, size) in enumerate(plan):
|
|
target = dest + '/' + name if dest else name
|
|
stage = '%s/f%d' % (tmp, number)
|
|
try:
|
|
for chunk in uid_chunks(uids):
|
|
doveadm(*no_quota, 'import', '-u', user, '-s', location(root), stage, 'mailbox', 'INBOX', 'uid', chunk)
|
|
guid = folder_guid(user, stage + '/INBOX')
|
|
if target not in names:
|
|
doveadm('mailbox', 'create', '-u', user, '-s', '--', target)
|
|
names.add(target)
|
|
created.append(target)
|
|
doveadm(*no_quota, 'move', '-u', user, '--', target, 'mailbox-guid', guid, 'all')
|
|
result['added'] += len(uids)
|
|
result['folders'] += 1
|
|
except RuntimeError as e:
|
|
result['errors'].append('%s: %s' % (name, e))
|
|
result['folders_failed'] += 1
|
|
finally:
|
|
remove_tree(user, tmp)
|
|
result['folders_created'] = len(created)
|
|
if mode == 'original':
|
|
result['subscribed'] = restore_subscriptions(user, subscriptions, prefixes)
|
|
return result
|
|
|
|
def acl_entries(path):
|
|
entries = []
|
|
try:
|
|
lines = read_file(path).decode('utf-8', 'replace').splitlines()
|
|
except OSError:
|
|
return entries
|
|
for line in lines:
|
|
parts = line.split()
|
|
# negative rights would take rights away
|
|
if len(parts) < 2 or parts[0].startswith('-'):
|
|
continue
|
|
rights = [rights_by_letter[c] for c in parts[1] if c in rights_by_letter] + [p for p in parts[2:] if p.startswith(':')]
|
|
if rights:
|
|
entries.append((parts[0], rights))
|
|
return entries
|
|
|
|
# (folder, entries) of the dovecot-acl files of a restored maildir
|
|
def maildir_acls(maildir):
|
|
acls = []
|
|
if not maildir or not is_dir(maildir):
|
|
return acls
|
|
for entry in [''] + sorted(e for e in os.listdir(maildir) if e.startswith('.') and e not in ('.', '..')):
|
|
path = os.path.join(maildir, entry, 'dovecot-acl') if entry else os.path.join(maildir, 'dovecot-acl')
|
|
if entry and not is_dir(os.path.join(maildir, entry)):
|
|
continue
|
|
try:
|
|
if stat.S_ISREG(os.lstat(path).st_mode):
|
|
acls.append((folder_name(entry), acl_entries(path)))
|
|
except (OSError, ValueError, UnicodeDecodeError):
|
|
continue
|
|
return acls
|
|
|
|
# owner => (folder, entries) of every mailbox in the ACL staging (domain admins: only their domains)
|
|
def staged_acls(acl_dir, allowed):
|
|
acls = {}
|
|
base = (acl_dir or '') + '/var/vmail'
|
|
sub = os.environ.get('MAILDIR_SUB', '')
|
|
if not acl_dir or not is_dir(base):
|
|
return acls
|
|
for domain in os.listdir(base):
|
|
if '.' not in domain or not is_dir(os.path.join(base, domain)) or (allowed is not None and domain.lower() not in allowed):
|
|
continue
|
|
for local in os.listdir(os.path.join(base, domain)):
|
|
home = os.path.join(base, domain, local)
|
|
maildir = os.path.join(home, sub) if sub and is_dir(os.path.join(home, sub)) else home
|
|
found = maildir_acls(maildir)
|
|
if found:
|
|
acls['%s@%s' % (local, domain)] = found
|
|
return acls
|
|
|
|
class AclRestore:
|
|
def __init__(self, prefixes, allowed):
|
|
self.prefixes = prefixes
|
|
self.allowed = allowed
|
|
self.users, self.folders = {}, {}
|
|
# anyone/authenticated only when the configuration allows them
|
|
self.anyone = os.environ.get('ACL_ANYONE', 'disallow') == 'allow'
|
|
|
|
def in_scope(self, address):
|
|
return self.allowed is None or address.rpartition('@')[2].lower() in self.allowed
|
|
|
|
def user_exists(self, user):
|
|
if user not in self.users:
|
|
self.users[user] = query("SELECT 1 FROM mailbox WHERE username = %s", (user,)).fetchone() is not None
|
|
return self.users[user]
|
|
|
|
def identifier_ok(self, identifier):
|
|
if identifier.startswith('user='):
|
|
return self.user_exists(identifier[5:].lower())
|
|
# group=mcg_<id> belongs to the mailbox groups, the group grants cron keeps them
|
|
if identifier.startswith('group=mcg_'):
|
|
return False
|
|
if identifier in ('anyone', 'authenticated'):
|
|
return self.anyone
|
|
return True
|
|
|
|
def shown(self, identifier):
|
|
# domain admins do not see addresses of other domains
|
|
if identifier.startswith('user=') and not self.in_scope(identifier[5:]):
|
|
return 'user=(other domain)'
|
|
return identifier
|
|
|
|
def add(self, owner, folder, identifier, rights, result):
|
|
if not self.in_scope(owner) or masked(owner):
|
|
result['skipped'] += 1
|
|
return
|
|
try:
|
|
if owner not in self.folders:
|
|
self.folders[owner] = set(own_folders(owner, self.prefixes)) | {'INBOX'}
|
|
if folder not in self.folders[owner] or not self.identifier_ok(identifier):
|
|
result['skipped'] += 1
|
|
return
|
|
have = set()
|
|
for row in doveadm_json('acl', 'get', '-u', owner, '--', folder):
|
|
if row.get('id') == identifier:
|
|
have.update((row.get('rights') or '').split())
|
|
missing = [right for right in rights if right not in have]
|
|
if not missing:
|
|
result['skipped'] += 1
|
|
return
|
|
doveadm('acl', 'add', '-u', owner, '--', folder, identifier, *missing)
|
|
result['added'] += 1
|
|
except RuntimeError as e:
|
|
result['errors'].append('%s %s %s: %s' % (owner, folder, self.shown(identifier), e))
|
|
|
|
# Rights the mailbox granted on its folders and rights other mailboxes granted to it; added, never removed
|
|
def restore_acl(acl, user, own, granted):
|
|
result = component()
|
|
for folder, entries in own:
|
|
for identifier, rights in entries:
|
|
acl.add(user, folder, identifier, rights, result)
|
|
if acl.user_exists(user):
|
|
for owner, folders in granted.items():
|
|
if owner == user or not acl.in_scope(owner) or not acl.user_exists(owner):
|
|
continue
|
|
for folder, entries in folders:
|
|
for identifier, rights in entries:
|
|
if identifier.lower() == 'user=' + user:
|
|
acl.add(owner, folder, identifier, rights, result)
|
|
return result
|
|
|
|
# Own Sieve scripts (incl. the SOGo vacation script): missing names are added; with activate the active script of the
|
|
# backup is activated if none is active now
|
|
def restore_sieve(user, home, active_link, activate):
|
|
result = component()
|
|
folder = home + '/sieve'
|
|
scripts = []
|
|
if is_dir(folder):
|
|
scripts = sorted(f[:-6] for f in os.listdir(folder) if f.endswith('.sieve') and stat.S_ISREG(os.lstat(os.path.join(folder, f)).st_mode))
|
|
current, active = set(), None
|
|
for line in doveadm('sieve', 'list', '-u', user).splitlines():
|
|
name, _, flag = line.rpartition(' ')
|
|
if name:
|
|
current.add(name)
|
|
if flag == 'ACTIVE':
|
|
active = name
|
|
for name in scripts:
|
|
if name in current:
|
|
result['skipped'] += 1
|
|
continue
|
|
try:
|
|
doveadm('sieve', 'put', '-u', user, '--', name, data=read_file(os.path.join(folder, name + '.sieve')))
|
|
current.add(name)
|
|
result['added'] += 1
|
|
except (OSError, RuntimeError) as e:
|
|
result['errors'].append('%s: %s' % (name, e))
|
|
if not activate:
|
|
return result
|
|
target = os.readlink(active_link) if os.path.islink(active_link) else ''
|
|
backup_active = os.path.basename(target)[:-6] if target.endswith('.sieve') else None
|
|
if backup_active and not active and backup_active in current:
|
|
try:
|
|
doveadm('sieve', 'activate', '-u', user, '--', backup_active)
|
|
result['activated'] = backup_active
|
|
except RuntimeError as e:
|
|
result['errors'].append('%s: %s' % (backup_active, e))
|
|
return result
|
|
|
|
# filters: backup.php reported its part already, the counts are added up
|
|
def merge(results, key, part):
|
|
old = results.get(key)
|
|
if not isinstance(old, dict):
|
|
results[key] = part
|
|
return
|
|
for count in ('added', 'skipped'):
|
|
old[count] = (old.get(count) or 0) + part.get(count, 0)
|
|
old['errors'] = (old.get('errors') or []) + part.get('errors', [])
|
|
for name, value in part.items():
|
|
if name not in ('added', 'skipped', 'errors'):
|
|
old[name] = value
|
|
|
|
def count_errors(result):
|
|
count = sum(len(result.get(key, {}).get('errors') or []) for key in ('domain', 'aliases') if isinstance(result.get(key), dict))
|
|
for entry in (result.get('mailboxes') or {}).values():
|
|
count += int(bool(entry.get('error')))
|
|
count += sum(len(value.get('errors') or []) for value in entry.values() if isinstance(value, dict))
|
|
return count
|
|
|
|
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'}:
|
|
merge(result, key, component(['the mailbox does not exist now or is inactive']))
|
|
return
|
|
maildir = staged_maildir(staging, home, location)
|
|
# the ACL files are read before the folders of the maildir are moved for the import
|
|
own = maildir_acls(maildir) if 'imap_acl' in components else []
|
|
if 'mails' in components:
|
|
try:
|
|
result['mails'] = restore_mails(job_id, user, home, maildir, mode, snapshot_time, prefixes, check_quota)
|
|
except Exception as e:
|
|
result['mails'] = component([str(e)[:1000]])
|
|
try:
|
|
doveadm('quota', 'recalc', '-u', user)
|
|
except RuntimeError as e:
|
|
result['mails']['errors'].append('quota recalc: %s' % e)
|
|
# new folders
|
|
acl.folders.pop(user, None)
|
|
if 'imap_acl' in components:
|
|
try:
|
|
result['imap_acl'] = restore_acl(acl, user, own, granted)
|
|
except Exception as e:
|
|
result['imap_acl'] = component([str(e)[:1000]])
|
|
if 'filters' in components:
|
|
try:
|
|
part = restore_sieve(user, home, '%s/var/vmail/sieve/%s.sieve' % (staging, user), 'sieve_activate' in components)
|
|
except Exception as e:
|
|
part = component([str(e)[:1000]])
|
|
merge(result, 'filters', part)
|
|
|
|
def fail(job_id, error):
|
|
query("""UPDATE backup_job SET state = IF(state = 'cancelled', state, 'failed'), error = %s, finished = NOW()
|
|
WHERE id = %s""", (error, job_id))
|
|
log(job_id, 'restore job failed: ' + error)
|
|
notify(job_id, error)
|
|
|
|
def notify(job_id, error):
|
|
hostname = os.environ.get('MAILCOW_HOSTNAME', '')
|
|
row = query("SELECT value FROM backup_setting WHERE name = 'notify_email'").fetchone()
|
|
rcpts = [r.strip() for r in (row['value'] if row else '').split(',') if re.fullmatch(r'[^@\s<>,]+@[^@\s<>,]+', r.strip())]
|
|
if not rcpts:
|
|
return
|
|
msg = EmailMessage()
|
|
msg['From'] = 'backup@' + hostname
|
|
msg['To'] = ', '.join(rcpts)
|
|
msg['Subject'] = 'mailcow backup on %s: restore job %d failed' % (hostname, job_id)
|
|
msg['Date'] = formatdate(localtime=True)
|
|
msg['Message-ID'] = make_msgid(domain=hostname or None)
|
|
msg.set_content('The restore job %d failed:\n\n%s\n\nThe job log is in the backup section of the mailcow UI.\n'
|
|
% (job_id, error))
|
|
try:
|
|
with smtplib.SMTP('postfix', 588, hostname or None, timeout=60) as smtp:
|
|
smtp.send_message(msg)
|
|
except (OSError, smtplib.SMTPException) as e:
|
|
print('restore job %d: the notification could not be sent: %s' % (job_id, e), file=sys.stderr)
|
|
|
|
def staging_dir(job_id, path):
|
|
# the batch folder of this job, never a symlink
|
|
if not isinstance(path, str) or not re.fullmatch(r'/restore/%d/[A-Za-z0-9_-]+' % job_id, path) or \
|
|
os.path.realpath(path) != path or not is_dir(path):
|
|
return None
|
|
return path
|
|
|
|
def run_batch(job):
|
|
job_id = job['id']
|
|
params = json.loads(job['params'] or '{}')
|
|
result = json.loads(job['result'] or '{}')
|
|
batch = result.get('batch') or {}
|
|
staging = staging_dir(job_id, batch.get('dir'))
|
|
acl_dir = staging_dir(job_id, batch.get('acl_dir')) if batch.get('acl_dir') else None
|
|
mailboxes = [m for m in batch.get('mailboxes') or [] if isinstance(m, str)]
|
|
components = {c for c in params.get('components') or [] if c in components_here}
|
|
mode = 'original' if params.get('mail_target') == 'original' else 'folder'
|
|
allowed = {d.lower() for d in params['allowed_domains']} if isinstance(params.get('allowed_domains'), list) else None
|
|
check_quota = params.get('requested_role', 'admin') != 'admin'
|
|
snapshot_time = (result.get('snapshot') or {}).get('time') or time.strftime('%Y-%m-%d %H:%M')
|
|
results = result.setdefault('mailboxes', {})
|
|
prefixes = shared_prefixes()
|
|
acl = AclRestore(prefixes, allowed)
|
|
granted = staged_acls(acl_dir, allowed) if 'imap_acl' in components else {}
|
|
log(job_id, 'restoring %s for %d mailboxes%s' % (', '.join(sorted(components)), len(mailboxes),
|
|
' (mails: %s)' % mode if 'mails' in components else ''))
|
|
for user in mailboxes:
|
|
entry = results.setdefault(user, {})
|
|
if entry.get('error'):
|
|
continue
|
|
started = time.monotonic()
|
|
if not staging:
|
|
for key in components & {'mails', 'imap_acl', 'filters'}:
|
|
merge(entry, key, component(['the restored files are missing']))
|
|
else:
|
|
restore_mailbox(job_id, user, components, mode, staging, snapshot_time, prefixes, acl, granted, check_quota, entry)
|
|
summary = ', '.join('%s +%d' % (key, entry[key].get('added', 0)) for key in sorted(components) if isinstance(entry.get(key), dict))
|
|
errors = sum(len(entry[key].get('errors') or []) for key in components if isinstance(entry.get(key), dict))
|
|
log(job_id, '%s: %s%s (%d s)' % (user, summary, ', %d errors' % errors if errors else '', time.monotonic() - started))
|
|
result['errors'] = count_errors(result)
|
|
save(job_id, result)
|
|
result['errors'] = count_errors(result)
|
|
save(job_id, result)
|
|
# the restic runner removes the staging and goes on
|
|
query("UPDATE backup_job SET step = 'mails_done' WHERE id = %s AND state = 'running' AND step = 'mails_running'", (job_id,))
|
|
|
|
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)
|
|
try:
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
sys.exit("restore: can't lock " + lock_file)
|
|
conn = connect()
|
|
heartbeat = Heartbeat()
|
|
heartbeat.start()
|
|
try:
|
|
# under the lock: a batch in mails_running was left by a crashed or stopped run, its temporary folders go
|
|
for row in query("""SELECT id, result FROM backup_job WHERE type = 'restore' AND state = 'running'
|
|
AND step = 'mails_running'""").fetchall():
|
|
try:
|
|
for user in (json.loads(row['result'] or '{}').get('batch') or {}).get('mailboxes') or []:
|
|
remove_tree(user, 'Restoring %d' % row['id'])
|
|
doveadm('quota', 'recalc', '-u', user, check=False)
|
|
except (ValueError, AttributeError, RuntimeError):
|
|
pass
|
|
fail(row['id'], 'interrupted: the restore of the mails was stopped')
|
|
last, waited = None, 0
|
|
while True:
|
|
job = query("""SELECT id, params, result FROM backup_job WHERE type = 'restore' AND state = 'running'
|
|
AND step = 'mails' ORDER BY id LIMIT 1""").fetchone()
|
|
if not job:
|
|
# the next batch of a restore follows within seconds: wait for it instead of the next ofelia run
|
|
row = query("SELECT state FROM backup_job WHERE id = %s", (last,)).fetchone() if last else None
|
|
if row and row['state'] == 'running' and waited < batch_wait:
|
|
time.sleep(2)
|
|
waited += 2
|
|
continue
|
|
break
|
|
last, waited = job['id'], 0
|
|
if query("UPDATE backup_job SET step = 'mails_running' WHERE id = %s AND state = 'running' AND step = 'mails'",
|
|
(job['id'],)).rowcount != 1:
|
|
continue
|
|
try:
|
|
run_batch(job)
|
|
except Exception as e:
|
|
fail(job['id'], str(e)[:1000])
|
|
finally:
|
|
heartbeat.done.set()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|