diff --git a/_modules/scripts/backup.sh b/_modules/scripts/backup.sh new file mode 100644 index 000000000..e80d30d29 --- /dev/null +++ b/_modules/scripts/backup.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# _modules/scripts/backup.sh +# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY! +# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!! + +# Backup with restic before the update when it is set up (target, confirmed password, initialized repository): +# interactive: asked for; --backup: without asking; --force: none (with --backup: without asking, a failure or a backup +# that cannot run stops the update; only "not set up" continues) +backup_failed() { + echo -e "\e[31m${1}\e[0m" + if [ "$FORCE" ]; then + echo "The update was started with --force --backup, it stops here. Nothing was changed." + exit 1 + fi + read -r -p "Continue the update without a backup? [y/N] " backup_response + if [[ ! "${backup_response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + echo "OK, exiting." + exit 1 + fi +} + +# Whether to back up (asked with the other questions); the backup itself runs before the first change of the checkout +backup_decide() { + local output rc + RUN_BACKUP= + if [ "$FORCE" ] && [ ! "$BACKUP" ]; then + return 0 + fi + if [[ -z "$($COMPOSE_COMMAND ps -q restic-mailcow 2> /dev/null)" ]]; then + output="the backup container does not run" + rc=1 + else + # exit code 0: set up, 2: not set up, others: error (e.g. credentials this container cannot read) + output=$($COMPOSE_COMMAND exec -T restic-mailcow python3 /usr/local/bin/restic_runner.py --configured 2>&1 < /dev/null) + rc=$? + fi + if [[ ${rc} -eq 2 ]]; then + [ "$BACKUP" ] && echo -e "\e[33mNo backup before the update: ${output}.\e[0m" + return 0 + elif [[ ${rc} -ne 0 ]]; then + if [ "$BACKUP" ]; then + backup_failed "No backup before the update possible: ${output}" + elif [[ -n "$($COMPOSE_COMMAND ps -q restic-mailcow 2> /dev/null)" ]]; then + echo -e "\e[33mNo backup before the update: ${output}\e[0m" + fi + return 0 + fi + if [ ! "$BACKUP" ]; then + read -r -p "Create a backup before the update? [Y/n] " backup_response + if [[ "${backup_response}" =~ ^([nN][oO]|[nN])$ ]]; then + echo "OK, no backup." + return 0 + fi + fi + RUN_BACKUP=y +} + +# The backup chosen by backup_decide, once +backup_run() { + if [ ! "$RUN_BACKUP" ]; then + return 0 + fi + RUN_BACKUP= + if [[ "${RESTIC_BACKUP_MODE}" =~ ^(data|config)$ ]]; then + echo -e "\e[33mRESTIC_BACKUP_MODE=${RESTIC_BACKUP_MODE}: the backup holds no complete mailcow directory, a restore does not bring back this mailcow version.\e[0m" + fi + echo -e "\e[32mCreating a backup before the update...\e[0m" + if ! $COMPOSE_COMMAND exec -T restic-mailcow python3 /usr/local/bin/restic_runner.py --backup < /dev/null; then + backup_failed "The backup before the update failed, see the output above and the backup jobs in the mailcow UI." + fi +} diff --git a/_modules/scripts/new_options.sh b/_modules/scripts/new_options.sh index 7d67799ba..db4080cb5 100644 --- a/_modules/scripts/new_options.sh +++ b/_modules/scripts/new_options.sh @@ -61,6 +61,8 @@ adapt_new_options() { "ACME_DNS_PROVIDER" "ACME_ACCOUNT_EMAIL" "NGINX_MAX_BODY_SIZE" + "RESTIC_LOCAL_PATH" + "RESTIC_BACKUP_MODE" ) sed -i --follow-symlinks '$a\' mailcow.conf @@ -315,6 +317,19 @@ adapt_new_options() { echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf ;; + RESTIC_LOCAL_PATH) + echo '# Folder on the host for backups to the target "local" (backup settings in the mailcow UI), as an absolute path,' >> mailcow.conf + echo '# e.g. a NAS mounted from another disk. The repository is created in a subfolder of it.' >> mailcow.conf + echo '# Empty: the Docker volume restic-repo-vol-1 of mailcow, on the same disk as the mailcow data.' >> mailcow.conf + echo 'RESTIC_LOCAL_PATH=' >> mailcow.conf + ;; + RESTIC_BACKUP_MODE) + echo '# What the backups of the mailcow UI hold:' >> mailcow.conf + echo '# - all (default),' >> mailcow.conf + echo '# - data (vmail, vmail-index, database, redis, mail crypt keys and mailcow.conf)' >> mailcow.conf + echo '# - config (database, redis, mail crypt keys, mailcow.conf and data/conf).' >> mailcow.conf + echo 'RESTIC_BACKUP_MODE=all' >> mailcow.conf + ;; *) echo "${option}=" >> mailcow.conf ;; diff --git a/data/Dockerfiles/dockerapi/main.py b/data/Dockerfiles/dockerapi/main.py index bf197bd61..c2d48558e 100644 --- a/data/Dockerfiles/dockerapi/main.py +++ b/data/Dockerfiles/dockerapi/main.py @@ -159,6 +159,9 @@ async def post_containers(container_id : str, post_action : str, request: Reques api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json")) dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id)) + if getattr(api_call_method, 'long_running', False): + # runs for minutes (e.g. sogo-tool of a backup restore): in a worker thread, the other api calls go on + return await asyncio.to_thread(api_call_method, request_json, container_id=container_id) return api_call_method(request_json, container_id=container_id) except Exception as e: dockerapi.logger.error("error - container_post: %s" % str(e)) diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py index 3de02ac3f..414096c64 100644 --- a/data/Dockerfiles/dockerapi/modules/DockerApi.py +++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py @@ -6,9 +6,15 @@ import time import json import asyncio import platform +import docker from datetime import datetime from fastapi import FastAPI, Response, Request +def long_running(method): + """Marks an api call that runs for minutes: main.py runs it in a worker thread, the other api calls go on""" + method.long_running = True + return method + class DockerApi: def __init__(self, redis_client, sync_docker_client, async_docker_client, logger): self.redis_client = redis_client @@ -445,6 +451,35 @@ class DockerApi: return Response(content=json.dumps({ 'type': 'danger', 'msg': 'users required' }, indent=4), media_type="application/json") init_return = container.exec_run(["/bin/bash", "-c", 'for user in "$@"; do sogo-tool init-user "$user" || exit 1; done', "init_user"] + users, user='sogo') return self.exec_run_handler('generic', init_return) + # api call: container_post - post_action: exec - cmd: sogo - task: restore + # Resets calendars, address books and preferences of a user to the sogo-tool backup file / in a + # restore batch of the backup (/restore//b/sogo_backup, only the sogo user can read it); values are + # checked and passed as arguments, never through the shell. Runs in a worker thread (main.py) with its own client. + @long_running + def container_post__exec__sogo__restore(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + directory = str(request_json.get('dir', '')) + username = str(request_json.get('username', '')) + if (not re.fullmatch(r'/restore/[0-9]+/b[0-9]+/sogo_backup', directory) + or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._+-]*@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+', username)): + return Response(content=json.dumps({ 'type': 'danger', 'msg': 'dir and username required' }, indent=4), media_type="application/json") + # the folder: no symlink, uid 999, mode 0700; the file: plain, uid 999, mode 0600. sogo-tool restores folders and + # preferences in two runs, both stopped before the runner gives up waiting (30 minutes) + script = ('[ -d "$1" ] && [ ! -L "$1" ] && [ "$(realpath -e -- "$1")" = "$1" ] && [ "$(stat -c "%u %a" -- "$1")" = "999 700" ] ' + '&& [ -f "$1/$2" ] && [ ! -L "$1/$2" ] && [ "$(stat -c "%u %a" -- "$1/$2")" = "999 600" ] ' + '|| { echo "invalid directory or file"; exit 1; }; ' + 'timeout -k 30 850 sogo-tool restore -f ALL "$1" "$2" && exec timeout -k 30 850 sogo-tool restore -p "$1" "$2"') + client = docker.DockerClient(base_url='unix:///var/run/docker.sock', version='auto', timeout=1800) + try: + for container in client.containers.list(filters=filters): + restore_return = container.exec_run(["/bin/bash", "-c", script, "restore", directory, username], user='sogo') + return self.exec_run_handler('generic', restore_return) + finally: + client.close() # api call: container_post - post_action: exec - cmd: doveadm - task: subscribe_shared # Subscribes users to every folder of a mailbox shared with them (Shared//...), or unsubscribes them def container_post__exec__doveadm__subscribe_shared(self, request_json, **kwargs): diff --git a/data/Dockerfiles/dovecot/Dockerfile b/data/Dockerfiles/dovecot/Dockerfile index 4f7582608..98c13afa4 100644 --- a/data/Dockerfiles/dovecot/Dockerfile +++ b/data/Dockerfiles/dovecot/Dockerfile @@ -151,6 +151,7 @@ COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf COPY imapsync /usr/local/bin/imapsync COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py COPY retention_runner.py /usr/local/bin/retention_runner.py +COPY restore_runner.py /usr/local/bin/restore_runner.py COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh index c35151e9e..9af208fc8 100755 --- a/data/Dockerfiles/dovecot/docker-entrypoint.sh +++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh @@ -282,6 +282,7 @@ chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \ /usr/lib/dovecot/sieve/rspamd-pipe-spam \ /usr/local/bin/imapsync_runner.py \ /usr/local/bin/retention_runner.py \ + /usr/local/bin/restore_runner.py \ /usr/local/bin/imapsync \ /usr/local/bin/trim_logs.sh \ /usr/local/bin/sa-rules.sh \ diff --git a/data/Dockerfiles/dovecot/restore_runner.py b/data/Dockerfiles/dovecot/restore_runner.py new file mode 100644 index 000000000..47dc25d7b --- /dev/null +++ b/data/Dockerfiles/dovecot/restore_runner.py @@ -0,0 +1,702 @@ +#!/usr/bin/python3 +# Mail part of restores (ofelia, dovecot container, as vmail): the restic container restores a batch of mailboxes +# into /restore//b and sets the job to step 'mails'. Per mailbox this imports the mails folder by folder +# (into "Restored " 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 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///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 " +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_ 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): + 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) + 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 + 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() diff --git a/data/Dockerfiles/restic/Dockerfile b/data/Dockerfiles/restic/Dockerfile new file mode 100644 index 000000000..b24ba87c0 --- /dev/null +++ b/data/Dockerfiles/restic/Dockerfile @@ -0,0 +1,28 @@ +FROM alpine:3.24 + +LABEL maintainer="The Infrastructure Company GmbH " + +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +# MariaDB Connector/C >= 3.4 refuses DB servers without TLS, the runner connects through the socket +ENV MARIADB_TLS_DISABLE_PEER_VERIFICATION=1 + +RUN apk add --no-cache --update \ + ca-certificates \ + git \ + mariadb-client \ + openssh-client \ + py3-mysqlclient \ + py3-pynacl \ + python3 \ + restic \ + tini \ + tzdata \ + # dump, export, manifest and the mailcow directory are below /mailcow (paths of the snapshot), only root may read them + && mkdir -m 700 /mailcow + +COPY restic_runner.py /usr/local/bin/restic_runner.py +COPY sogo_restore.py /usr/local/bin/sogo_restore.py + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["/usr/bin/python3", "-u", "/usr/local/bin/restic_runner.py"] diff --git a/data/Dockerfiles/restic/restic_runner.py b/data/Dockerfiles/restic/restic_runner.py new file mode 100644 index 000000000..093b8678d --- /dev/null +++ b/data/Dockerfiles/restic/restic_runner.py @@ -0,0 +1,1941 @@ +#!/usr/bin/python3 +# Backup runner of the restic container (daemon, tini is PID 1): runs the jobs of backup_job one at a time (backup, +# check, prune, init and restores), queues the daily backup of the schedule in backup_setting and keeps the state and +# the snapshot cache (backup_snapshot) up to date. backup.php (nginx:8081) exports the domains and merges the restored +# settings (and creates deleted domains and mailboxes), sogo_restore.py merges the SOGo data, restore_runner.py in the +# dovecot container restores mails, IMAP ACLs and Sieve scripts batch by batch. + +import base64 +import collections +import datetime +import fcntl +import gzip +import hashlib +import hmac +import json +import os +import platform +import re +import secrets +import shutil +import signal +import smtplib +import socket +import stat +import subprocess +import sys +import tempfile +import threading +import time +import traceback +import urllib.error +import urllib.parse +import urllib.request +from email.message import EmailMessage +from email.utils import formatdate, make_msgid +import MySQLdb +import MySQLdb.cursors +import nacl.exceptions +import nacl.public +import sogo_restore + +poll_interval = 10 +lock_file = '/cache/runner.lock' +cache_dir = '/cache/restic' +ssh_dir = '/cache/ssh' +ssh_key = ssh_dir + '/id_ed25519' +known_hosts = ssh_dir + '/known_hosts' +# random id of this installation (in the volume, not the database: a database restored on another server gets a new +# one); snapshots carry it as tag instance:, forget and prune only touch the own snapshots +instance_file = '/cache/instance_id' +# X25519 key pair of the stored secrets: PHP seals them with the public key (state_secrets_pubkey), only this +# container can open them; a restored database on another server needs the secrets entered again +secrets_key_file = '/cache/secrets_key' +secret_keys = ('password', 's3_secret_key', 'rest_password') +sealed_prefix = 'sealed:v1:' +secrets_error = 'the stored credentials cannot be decrypted by this backup container (another server or a new backup ' \ + 'volume): enter the repository password and the target credentials again in the backup settings' +# the mailcow directory (read-only, with .git) is /mailcow/install; dump, export and manifest are written next to it +# in the container (restic stores absolute paths) +staging_dir = '/mailcow' +install_dir = staging_dir + '/install' +dump_dir = staging_dir + '/mysql' +export_dir = staging_dir + '/export' +# what a backup holds (RESTIC_BACKUP_MODE in mailcow.conf): all; data without the mailcow directory (its configuration +# may hold keys of an operator); config without mails (database, exports, Redis, mail crypt keys, mailcow.conf and +# data/conf). Snapshots of data and config carry the tag mode: +backup_modes = ('all', 'data', 'config') +# restore components that need the mails (not in config snapshots) +mail_components = {'mails', 'imap_acl', 'sogo', 'sieve_activate'} +# a local repository inside the mailcow directory is not backed up: this file marks it (only with its header, which +# holds the instance id, so a file of the same name elsewhere excludes nothing) +repo_marker = '.mailcow-restic-repository' +restore_dir = '/restore' +repository_root = '/repository' +backup_url = 'http://nginx:8081/backup.php' +max_log = 1000000 +keep_jobs_days = 180 +# requests to backup.php are tried this often +tries = 3 +# unreadable files listed in the result of a backup +max_errors = 50 +# a progress line in the job log at most this often (seconds) +progress_interval = 300 +# restic retries an unreachable repository for many minutes, the connection test gives up earlier (seconds) +connect_timeout = 120 +# mailboxes per batch of a restore: the staging of a batch is deleted before the next one +restore_batch = 20 +# aliases per restore_aliases request +alias_chunk = 500 +# a batch waits this long for the dovecot runner to take it, whose heartbeat may then be this old (seconds) +pickup_timeout = 1800 +dovecot_timeout = 900 +# free space kept besides what a restore needs +space_reserve = 256 << 20 +# uid and gid of sogo in the sogo container (sogo-tool reads the staged backup files) +sogo_uid = 999 +# a mail when the last successful backup is older than the longest gap of the schedule plus this (seconds), once a day +stale_margin = 12 * 3600 +# lines of restic and ssh errors that tell the reason +error_hints = ('Fatal:', 'Host key', 'Permission denied', 'Connection refused', 'timed out', 'no such host', + 'Could not resolve', 'No route to host', 'Access Denied', 'AccessDenied', 'Unauthorized', 'NoSuchBucket') +# restore errors domain admins see instead of repository details +repo_error_generic = 'the backup repository could not be read (details in the log of the restic container)' + +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_user': '', + 'sftp_path': '', 'sftp_port': '22', 'sftp_public_key': '', 'sftp_host_key': '', 'rest_url': '', 'rest_user': '', + 'rest_password': '', 'local_path': 'mailcow', 'password': '', 'password_confirmed': '0', + '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': '', +} +# restore components merged by backup.php, sogo_restore.py and the dovecot runner (filters: backup.php and dovecot) +php_components = {'filters', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'policies', 'spam', + 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'} +dovecot_components = {'mails', 'imap_acl', 'filters', 'sieve_activate'} + +domain_re = re.compile(r'[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+') +local_re = re.compile(r'[^@/\s\x00]+') + +hostname = os.environ.get('MAILCOW_HOSTNAME') or socket.getfqdn() +token = hmac.new(os.environ.get('DBPASS', '').encode(), b'backup', hashlib.sha256).hexdigest() + +conn = None +stopping = False +# the running job and its process, for the heartbeat, a cancel and a stop of the container +current = {'job': None, 'proc': None, 'interrupt': None} +last_cleanup = 0 + + +class JobError(Exception): + pass + +# restic or the repository failed: details are not shown to domain admins +class RepoError(JobError): + pass + +# the job was ended by someone else (the dovecot runner failed it) +class JobGone(Exception): + pass + +# the container stops while the dovecot runner works on a batch of the job +class JobDetached(Exception): + pass + + +def num(value): + try: + return int(value) + except (TypeError, ValueError): + return 0 + +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 (jobs 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 + +def secrets_private_key(): + try: + return nacl.public.PrivateKey(base64.b64decode(read_private(secrets_key_file).strip())) + except (OSError, ValueError, nacl.exceptions.CryptoError): + return None + +# Creates the key pair on the first start of the container +def secrets_setup(): + key = secrets_private_key() + if key is None: + key = nacl.public.PrivateKey.generate() + write_private(secrets_key_file, base64.b64encode(bytes(key)) + b'\n') + return key + +def seal(public_key, value): + return sealed_prefix + base64.b64encode(nacl.public.SealedBox(public_key).encrypt(value.encode())).decode() + +def unseal(private_key, value): + return nacl.public.SealedBox(private_key).decrypt(base64.b64decode(value[len(sealed_prefix):])).decode() + +# The settings with defaults and opened secrets; secrets this container cannot open are empty and _secrets_error is set +def settings(): + s = dict(defaults) + for row in query("SELECT name, value FROM backup_setting"): + s[row['name']] = row['value'] or '' + key = None + for name in secret_keys: + if not s[name].startswith(sealed_prefix): + continue + key = key or secrets_private_key() + try: + if key is None: + raise ValueError('no key') + s[name] = unseal(key, s[name]) + except (ValueError, TypeError, UnicodeDecodeError, nacl.exceptions.CryptoError): + s[name] = '' + s['_secrets_error'] = secrets_error + return s + +def set_setting(name, value): + query("INSERT INTO backup_setting (name, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)", + (name, str(value))) + +def instance_id(): + try: + with open(instance_file) as f: + value = f.read().strip() + if re.fullmatch(r'[0-9a-f]{16}', value): + return value + except OSError: + pass + value = secrets.token_hex(8) + write_private(instance_file, value.encode()) + return value + +def instance_tag(): + return 'instance:' + instance_id() + +# Content of the marker file of the local repository; restic excludes a folder only with this header +def marker_header(): + return 'mailcow restic repository ' + instance_id() + +def configured_mode(): + return os.environ.get('RESTIC_BACKUP_MODE', '').strip().lower()[:32] or 'all' + +def backup_mode(): + mode = configured_mode() + if mode not in backup_modes: + raise JobError('RESTIC_BACKUP_MODE in mailcow.conf must be all, data or config, not "%s"' % mode) + return mode + +def mailbox_valid(username): + local, _, domain = username.rpartition('@') + return bool(local_re.fullmatch(local)) and local not in ('.', '..') and bool(domain_re.fullmatch(domain)) + +# restic include patterns are globs +def glob_escape(text): + return re.sub(r'([*?\[\]\\])', r'\\\1', text) + +def local_time(value): + # restic times are RFC 3339 with up to nanoseconds, stored in the local time zone like the other DATETIME columns + m = re.match(r'(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d+)?(Z|[+-]\d\d:\d\d)$', value or '') + if not m: + return None + stamp = datetime.datetime.fromisoformat(m.group(1) + m.group(2).replace('Z', '+00:00')) + return stamp.astimezone().strftime('%Y-%m-%d %H:%M:%S') + +def tail(lines, size=1000): + return '\n'.join(line for line in lines if line.strip())[-size:] + +def mib(size): + return '%.1f MiB' % (size / 1048576) + +def gib(size): + return '%.1f GiB' % (size / 1073741824) + + +class Job: + def __init__(self, row): + self.id, self.type, self.requested_by = row['id'], row['type'], row['requested_by'] + try: + self.params = json.loads(row['params'] or '{}') + except ValueError: + self.params = {} + if not isinstance(self.params, dict): + self.params = {} + self.result = {} + self.warnings = [] + # restores of domain admins: no repository details in their log, errors and results + self.restricted = self.type == 'restore' and self.params.get('requested_role', 'admin') != 'admin' + # called with every heartbeat while restic runs, returns a line for the log or None + self.progress = None + + def log(self, text): + print('backup job %d (%s): %s' % (self.id, self.type, 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, self.id)) + + def step(self, name): + query("UPDATE backup_job SET step = %s WHERE id = %s", (name, self.id)) + + def warn(self, text): + self.warnings.append(text) + self.log('warning: ' + text) + + def save(self): + if self.warnings: + self.result['warnings'] = self.warnings + query("UPDATE backup_job SET result = %s WHERE id = %s", (json.dumps(self.result), self.id)) + + +# Repository string, options and environment of a target; secrets only go to the environment of restic +class Repository: + def __init__(self, s): + if s.get('_secrets_error'): + raise JobError(s['_secrets_error']) + if not s['password']: + raise JobError('no repository password is set') + self.env = base_env() + self.env.update(RESTIC_PASSWORD=s['password'], RESTIC_CACHE_DIR=cache_dir) + self.options = [] + self.sftp = False + target = s['target'] + if target == 's3': + endpoint = s['s3_endpoint'].strip().rstrip('/') + bucket, path = s['s3_bucket'].strip().strip('/'), s['s3_path'].strip().strip('/') + if not re.fullmatch(r'https?://[^/\s@]+', endpoint): + raise JobError('the S3 endpoint must be a URL like https://s3.example.com') + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', bucket): + raise JobError('the S3 bucket is missing or invalid') + if '..' in path.split('/'): + raise JobError('the S3 path must not contain ..') + if not s['s3_access_key'] or not s['s3_secret_key']: + raise JobError('the S3 access key or secret key is missing') + self.name = 's3:%s/%s' % (endpoint, bucket) + ('/' + path if path else '') + self.env.update(AWS_ACCESS_KEY_ID=s['s3_access_key'], AWS_SECRET_ACCESS_KEY=s['s3_secret_key']) + if s['s3_region'].strip(): + self.options += ['-o', 's3.region=' + s['s3_region'].strip()] + if s['s3_path_style'] == '1': + self.options += ['-o', 's3.bucket-lookup=path'] + elif target == 'sftp': + host, user, path = s['sftp_host'].strip(), s['sftp_user'].strip(), s['sftp_path'].strip() + port = num(s['sftp_port']) + # these go into the ssh command line: no spaces, nothing ssh could take for an option + if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*', host): + raise JobError('the SFTP host is missing or invalid') + if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._-]*', user): + raise JobError('the SFTP user is missing or invalid') + if not 0 < port < 65536: + raise JobError('the SFTP port is invalid') + if not path or '..' in path.split('/'): + raise JobError('the SFTP path is missing or contains ..') + self.sftp = True + self.strict = bool(s['sftp_host_key'].strip()) + self.name = 'sftp:%s@%s:%s' % (user, '[%s]' % host if ':' in host else host, path) + command = ['ssh', '-F', '/dev/null', '-i', ssh_key, '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', + '-o', 'UserKnownHostsFile=' + known_hosts, '-o', 'GlobalKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=' + ('yes' if self.strict else 'accept-new'), '-o', 'LogLevel=ERROR', + '-o', 'ConnectTimeout=30', '-o', 'ServerAliveInterval=60', '-o', 'ServerAliveCountMax=5', + '-p', str(port), '-l', user, '-s', '--', host, 'sftp'] + self.options += ['-o', 'sftp.command=' + ' '.join(command)] + elif target == 'rest': + url = s['rest_url'].strip() + parts = urllib.parse.urlsplit(url) + if parts.scheme not in ('http', 'https') or not parts.hostname: + raise JobError('the REST server URL must look like https://host:8000/path') + if parts.username or parts.password: + raise JobError('the REST server URL must not contain credentials, they belong into user and password') + self.name = 'rest:' + url + if s['rest_user']: + self.env.update(RESTIC_REST_USERNAME=s['rest_user'], RESTIC_REST_PASSWORD=s['rest_password']) + elif target == 'local': + path = s['local_path'].strip().strip('/') + if not re.fullmatch(r'[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*', path) or '..' in path.split('/'): + raise JobError('the local path must be a subdirectory of the repository folder without ..') + self.name = repository_root + '/' + path + else: + raise JobError('no backup target is configured') + self.env['RESTIC_REPOSITORY'] = self.name + if num(s['limit_upload']) > 0: + self.options += ['--limit-upload', str(num(s['limit_upload']))] + + # known_hosts comes from the settings: empty = the next connection accepts the key of the host + def prepare(self, s): + if self.sftp: + with open(known_hosts, 'w') as f: + f.write(s['sftp_host_key'].strip() + '\n' if self.strict else '') + + # store the host key accepted on the first connection + def remember_host(self, s): + if self.sftp and not self.strict: + try: + with open(known_hosts) as f: + key = f.read().strip() + except OSError: + key = '' + if key: + set_setting('sftp_host_key', key) + s['sftp_host_key'] = key + self.strict = True + + +def base_env(): + env = {k: os.environ[k] for k in ('PATH', 'TZ', 'LANG', 'LC_ALL') if k in os.environ} + env['HOME'] = '/root' + return env + +# state_runner_seen every poll_interval, in its own thread and connection: also while a call blocks the runner +class Heartbeat(threading.Thread): + def __init__(self): + super().__init__(daemon=True) + + def run(self): + db = None + while not stopping: + try: + if db is None: + db = connect() + db.cursor().execute("""INSERT INTO backup_setting (name, value) VALUES ('state_runner_seen', %s) + ON DUPLICATE KEY UPDATE value = VALUES(value)""", (str(int(time.time())),)) + except MySQLdb.Error: + db = None + time.sleep(poll_interval) + +# Checks while a job runs (cancel, stop, progress) and the cleanup of the restore staging; returns why the running +# job has to stop +def tick(): + global last_cleanup + try: + if time.monotonic() - last_cleanup >= 60: + last_cleanup = time.monotonic() + clean_restore() + job = current['job'] + if job: + row = query("SELECT state FROM backup_job WHERE id = %s", (job.id,)).fetchone() + if row and row['state'] == 'cancelled': + return 'cancelled' + if job.progress: + line = job.progress() + if line: + job.log(line) + except MySQLdb.Error as e: + print('backup runner: database error: %s' % e, file=sys.stderr) + return 'stopped' if stopping else None + +def interrupted_error(): + return JobError('cancelled' if current['interrupt'] == 'cancelled' else 'interrupted: the container was stopped') + +# Last heartbeat of the dovecot runner (unix time, 0 before the first) +def restore_seen(): + row = query("SELECT value FROM backup_setting WHERE name = 'state_restore_seen'").fetchone() + return num(row['value']) if row else 0 + +# Heartbeat in long phases without restic: stops the job when it was cancelled or the container stops +def check_cancel(): + reason = tick() + if reason: + current['interrupt'] = current['interrupt'] or reason + raise interrupted_error() + +# The reason in the error output of restic (and ssh): hint lines first, retry lines only once +def error_text(err): + lines = [] + for line in err: + line = line.strip() + if line.startswith('{'): + try: + message = json.loads(line) + line = (message.get('message') or '') if isinstance(message, dict) else line + except ValueError: + pass + line = re.sub(r', retrying after [^:]*:', ':', re.sub(r'^subprocess ssh: ', '', line)) + # user names in URLs (the passwords are masked by restic) + line = re.sub(r'//[^/@\s]+@', '//', line) + if line and line not in lines: + lines.append(line) + hints = [line for line in lines if any(hint in line for hint in error_hints)] + return '\n'.join((hints or lines)[-6:])[:1000] + +# Run a command, heartbeat every poll_interval. stdout lines go to on_line or are returned, stderr lines to on_err and +# the end of stderr is kept. timeout: interrupt it after that many seconds (RepoError) +def execute(cmd, env, on_line=None, capture=True, timeout=None, on_err=None): + proc = subprocess.Popen(cmd, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE if on_line or capture else subprocess.DEVNULL, stderr=subprocess.PIPE) + out, err = [], collections.deque(maxlen=40) + + def read_out(): + for line in proc.stdout: + if on_line: + on_line(line) + else: + out.append(line) + + def read_err(): + for line in proc.stderr: + if on_err: + on_err(line) + err.append(line.decode('utf-8', 'replace').rstrip()) + + readers = [threading.Thread(target=read_err, daemon=True)] + if proc.stdout: + readers.append(threading.Thread(target=read_out, daemon=True)) + for reader in readers: + reader.start() + current['proc'] = proc + signalled, deadline = None, time.monotonic() + timeout if timeout else None + try: + while True: + try: + proc.wait(timeout=poll_interval if not deadline else max(1, min(poll_interval, deadline - time.monotonic()))) + break + except subprocess.TimeoutExpired: + pass + reason = tick() + if not reason and deadline and time.monotonic() >= deadline: + reason = 'timeout' + if reason and not signalled: + # restic removes its lock on SIGINT + if reason != 'timeout': + current['interrupt'] = current['interrupt'] or reason + signalled = time.monotonic() + proc.send_signal(signal.SIGINT) + elif signalled and time.monotonic() - signalled > 60: + proc.kill() + finally: + current['proc'] = None + for reader in readers: + reader.join() + if current['interrupt']: + raise interrupted_error() + if signalled: + raise RepoError('no answer within %d s: %s' % (timeout, error_text(err))) + return proc.returncode, b''.join(out), list(err) + +def restic_error(rc, err): + known = {10: 'the repository does not exist', 11: 'the repository is locked by another process', + 12: 'wrong repository password'} + text = error_text(err) + return known.get(rc, 'restic failed with exit code %d' % rc) + (': ' + text if text else '') + +# restic with the repository; a lock error removes stale locks once and tries again +def restic(job, repo, args, on_line=None, capture=True, env=None, timeout=None, on_err=None): + cmd = ['restic'] + repo.options + args + run_env = dict(repo.env, **(env or {})) + for attempt in (1, 2): + rc, out, err = execute(cmd, run_env, on_line, capture, timeout, on_err) + if rc != 11 or attempt == 2: + break + job.log('the repository is locked, removing stale locks') + execute(['restic'] + repo.options + ['unlock'], repo.env) + return rc, out, err + +def restic_json(job, repo, args): + rc, out, err = restic(job, repo, args) + if rc != 0: + raise RepoError(restic_error(rc, err)) + try: + return json.loads(out or b'null') + except ValueError: + raise RepoError('unexpected output of restic %s' % args[0]) + +def restic_version(): + try: + return subprocess.run(['restic', 'version'], capture_output=True, text=True, timeout=30).stdout.split()[1] + except (OSError, IndexError, subprocess.SubprocessError): + return '' + +# Use the repository: a changed target resets the state, create = initialize it if it does not exist yet +def open_repository(job, s, repo, create): + if s.get('state_repository') != repo.name: + if s.get('state_repository'): + job.log('the repository changed, its state and the snapshot list are reset') + query("DELETE FROM backup_snapshot") + for name, value in (('state_initialized', 0), ('state_repo_size', ''), ('state_repo_stats', ''), + ('state_repo_id', ''), ('state_repository', repo.name)): + set_setting(name, value) + s[name] = str(value) + if s.get('state_initialized') == '1' and job.type != 'init': + return + job.step('connect') + rc, out, err = restic(job, repo, ['cat', 'config'], timeout=connect_timeout) + if rc == 10 and create: + job.log('the repository does not exist, creating it at %s' % repo.name) + job.step('init') + rc, out, err = restic(job, repo, ['init'], timeout=connect_timeout) + if rc != 0: + raise RepoError('the repository could not be created: ' + restic_error(rc, err)) + job.result['created'] = True + rc, out, err = restic(job, repo, ['cat', 'config']) + if rc != 0: + raise RepoError(restic_error(rc, err)) + try: + config = json.loads(out) + except ValueError: + raise RepoError('unexpected output of restic cat config') + repo.remember_host(s) + set_setting('state_initialized', 1) + set_setting('state_repo_id', config.get('id', '')) + s['state_initialized'] = '1' + if not job.restricted: + job.result['repository'] = {'id': config.get('id', ''), 'version': config.get('version')} + job.log('repository %s is ready' % ('' if job.restricted else '%s (format %s)' % (repo.name, config.get('version')))) + +def keep_args(s): + args = [] + for period in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'): + count = num(s['keep_' + period]) + if count > 0: + args += ['--keep-' + period, str(count)] + return args + +def max_unused(s): + value = s['max_unused'].strip() + return value if re.fullmatch(r'\d+(\.\d+)?%|\d+[kKmMgGtT]?|unlimited', value) else defaults['max_unused'] + +def forget(job, s, repo, prune): + keep = keep_args(s) + if keep: + # only the snapshots of this installation, in one group (also after changes of hostname, paths or backup mode) + args = ['forget', '--tag', 'mailcow,' + instance_tag(), '--group-by', ''] + keep + elif prune: + args = ['prune'] + else: + job.log('no retention policy, all snapshots are kept') + return + if prune: + args = ['--cleanup-cache'] + args + ['--max-unused', max_unused(s)] + (['--prune'] if keep else []) + rc, out, err = restic(job, repo, args) + if rc != 0: + raise RepoError(restic_error(rc, err)) + job.result['output'] = out.decode('utf-8', 'replace').splitlines()[-30:] + job.log('prune finished') + return + groups = restic_json(job, repo, args + ['--json']) or [] + removed = sum(len(group.get('remove') or []) for group in groups) + job.result['forget'] = {'removed': removed} + job.log('forget: %d snapshots removed by the retention policy' % removed) + +# The domains of a snapshot made by mailcow with their creation time, from its manifest: {domain: unix time} +def snapshot_domains(job, repo, snapshot): + if 'mailcow' not in (snapshot.get('tags') or []): + return None + rc, out, err = restic(job, repo, ['dump', snapshot['id'], staging_dir + '/manifest.json']) + try: + created = json.loads(out)['domains_created'] if rc == 0 else None + return json.dumps({domain: created[domain] for domain in sorted(created)}) if isinstance(created, dict) else None + except (ValueError, KeyError, TypeError): + return None + +# Replaces the snapshot cache; content (gzip JSON {domain: {username: name}}) of known snapshots is kept +def sync_snapshots(job, repo): + snapshots = restic_json(job, repo, ['snapshots', '--json']) or [] + known = {row['id']: row['domains'] for row in query("SELECT id, domains FROM backup_snapshot")} + for snapshot in snapshots: + sid = snapshot['id'] + domains = known[sid] if sid in known else snapshot_domains(job, repo, snapshot) + query("""INSERT INTO backup_snapshot (id, short_id, time, hostname, tags, summary, domains) + VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE short_id = VALUES(short_id), time = VALUES(time), + hostname = VALUES(hostname), tags = VALUES(tags), summary = VALUES(summary), domains = VALUES(domains)""", + (sid, snapshot.get('short_id') or sid[:8], local_time(snapshot.get('time')), snapshot.get('hostname') or '', + ','.join(snapshot.get('tags') or [])[:255], + json.dumps(snapshot['summary']) if snapshot.get('summary') else None, domains)) + gone = set(known) - {snapshot['id'] for snapshot in snapshots} + for sid in gone: + query("DELETE FROM backup_snapshot WHERE id = %s", (sid,)) + job.result['snapshots'] = len(snapshots) + +def repo_stats(job, repo): + try: + stats = restic_json(job, repo, ['stats', '--json', '--mode', 'raw-data']) or {} + except JobError as e: + job.warn('the repository size could not be read: %s' % e) + return + set_setting('state_repo_size', num(stats.get('total_size'))) + set_setting('state_repo_stats', json.dumps(stats)) + +def unlink(path): + try: + os.unlink(path) + except OSError: + pass + +def clean_staging(): + shutil.rmtree(dump_dir, ignore_errors=True) + shutil.rmtree(export_dir, ignore_errors=True) + unlink(staging_dir + '/manifest.json') + +def write_private(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(data) + +def read_private(path): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as f: + return f.read() + +def dump_database(job): + os.makedirs(dump_dir, mode=0o700, exist_ok=True) + os.chmod(dump_dir, 0o700) + # credentials in an option file, not in argv + fd, cnf = tempfile.mkstemp(prefix='client.', suffix='.cnf') + quote = lambda value: '"%s"' % value.replace('\\', '\\\\').replace('"', '\\"') + try: + with os.fdopen(fd, 'w') as f: + f.write('[client]\nuser=%s\npassword=%s\nsocket=/var/run/mysqld/mysqld.sock\nskip-ssl\n' % ( + quote(os.environ['DBUSER']), quote(os.environ['DBPASS']))) + started = time.monotonic() + db = os.environ['DBNAME'] + dump = ['mariadb-dump', '--defaults-extra-file=' + cnf, '--single-transaction', '--hex-blob', + '--default-character-set=utf8mb4'] + # the service API keys of mailcow.conf stay out (php-fpm adds them again at every start) + rc, _, err = execute(dump + ['--routines', '--triggers', '--events', '--ignore-table=%s.api' % db, + '--result-file=' + dump_dir + '/mailcow.sql', db], base_env(), capture=False) + if rc == 0: + rc, _, err = execute(dump + ['--where=service = 0', '--result-file=' + dump_dir + '/api.sql', db, 'api'], base_env(), + capture=False) + if rc == 0: + with open(dump_dir + '/mailcow.sql', 'ab') as out, open(dump_dir + '/api.sql', 'rb') as part: + shutil.copyfileobj(part, out) + finally: + unlink(cnf) + unlink(dump_dir + '/api.sql') + if rc != 0: + raise JobError('the database dump failed: ' + tail(err)) + size = os.path.getsize(dump_dir + '/mailcow.sql') + job.result['dump_bytes'] = size + job.log('database dump: %d bytes in %d s' % (size, time.monotonic() - started)) + +def request(url, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'X-Backup-Token': token}, + method='GET' if body is None else 'POST') + with urllib.request.urlopen(req, timeout=600) as response: + return response.read() + +# Retries on connection errors and server errors, not on refused requests +def retried(url, body=None): + for attempt in range(1, tries + 1): + try: + return request(url, body) + except urllib.error.HTTPError as e: + if e.code < 500 or attempt == tries: + raise + except (OSError, ValueError): + if attempt == tries: + raise + time.sleep(5 * attempt) + +def post(body): + answer = json.loads(retried(backup_url, body)) + return answer.get('result') if isinstance(answer, dict) and isinstance(answer.get('result'), dict) else {} + +def http_error(e): + if isinstance(e, urllib.error.HTTPError): + try: + detail = e.read(300).decode('utf-8', 'replace').strip() + except OSError: + detail = '' + return 'HTTP %d%s' % (e.code, ': ' + detail if detail else '') + return str(e) + +# Export of one domain, page by page (after the last username of the previous page), into one file with all mailboxes; +# returns {username: name} +def export_domain(path, domain): + names, pages, opened, query_args = {}, 0, False, {'export': domain} + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w', encoding='utf-8') as f: + while query_args is not None: + check_cancel() + data = json.loads(retried(backup_url + '?' + urllib.parse.urlencode(query_args))) + if not isinstance(data, dict): + raise ValueError('no JSON object') + if not opened: + # version, domain, domain_data, aliases of the first page, then the mailboxes of all pages + head = json.dumps({k: v for k, v in data.items() if k not in ('mailboxes', 'next_after')}) + f.write(head[:-1] + (', ' if len(head) > 2 else '') + '"mailboxes": {') + opened = True + for username, section in (data.get('mailboxes') or {}).items(): + if username in names: + continue + f.write((', ' if names else '') + json.dumps(username) + ': ' + json.dumps(section)) + mailbox = section.get('mailbox') if isinstance(section, dict) else None + names[username] = str((mailbox or {}).get('name') or '') if isinstance(mailbox, dict) else '' + pages += 1 + after = data.get('next_after') + if isinstance(after, str) and after and after != query_args.get('after') and pages < 100000: + query_args = {'export': domain, 'after': after} + else: + query_args = None + f.write('}}') + return names + +# Export of every domain by backup.php (/mailcow/export/.json); failures are warnings, the mails are saved anyway. +# Returns {domain: {username: name}} of the exported domains +def export_domains(job): + folder = export_dir + os.makedirs(folder, mode=0o700, exist_ok=True) + content = {} + try: + listed = json.loads(retried(backup_url + '?domains=1'))['domains'] + except Exception as e: + job.warn('the domain list for the export could not be fetched, the snapshot has no export: %s' % http_error(e)) + job.result['export'] = {'domains': 0, 'failed': ['*']} + return content + exported, failed = 0, [] + for domain in listed: + if not isinstance(domain, str) or not domain_re.fullmatch(domain): + failed.append(str(domain)) + continue + try: + content[domain] = export_domain('%s/%s.json' % (folder, domain), domain) + exported += 1 + except JobError: + raise + except Exception as e: + failed.append(domain) + unlink('%s/%s.json' % (folder, domain)) + job.log('export of %s failed: %s' % (domain, http_error(e))) + if failed: + job.warn('the export failed for %d domains: %s' % (len(failed), ', '.join(failed[:20]))) + job.result['export'] = {'domains': exported, 'failed': failed} + job.log('export: %d domains' % exported) + return content + +# Content of a snapshot for the restore page: {"domains": {domain: {"created": unix time, "mailboxes": {username: name}}}}; +# mailboxes of domains without export come from the database +def snapshot_content(exported): + domains = {} + for row in query("SELECT domain, UNIX_TIMESTAMP(created) AS created FROM domain").fetchall(): + domain = row['domain'] + mailboxes = exported.get(domain) + if mailboxes is None: + mailboxes = {m['username']: m['name'] or '' for m in query("SELECT username, name FROM mailbox WHERE domain = %s", + (domain,)).fetchall()} + domains[domain] = {'created': num(row['created']), 'mailboxes': mailboxes} + return {'domains': domains} + +# A fresh dump.rdb: BGSAVE and wait for LASTSAVE to change; the older dump is backed up if that fails +def save_redis(job): + try: + with socket.create_connection(('redis', 6379), timeout=30) as sock: + stream = sock.makefile('rwb') + + def command(*args): + stream.write(b'*%d\r\n' % len(args) + b''.join(b'$%d\r\n%s\r\n' % (len(a), a) for a in (x.encode() for x in args))) + stream.flush() + reply = stream.readline() + if reply[:1] == b'-': + raise RuntimeError(reply[1:].strip().decode('utf-8', 'replace')) + return reply[1:].strip() + + if os.environ.get('REDISPASS'): + command('AUTH', os.environ['REDISPASS']) + last = command('LASTSAVE') + try: + command('BGSAVE') + except RuntimeError as e: + # a save in progress also ends with a new LASTSAVE + if 'progress' not in str(e): + raise + deadline = time.monotonic() + 600 + while command('LASTSAVE') == last: + if time.monotonic() > deadline: + raise RuntimeError('timeout') + check_cancel() + time.sleep(1) + except JobError: + raise + except Exception as e: + job.warn('redis did not save a fresh dump, the last one is backed up: %s' % e) + +# Version of the mailcow directory: git commit, branch, locally changed files, image tags +def install_info(): + info = {} + + def git(*args): + result = subprocess.run(['git', '-c', 'safe.directory=*', '--no-optional-locks', '-C', install_dir] + list(args), + capture_output=True, text=True, timeout=300) + return result.stdout if result.returncode == 0 else None + + try: + info['commit'] = (git('rev-parse', 'HEAD') or '').strip() + info['branch'] = (git('rev-parse', '--abbrev-ref', 'HEAD') or '').strip() + status = git('status', '--porcelain', '--untracked-files=all') + if status is not None: + changed = [line for line in status.splitlines() if line.strip()] + info['changed'] = changed[:1000] + info['changed_count'] = len(changed) + except (OSError, subprocess.SubprocessError): + pass + try: + with open(install_dir + '/data/web/inc/app_info.inc.php', encoding='utf-8', errors='replace') as f: + m = re.search(r'\$MAILCOW_GIT_VERSION="([^"]*)"', f.read()) + if m: + info['version'] = m.group(1) + except OSError: + pass + images, service = {}, None + try: + with open(install_dir + '/docker-compose.yml', encoding='utf-8', errors='replace') as f: + for line in f: + m = re.match(r'^ ([A-Za-z0-9_.-]+):\s*$', line) + if m: + service = m.group(1) + m = re.match(r'^\s+image:\s*(\S+)', line) + if m and service: + images[service] = m.group(1) + except OSError: + pass + info['images'] = images + return info + +def db_version(): + row = query("SELECT version FROM versions WHERE application = 'db_schema'").fetchone() + return row['version'] if row else '' + +def write_manifest(job, mode): + rows = query("SELECT domain, UNIX_TIMESTAMP(created) AS created FROM domain").fetchall() + domains = sorted(row['domain'] for row in rows) + install = install_info() + # without the directory only its version + if mode == 'data': + install = {key: install[key] for key in ('commit', 'branch', 'version') if key in install} + manifest = {'created': int(time.time()), 'hostname': hostname, 'db_version': db_version(), 'restic': restic_version(), + 'domains': domains, 'domains_created': {row['domain']: num(row['created']) for row in rows}, 'arch': platform.machine(), + 'instance': instance_id(), 'mode': mode, 'install': install} + write_private(staging_dir + '/manifest.json', json.dumps(manifest).encode()) + job.result['domains'] = len(domains) + +# What a backup of the mode holds: paths and excludes +def backup_paths(s, mode): + staged = [dump_dir, export_dir, staging_dir + '/manifest.json'] + if mode == 'config': + return ['/mail_crypt', '/redis'] + staged + [install_dir + '/mailcow.conf', install_dir + '/data/conf'], [] + paths, excludes = ['/var/vmail'], [] + if s['include_index'] == '1': + paths.append('/var/vmail_index') + if s['include_fts'] != '1': + excludes += ['--exclude', '/var/vmail_index/**/fts-flatcurve'] + # data: of the mailcow directory only mailcow.conf (data/conf may hold keys of the operator) + paths += ['/mail_crypt', '/redis', '/rspamd', '/sogo_backup'] + ([staging_dir] if mode == 'all' else staged + [install_dir + '/mailcow.conf']) + return paths, excludes + +def run_backup(job, s, repo, mode): + args = ['backup', '--json', '--host', hostname, '--tag', 'mailcow', '--tag', instance_tag()] + \ + ([] if mode == 'all' else ['--tag', 'mode:' + mode]) + [ + '--exclude', '/var/vmail/_garbage', + # the mailcow directory without the local repository, the template cache and editor files + '--exclude-if-present', '%s:%s' % (repo_marker, marker_header()), + '--exclude', install_dir + '/data/web/templates/cache/*', '--exclude', '!' + install_dir + '/data/web/templates/cache/.gitkeep', + '--exclude', install_dir + '/**/*.swp', '--exclude', install_dir + '/**/*~'] + local = os.environ.get('RESTIC_LOCAL_PATH', '').strip() + if local and not local.startswith('/') and '..' not in local.split('/'): + args += ['--exclude', os.path.normpath(os.path.join(install_dir, local))] + paths, excludes = backup_paths(s, mode) + args += excludes + state = {'summary': None, 'errors': [], 'count': 0, 'status': None, 'logged': time.monotonic()} + + # runs in the reader threads, no database access here; errors come on stderr + def on_line(line): + try: + message = json.loads(line) + except ValueError: + return + kind = message.get('message_type') if isinstance(message, dict) else None + if kind == 'summary': + state['summary'] = message + elif kind == 'status': + state['status'] = message + elif kind == 'error': + state['count'] += 1 + if len(state['errors']) < max_errors: + state['errors'].append({'item': message.get('item', ''), 'error': (message.get('error') or {}).get('message', '')}) + + def progress(): + status = state['status'] + if not status or time.monotonic() - state['logged'] < progress_interval: + return None + state['logged'] = time.monotonic() + return 'backup: %d%% done, %d of %d files, %d MiB of %d MiB' % (100 * (status.get('percent_done') or 0), + status.get('files_done', 0), status.get('total_files', 0), status.get('bytes_done', 0) >> 20, + status.get('total_bytes', 0) >> 20) + + job.progress = progress + try: + rc, _, err = restic(job, repo, args + paths, on_line=on_line, env={'RESTIC_PROGRESS_FPS': '0.1'}, on_err=on_line) + finally: + job.progress = None + summary = state['summary'] + if rc not in (0, 3) or not summary: + raise RepoError(restic_error(rc, err)) + job.result['snapshot'] = summary.get('snapshot_id') + job.result['summary'] = summary + if state['errors']: + job.result['file_errors'] = state['errors'] + if rc == 3: + job.warn('%d files could not be read, the snapshot misses them' % max(state['count'], 1)) + job.log('snapshot %s: %d new and %d changed files, %d MiB added, %d MiB processed' % ( + (summary.get('snapshot_id') or '')[:8], summary.get('files_new', 0), summary.get('files_changed', 0), + summary.get('data_added', 0) >> 20, summary.get('total_bytes_processed', 0) >> 20)) + +# Free space for a local repository (the default volume shares the disk with the mail data, a full disk stops mail): +# what the backup adds (twice the data added by the last snapshot of this installation, else an estimate of everything) +# must leave a reserve of max(2 GiB, 5 % of the filesystem) +def local_space_needed(added, mails, database, dump): + # twice the data the last snapshot added; for the first backup everything plus 1 GiB + if added is not None: + return max(1 << 30, 2 * added) + return mails + database + dump + (1 << 30) + +# as in restore_runner.py of the dovecot image (also mib and count_errors; tests/python/backup compares them) +def disk_reserve(total): + return max(2 << 30, total // 20) + +def check_local_space(job, s): + if s['target'] != 'local': + return + fs = os.statvfs(repository_root) + free, total = fs.f_bavail * fs.f_frsize, fs.f_blocks * fs.f_frsize + added, mails, database, dump = None, 0, 0, 0 + row = query("""SELECT summary FROM backup_snapshot WHERE FIND_IN_SET(%s, tags) AND summary IS NOT NULL + ORDER BY time DESC LIMIT 1""", (instance_tag(),)).fetchone() + if row: + try: + added = int(json.loads(row['summary']).get('data_added')) + except (ValueError, TypeError, AttributeError): + added = None + if added is None: + # first backup: mails, database, the last dump if there is one + mails = num(query("SELECT IFNULL(SUM(bytes), 0) AS bytes FROM quota2").fetchone()['bytes']) + database = num(query("""SELECT IFNULL(SUM(data_length + index_length), 0) AS bytes FROM information_schema.tables + WHERE table_schema = DATABASE()""").fetchone()['bytes']) + for row in query("""SELECT result FROM backup_job WHERE type = 'backup' AND state = 'done' AND result IS NOT NULL + ORDER BY id DESC LIMIT 1"""): + try: + dump = num(json.loads(row['result']).get('dump_bytes')) + except (ValueError, AttributeError): + pass + needed = local_space_needed(added, mails, database, dump) + reserve = disk_reserve(total) + text = '%s free, about %s needed plus %s reserve' % (gib(free), gib(needed), gib(reserve)) + if free - needed < reserve: + raise JobError('not enough free space for the local repository: ' + text) + job.log('local repository: ' + text) + +def job_backup(job, s, repo): + set_setting('state_last_backup', job.id) + mode = backup_mode() + check_local_space(job, s) + open_repository(job, s, repo, create=True) + try: + job.step('dump') + dump_database(job) + job.step('export') + exported = export_domains(job) + save_redis(job) + write_manifest(job, mode) + job.step('backup') + run_backup(job, s, repo, mode) + finally: + clean_staging() + # the data is safe from here on: the snapshot gets its row with the content at once, forget and the snapshot list + # are warnings when they fail + set_setting('state_last_success', int(time.time())) + store_snapshot(job, snapshot_content(exported), mode) + job.save() + try: + job.step('forget') + forget(job, s, repo, prune=False) + except RepoError as e: + job.warn('forget failed, the snapshot is saved: %s' % e) + try: + job.step('snapshots') + sync_snapshots(job, repo) + except RepoError as e: + job.warn('the snapshot list could not be refreshed: %s' % e) + # the exact size is read after prunes; here the data the snapshot added is counted + if not s.get('state_repo_size'): + job.step('stats') + repo_stats(job, repo) + else: + set_setting('state_repo_size', num(s['state_repo_size']) + num((job.result.get('summary') or {}).get('data_added_packed') + or (job.result.get('summary') or {}).get('data_added'))) + +# Row of the new snapshot with its content (the restore page lists domains and mailboxes from it); the snapshot sync +# fills in the rest and keeps the content +def store_snapshot(job, content, mode): + summary = job.result.get('summary') or {} + sid = job.result.get('snapshot') + if not sid: + return + query("""INSERT INTO backup_snapshot (id, short_id, time, hostname, tags, summary, domains, content) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE content = VALUES(content)""", + (sid, sid[:8], local_time(summary.get('backup_start')) or time.strftime('%Y-%m-%d %H:%M:%S'), hostname, + ','.join(['mailcow', instance_tag()] + ([] if mode == 'all' else ['mode:' + mode])), json.dumps(summary), + json.dumps({domain: content['domains'][domain].get('created') for domain in sorted(content['domains'])}), + gzip.compress(json.dumps(content).encode()))) + +def job_init(job, s, repo): + open_repository(job, s, repo, create=True) + job.step('snapshots') + sync_snapshots(job, repo) + job.step('stats') + repo_stats(job, repo) + +def job_check(job, s, repo): + open_repository(job, s, repo, create=False) + job.step('check') + parts = num(s['check_subset']) + args = ['check', '--with-cache'] + if parts > 0: + # each run reads the next part of the data + part = num(s.get('state_check_part')) % parts + 1 + args.append('--read-data-subset=%d/%d' % (part, parts)) + job.result['subset'] = '%d/%d' % (part, parts) + rc, out, err = restic(job, repo, args) + job.result['output'] = out.decode('utf-8', 'replace').splitlines()[-30:] + if rc != 0: + raise RepoError(restic_error(rc, err)) + if parts > 0: + set_setting('state_check_part', part) + job.log('check finished' + (' (part %s)' % job.result['subset'] if parts > 0 else '')) + +def job_prune(job, s, repo): + open_repository(job, s, repo, create=False) + job.step('prune') + forget(job, s, repo, prune=True) + job.step('snapshots') + sync_snapshots(job, repo) + job.step('stats') + repo_stats(job, repo) + +# Restore staging: /restore/ belongs to root and only lets others pass; vmail and sogo get their own subtrees +def make_dir(path, uid=0, gid=0, mode=0o711): + os.mkdir(path, mode) + os.chown(path, uid, gid, follow_symlinks=False) + os.chmod(path, mode) + +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))) + +# 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 +def snapshot_sizes(job, repo, snap_id, dirs): + sizes = {} + if not dirs: + return sizes + wanted = set(dirs) + + def on_line(line): + try: + node = json.loads(line) + except ValueError: + return + path = node.get('path') if isinstance(node, dict) else None + if not isinstance(path, str): + return + # the directory given on the command line the path is in + parts = path.split('/') + for depth in range(len(parts), 1, -1): + prefix = '/'.join(parts[:depth]) + if prefix in wanted: + sizes[prefix] = sizes.get(prefix, 0) + (num(node.get('size')) if node.get('type') == 'file' else 0) + break + + rc, _, err = restic(job, repo, ['ls', '--json', '--recursive', snap_id] + dirs, on_line=on_line) + if rc != 0: + raise RepoError('the snapshot could not be listed: ' + restic_error(rc, err)) + return sizes + +# Names of the entries of a directory of a snapshot +def snapshot_names(job, repo, snap_id, folder): + names = set() + + def on_line(line): + try: + node = json.loads(line) + except ValueError: + return + path = node.get('path') if isinstance(node, dict) else None + if isinstance(path, str) and path.startswith(folder + '/'): + names.add(path[len(folder) + 1:].split('/')[0]) + + rc, _, err = restic(job, repo, ['ls', '--json', snap_id, folder], on_line=on_line) + if rc != 0: + 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) + args = [] + for include in includes: + args += ['--include', include] + rc, _, err = restic(job, repo, ['restore', snap_id, '--target', target] + args, capture=False) + if rc != 0: + raise RepoError('the files could not be restored: ' + restic_error(rc, err)) + +# The exports of the domains (password hashes): read into memory, the files are deleted at once +def load_exports(job, repo, snap_id, target, domains): + paths = ['/mailcow/export/%s.json' % d for d in domains] + sizes = snapshot_sizes(job, repo, snap_id, ['/mailcow/export']) + folder = target + '/export' + make_dir(folder, mode=0o700) + exports = {} + try: + restic_restore(job, repo, snap_id, folder, [glob_escape(p) for p in paths], sizes.get('/mailcow/export', 0) * 2, + 'the export of the snapshot') + for domain, path in zip(domains, paths): + try: + data = json.loads(read_private(folder + path)) + exports[domain] = data if isinstance(data, dict) else None + except (OSError, ValueError): + exports[domain] = None + if exports[domain]: + section_aliases(exports[domain]) + finally: + shutil.rmtree(folder, ignore_errors=True) + return exports + +# The aliases naming the mailboxes come once with the first page of an export: into the sections, as backup.php expects them +def section_aliases(export): + sections = export.get('mailboxes') if isinstance(export.get('mailboxes'), dict) else {} + for username, rows in (export.pop('mailbox_aliases', None) or {}).items(): + if isinstance(sections.get(username), dict) and isinstance(rows, list): + sections[username]['aliases'] = (sections[username].get('aliases') or []) + rows + +# The domain (with alias domains, DKIM and its settings) before its mailboxes; without it the restore fails +def restore_domain(job, domain, export, mailboxes, results): + data = (export or {}).get('domain_data') + if not isinstance(data, dict): + job.result['domain'] = component_error('the snapshot has no data of the domain') + else: + try: + job.result['domain'] = post({'action': 'restore_domain', 'job': job.id, 'data': data}).get('domain') or \ + component_error('no result') + except Exception as e: + job.result['domain'] = component_error('the restore request failed: %s' % http_error(e)) + if query("SELECT 1 FROM domain WHERE domain = %s", (domain,)).fetchone(): + job.log('domain %s: %s' % (domain, 'created' if job.result['domain'].get('created') else 'exists')) + return + for username in mailboxes: + results[username] = {'error': 'the domain could not be created'} + job.save() + raise JobError('the domain %s could not be created: %s' % (domain, '; '.join(job.result['domain'].get('errors') or []))) + +# Aliases of the domain (scope domain), in chunks; the own alias of a mailbox comes with the mailbox +def restore_aliases(job, export): + mailboxes = (export or {}).get('mailboxes') or {} + aliases = [a for a in (export or {}).get('aliases') or [] if not (isinstance(a, dict) and str(a.get('address') or '').lower() in mailboxes)] + total = {'added': 0, 'skipped': 0, 'errors': []} + for i in range(0, len(aliases), alias_chunk): + check_cancel() + try: + part = post({'action': 'restore_aliases', 'job': job.id, 'aliases': aliases[i:i + alias_chunk]}).get('aliases') or {} + except Exception as e: + part = component_error('the restore request failed: %s' % http_error(e)) + total['added'] += num(part.get('added')) + total['skipped'] += num(part.get('skipped')) + total['errors'] += [str(e) for e in part.get('errors') or []] + job.result['aliases'] = total + job.save() + job.log('aliases of the domain: %d added, %d skipped, %d errors' % (total['added'], total['skipped'], len(total['errors']))) + +# backup.php creates a missing mailbox and merges the settings of each mailbox of a batch +def restore_settings(job, batch, sections, components, results): + create = set(job.params.get('create_mailboxes') or []) + for username in batch: + result = results[username] + if result.get('error') or not (components or username in create): + continue + check_cancel() + section = sections.get(username) + if not isinstance(section, dict): + for component in components: + result[component] = component_error('the snapshot has no export of this mailbox') + if username in create: + result['mailbox'] = component_error('the snapshot has no export of this mailbox, it cannot be created') + job.log('%s: no export in the snapshot' % username) + continue + 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')) + except Exception as e: + for component in components: + result[component] = component_error('the restore request failed: %s' % http_error(e)) + job.log('%s: the restore request failed: %s' % (username, http_error(e))) + job.save() + +# sogo_restore.py resets calendars, address books and SOGo preferences of each mailbox to the backup (sogo-tool +# restore in the sogo container through dockerapi) +def restore_sogo(job, sogo_dir, batch, results): + for username in batch: + check_cancel() + db = connect() + try: + results[username]['sogo'] = sogo_restore.restore(db, sogo_dir, username, + lambda text: job.log('%s: %s' % (username, text))) + except Exception as e: + results[username]['sogo'] = component_error('the SOGo restore failed: %s' % e) + finally: + db.close() + job.save() + +# sogo-tool backup files are world-readable and hold password hashes: only the sogo user reads them, no symlinks +def protect_sogo_files(folder): + os.chown(folder, sogo_uid, sogo_uid, follow_symlinks=False) + os.chmod(folder, 0o700) + for name in os.listdir(folder): + path = os.path.join(folder, name) + mode = os.lstat(path).st_mode + if stat.S_ISREG(mode): + os.chown(path, sogo_uid, sogo_uid, follow_symlinks=False) + os.chmod(path, 0o600) + elif stat.S_ISDIR(mode): + shutil.rmtree(path, ignore_errors=True) + else: + unlink(path) + +# Hands a batch to the dovecot runner (step 'mails') and waits until it is done with it ('mails_done') +def handover(job, batch_dir, acl_dir, mailboxes): + job.result['batch'] = {'dir': batch_dir, 'acl_dir': acl_dir, 'mailboxes': mailboxes} + job.result['handover'] = int(time.time()) + job.save() + query("UPDATE backup_job SET step = 'mails' WHERE id = %s AND state = 'running'", (job.id,)) + job.log('mails, IMAP ACLs and Sieve scripts of %d mailboxes are restored by the dovecot container' % len(mailboxes)) + started = time.monotonic() + while True: + sleep(5) + tick() + if stopping: + # the dovecot runner finishes the batch, the job fails after the restart + raise JobDetached() + row = query("SELECT state, step, result FROM backup_job WHERE id = %s", (job.id,)).fetchone() + if not row or row['state'] != 'running': + raise JobGone() + if row['step'] == 'mails_done': + job.result = json.loads(row['result'] or '{}') + break + if row['step'] == 'mails' and time.monotonic() - started > pickup_timeout and query("""UPDATE backup_job SET step = 'files' + WHERE id = %s AND step = 'mails'""", (job.id,)).rowcount == 1: + raise JobError('the dovecot container did not take over the restore (ofelia job dovecot_restore_runner)') + if row['step'] == 'mails_running' and time.time() - restore_seen() > dovecot_timeout: + raise JobError('the restore of the mails in the dovecot container stopped answering') + for key in ('batch', 'handover'): + job.result.pop(key, None) + job.warnings = job.result.get('warnings') or job.warnings + job.step('files') + +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 + +# What restic restores for a batch: the home only when mails are restored, else only the files of the other +# components; returns (include patterns, bytes of the homes) +def batch_includes(ready, homes, components): + includes, need = [], 0 + for username in ready: + local, _, domain = username.rpartition('@') + home = '/var/vmail/%s/%s' % (domain, local) + if home in homes: + if 'mails' in components: + includes.append(glob_escape(home)) + need += homes[home] + else: + if 'imap_acl' in components: + includes.append(glob_escape(home) + '/**/dovecot-acl') + if components & {'filters', 'sieve_activate'}: + includes.append(glob_escape(home) + '/sieve') + if components & {'filters', 'sieve_activate'}: + includes.append('/var/vmail/sieve/%s.sieve' % glob_escape(username)) + if 'sogo' in components: + includes.append('/sogo_backup/%s' % glob_escape(username)) + return includes, need + +# A restore: deleted domains and mailboxes are created by backup.php first, then the mailboxes are restored in +# batches: restic restores only what the components need into /restore//b, backup.php merges the settings, +# sogo_restore.py the SOGo data, the dovecot runner mails, IMAP ACLs and Sieve scripts; the staging of a batch is +# deleted before the next one +def job_restore(job, s, repo): + params = job.params + snapshot = str(params.get('snapshot') or '') + if not re.fullmatch(r'[0-9a-f]{8,64}', snapshot): + raise JobError('invalid snapshot id') + mailboxes = params.get('mailboxes') if isinstance(params.get('mailboxes'), list) else [] + create_domain = bool(params.get('create_domain')) + # a deleted domain without mailboxes: domain data and aliases only + if (not mailboxes and not create_domain) or not all(isinstance(m, str) and mailbox_valid(m) for m in mailboxes): + raise JobError('no or invalid mailboxes') + components = {c for c in params.get('components') or [] if isinstance(c, str)} + if not components: + raise JobError('no components to restore') + domains = sorted({m.rpartition('@')[2] for m in mailboxes} or {str(params.get('domain') or '')}) + if len(domains) != 1 or not domain_re.fullmatch(domains[0]): + raise JobError('invalid domain') + allowed = params.get('allowed_domains') if isinstance(params.get('allowed_domains'), list) else None + if allowed is not None and not all(isinstance(d, str) and domain_re.fullmatch(d) for d in allowed): + raise JobError('invalid allowed domains') + open_repository(job, s, repo, create=False) + job.step('files') + found = restic_json(job, repo, ['snapshots', '--json', snapshot]) or [] + if not found: + raise JobError('snapshot %s not found' % snapshot) + snap = found[0] + snap_id = snap['id'] + if 'mode:config' in (snap.get('tags') or []) and components & mail_components: + raise JobError('the snapshot holds no mails (RESTIC_BACKUP_MODE=config): ' + ', '.join(sorted(components & mail_components))) + job.result['snapshot'] = {'id': snap_id, 'short_id': snap.get('short_id') or snap_id[:8], + 'time': (local_time(snap.get('time')) or '')[:16]} + target = '%s/%d' % (restore_dir, job.id) + shutil.rmtree(target, ignore_errors=True) + make_dir(target) + results = job.result['mailboxes'] = {m: {} for m in mailboxes} + create = set(params.get('create_mailboxes') or []) + scope_domain = params.get('scope') == 'domain' + # the export also tells which mailboxes the snapshot has + exports = load_exports(job, repo, snap_id, target, domains) + sogo_files = snapshot_names(job, repo, snap_id, '/sogo_backup') if 'sogo' in components else set() + sections = {} + for domain in domains: + sections.update((exports.get(domain) or {}).get('mailboxes') or {}) + job.save() + if create_domain: + job.step('domain') + restore_domain(job, domains[0], exports.get(domains[0]), mailboxes, results) + if scope_domain and 'aliases' in components: + job.step('aliases') + restore_aliases(job, exports.get(domains[0])) + exports = None + acl_dir = None + if 'imap_acl' in components and mailboxes: + # the other side: rights other mailboxes granted to the restored ones (domain admins: only their domains) + acl_dir = target + '/acl' + make_dir(acl_dir) + owners = ['/var/vmail/%s/**/dovecot-acl' % glob_escape(d) for d in allowed] if allowed is not None \ + else ['/var/vmail/**/dovecot-acl'] + restic_restore(job, repo, snap_id, acl_dir, owners, 0, 'the IMAP ACLs') + homes_needed = bool(components & {'mails', 'imap_acl', 'filters', 'sieve_activate'}) + batches = [mailboxes[i:i + restore_batch] for i in range(0, len(mailboxes), restore_batch)] + for number, batch in enumerate(batches, 1): + check_cancel() + job.step('files') + homes = {} + if homes_needed: + homes = snapshot_sizes(job, repo, snap_id, ['/var/vmail/%s/%s' % (m.rpartition('@')[2], m.rpartition('@')[0]) + for m in batch]) + for username in batch: + local, _, domain = username.rpartition('@') + # a mailbox that never got mail has no home yet, the export still knows it + if '/var/vmail/%s/%s' % (domain, local) not in homes and username not in sections and username not in sogo_files: + results[username] = {'error': 'the mailbox is not in the snapshot'} + job.log('%s: not in the snapshot' % username) + job.step('settings') + restore_settings(job, batch, sections, components & php_components, results) + # mailboxes that exist now (created or not) get the rest + live = {row['username'] for row in query("SELECT username FROM mailbox WHERE username IN (%s)" % ','.join(['%s'] * len(batch)), + tuple(batch))} + ready = [] + for username in batch: + if results[username].get('error'): + continue + if username not in live: + results[username]['error'] = 'the mailbox does not exist' + (' and could not be created' if username in create else '') + continue + ready.append(username) + job.save() + batch_dir = '%s/b%d' % (target, number) + includes, need = batch_includes(ready, homes, components) + 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') + job.log('files of %d mailboxes restored in %d s (batch %d of %d)' % (len(ready), time.monotonic() - started, number, + len(batches))) + detached = False + try: + if 'sogo' in components and ready: + job.step('sogo') + sogo_dir = batch_dir + '/sogo_backup' + if os.path.isdir(sogo_dir) and not os.path.islink(sogo_dir): + protect_sogo_files(sogo_dir) + restore_sogo(job, sogo_dir, ready, results) + shutil.rmtree(sogo_dir, ignore_errors=True) + if components & dovecot_components and ready: + job.result['errors'] = count_errors(job.result) + handover(job, batch_dir, acl_dir, ready) + results = job.result['mailboxes'] + except JobDetached: + detached = True + raise + finally: + # the dovecot runner may still read the batch of a detached job + if not detached: + shutil.rmtree(batch_dir, ignore_errors=True) + job.result['errors'] = count_errors(job.result) + job.save() + shutil.rmtree(target, ignore_errors=True) + job.result['errors'] = count_errors(job.result) + if results and not any(not entry.get('error') for entry in results.values()): + raise JobError('none of the mailboxes could be restored') + +handlers = {'backup': job_backup, 'init': job_init, 'check': job_check, 'prune': job_prune, 'restore': job_restore} + +# Mail to notify_email through postfix (internal network, port 588) +def append_log(job_id, text): + print('backup job %d: %s' % (job_id, text), flush=True) + query("UPDATE backup_job SET log = RIGHT(CONCAT(IFNULL(log, ''), %s), %s) WHERE id = %s", + ('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), text), max_log, job_id)) + +# Mail through postfix (internal network, port 588) to notify_email or the given addresses; refused recipients and +# errors go to the container log and the log of the job +def notify(s, subject, text, job_id=None, addresses=None): + if addresses is None: + addresses = s['notify_email'].split(',') + rcpts = [r.strip() for r in addresses if re.fullmatch(r'[^@\s<>,]+@[^@\s<>,]+', str(r).strip())] + if not rcpts: + return + msg = EmailMessage() + msg['From'] = 'backup@' + hostname + msg['To'] = ', '.join(rcpts) + msg['Subject'] = 'mailcow backup on %s: %s' % (hostname, subject) + msg['Date'] = formatdate(localtime=True) + msg['Message-ID'] = make_msgid(domain=hostname) + msg.set_content(text + ('\n\nThe job log is in the backup section of the mailcow UI.\n' if job_id else '\n')) + problem = None + try: + with smtplib.SMTP('postfix', 588, hostname, timeout=60) as smtp: + refused = smtp.send_message(msg) + if refused: + problem = 'the notification was refused for %s' % ', '.join('%s (%s %s)' % (r, code, detail.decode('utf-8', 'replace') + if isinstance(detail, bytes) else detail) for r, (code, detail) in refused.items()) + except smtplib.SMTPRecipientsRefused as e: + problem = 'the notification was refused for %s' % ', '.join(e.recipients) + except (OSError, smtplib.SMTPException) as e: + problem = 'the notification could not be sent: %s' % e + if problem: + if job_id: + try: + append_log(job_id, problem) + except MySQLdb.Error: + pass + else: + print('backup runner: %s: %s' % (subject, problem), file=sys.stderr, flush=True) + +def notify_failed(s, job_id, job_type, error): + notify(s, '%s job %d failed' % (job_type, job_id), 'The %s job %d failed:\n\n%s' % (job_type, job_id, error), job_id) + +def queue(job_type, params=None): + query("INSERT INTO backup_job (type, params, requested_by) VALUES (%s, %s, 'schedule')", + (job_type, json.dumps(params or {}))) + +def finish(job, state, error): + try: + job.save() + except MySQLdb.Error as e: + # the state matters more than the result + print('backup job %d: the result could not be saved: %s' % (job.id, e), file=sys.stderr) + query("""UPDATE backup_job SET state = IF(state = 'cancelled', state, %s), error = %s, finished = NOW(), + step = IF(%s = 'done', NULL, step) WHERE id = %s""", (state, error, state, job.id)) + job.log('%s job %s%s' % (job.type, state, ': ' + error if error else '')) + +def run_job(row): + job = Job(row) + current.update(job=job, interrupt=None) + s = settings() + state, error = 'done', None + try: + job.log('%s job started (requested by %s)' % (job.type, job.requested_by or 'unknown')) + repo = Repository(s) + repo.prepare(s) + handlers[job.type](job, s, repo) + repo.remember_host(s) + except JobGone: + state = None + except JobDetached: + # the dovecot runner still imports the batch: its staging stays, the job fails after the restart (fail_orphans) + current['job'] = None + return + except JobError as e: + state, error = 'failed', str(e)[:2000] + if isinstance(e, RepoError) and job.restricted: + print('backup job %d: %s' % (job.id, error), file=sys.stderr, flush=True) + error = repo_error_generic + except MySQLdb.Error as e: + state, error = 'failed', 'database error: %s' % e + except Exception as e: + traceback.print_exc() + state, error = 'failed', 'internal error: %s' % e + finally: + current['job'] = None + if job.type == 'restore': + shutil.rmtree('%s/%d' % (restore_dir, job.id), ignore_errors=True) + if state is None: + # failed by the dovecot runner, which also sent the notification + return + if current['interrupt'] == 'cancelled': + state, error = 'cancelled', None + finish(job, state, error) + if state == 'failed': + notify_failed(s, job.id, job.type, error) + elif state == 'done' and job.warnings and job.type == 'backup': + notify(s, 'backup job %d finished with warnings' % job.id, 'The backup job %d finished with warnings:\n\n%s' % ( + job.id, '\n'.join('- ' + w for w in job.warnings)), job.id) + if state == 'done' and job.type == 'backup' and job.requested_by == 'schedule': + after_scheduled_backup(s) + +# After the first scheduled backup of a day: prune once a week from the prune weekday on (0 = Sunday, '' = never), then +# check the next part of the data +def after_scheduled_backup(s): + today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + weekday = s['prune_weekday'].strip() + if weekday.isdigit() and not scheduled_since('prune', today - datetime.timedelta(days=(weekday_of(today) - int(weekday)) % 7)): + queue('prune') + if num(s['check_subset']) > 0 and not scheduled_since('check', today): + queue('check') + +def scheduled_since(job_type, since): + return query("""SELECT 1 FROM backup_job WHERE type = %s AND requested_by = 'schedule' AND created >= %s LIMIT 1""", + (job_type, since.strftime('%Y-%m-%d %H:%M:%S'))).fetchone() is not None + +# 0 = Sunday, as prune_weekday and the days of the schedule +def weekday_of(day): + return (day.weekday() + 1) % 7 + +# The schedule (JSON list of {days: [0 = Sunday ...], time: "HH:MM"}) as sorted (weekday, hour, minute) slots +def schedule_slots(s): + try: + entries = json.loads(s['schedule']) + except ValueError: + return [] + slots = set() + for entry in entries if isinstance(entries, list) else []: + m = re.fullmatch(r'(\d{1,2}):(\d{2})', str(entry.get('time', '')) if isinstance(entry, dict) else '') + if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59 or not isinstance(entry.get('days'), list): + continue + slots |= {(day, int(m.group(1)), int(m.group(2))) for day in entry['days'] if isinstance(day, int) and 0 <= day <= 6} + return sorted(slots) + +# The newest schedule time up to now, None without slots +def last_slot(slots, now): + for back in range(8): + day = now - datetime.timedelta(days=back) + for weekday, hour, minute in reversed(slots): + if weekday == weekday_of(day): + slot = day.replace(hour=hour, minute=minute, second=0, microsecond=0) + if slot <= now: + return slot + return None + +# The longest time between two schedule times of a week (seconds), None without slots +def schedule_gap(slots): + times = [day * 86400 + hour * 3600 + minute * 60 for day, hour, minute in slots] + if not times: + return None + return max([b - a for a, b in zip(times, times[1:])] + [times[0] + 7 * 86400 - times[-1]]) + +# Scheduled backup: enabled, password confirmed, target complete, a schedule time passed since the schedule was +# switched on (or changed) and no scheduled backup since that time +def schedule_due(s): + if s['enabled'] != '1' or s['password_confirmed'] != '1': + if s.get('state_schedule_since'): + set_setting('state_schedule_since', '') + return False + try: + Repository(s) + except JobError: + return False + slots = schedule_slots(s) + if not slots: + return False + now = datetime.datetime.now() + if not s.get('state_schedule_since') or s.get('state_schedule') != s['schedule']: + set_setting('state_schedule_since', int(now.timestamp())) + set_setting('state_schedule', s['schedule']) + return False + due = last_slot(slots, now) + if due is None or num(s['state_schedule_since']) > due.timestamp(): + return False + return not scheduled_since('backup', due) + +# A mail when backups are on but the last success is older than the longest gap of the schedule plus stale_margin +# (36 hours for one backup a day), once a day +def check_last_success(s): + if s['enabled'] != '1': + return + limit = (schedule_gap(schedule_slots(s)) or 86400) + stale_margin + last = num(s.get('state_last_success')) or num(s.get('state_schedule_since')) + today = time.strftime('%Y-%m-%d') + if not last or time.time() - last < limit or s.get('state_stale_notified') == today: + return + set_setting('state_stale_notified', today) + notify(s, 'no successful backup for %d hours' % ((time.time() - last) // 3600), + 'Backups are enabled, but the last successful backup is older than %d hours.' % (limit // 3600)) + +# Target and connection values of settings, without secrets +def target_summary(values): + fields = {'s3': ('s3_endpoint', 's3_bucket', 's3_path', 's3_region'), 'sftp': ('sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'), + 'rest': ('rest_url', 'rest_user'), 'local': ('local_path',)} + target = str(values.get('target') or '') + return target + ''.join(', %s=%s' % (key, values.get(key)) for key in fields.get(target, ()) if values.get(key) not in (None, '')) + +# Mail to the notify addresses that were set before the target or its connection changed (backup.php stores them +# with the change); then the marker is removed, unless it changed meanwhile +def target_changed(s): + marker = s['state_target_changed'] + try: + change = json.loads(marker) + if not isinstance(change, dict): + raise ValueError() + except ValueError: + query("UPDATE backup_setting SET value = '' WHERE name = 'state_target_changed' AND value = %s", (marker,)) + return + addresses = change.get('old_addresses') or [] + if isinstance(addresses, str): + addresses = addresses.split(',') + when = change.get('time') + when = time.strftime('%Y-%m-%d %H:%M', time.localtime(int(when))) if str(when).isdigit() else str(when or '') + changed = change.get('changed') if isinstance(change.get('changed'), list) else [] + old = change.get('old_target') + notify(s, 'backup settings changed', 'The backup settings of mailcow on %s were changed%s%s%s.\n\nTarget before: %s\n' + 'Target now: %s\nNotification addresses now: %s\n\nIf you did not make this change, check the backup settings and ' + 'the API keys of mailcow.' % (hostname, ' by %s' % change['changed_by'] if change.get('changed_by') else '', + ' at %s' % when if when else '', ' (%s)' % ', '.join(str(c) for c in changed) if changed else '', + target_summary(old) if isinstance(old, dict) else str(old or 'unknown'), target_summary(s), s['notify_email'] or 'none'), + addresses=addresses) + query("UPDATE backup_setting SET value = '' WHERE name = 'state_target_changed' AND value = %s", (marker,)) + +# Restore staging of jobs that do not run anymore (finished, failed, crashed or deleted) +def clean_restore(): + job = current['job'] + for name in os.listdir(restore_dir): + if not name.isdigit() or (job and job.id == int(name)): + continue + row = query("SELECT state FROM backup_job WHERE id = %s", (int(name),)).fetchone() + if row and row['state'] == 'running': + continue + shutil.rmtree(os.path.join(restore_dir, name), ignore_errors=True) + +# Restores waiting for a runner that no longer waits (restart): a batch the dovecot runner finished, did not take or +# stopped working on +def fail_orphans(s): + seen = num(s.get('state_restore_seen')) + for row in query("""SELECT id, step FROM backup_job WHERE type = 'restore' AND state = 'running' + AND step IN ('mails', 'mails_running', 'mails_done')""").fetchall(): + if current['job'] and current['job'].id == row['id']: + continue + if row['step'] == 'mails_running' and time.time() - seen < dovecot_timeout: + continue + error = 'interrupted: the backup runner was restarted' + if query("UPDATE backup_job SET state = 'failed', error = %s, finished = NOW() WHERE id = %s AND state = 'running'", + (error, row['id'])).rowcount == 1: + notify_failed(s, row['id'], 'restore', error) + +# After a restart: jobs this runner was running are lost; a scheduled backup is queued again once +def recover(): + stale = query("""SELECT id, type, params, requested_by FROM backup_job WHERE state = 'running' + AND (type <> 'restore' OR step IS NULL OR step <> 'mails_running')""").fetchall() + s = settings() + for row in stale: + error = 'interrupted: the backup runner was restarted' + query("UPDATE backup_job SET state = 'failed', error = %s, finished = NOW() WHERE id = %s AND state = 'running'", + (error, row['id'])) + notify_failed(s, row['id'], row['type'], error) + try: + retry = bool(json.loads(row['params'] or '{}').get('retry_of')) + except (ValueError, AttributeError): + retry = True + if row['type'] == 'backup' and row['requested_by'] == 'schedule' and not retry: + queue('backup', {'retry_of': row['id']}) + clean_restore() + clean_staging() + +def ssh_setup(): + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + if not os.path.exists(ssh_key): + subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'mailcow-backup@' + hostname, '-f', ssh_key], + check=True, stdin=subprocess.DEVNULL) + if not os.path.exists(ssh_key + '.pub'): + public = subprocess.run(['ssh-keygen', '-y', '-f', ssh_key], check=True, capture_output=True, text=True).stdout + with open(ssh_key + '.pub', 'w') as f: + f.write(public) + with open(ssh_key + '.pub') as f: + return f.read().strip() + +def stop(signum, frame): + global stopping + stopping = True + proc = current['proc'] + if proc and proc.poll() is None: + current['interrupt'] = current['interrupt'] or 'stopped' + try: + proc.send_signal(signal.SIGINT) + except OSError: + pass + +def sleep(seconds): + end = time.monotonic() + seconds + while not stopping and time.monotonic() < end: + time.sleep(min(1, end - time.monotonic())) + +def main(): + global conn + # dumps, exports and keys are for root only + os.umask(0o077) + 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("backup runner: can't lock " + lock_file) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT): + signal.signal(sig, stop) + if os.environ.get('MASTER', 'y') != 'y': + print('backup runner: MASTER is not y, idle', flush=True) + while not stopping: + sleep(3600) + return + os.makedirs(cache_dir, mode=0o700, exist_ok=True) + # /restore and /restore/: only passable for vmail and sogo, the subtrees decide who reads them + os.chown(restore_dir, 0, 0) + os.chmod(restore_dir, 0o711) + # marks the folder of the local repository, also when it lies inside the mailcow directory + try: + marker = repository_root + '/' + repo_marker + content = (marker_header() + '\n').encode() + if not os.path.exists(marker) or read_private(marker) != content: + write_private(marker, content) + except OSError as e: + print('backup runner: %s: %s' % (repository_root, e), file=sys.stderr) + public_key = ssh_setup() + secrets_key = secrets_setup() + secrets_public = base64.b64encode(bytes(secrets_key.public_key)).decode() + print('backup runner: started (restic %s, %s)' % (restic_version(), instance_tag()), flush=True) + Heartbeat().start() + recovered, last_housekeeping = False, 0 + while not stopping: + try: + if not recovered: + if settings().get('state_secrets_pubkey') != secrets_public: + set_setting('state_secrets_pubkey', secrets_public) + recover() + recovered = True + tick() + s = settings() + if s['sftp_public_key'] != public_key: + set_setting('sftp_public_key', public_key) + if s.get('_secrets_error', '') != s.get('state_secrets_error', ''): + set_setting('state_secrets_error', s.get('_secrets_error', '')) + if s.get('state_backup_mode', '') != configured_mode(): + set_setting('state_backup_mode', configured_mode()) + if s.get('state_target_changed'): + target_changed(s) + fail_orphans(s) + if time.monotonic() - last_housekeeping >= 3600: + last_housekeeping = time.monotonic() + query("""DELETE FROM backup_job WHERE state IN ('done', 'failed', 'cancelled') + AND IFNULL(finished, created) < NOW() - INTERVAL %s DAY""", (keep_jobs_days,)) + check_last_success(s) + if schedule_due(s): + queue('backup') + row = query("""SELECT id, type, params, requested_by FROM backup_job WHERE state = 'queued' + ORDER BY id LIMIT 1""").fetchone() + if row and query("UPDATE backup_job SET state = 'running', started = NOW() WHERE id = %s AND state = 'queued'", + (row['id'],)).rowcount == 1: + run_job(row) + continue + except MySQLdb.Error as e: + print('backup runner: database error: %s' % e, file=sys.stderr, flush=True) + conn = None + except Exception: + traceback.print_exc() + sleep(poll_interval) + +# Command line for update.sh: --configured (exit 0 when backups are set up, 2 when they are not, 1 on errors such as +# credentials this container cannot open), --backup (queue a backup, wait for it and print its log; exit 0 when it is +# done) +def cli(argv): + global conn + conn = connect() + s = settings() + reason = None + if s.get('_secrets_error'): + print(s['_secrets_error'], file=sys.stderr) + return 1 + try: + backup_mode() + except JobError as e: + print(e, file=sys.stderr) + return 1 + try: + repo = Repository(s) + if s['password_confirmed'] != '1': + reason = 'the repository password is not confirmed' + elif s.get('state_initialized') != '1' or s.get('state_repository') != repo.name: + reason = 'the repository is not initialized' + except JobError as e: + reason = str(e) + if argv[1] == '--configured': + print(reason or 'backups are set up') + return 0 if reason is None else 2 + if argv[1] != '--backup': + print('usage: %s --configured | --backup' % argv[0], file=sys.stderr) + return 2 + if reason: + print('no backup: %s' % reason, file=sys.stderr) + return 2 + if time.time() - num(s.get('state_runner_seen')) > 120: + print('no backup: the backup runner does not run', file=sys.stderr) + return 2 + job_id = query("INSERT INTO backup_job (type, params, requested_by) VALUES ('backup', '{}', 'update')").lastrowid + print('backup job %d queued' % job_id, flush=True) + printed, waiting = 0, None + try: + while True: + row = query("SELECT state, error, log, result FROM backup_job WHERE id = %s", (job_id,)).fetchone() + if not row: + print('the backup job was deleted', file=sys.stderr) + return 1 + if row['state'] == 'queued': + other = query("SELECT id, type FROM backup_job WHERE state = 'running' ORDER BY id LIMIT 1").fetchone() + if other and other['id'] != waiting: + waiting = other['id'] + print('waiting for the running %s job %d' % (other['type'], other['id']), flush=True) + log = row['log'] or '' + if len(log) > printed: + print(log[printed:], end='', flush=True) + printed = len(log) + if row['state'] in ('done', 'failed', 'cancelled'): + break + time.sleep(2) + except KeyboardInterrupt: + print('\nthe backup job %d goes on in the background' % job_id, file=sys.stderr) + return 130 + if row['state'] == 'done': + result = json.loads(row['result'] or '{}') + print('backup done: snapshot %s%s' % ((result.get('snapshot') or '')[:8], + ', %d warnings' % len(result['warnings']) if result.get('warnings') else '')) + return 0 + print('backup %s%s' % (row['state'], ': ' + row['error'] if row['error'] else ''), file=sys.stderr) + return 1 + +if __name__ == '__main__': + sys.exit(cli(sys.argv)) if len(sys.argv) > 1 else main() diff --git a/data/Dockerfiles/restic/sogo_restore.py b/data/Dockerfiles/restic/sogo_restore.py new file mode 100644 index 000000000..5648e41ec --- /dev/null +++ b/data/Dockerfiles/restic/sogo_restore.py @@ -0,0 +1,118 @@ +"""SOGo part of a mailbox restore (component sogo, overwrites), called by the restic runner for one mailbox. + +Runs `sogo-tool restore -f ALL ` and `sogo-tool restore -p ` in the sogo container through +dockerapi on the user's file of the nightly sogo-tool backup: missing calendars, address books and entries come back, +the names and shares of existing folders and all SOGo preferences are replaced by the backup's, entries changed since +stay. Then the user's entries in SOGo's cache (memcached) are removed, so the change shows at once. +""" + +import json +import os +import re +import socket +import ssl +import urllib.request + +dockerapi_url = 'https://dockerapi:443' +memcached_addr = ('memcached-mailcow', 11211) +# dockerapi stops each of the two sogo-tool runs after 850 s +restore_timeout = 1800 +# accepted by the dockerapi handler as well +user_re = re.compile(r'[A-Za-z0-9_][A-Za-z0-9._+-]*@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+') + + +def _short(text, limit=300): + # sogo-tool may print database URLs: no credentials, one line, the end has the error + text = re.sub(r'([A-Za-z][A-Za-z0-9+.-]*://)[^\s/@]*@', r'\1***@', str(text)) + text = ' '.join(text.split()) + return text if len(text) <= limit else '...' + text[-limit:] + + +def _api(path, body=None, timeout=60): + # dockerapi uses the mail certificate, its name does not match + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(dockerapi_url + path, data=data, headers={'Content-Type': 'application/json'}, + method='GET' if body is None else 'POST') + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as response: + return json.load(response) + + +def _sogo_container(): + project = os.environ.get('COMPOSE_PROJECT_NAME', '').lower() + by_name = None + for cid, info in _api('/containers/json').items(): + if not cid.isalnum() or not isinstance(info, dict): + continue + labels = (info.get('Config') or {}).get('Labels') or {} + if labels.get('com.docker.compose.service') == 'sogo-mailcow': + if not project or labels.get('com.docker.compose.project', '').lower() == project: + return cid + elif by_name is None and 'sogo-mailcow' in str(info.get('Name', '')): + by_name = cid + if by_name: + return by_name + raise RuntimeError('sogo container not found') + + +def _memcached_delete(keys): + keys = [k for k in keys if 0 < len(k.encode()) <= 250 and not re.search(r'[\s\x00-\x1f\x7f]', k)] + if not keys: + return + with socket.create_connection(memcached_addr, timeout=10) as s: + for key in keys: + s.sendall(b'delete ' + key.encode() + b'\r\n') + reply = b'' + while not reply.endswith(b'\r\n'): + chunk = s.recv(64) + if not chunk: + raise RuntimeError('memcached closed the connection') + reply += chunk + if reply.strip() not in (b'DELETED', b'NOT_FOUND'): + raise RuntimeError('memcached: %s' % _short(reply.decode('ascii', 'replace'))) + + +def _cache_keys(db, username): + # preferences and the ACLs of the user's folders (/Calendar/personal+acl); never read the c_*location + # columns of sogo_folder_info (database credentials) + keys = ['%s+defaults' % username, '%s+settings' % username] + cur = db.cursor() + try: + cur.execute('SELECT c_path FROM sogo_folder_info WHERE c_path2 = %s', (username,)) + keys += ['%s+acl' % row[0][len('/Users/'):] for row in cur.fetchall() if row[0].startswith('/Users/')] + finally: + cur.close() + return keys + + +def restore(db, backup_dir, username, log): + """Restores the SOGo data of one user from / (staged readable for the sogo user only). + db: open MySQLdb connection; log: callable for short progress lines. Returns {"added": 0|1, "errors": [...]}.""" + result = {'added': 0, 'errors': []} + if not user_re.fullmatch(username): + result['errors'].append('username not supported by the SOGo restore') + return result + path = os.path.join(backup_dir, username) + if not os.path.isfile(path) or os.path.islink(path): + # sogo-tool backup ALL skips users without SOGo folders + # a key the UI translates + result['note'] = 'no_sogo_data' + log('sogo: no SOGo data in the snapshot') + return result + try: + res = _api('/containers/%s/exec' % _sogo_container(), {'cmd': 'sogo', 'task': 'restore', 'dir': backup_dir, + 'username': username}, timeout=restore_timeout) + if not isinstance(res, dict) or res.get('type') != 'success': + raise RuntimeError('sogo-tool restore failed: %s' % _short(res.get('msg', '') if isinstance(res, dict) else res)) + result['added'] = 1 + log('sogo: calendars, address books and preferences reset to the backup') + except Exception as e: + result['errors'].append(_short(e)) + try: + # SOGo caches ACLs and preferences for 15 minutes + _memcached_delete(_cache_keys(db, username)) + except Exception as e: + result['errors'].append('SOGo cache not cleared, changes may show up after 15 minutes: %s' % _short(e)) + return result diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index d97bb8b99..82a557183 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -150,6 +150,15 @@ http { access_log /var/log/nginx/access.log; root /dynmaps; + # backup endpoint of the restic runner: large restore requests, long merges of mailboxes and domains + location = /backup.php { + client_max_body_size 32m; + fastcgi_read_timeout 600s; + fastcgi_pass {{ PHPFPMHOST }}:9001; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; diff --git a/data/conf/rspamd/dynmaps/backup.php b/data/conf/rspamd/dynmaps/backup.php new file mode 100644 index 000000000..50485f8f2 --- /dev/null +++ b/data/conf/rspamd/dynmaps/backup.php @@ -0,0 +1,97 @@ +[&after=]: a page of the export of a domain (the first with domain_data and aliases, +// every page with a part of the mailboxes and "next_after", the last username to ask for the next page with, null +// after the last), incl. password hashes, DKIM keys and secrets; the runner merges the pages into +// /mailcow/export/.json of the snapshot. +// POST {"action": "restore_domain", "job": , "data": }: recreates a deleted domain +// POST {"action": "restore_aliases", "job": , "aliases": [...]}: a part (at most 500) of the aliases of a domain restore +// POST {"action": "restore", "job": , "username": , "data": }: one mailbox +// (created first when it was deleted); all answer {"result": {: {"added", "skipped", "errors"}}} +// All need the header X-Backup-Token: HMAC-SHA256 of "backup" with the database password as key +header('Content-Type: application/json'); +require_once "vars.inc.php"; +require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php'; +// Do not show errors, we log to using error_log +ini_set('error_reporting', 0); +if (empty($database_pass) || !hash_equals(hash_hmac('sha256', 'backup', $database_pass), (string)($_SERVER['HTTP_X_BACKUP_TOKEN'] ?? ''))) { + http_response_code(403); + exit; +} +// Init database +$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name; +$opt = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} +catch (PDOException $e) { + error_log("BACKUP: " . $e . PHP_EOL); + http_response_code(501); + exit; +} + +try { + // DKIM keys, rate limits and the domain map are kept in Redis + $redis = new Redis(); + if (!empty(getenv('REDIS_SLAVEOF_IP'))) { + $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); + } + else { + $redis->connect('redis-mailcow', 6379); + } + $redis->auth(getenv("REDISPASS")); + $config = array( + 'timezone' => getenv('TZ') ?: 'UTC', + 'alias_disable_external_domains' => ($ALIAS_DISABLE_EXTERNAL_DOMAINS ?? false) === true, + ); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $body = json_decode((string)file_get_contents('php://input'), true); + if (!is_array($body)) { + http_response_code(400); + exit; + } + // The restore has the side effects of the UI: SOGo view and restart, Dovecot auth cache, group shares (legacy code) + require_once __DIR__ . '/../web/inc/functions.inc.php'; + require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; + require_once __DIR__ . '/../web/inc/functions.docker.inc.php'; + require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; + require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php'; + require_once __DIR__ . '/../web/inc/functions.acl.inc.php'; + // The legacy mailbox functions run as admin, without a session + $_SESSION = array( + 'mailcow_cc_username' => 'admin', + 'mailcow_cc_role' => 'admin', + 'access_all_exception' => '0', + 'acl' => array_fill_keys(array('tls_policy', 'quarantine_notification', 'quarantine_category', 'ratelimit', 'sogo_access', + 'protocol_access', 'mailbox_relayhost', 'unlimited_quota'), '1'), + ); + $backup = \Mailcow\Backup\BackupModule::create($pdo, \Mailcow\Core\Context::system(), $config, $redis); + echo json_encode($backup->api->post($body), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + else { + $backup = \Mailcow\Backup\BackupModule::create($pdo, \Mailcow\Core\Context::system(), $config, $redis); + if (isset($_GET['export'])) { + echo json_encode($backup->api->export((string)$_GET['export'], (isset($_GET['after']) && is_string($_GET['after'])) ? $_GET['after'] : null), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + elseif (isset($_GET['domains'])) { + echo json_encode($backup->api->domains()); + } + else { + http_response_code(400); + } + } +} +catch (\Mailcow\Core\UserError $e) { + http_response_code(($e->key === 'domain_not_found') ? 404 : 400); + echo json_encode(array('error' => $e->key, 'args' => $e->args)); +} +catch (Throwable $e) { + error_log("BACKUP: " . $e->getMessage() . PHP_EOL); + http_response_code(500); +} diff --git a/data/web/admin/restore.php b/data/web/admin/restore.php new file mode 100644 index 000000000..5715979a3 --- /dev/null +++ b/data/web/admin/restore.php @@ -0,0 +1,8 @@ +add('/web/js/site/admin.js'); $js_minifier->add('/web/js/presets/rspamd.js'); $js_minifier->add('/web/js/site/pwgen.js'); $js_minifier->add('/web/js/site/identity.js'); +$js_minifier->add('/web/js/site/backup.js'); // all domains $domains = mailbox('get', 'domains'); @@ -121,6 +122,8 @@ $template_data = [ 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'iam_settings' => $iam_settings, 'mbox_templates' => $mbox_templates, + 'backup_timezone' => getenv('TZ') ?: 'UTC', + 'backup_hostname' => getenv('MAILCOW_HOSTNAME') ?: '', 'lang_admin' => json_encode($lang['admin']), 'lang_datatables' => json_encode($lang['datatables']) ]; diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index cafde2456..1455699f9 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1597,6 +1597,174 @@ paths: type: boolean type: object summary: Create retention rule + /api/v1/add/backup-job: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - backup + - add + - job + - type: backup + msg: + - backup_job_added + - backup + - 11 + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Backup + description: >- + Queue a job for the backup container (admins): backup (database dump, + export of the domains, restic backup, forget by the keep policy), check + (restic check of the structure and 1/N of the data), prune (forget and + delete unused data) or init (test the connection, create the repository + if it does not exist). One job of each type at a time + (backup_job_active). Needs a complete target and repository password + (backup_not_configured); a backup also the confirmation that the + repository password is stored outside of mailcow + (backup_password_not_confirmed). Follow the job with + get/backup-job/{id}. + operationId: Create backup job + requestBody: + content: + application/json: + schema: + example: + type: backup + properties: + type: + description: backup, check, prune or init + type: string + type: object + summary: Create backup job + /api/v1/add/backup-restore: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - backup + - add + - restore + - snapshot: 3ee0b6f7 + scope: mailbox + mailboxes: + - user@example.org + components: + - mails + - aliases + mail_target: folder + msg: + - backup_restore_added + - 12 + - 1 + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Backup + description: >- + Queue a restore from a snapshot (admins; domain admins for their + domains). Scope "mailbox" restores the given mailboxes of one domain, + scope "domain" all mailboxes of the domain; admins can also restore a + deleted domain. Deleted mailboxes are created from the backup first. + The restore only adds what is missing, except "sogo", "settings" and + "password", which replace the current values with the backup's. + Components: mails (mail_target "folder": a copy below "Restored + "; "original": only missing messages, into their + folders), imap_acl, aliases, groups, sender_acl, mail_policies, + retention_rules, filters, sieve_activate (needs filters), spam, bcc, + syncjobs, app_passwords, tfa, sogo, settings, password. Domain admins + need the matching ACL (else backup_component_denied). Snapshots made + with RESTIC_BACKUP_MODE=config hold no mails (backup_components_no_mails). + Results per mailbox and component: get/backup-job/{id}. + operationId: Create restore job + requestBody: + content: + application/json: + schema: + example: + snapshot: 3ee0b6f7 + scope: mailbox + mailboxes: + - user@example.org + components: + - mails + - imap_acl + - aliases + - sender_acl + - filters + mail_target: folder + properties: + snapshot: + description: id or short id of the snapshot (get/backup-snapshots) + type: string + scope: + description: mailbox (the given mailboxes, default) or domain (all mailboxes of the domain) + type: string + mailboxes: + description: mailboxes of one domain, scope mailbox only, max. 1000; deleted ones from the snapshot's content list are created + type: array + domain: + description: the domain (required for scope domain, a deleted one for admins; for scope mailbox it must exist and match the mailboxes) + type: string + components: + description: what to restore, see above + type: array + mail_target: + description: folder (default) or original + type: string + type: object + summary: Create restore job /api/v1/add/oauth2-client: post: responses: @@ -3186,6 +3354,67 @@ paths: type: object type: object summary: Delete retention rules + /api/v1/delete/backup-job: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - backup + - delete + - job + - id: + - 12 + msg: + - backup_job_cancelled + - 12 + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Backup + description: >- + Cancel backup jobs by ID (admins any job, domain admins the restores of + their domains). Queued jobs and running jobs can be cancelled, except a + restore while the dovecot container restores the mails (step mails or + mails_running; backup_job_not_cancellable). A running job stops at its + next check; what it restored so far stays. One message per ID. + operationId: Cancel backup jobs + requestBody: + content: + application/json: + schema: + example: + - 12 + properties: + items: + description: contains list of job IDs you want to cancel + type: object + type: object + summary: Cancel backup jobs /api/v1/delete/identity-link: post: responses: @@ -5029,6 +5258,198 @@ paths: type: object type: object summary: Update retention rules + /api/v1/edit/backup-settings: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - backup + - edit + - settings + - enabled: "1" + schedule: + - days: [1, 3] + time: "12:00" + - days: [5, 6, 0] + time: "18:00" + keep_daily: 14 + msg: backup_settings_saved + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Backup + description: >- + Save the backup settings (admins). Settings you do not send keep their + value. Secrets (password, s3_secret_key, rest_password) are write-only: + an empty value keeps the stored one. A new repository password resets + password_confirmed unless the same request confirms it. Secrets are + stored encrypted with the public key of the backup container (sealed + box); until the container has published its key, saving a secret + fails with backup_secrets_key_missing. A new repository password only + works for a new repository (restic keeps the password a repository was + created with). Changing the target, its connection or the password + makes the backup container test the repository again; changing the + SFTP host or port forgets the host key. enabled needs a complete + target and repository password (backup_not_configured). Invalid values + are refused with backup_setting_invalid and the name of the setting; + nothing is saved then. + operationId: Update backup settings + requestBody: + content: + application/json: + schema: + example: + items: + - settings + attr: + enabled: "1" + target: s3 + s3_endpoint: https://s3.eu-central-1.amazonaws.com + s3_bucket: mailcow-backup + s3_access_key: AKIAEXAMPLE + s3_secret_key: secret + password: a-long-repository-password + password_confirmed: "1" + schedule: + - days: [1, 3] + time: "12:00" + - days: [5, 6, 0] + time: "18:00" + keep_daily: 14 + properties: + attr: + properties: + enabled: + description: back up at the times of schedule (needs at least one entry) + type: boolean + target: + description: s3, sftp, rest or local + type: string + s3_endpoint: + description: http(s) URL of the S3 endpoint + type: string + s3_bucket: + type: string + s3_path: + description: path in the bucket (optional) + type: string + s3_region: + description: optional + type: string + s3_access_key: + type: string + s3_secret_key: + description: write-only + type: string + s3_path_style: + description: path-style bucket access (restic -o s3.bucket-lookup=path) + type: boolean + sftp_host: + type: string + sftp_port: + type: number + sftp_user: + type: string + sftp_path: + description: directory of the repository, relative to the home directory or absolute + type: string + rest_url: + description: URL of the restic REST server without credentials, e.g. https://backup.example.org:8000/mailcow + type: string + rest_user: + type: string + rest_password: + description: write-only + type: string + local_path: + description: subdirectory below RESTIC_LOCAL_PATH (default ./data/restic), no ".." + type: string + password: + description: restic repository password, at least 8 characters, write-only + type: string + password_confirmed: + description: the admin confirms that the repository password is stored outside of mailcow; backups need it + type: boolean + schedule: + description: the scheduled backups, up to 24 entries (also as a JSON string); each backs up on its weekdays at its time + type: array + items: + type: object + properties: + days: + description: weekdays, 0 (Sunday) to 6, at least one + type: array + items: + type: number + time: + description: HH:MM in the time zone of mailcow + type: string + keep_hourly: + description: hourly snapshots to keep (for several backups a day), 0 to 1000 + type: number + keep_daily: + description: daily snapshots to keep, 0 to 1000 + type: number + keep_weekly: + type: number + keep_monthly: + type: number + keep_yearly: + type: number + prune_weekday: + description: 0 (Sunday) to 6, prune once a week after the first scheduled backup from that day on; empty string = never (manual prune jobs only, e.g. for append-only or object-lock targets) + oneOf: + - type: number + - type: string + max_unused: + description: restic prune --max-unused, 0% to 100% + type: string + check_subset: + description: after the first scheduled backup of a day check 1/N of the data (0 = off), 0 to 365 + type: number + include_index: + description: back up the Dovecot index + type: boolean + include_fts: + description: include the full-text search index (with include_index) + type: boolean + limit_upload: + description: KiB/s, 0 = unlimited + type: number + notify_email: + description: addresses separated by commas that get a mail when a job fails, max. 20 + type: string + type: object + items: + description: 'contains ["settings"]' + type: object + type: object + summary: Update backup settings /api/v1/edit/identity-provider-sync: post: responses: @@ -7257,6 +7678,516 @@ paths: mailboxes they manage. operationId: Get effective retention rules summary: Get effective retention rules + /api/v1/get/backup-settings: + get: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + enabled: 1 + target: sftp + s3_endpoint: https://s3.amazonaws.com + s3_bucket: "" + s3_path: "" + s3_region: "" + s3_access_key: "" + s3_secret_key_set: 0 + s3_path_style: 0 + sftp_host: backup.example.org + sftp_port: 22 + sftp_user: mailcow + sftp_path: backups/mailcow + sftp_public_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... mailcow-backup@mail.example.org + sftp_host_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... + rest_url: "" + rest_user: "" + rest_password_set: 0 + local_path: mailcow + password_set: 1 + password_confirmed: 1 + schedule: + - days: [0, 1, 2, 3, 4, 5, 6] + time: "02:30" + keep_hourly: 0 + keep_daily: 14 + 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: admin@example.org + state_initialized: 1 + state_runner_seen: 1794093697 + state_last_backup: 6 + state_last_success: 1794089278 + state_repo_size: 84418594 + state_repo_stats: + total_size: 84418594 + snapshots_count: 2 + configured: 1 + description: OK + headers: {} + tags: + - Backup + description: >- + The backup settings (admins, also read-only API keys). Secrets are + never returned, password_set, s3_secret_key_set and rest_password_set + tell whether they are set. sftp_public_key (add it to the + authorized_keys of the SFTP user), sftp_host_key and the state_* values + are written by the backup container. configured: target and repository + password are complete. {} for everybody else. + operationId: Get backup settings + summary: Get backup settings + /api/v1/get/backup-status: + get: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + enabled: 1 + target: local + configured: 1 + password_confirmed: 1 + initialized: 1 + runner_seen: 1794094257 + runner_age: 4 + runner_ok: 1 + last_success: 1794089278 + repo_size: 84418594 + repo_stats: + total_size: 84418594 + snapshots_count: 2 + snapshots: 2 + latest_snapshot: 1794089277 + next_run: 1794101400 + schedule_gap: 86400 + secrets_error: null + mode: all + jobs: + backup: + id: 26 + type: backup + state: done + step: null + params: {} + result: + snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e + error: null + domain: "" + requested_by: schedule + created: 1794093646 + started: 1794093646 + finished: 1794093653 + check: null + prune: null + init: null + restore: null + active: [] + description: OK + headers: {} + tags: + - Backup + description: >- + State of the backup (admins): configuration, heartbeat of the backup + container (runner_ok: reported within the last 120 seconds), last + successful backup, repository size, number of snapshots, the next + scheduled run (null while the schedule is off or waits for the + password confirmation), schedule_gap (the longest time between two + scheduled backups in seconds; a last success older than that is + overdue), the newest job of each type and the queued and running jobs. secrets_error (null when fine): the backup container + cannot decrypt the stored secrets (e.g. after the database was restored on another + server); enter the repository password and the credentials again. + mode: RESTIC_BACKUP_MODE of mailcow.conf (all, data or config; null + until the backup container reported it). Times are unix timestamps. + operationId: Get backup status + summary: Get backup status + /api/v1/get/backup-snapshots: + get: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f + short_id: 3ee0b6f7 + time: 1794089277 + hostname: mail.example.org + tags: + - mailcow + summary: + files_new: 104 + files_changed: 24 + data_added: 4103499 + total_bytes_processed: 92154276 + domains: + - example.org + mode: all + description: OK + headers: {} + tags: + - Backup + description: >- + The snapshots in the repository, newest first, as of the last backup + or prune (admins). Domain admins get the snapshots that hold one of + their domains and were made after the domain was created, with id, + short_id, time, mode and those domains only. domains is null for + snapshots not made by mailcow. mode: what the backup holds, from + RESTIC_BACKUP_MODE (tag mode:): all, data (without the mailcow + directory) or config (without mails). + operationId: Get backup snapshots + summary: Get backup snapshots + "/api/v1/get/backup-snapshot-content/{snapshot}": + get: + parameters: + - description: id or short id of the snapshot + example: 3ee0b6f7 + in: path + name: snapshot + required: true + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f + time: 1794089277 + available: 1 + domains: + - domain: example.org + exists: 1 + mailboxes: 42 + missing: 2 + - domain: old.example.org + exists: 0 + mailboxes: 5 + missing: 5 + description: OK + headers: {} + tags: + - Backup + description: >- + The domains of a snapshot with their state now: exists (the domain + exists now), mailboxes (in the snapshot) and missing (of them deleted + now). Admins get all domains, domain admins the domains they + administer now (snapshots made after the domain was created). available + 0: the snapshot has no content list (made before mailcow kept one), the + list then comes from its manifest and counts the mailboxes that exist + now; only those can be restored. {} if the snapshot does not exist or + is not visible. + operationId: Get backup snapshot content + summary: Get backup snapshot content + "/api/v1/get/backup-snapshot-content/{snapshot}/{domain}": + get: + parameters: + - description: id or short id of the snapshot + example: 3ee0b6f7 + in: path + name: snapshot + required: true + schema: + type: string + - description: a domain of the snapshot + example: example.org + in: path + name: domain + required: true + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + domain: example.org + exists: 1 + mailboxes: + - username: info@example.org + name: Info + exists: 1 + - username: former@example.org + name: Former Colleague + exists: 0 + description: OK + headers: {} + tags: + - Backup + description: >- + The mailboxes of a domain in a snapshot with their state now (exists 0: + deleted since, a restore creates it from the backup). Same visibility + as get/backup-snapshot-content/{snapshot}; {} if not visible. + operationId: Get backup snapshot domain content + summary: Get backup snapshot domain content + "/api/v1/get/backup-snapshot-search/{snapshot}/{term}": + get: + parameters: + - description: id or short id of the snapshot + example: 3ee0b6f7 + in: path + name: snapshot + required: true + schema: + type: string + - description: search term, URL-encoded (path segment) + example: carol + in: path + name: term + required: true + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + domains: + - domain: example.org + exists: 0 + mailboxes: + - username: carol@example.org + name: Carol + exists: 0 + truncated: 0 + description: OK + headers: {} + tags: + - Backup + description: >- + Search the domains and mailboxes of a snapshot (case-insensitive + "contains"). A plain term matches the domain name, the local part, the + full address or the display name; a matching domain comes with all its + mailboxes. "x@" matches the local part only, "@d" returns all mailboxes + of the matching domains (a matching domain also appears without + mailboxes), "x@d" matches the full address only. An empty term or "@" + returns no domains. At most 500 mailboxes, then truncated is 1. Same + visibility as get/backup-snapshot-content (domain admins: domains they + administer, snapshots made after the domain was created); {} when the + snapshot does not exist or is not visible. For snapshots without a + content list the current mailboxes of the snapshot's domains are + searched. The path is decoded before json_api.php reads it: encode the + term twice when it may contain "&", "+", "%" or "#" (the UI always + encodes it twice). + operationId: Search backup snapshot + summary: Search backup snapshot + "/api/v1/get/backup-jobs/{count}": + get: + parameters: + - description: Number of jobs to return, newest first (default 100 without this part, max. 1000) + example: 100 + in: path + name: count + required: true + schema: + type: number + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 12 + type: restore + state: done + step: null + params: + snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f + snapshot_time: 1794089277 + scope: mailbox + domain: example.org + mailboxes: + - user@example.org + components: + - mails + - aliases + mail_target: folder + requested_role: admin + create_domain: 0 + create_mailboxes: + - user@example.org + result: + snapshot: + id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f + short_id: 3ee0b6f7 + time: "2026-11-07 23:07" + mailboxes: + user@example.org: + mailbox: + added: 1 + created: 1 + mails: + added: 32 + skipped: 0 + errors: [] + folder: Restored 2026-11-07 23:07 + aliases: + added: 1 + skipped: 2 + errors: [] + errors: 0 + error: null + domain: example.org + requested_by: admin + created: 1794094015 + started: 1794094016 + finished: 1794094062 + description: OK + headers: {} + tags: + - Backup + description: >- + Backup jobs without their log, newest first: admins all jobs, domain + admins the restores of their domains. state: queued, running, done, + failed or cancelled; step: what a running job does. The result of a + restore holds per mailbox and component the numbers of added and + already existing (skipped) entries and the errors; "mailbox" tells + whether a deleted mailbox was created, "domain" (next to "mailboxes") + the creation of a deleted domain. Finished jobs are kept for 180 days. + operationId: Get backup jobs + summary: Get backup jobs + "/api/v1/get/backup-jobs/{count}/{type}": + get: + parameters: + - description: Number of jobs to return, newest first; 0 = 100, max. 1000 + example: 0 + in: path + name: count + required: true + schema: + type: number + - description: only jobs of this type (backup, check, prune, init or restore); an unknown type returns [] + example: restore + in: path + name: type + required: true + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 12 + type: restore + state: done + step: null + params: + snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f + scope: mailbox + domain: example.org + mailboxes: + - user@example.org + components: + - mails + mail_target: folder + result: null + error: null + domain: example.org + requested_by: admin + created: 1794094015 + started: 1794094016 + finished: 1794094062 + description: OK + headers: {} + tags: + - Backup + description: >- + Backup jobs of one type without their log, newest first, with the + visibility of get/backup-jobs/{count} (domain admins: the restores of + their domains). E.g. get/backup-jobs/0/restore for the restores. + operationId: Get backup jobs of a type + summary: Get backup jobs of a type + "/api/v1/get/backup-job/{id}": + get: + parameters: + - description: id of the job + example: 12 + in: path + name: id + required: true + schema: + type: number + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + id: 11 + type: backup + state: done + step: null + params: {} + result: + dump_bytes: 667156 + export: + domains: 1 + failed: [] + snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e + summary: + files_new: 1696 + files_changed: 0 + data_added: 93096838 + total_bytes_processed: 95911108 + forget: + removed: 0 + snapshots: 3 + error: null + domain: "" + requested_by: schedule + created: 1794093646 + started: 1794093646 + finished: 1794093653 + log: "2026-11-08 02:30:01 backup job started (requested by schedule)\n2026-11-08 02:30:03 snapshot f4f5a5fe: 1696 new and 0 changed files, 88 MiB added, 91 MiB processed\n2026-11-08 02:30:07 backup job done" + description: OK + headers: {} + tags: + - Backup + description: >- + A backup job with its log (the same entry as in get/backup-jobs plus + "log"). {} if the job does not exist or the caller may not see it. + operationId: Get backup job + summary: Get backup job /api/v1/get/identity-provider/sync-status: get: responses: @@ -8817,6 +9748,8 @@ tags: description: Whom mailboxes and groups may send to and receive from - name: Retention rules description: Delete mail from folders after a number of days + - name: Backup + description: Encrypted backups with restic, restore of mailboxes and domains - name: Routing description: Define your own email routes - name: Resources diff --git a/data/web/css/site/admin.css b/data/web/css/site/admin.css index b54148026..465404cab 100644 --- a/data/web/css/site/admin.css +++ b/data/web/css/site/admin.css @@ -1,83 +1,133 @@ -.pagination a { - text-decoration: none !important; -} -.panel.panel-default { - overflow: visible !important; -} -.table-responsive { - overflow: visible !important; -} -.table-responsive { - overflow-x: scroll !important; -} -body { - overflow-y:scroll; -} -/* Fix modal moving content left */ -body.modal-open { - overflow-y:scroll; - padding-right: inherit !important; -} -@media (min-width: 992px) { - .container { - width: 80%; - } -} -.mass-actions-admin { - user-select: none; -} -.inputMissingAttr { - border-color: #FF4136; -} -.rotate { - -moz-transition: all 0.3s linear; - -webkit-transition: all 0.3s linear; - transition: all 0.3s linear; -} -.rotate.animation { - -ms-transform:rotateX(180deg); - -moz-transform:rotateX(180deg); - -webkit-transform:rotateX(180deg); - transform:rotateX(180deg); -} -.anchor { - display: block; - height: 65px; - margin-top: -65px; - visibility: hidden; -} -.thumbnail img { - min-height:100px; - height:100px; -} -.nav-tabs>li>a { - z-index: 1; -} -.table-condensed .input-sm { - width: 100%!important; -} -.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td { - padding: 3px; -} -table tbody tr td input[type="checkbox"] { - cursor: pointer; -} -#quarantine_template { - margin:20px; -} -.regex-input { - font-family: Consolas,monaco,monospace; - font-size: 1rem; -} -.label-keys { - font-size:100%; - margin: 0px !important; - white-space: normal !important; -} -.key-action { - font-weight:bold; - color:white !important; -} -.dkim-label { - margin: 0 0 8px !important; -} \ No newline at end of file +.pagination a { + text-decoration: none !important; +} +.panel.panel-default { + overflow: visible !important; +} +.table-responsive { + overflow: visible !important; +} +.table-responsive { + overflow-x: scroll !important; +} +body { + overflow-y:scroll; +} +/* Fix modal moving content left */ +body.modal-open { + overflow-y:scroll; + padding-right: inherit !important; +} +@media (min-width: 992px) { + .container { + width: 80%; + } +} +.mass-actions-admin { + user-select: none; +} +.inputMissingAttr { + border-color: #FF4136; +} +.rotate { + -moz-transition: all 0.3s linear; + -webkit-transition: all 0.3s linear; + transition: all 0.3s linear; +} +.rotate.animation { + -ms-transform:rotateX(180deg); + -moz-transform:rotateX(180deg); + -webkit-transform:rotateX(180deg); + transform:rotateX(180deg); +} +.anchor { + display: block; + height: 65px; + margin-top: -65px; + visibility: hidden; +} +.thumbnail img { + min-height:100px; + height:100px; +} +.nav-tabs>li>a { + z-index: 1; +} +.table-condensed .input-sm { + width: 100%!important; +} +.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td { + padding: 3px; +} +table tbody tr td input[type="checkbox"] { + cursor: pointer; +} +#quarantine_template { + margin:20px; +} +.regex-input { + font-family: Consolas,monaco,monospace; + font-size: 1rem; +} +.label-keys { + font-size:100%; + margin: 0px !important; + white-space: normal !important; +} +.key-action { + font-weight:bold; + color:white !important; +} +.dkim-label { + margin: 0 0 8px !important; +} +/* backup schedule: the weekday toggles and the remove buttons of the entries react to the mouse, in every theme */ +.backup-schedule-days .btn:hover { + background-image: linear-gradient(rgba(128, 128, 128, .3), rgba(128, 128, 128, .3)); +} +.backup-schedule-remove { + opacity: .6; +} +.backup-schedule-remove:hover, .backup-schedule-remove:focus-visible { + opacity: 1; + color: var(--bs-danger) !important; +} +/* result and log of a backup job: the border of the theme in light and dark mode (Bootstrap's .border stays light) */ +.backup-pre { + border: 1px solid var(--mc-border); +} +/* result of a restore job: an entry to open per mailbox, numbers in columns */ +.backup-result { + border: 1px solid var(--mc-border); +} +.backup-result > details + details { + border-top: 1px solid var(--mc-border); +} +.backup-result summary { + list-style: none; + cursor: pointer; +} +.backup-result summary::-webkit-details-marker { + display: none; +} +.backup-result summary:hover { + background-image: linear-gradient(rgba(128, 128, 128, .12), rgba(128, 128, 128, .12)); +} +.backup-result summary .bi-chevron-right { + transition: transform .15s; +} +.backup-result details[open] > summary .bi-chevron-right { + transform: rotate(90deg); +} +.backup-result-body { + padding-left: 2.6rem !important; +} +.backup-result-body .table { + --bs-table-bg: transparent; +} +.backup-result-body .num { + width: 8.5rem; + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/data/web/css/themes/mailcow-darkmode.css b/data/web/css/themes/mailcow-darkmode.css index 901a6bcc6..e61368bc6 100644 --- a/data/web/css/themes/mailcow-darkmode.css +++ b/data/web/css/themes/mailcow-darkmode.css @@ -495,4 +495,16 @@ input::placeholder { } .btn-light, .btn-light:hover { background-image: none; -} \ No newline at end of file +} +/* native time picker icon and popup in dark mode */ +input[type="time"] { + color-scheme: dark; +} +/* switches while off: a light knob and a visible border (Bootstrap's dark knob is for light pages, the dark mode + does not set data-bs-theme) */ +.form-switch .form-check-input:not(:checked):not(:focus) { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.6%29'/%3e%3c/svg%3e"); +} +.form-switch .form-check-input:not(:checked) { + border-color: var(--mc-btn-outline-border); +} diff --git a/data/web/domainadmin/restore.php b/data/web/domainadmin/restore.php new file mode 100644 index 000000000..a51b2b11e --- /dev/null +++ b/data/web/domainadmin/restore.php @@ -0,0 +1,8 @@ + json_encode($lang['groups']), 'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []), 'lang_retention' => json_encode($lang['retention'] ?? []), + 'lang_backup' => json_encode($lang['backup'] ?? []), 'docker_timeout' => $DOCKER_TIMEOUT, 'session_lifetime' => (int)$SESSION_LIFETIME, 'csrf_token' => $_SESSION['CSRF']['TOKEN'], diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index beaffeb3a..e1cb8640c 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -681,6 +681,67 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "backup_setting" => array( + "cols" => array( + "name" => "VARCHAR(64) NOT NULL", + "value" => "TEXT NOT NULL", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("name") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "backup_job" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "type" => "ENUM('backup', 'check', 'prune', 'init', 'restore') NOT NULL", + "state" => "ENUM('queued', 'running', 'done', 'failed', 'cancelled') NOT NULL DEFAULT 'queued'", + "step" => "VARCHAR(32) NULL DEFAULT NULL", + "params" => "MEDIUMTEXT NULL DEFAULT NULL", + "result" => "MEDIUMTEXT NULL DEFAULT NULL", + "log" => "MEDIUMTEXT NULL DEFAULT NULL", + "error" => "TEXT NULL DEFAULT NULL", + "domain" => "VARCHAR(255) NOT NULL DEFAULT ''", + "requested_by" => "VARCHAR(255) NOT NULL DEFAULT ''", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "started" => "DATETIME NULL DEFAULT NULL", + "finished" => "DATETIME NULL DEFAULT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "key" => array( + "state_type" => array("state", "type"), + "domain_created" => array("domain", "created") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "backup_snapshot" => array( + "cols" => array( + "id" => "VARCHAR(64) NOT NULL", + "short_id" => "VARCHAR(16) NOT NULL", + "time" => "DATETIME NOT NULL", + "hostname" => "VARCHAR(255) NOT NULL DEFAULT ''", + "tags" => "VARCHAR(255) NOT NULL DEFAULT ''", + "summary" => "TEXT NULL DEFAULT NULL", + "domains" => "MEDIUMTEXT NULL DEFAULT NULL", + "content" => "MEDIUMBLOB NULL DEFAULT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "key" => array( + "time" => array("time") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "mailbox_group_imap_grant" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", diff --git a/data/web/inc/lib/mailcow/Backup/BackupModule.php b/data/web/inc/lib/mailcow/Backup/BackupModule.php new file mode 100644 index 000000000..c7fc3eedd --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/BackupModule.php @@ -0,0 +1,87 @@ +api->getSettings()); + * echo json_encode(BackupModule::create($pdo, Context::system(), $config, $redis)->api->export($domain, $page)); // backup.php + * + * $config: timezone (TZ of mailcow, for the schedule), alias_disable_external_domains ($ALIAS_DISABLE_EXTERNAL_DOMAINS). + * $redis: for DKIM keys, rate limits and the domain map (export and restore). + */ +class BackupModule +{ + public function __construct( + public readonly SettingsService $settings, + public readonly JobService $jobs, + public readonly ExportService $export, + public readonly RestoreService $restore, + public readonly DomainRestoreService $domainRestore, + public readonly BackupApiController $api + ) { + } + + public static function create(PDO $pdo, Context $context, array $config = [], ?Redis $redis = null): self + { + $jobRepo = new JobRepository($pdo); + $restoreRepo = new RestoreRepository($pdo); + $legacy = new LegacyDomain($redis); + $settings = new SettingsService(new SettingsRepository($pdo), $context); + $jobs = new JobService($jobRepo, new SnapshotRepository($pdo), $settings, $context, (string)($config['timezone'] ?? 'UTC')); + $export = new ExportService(new ExportRepository($pdo), $legacy); + $sogo = SogoModule::create()->sogo; + $dovecot = DovecotModule::create()->dovecot; + // the group module without group templates: a restored mailbox keeps the settings of the backup + $groupRepo = new GroupRepository($pdo); + $lookup = new LookupRepository($pdo); + $templates = new TemplateService($lookup, new NoTemplates()); + $effects = new ChangeEffects($templates, $sogo, $groupRepo, $dovecot); + $groups = new GroupMembers($groupRepo, $lookup, $templates, $effects, new SharingService($groupRepo, $dovecot, $sogo, $effects)); + $restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false)); + $domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy); + return new self($settings, $jobs, $export, $restore, $domainRestore, + new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS))); + } + + /** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ and $ALIAS_DISABLE_EXTERNAL_DOMAINS */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [ + 'timezone' => getenv('TZ') ?: 'UTC', + 'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true, + ], ($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php b/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php new file mode 100644 index 000000000..38cd4b764 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php @@ -0,0 +1,176 @@ +_set; {} for non-admins */ + public function getSettings(): array + { + return $this->settings->get()?->toArray() ?? []; + } + + /** POST edit/backup-settings */ + public function editSettings(array $attr): bool + { + return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool { + $this->settings->save($attr); + return $this->response->success('backup_settings_saved', $log); + }); + } + + /** GET backup-status */ + public function getStatus(): array + { + return $this->jobs->status(); + } + + /** GET backup-snapshots */ + public function getSnapshots(): array + { + return $this->jobs->snapshots(); + } + + /** GET backup-snapshot-content/[/]: domains of a snapshot, or the mailboxes of one, with their state now */ + public function getSnapshotContent(mixed $snapshot, mixed $domain = null): array + { + return ($domain === null || $domain === '') ? $this->jobs->content($snapshot) : $this->jobs->domainContent($snapshot, $domain); + } + + /** GET backup-snapshot-search//: domains and mailboxes of a snapshot matching the term */ + public function getSnapshotSearch(mixed $snapshot, mixed $term): array + { + return $this->jobs->search($snapshot, $term); + } + + /** GET backup-jobs[/[/]]: newest first, without logs; count 0 = default */ + public function getJobs(int $limit = 0, mixed $type = null): array + { + return array_map(fn(Job $job): array => $this->jobs->view($job), $this->jobs->list($limit, $type)); + } + + /** GET backup-job/: with its log */ + public function getJob(mixed $id): array + { + $id = $this->toId($id); + $job = ($id === null) ? null : $this->jobs->get($id); + return ($job === null) ? [] : $this->jobs->view($job, true); + } + + /** POST add/backup-job: {type: backup|check|prune|init} */ + public function addJob(array $attr): bool + { + return $this->handle('add', 'job', $attr, function (array $log) use ($attr): bool { + $job = $this->jobs->create($attr); + return $this->response->success(['backup_job_added', $job->type, $job->id], $log); + }); + } + + /** POST add/backup-restore: {snapshot, scope, mailboxes | domain, components, mail_target} */ + public function addRestore(array $attr): bool + { + return $this->handle('add', 'restore', $attr, function (array $log) use ($attr): bool { + $job = $this->jobs->restore($attr); + return $this->response->success(['backup_restore_added', $job->id, count($job->mailboxes())], $log); + }); + } + + /** POST delete/backup-job: cancels queued and running jobs, one message per id */ + public function deleteJobs(array $items): bool + { + return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool { + foreach ($items as $id) { + try { + $job = $this->jobs->cancel($this->requireId($id)); + $this->response->success(['backup_job_cancelled', $job->id], $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + }); + } + + /** backup.php ?domains=1 */ + public function domains(): array + { + return ['domains' => $this->export->domains()]; + } + + /** backup.php ?export=[&after=] @throws UserError */ + public function export(string $domain, ?string $after = null): array + { + return $this->export->export($domain, $after); + } + + /** + * backup.php POST of the runner, {"result": ...} of + * {"action": "restore_domain", "job": id, "data": }: creates a deleted domain + * {"action": "restore_aliases", "job": id, "aliases": [...]}: a part of the domain's aliases (scope domain) + * {"action": "restore", "job": id, "username": u, "data": }: one mailbox + * + * @throws UserError + */ + public function post(array $body): array + { + return ['result' => match ($body['action'] ?? null) { + 'restore' => $this->restore->merge($body['job'] ?? null, $body['username'] ?? null, $body['data'] ?? null), + 'restore_aliases' => $this->restore->mergeAliases($body['job'] ?? null, $body['aliases'] ?? null), + 'restore_domain' => $this->domainRestore->restore($body['job'] ?? null, $body['data'] ?? null), + default => throw new UserError('backup_action_invalid'), + }]; + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, string $object, mixed $data, callable $work): bool + { + $log = ['backup', $action, $object, $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + /** Integer id from request input ("12", 12); null for anything else */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php b/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php new file mode 100644 index 000000000..946f87a4f --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php @@ -0,0 +1,84 @@ +redis()->hGet('DKIM_PUB_KEYS', $domain); + if (!is_string($pubkey) || $pubkey === '') { + return null; + } + $selector = (string)$this->redis()->hGet('DKIM_SELECTORS', $domain); + return ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => (string)$this->redis()->hGet('DKIM_PRIV_KEYS', $selector . '.' . $domain)]; + } + + /** Stores a DKIM key like dkim('import') does */ + public function addDkim(string $domain, string $selector, string $pubkey, string $privkey): void + { + $this->redis()->hSet('DKIM_PUB_KEYS', $domain, $pubkey); + $this->redis()->hSet('DKIM_SELECTORS', $domain, $selector); + $this->redis()->hSet('DKIM_PRIV_KEYS', $selector . '.' . $domain, $privkey); + } + + /** + * @param string[] $objects domains or mailboxes + * @return array object => rate limit ("10 / 1h") of those with one + */ + public function rateLimits(array $objects): array + { + if (empty($objects)) { + return []; + } + $values = $this->redis()->hMGet('RL_VALUE', array_values($objects)); + return array_filter(is_array($values) ? $values : [], fn(mixed $value): bool => is_string($value) && $value !== ''); + } + + public function setRateLimit(string $object, string $value): void + { + $this->redis()->hSet('RL_VALUE', $object, $value); + } + + /** The domain is local for rspamd (legacy domain and alias domain add) */ + public function addDomainMap(string $domain): void + { + $this->redis()->hSet('DOMAIN_MAP', $domain, 1); + } + + /** + * SOGo reads its domains at start: restart it after a domain was added (like the "restart SOGo" option of the form) + * + * @return ?string null on success, else the error of dockerapi + */ + public function restartSogo(): ?string + { + $result = json_decode((string)$this->dockerRestart('sogo-mailcow'), true); + return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer'); + } + + protected function redis(): Redis + { + return $this->redis ?? throw new RuntimeException('Redis is not connected'); + } + + protected function dockerRestart(string $service): mixed + { + return docker('post', $service, 'restart'); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php b/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php new file mode 100644 index 000000000..694cba2a6 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php @@ -0,0 +1,19 @@ +type = $type; + } + + /** Row of `backup_job` with the times as unix timestamps */ + public static function fromRow(array $row): self + { + $job = new self((string)$row['type']); + $job->id = (int)$row['id']; + $job->state = (string)$row['state']; + $job->step = isset($row['step']) ? (string)$row['step'] : null; + $params = is_string($row['params'] ?? null) ? json_decode($row['params'], true) : null; + $job->params = is_array($params) ? $params : []; + $result = is_string($row['result'] ?? null) ? json_decode($row['result'], true) : null; + $job->result = is_array($result) ? $result : null; + $job->log = isset($row['log']) ? (string)$row['log'] : null; + $job->error = isset($row['error']) ? (string)$row['error'] : null; + $job->domain = (string)($row['domain'] ?? ''); + $job->requestedBy = (string)($row['requested_by'] ?? ''); + foreach (['created', 'started', 'finished'] as $time) { + $job->$time = isset($row[$time]) ? (int)$row[$time] : null; + } + return $job; + } + + /** Column values for a new row */ + public function toRow(): array + { + return [ + 'type' => $this->type, + 'state' => $this->state, + 'step' => $this->step, + 'params' => json_encode($this->params ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'domain' => $this->domain, + 'requested_by' => $this->requestedBy, + ]; + } + + /** @return string[] mailboxes of a restore */ + public function mailboxes(): array + { + return array_values(array_filter((array)($this->params['mailboxes'] ?? []), 'is_string')); + } + + /** @return string[] components of a restore */ + public function components(): array + { + return array_values(array_filter((array)($this->params['components'] ?? []), 'is_string')); + } + + /** @return string[] mailboxes of a restore that did not exist when it was requested */ + public function createMailboxes(): array + { + return array_values(array_filter((array)($this->params['create_mailboxes'] ?? []), 'is_string')); + } + + /** The restore creates its domain (requested by an admin while the domain did not exist) */ + public function createsDomain(): bool + { + return (int)($this->params['create_domain'] ?? 0) === 1; + } + + public function isActive(): bool + { + return in_array($this->state, self::ACTIVE_STATES, true); + } + + /** Entry of the API; the log only with $withLog (get/backup-job/) */ + public function toArray(bool $withLog = false): array + { + $job = [ + 'id' => $this->id, + 'type' => $this->type, + 'state' => $this->state, + 'step' => $this->step, + 'params' => $this->params ?: new \stdClass(), + 'result' => $this->result, + 'error' => $this->error, + 'domain' => $this->domain, + 'requested_by' => $this->requestedBy, + 'created' => $this->created, + 'started' => $this->started, + 'finished' => $this->finished, + ]; + if ($withLog) { + $job['log'] = (string)$this->log; + } + return $job; + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php b/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php new file mode 100644 index 000000000..55cd13426 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php @@ -0,0 +1,116 @@ + */ + public array $results = []; + /** Mailboxes whose SOGo identities (aliases, send-as addresses) changed */ + public array $sogoUsers = []; + /** Mailboxes whose userdb or passdb values changed (Dovecot auth cache) */ + public array $authUsers = []; + + /** + * @param array $mailbox the mailbox row as it is now + * @param array $params params of the restore job (requested_role, acl of a domain admin, scope, ...) + * @param array $data the mailbox's section of the domain export in the snapshot + * @param string[] $domains domains whose aliases and send-as addresses the restore may change, [] = all (admins) + * @param string $requestedBy who requested the restore + */ + /** The restore created the mailbox (its own alias gets the forwarding of the backup) */ + public bool $created = false; + + public function __construct( + public readonly string $username, + public readonly string $domain, + public readonly array $mailbox, + public readonly array $params, + public readonly array $data, + public readonly array $domains = [], + public readonly string $requestedBy = '' + ) { + } + + /** + * The caller the group module checks: an admin, or the domain admin who requested the restore with his ACL of then + * and his domains now + */ + public function requester(): Context + { + if ($this->byAdmin()) { + return new Context('admin', 'backup', [], true); + } + return new Context('domainadmin', $this->requestedBy, array_map(fn(mixed $flag): string => (string)(int)$flag, (array)($this->params['acl'] ?? []))); + } + + public function start(string $component): void + { + $this->results[$component] = ['added' => 0, 'skipped' => 0, 'errors' => []]; + } + + public function added(string $component, int $count = 1): void + { + $this->results[$component]['added'] += $count; + } + + public function skipped(string $component, int $count = 1): void + { + $this->results[$component]['skipped'] += $count; + } + + public function error(string $component, string $error): void + { + $this->results[$component]['errors'][] = $error; + } + + /** The part of a component was rolled back: nothing of it counts */ + public function failed(string $component, string $error): void + { + $this->start($component); + $this->error($component, $error); + } + + /** The restore was requested by an admin (domain admins are limited by their ACL and the job's domain) */ + public function byAdmin(): bool + { + return ($this->params['requested_role'] ?? '') === 'admin'; + } + + /** Admins, or a domain admin with this ACL when he requested the restore */ + public function may(string $acl): bool + { + return $this->byAdmin() || (int)($this->params['acl'][$acl] ?? 0) === 1; + } + + public function mayChangeDomain(string $domain): bool + { + return $this->domains === [] || in_array(strtolower($domain), $this->domains, true); + } + + /** Rows of a list in the backup (sieve_filters, tfa, ...) */ + public function rows(string $key): array + { + return array_values(array_filter((array)($this->data[$key] ?? []), 'is_array')); + } + + public function sogo(string ...$usernames): void + { + array_push($this->sogoUsers, ...$usernames); + $this->sogoUsers = array_values(array_unique($this->sogoUsers)); + } + + public function auth(string ...$usernames): void + { + array_push($this->authUsers, ...$usernames); + $this->authUsers = array_values(array_unique($this->authUsers)); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Model/Settings.php b/data/web/inc/lib/mailcow/Backup/Model/Settings.php new file mode 100644 index 000000000..6d20c9f9d --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Model/Settings.php @@ -0,0 +1,189 @@ +`) and never leave toArray(): the API gets `_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 $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 + */ + 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 _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; + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php b/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php new file mode 100644 index 000000000..cdcb5b27f --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php @@ -0,0 +1,142 @@ + domain => its creation time (unix) when the snapshot was made, from the snapshot row */ + public array $created = []; + /** + * @var ?array}> per domain its creation time (unix, null + * when not recorded) and username => name of its mailboxes; null = not known (snapshots this installation did + * not make itself, e.g. of another server in the same repository) + */ + public ?array $content = null; + + /** Row of `backup_snapshot` with the time as unix timestamp */ + public static function fromRow(array $row): self + { + $snapshot = new self(); + $snapshot->id = (string)$row['id']; + $snapshot->shortId = (string)$row['short_id']; + $snapshot->time = (int)$row['time']; + $snapshot->hostname = (string)($row['hostname'] ?? ''); + $tags = (string)($row['tags'] ?? ''); + $decoded = json_decode($tags, true); + $tags = is_array($decoded) ? $decoded : explode(',', $tags); + $snapshot->tags = array_values(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen')); + $summary = is_string($row['summary'] ?? null) ? json_decode($row['summary'], true) : null; + $snapshot->summary = is_array($summary) ? $summary : null; + // {domain: creation time}: lists of snapshots need no content + $domains = is_string($row['domains'] ?? null) ? json_decode($row['domains'], true) : null; + if (is_array($domains)) { + $snapshot->domains = []; + foreach ($domains as $domain => $created) { + $snapshot->domains[] = (string)$domain; + $snapshot->created[strtolower((string)$domain)] = is_numeric($created) ? (int)$created : null; + } + } + if (is_string($row['content'] ?? null) && $row['content'] !== '') { + $snapshot->content = self::decodeContent($row['content']); + } + return $snapshot; + } + + /** + * `content` (gzip JSON) written by the runner: {"domains": {"": {"created": , "mailboxes": {"": ""}}}}. + * Keys lower case; null when broken. + */ + public static function decodeContent(string $gzip): ?array + { + $json = @gzdecode($gzip); + $data = is_string($json) ? json_decode($json, true) : null; + if (!is_array($data) || !is_array($data['domains'] ?? null)) { + return null; + } + $content = []; + foreach ($data['domains'] as $domain => $entry) { + $mailboxes = is_array($entry) ? ($entry['mailboxes'] ?? []) : []; + $created = is_numeric($entry['created'] ?? null) ? (int)$entry['created'] : null; + $names = []; + foreach (is_array($mailboxes) ? $mailboxes : [] as $username => $name) { + $names[strtolower((string)$username)] = is_scalar($name) ? (string)$name : ''; + } + $content[strtolower((string)$domain)] = ['created' => $created, 'mailboxes' => $names]; + } + return $content; + } + + /** Domains of the snapshot: those of its content and of its manifest (an export may have failed) */ + public function domainList(): array + { + return array_values(array_unique(array_merge(array_map('strval', array_keys($this->content ?? [])), array_map('strtolower', $this->domains ?? [])))); + } + + /** @return ?array username => name of the domain's mailboxes, null when the content does not list the domain */ + public function mailboxesOf(string $domain): ?array + { + return $this->content[strtolower($domain)]['mailboxes'] ?? null; + } + + /** Creation time of the domain when the snapshot was made, null when not recorded */ + public function createdOf(string $domain): ?int + { + return $this->content[strtolower($domain)]['created'] ?? $this->created[strtolower($domain)] ?? null; + } + + public function hasDomain(string $domain): bool + { + return in_array(strtolower($domain), $this->domainList(), true); + } + + /** + * RESTIC_BACKUP_MODE of the backup (tag mode:, none for all): all, data (without the mailcow directory) or + * config (without mails) + */ + public function mode(): string + { + foreach ($this->tags as $tag) { + if (str_starts_with($tag, 'mode:')) { + return substr($tag, 5); + } + } + return 'all'; + } + + /** Snapshots not made by mailcow know neither domains nor mailboxes */ + public function knowsDomains(): bool + { + return $this->content !== null || $this->domains !== null; + } + + /** Entry of the API for admins */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'short_id' => $this->shortId, + 'time' => $this->time, + 'hostname' => $this->hostname, + 'tags' => $this->tags, + 'summary' => $this->summary, + 'domains' => $this->domains, + 'mode' => $this->mode(), + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php new file mode 100644 index 000000000..18991f069 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php @@ -0,0 +1,169 @@ +query("SELECT `domain` FROM `domain` ORDER BY `domain`")->fetchAll(PDO::FETCH_COLUMN); + } + + public function dbVersion(): string + { + return (string)$this->query("SELECT `version` FROM `versions` WHERE `application` = 'db_schema'")->fetchColumn(); + } + + /** @return array[] rows of a table matching column => value (table and column names from code) */ + public function rows(string $table, string $column, string $value): array + { + self::identifier($table); + self::identifier($column); + return $this->query("SELECT * FROM `$table` WHERE `$column` = :value", ['value' => $value])->fetchAll(); + } + + /** @return string[] usernames of the domain's mailboxes */ + public function usernames(string $domain): array + { + return $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN); + } + + /** @return array[] the domain's mailbox rows after $after in username order (keyset paging), at most $limit */ + public function mailboxesAfter(string $domain, string $after, int $limit): array + { + return $this->query("SELECT * FROM `mailbox` WHERE `domain` = :domain AND `username` > :after ORDER BY `username` LIMIT " . max(1, $limit), + ['domain' => $domain, 'after' => $after])->fetchAll(); + } + + /** @return array[] alias rows of the domain and its alias domains */ + public function aliases(string $domain): array + { + return $this->query("SELECT * FROM `alias` WHERE `domain` = :domain + OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target) ORDER BY `id`", + ['domain' => $domain, 'target' => $domain])->fetchAll(); + } + + /** @return array[] alias rows of any domain whose goto may name a mailbox of the domain (the caller checks) */ + public function aliasesPointingTo(string $domain): array + { + return $this->query("SELECT * FROM `alias` WHERE `goto` LIKE :pattern ORDER BY `id`", + ['pattern' => '%@' . addcslashes($domain, '%_\\') . '%'])->fetchAll(); + } + + /** + * @param string $table table with rows of mailboxes + * @param string $column its column holding the username + * @param string[] $usernames + * @return array[] rows of these mailboxes + */ + public function rowsOfMailboxes(string $table, string $column, array $usernames): array + { + self::identifier($table); + self::identifier($column); + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in($usernames); + return $this->query("SELECT * FROM `$table` WHERE `$column` IN ($in)", $params)->fetchAll(); + } + + /** @return array[] username, id, name, domain, source of the group memberships of these mailboxes */ + public function groupMemberships(array $usernames): array + { + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in($usernames); + return $this->query("SELECT `gm`.`username`, `g`.`id`, `g`.`name`, `g`.`domain`, `gm`.`source` FROM `mailbox_group_member` `gm` + JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` WHERE `gm`.`username` IN ($in) ORDER BY `g`.`id`", $params)->fetchAll(); + } + + /** + * @param string $table mail_policy or retention_rule + * @param string $member its member table + * @param string $key the member table's column with the id + * @return array[] username, id, name, domain of the direct assignments of these mailboxes + */ + public function assignments(string $table, string $member, string $key, array $usernames): array + { + self::identifier($table); + self::identifier($member); + self::identifier($key); + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in($usernames); + return $this->query("SELECT `a`.`username`, `p`.`id`, `p`.`name`, `p`.`domain` FROM `$member` `a` + JOIN `$table` `p` ON `p`.`id` = `a`.`$key` WHERE `a`.`username` IN ($in) ORDER BY `p`.`id`", $params)->fetchAll(); + } + + /** @return array[] sync jobs of these mailboxes (without their log) with host1, port1 and name of the source */ + public function syncJobs(array $usernames): array + { + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in($usernames); + $rows = $this->query("SELECT `i`.*, `s`.`host1` AS `source_host1`, `s`.`port1` AS `source_port1`, `s`.`name` AS `source_name` + FROM `imapsync` `i` LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` IN ($in) ORDER BY `i`.`id`", $params)->fetchAll(); + foreach ($rows as &$row) { + unset($row['returned_text']); + } + return $rows; + } + + /** @return array[] BCC maps of the whole domain (local_dest "@domain", older rows "domain") */ + public function domainBccMaps(string $domain): array + { + return $this->query("SELECT * FROM `bcc_maps` WHERE `local_dest` IN (:domain, :at)", ['domain' => $domain, 'at' => '@' . $domain])->fetchAll(); + } + + /** + * @param string $table mail_policy or retention_rule + * @param string $member its member table + * @param string $key the member table's column with the id + * @return array[] rows of the domain, each with `group_members`: the names of the groups it is assigned to + */ + public function domainRules(string $table, string $member, string $key, string $domain): array + { + self::identifier($table); + self::identifier($member); + self::identifier($key); + $rows = $this->query("SELECT * FROM `$table` WHERE `domain` = :domain ORDER BY `id`", ['domain' => $domain])->fetchAll(); + foreach ($rows as &$row) { + $row['group_members'] = $this->query("SELECT `g`.`name` FROM `$member` `a` JOIN `mailbox_group` `g` ON `g`.`id` = `a`.`group_id` + WHERE `a`.`$key` = :id ORDER BY `g`.`name`", ['id' => $row['id']])->fetchAll(PDO::FETCH_COLUMN); + } + return $rows; + } + + /** @return array{0: string, 1: array} placeholders and parameters of an IN list */ + private static function in(array $values): array + { + $names = []; + $params = []; + foreach (array_values($values) as $i => $value) { + $names[] = ':in' . $i; + $params['in' . $i] = $value; + } + return [implode(', ', $names), $params]; + } + + /** Table and column names come from code only; this keeps it that way */ + private static function identifier(string $name): void + { + if (!preg_match('/^[a-z_0-9]+$/i', $name)) { + throw new \InvalidArgumentException('invalid identifier'); + } + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php new file mode 100644 index 000000000..cbeb1b8e4 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php @@ -0,0 +1,176 @@ +query("SELECT " . self::COLUMNS . ($withLog ? ", `log`" : "") . " FROM `backup_job` WHERE `id` = :id", ['id' => $id])->fetch(); + return is_array($row) ? Job::fromRow($row) : null; + } + + /** + * @param ?string $type only jobs of this type + * @return Job[] newest first: all jobs for admins, the restores of their domains (since the domain was created) for + * domain admins + */ + public function visible(Context $context, int $limit, ?string $type = null): array + { + if ($context->hasAdminRights()) { + [$where, $params] = ['1=1', []]; + } elseif ($context->isDomainAdmin()) { + // restores of their domains, requested since the domain was created (not those of a former domain of that name) + [$where, $params] = ["`type` = 'restore' AND `domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :da) + AND `created` >= (SELECT `d`.`created` FROM `domain` `d` WHERE `d`.`domain` = `backup_job`.`domain`)", ['da' => $context->username]]; + } else { + return []; + } + if ($type !== null) { + $where .= " AND `type` = :type"; + $params['type'] = $type; + } + return array_map(fn(array $row): Job => Job::fromRow($row), + $this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE $where ORDER BY `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll()); + } + + /** @return Job[] queued and running jobs */ + public function active(): array + { + return array_map(fn(array $row): Job => Job::fromRow($row), + $this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `state` IN ('queued', 'running') ORDER BY `id`")->fetchAll()); + } + + /** @return array type => its newest job */ + public function latestOfEachType(): array + { + $jobs = []; + foreach ($this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `id` IN (SELECT MAX(`id`) FROM `backup_job` GROUP BY `type`)")->fetchAll() as $row) { + $jobs[(string)$row['type']] = Job::fromRow($row); + } + return $jobs; + } + + /** A backup job was created by the schedule since this unix time */ + public function scheduledSince(int $since): bool + { + return (bool)$this->query("SELECT 1 FROM `backup_job` WHERE `type` = 'backup' AND `requested_by` = 'schedule' AND `created` >= FROM_UNIXTIME(:since) LIMIT 1", + ['since' => $since])->fetchColumn(); + } + + /** + * Inserts the job after $check(active jobs) passed; $check throws a UserError on a conflict. A named lock keeps two + * requests from queueing the same work at once. + * + * @param callable(Job[]): void $check + * @throws UserError + */ + public function insertExclusive(Job $job, callable $check): int + { + if ((int)$this->query("SELECT GET_LOCK('" . self::LOCK . "', 10)")->fetchColumn() !== 1) { + throw new UserError('backup_job_active', [$job->type]); + } + try { + $check($this->active()); + $job->id = $this->insertRow('backup_job', $job->toRow()); + return $job->id; + } finally { + $this->query("SELECT RELEASE_LOCK('" . self::LOCK . "')"); + } + } + + /** + * Cancels a queued or running job; the restic runner stops at its next check. A restore in the dovecot step + * (mails, mails_running) cannot be cancelled. False when the job finished or reached that step meanwhile. + */ + public function cancel(int $id): bool + { + return $this->query("UPDATE `backup_job` SET `state` = 'cancelled', `finished` = NOW() WHERE `id` = :id + AND `state` IN ('queued', 'running') AND NOT (`type` = 'restore' AND COALESCE(`step`, '') IN ('mails', 'mails_running'))", + ['id' => $id])->rowCount() > 0; + } + + public function isDomain(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + } + + /** Domain of a mailbox, null if there is none */ + public function mailboxDomain(string $username): ?string + { + $domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username", ['username' => $username])->fetchColumn(); + return is_string($domain) ? strtolower($domain) : null; + } + + /** Unix time the domain was created, null if it does not exist */ + public function domainCreated(string $domain): ?int + { + $created = $this->query("SELECT UNIX_TIMESTAMP(`created`) FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + return ($created === false || $created === null) ? null : (int)$created; + } + + /** + * @param string[] $domains + * @return array{0: array, 1: array>} the domains that exist, and per domain + * the usernames of its mailboxes; one query each per 500 domains + */ + public function domainsAndUsernames(array $domains): array + { + $existing = []; + $usernames = []; + foreach (array_chunk(array_values(array_unique($domains)), 500) as $chunk) { + $params = []; + foreach ($chunk as $i => $domain) { + $params['d' . $i] = $domain; + } + $in = implode(', ', array_map(fn(string $name): string => ':' . $name, array_keys($params))); + foreach ($this->query("SELECT `domain` FROM `domain` WHERE `domain` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN) as $domain) { + $existing[strtolower((string)$domain)] = true; + } + foreach ($this->query("SELECT `domain`, `username` FROM `mailbox` WHERE `domain` IN ($in)", $params)->fetchAll() as $row) { + $usernames[strtolower((string)$row['domain'])][strtolower((string)$row['username'])] = true; + } + } + return [$existing, $usernames]; + } + + /** @return array username => name of the mailboxes of a domain */ + public function mailboxNames(string $domain): array + { + $names = []; + foreach ($this->query("SELECT `username`, `name` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`", ['domain' => $domain])->fetchAll() as $row) { + $names[strtolower((string)$row['username'])] = (string)$row['name']; + } + return $names; + } + + /** @return string[] all mailboxes of a domain */ + public function mailboxesOf(string $domain): array + { + return array_map('strtolower', $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`", + ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** @return string[] alias domains of a domain */ + public function aliasDomainsOf(string $domain): array + { + return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain", + ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN)); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php new file mode 100644 index 000000000..cadf09193 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php @@ -0,0 +1,237 @@ + table => columns */ + private array $columns = []; + + /** Runs $work in one transaction */ + public function inTransaction(callable $work): mixed + { + return $this->transaction($work); + } + + /** Runs $work in a savepoint of the running transaction: an error undoes only its writes and is thrown on */ + public function savepoint(string $name, callable $work): mixed + { + self::identifier($name); + $this->pdo->exec("SAVEPOINT `$name`"); + try { + $result = $work(); + $this->pdo->exec("RELEASE SAVEPOINT `$name`"); + return $result; + } catch (Throwable $e) { + $this->pdo->exec("ROLLBACK TO SAVEPOINT `$name`"); + throw $e; + } + } + + /** + * Runs $work while holding a named lock, so a retried request of the runner waits for the first one instead of + * merging the same mailbox twice; null when the lock was not free within $timeout seconds + */ + public function locked(string $key, callable $work, int $timeout = 120): mixed + { + $name = 'mailcow_backup_' . md5($key); + if ((int)$this->query("SELECT GET_LOCK(:name, " . max(0, $timeout) . ")", ['name' => $name])->fetchColumn() !== 1) { + return null; + } + try { + return $work(); + } finally { + $this->query("SELECT RELEASE_LOCK(:name)", ['name' => $name]); + } + } + + /** Quota rows of a new mailbox (like the legacy mailbox add) */ + public function resetQuota(string $username): void + { + foreach (['quota2', 'quota2replica'] as $table) { + $this->query("INSERT INTO `$table` (`username`, `bytes`, `messages`) VALUES (:username, 0, 0) + ON DUPLICATE KEY UPDATE `bytes` = 0, `messages` = 0", ['username' => $username]); + } + } + + /** Regular mailboxes of a domain (the domain's mailbox limit counts these) */ + public function mailboxCount(string $domain): int + { + return (int)$this->query("SELECT COUNT(*) FROM `mailbox` WHERE `domain` = :domain AND `kind` = ''", ['domain' => $domain])->fetchColumn(); + } + + /** External send-as rights for a domain that is local now (legacy domain and alias domain add) */ + public function dropExternalSenderAcl(string $domain): void + { + $this->query("DELETE FROM `sender_acl` WHERE `external` = 1 AND `send_as` LIKE :domain", ['domain' => '%@' . addcslashes($domain, '%_\\')]); + } + + /** @return string[] the columns of a table in the current schema */ + public function columns(string $table): array + { + self::identifier($table); + return $this->columns[$table] ??= $this->query("SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS` + WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = :table ORDER BY `ORDINAL_POSITION`", ['table' => $table])->fetchAll(PDO::FETCH_COLUMN); + } + + /** @return array[] rows of a table matching every condition (column => value) */ + public function rows(string $table, array $where): array + { + [$condition, $params] = $this->where($table, $where); + return $this->query("SELECT * FROM `$table` WHERE $condition", $params)->fetchAll(); + } + + /** Inserts a row with the values of the columns the table has now; returns the new id (0 without one) */ + public function insert(string $table, array $values): int + { + return $this->insertRow($table, $this->existing($table, $values)); + } + + /** Updates the rows matching $where with the values of the columns the table has now */ + public function update(string $table, array $where, array $values): int + { + $values = $this->existing($table, $values); + if (empty($values)) { + return 0; + } + [$condition, $params] = $this->where($table, $where); + $set = []; + foreach (array_keys($values) as $i => $column) { + $set[] = "`$column` = :v$i"; + $params['v' . $i] = $values[$column]; + } + return $this->query("UPDATE `$table` SET " . implode(', ', $set) . " WHERE $condition", $params)->rowCount(); + } + + /** A primary or alias domain */ + public function domainExists(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias", + ['domain' => $domain, 'alias' => $domain])->fetchColumn(); + } + + /** The primary domain of a domain or alias domain, null if mailcow has neither */ + public function primaryDomain(string $domain): ?string + { + $primary = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain + UNION SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :alias", ['domain' => $domain, 'alias' => $domain])->fetchColumn(); + return is_string($primary) ? strtolower($primary) : null; + } + + /** An active primary or alias domain (like the legacy is_local_mailcow_domain()) */ + public function isLocalDomain(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain AND `active` = 1 + UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias AND `active` = 1", ['domain' => $domain, 'alias' => $domain])->fetchColumn(); + } + + /** @return string[] alias domains of a domain */ + public function aliasDomainsOf(string $domain): array + { + return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain", + ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** + * What an address is now: 'mailbox' (also local@alias-domain of a mailbox), 'resource' (a mailbox of kind + * location, thing or group), 'alias', 'spamalias' or null + */ + public function addressKind(string $address): ?string + { + $kind = $this->query("SELECT `kind` FROM `mailbox` WHERE `username` = :address", ['address' => $address])->fetchColumn(); + if (is_string($kind)) { + return preg_match('/location|thing|group/', $kind) ? 'resource' : 'mailbox'; + } + [$local, $domain] = array_pad(explode('@', $address, 2), 2, ''); + if ((bool)$this->query("SELECT 1 FROM `mailbox` `m` JOIN `alias_domain` `ad` ON `ad`.`alias_domain` = :domain + WHERE `m`.`username` = CONCAT(:local, '@', `ad`.`target_domain`)", ['domain' => $domain, 'local' => $local])->fetchColumn()) { + return 'mailbox'; + } + if ((bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) { + return 'alias'; + } + if ((bool)$this->query("SELECT 1 FROM `spamalias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) { + return 'spamalias'; + } + return null; + } + + /** Aliases the domain may still get (domain limit minus its aliases and those of its alias domains, like the legacy code) */ + public function aliasesLeft(string $domain): int + { + $limit = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + if ($limit === false) { + return 0; + } + $used = (int)$this->query("SELECT COUNT(*) FROM `alias` WHERE (`domain` = :domain + OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target)) + AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['domain' => $domain, 'target' => $domain])->fetchColumn(); + return (int)$limit - $used; + } + + /** Quota (bytes) the other mailboxes of the domain have */ + public function quotaInUse(string $domain, string $exceptUsername): int + { + return (int)$this->query("SELECT COALESCE(SUM(`quota`), 0) FROM `mailbox` WHERE `domain` = :domain AND `kind` = '' AND `username` != :username", + ['domain' => $domain, 'username' => $exceptUsername])->fetchColumn(); + } + + /** @return array[] user1, host1 and port1 of the mailbox's sync jobs */ + public function syncJobsOf(string $username): array + { + return $this->query("SELECT `i`.`user1`, `s`.`host1`, `s`.`port1` FROM `imapsync` `i` + LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` = :username", ['username' => $username])->fetchAll(); + } + + /** @return int[] ids of the sync sources with this host and port the mailbox may use, oldest first */ + public function syncSourcesFor(string $host, int $port, string $username, string $domain): array + { + return array_map('intval', $this->query("SELECT `s`.`id` FROM `imapsync_source` `s` WHERE `s`.`host1` = :host AND `s`.`port1` = :port + AND (`s`.`scope` = 'all' + OR (`s`.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` `d` WHERE `d`.`source_id` = `s`.`id` AND `d`.`domain` = :domain)) + OR (`s`.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` `u` WHERE `u`.`source_id` = `s`.`id` AND `u`.`username` = :username))) + ORDER BY `s`.`id`", ['host' => $host, 'port' => $port, 'domain' => $domain, 'username' => $username])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** @return array{0: string, 1: array} condition and parameters of column => value pairs */ + private function where(string $table, array $where): array + { + self::identifier($table); + $parts = []; + $params = []; + foreach (array_keys($where) as $i => $column) { + self::identifier((string)$column); + if ($where[$column] === null) { + $parts[] = "`$column` IS NULL"; + continue; + } + $parts[] = "`$column` = :w$i"; + $params['w' . $i] = $where[$column]; + } + return [empty($parts) ? '1=1' : implode(' AND ', $parts), $params]; + } + + /** The values of the columns the table has now */ + private function existing(string $table, array $values): array + { + return array_intersect_key($values, array_flip($this->columns($table))); + } + + /** Table, column and savepoint names come from code only; this keeps it that way */ + private static function identifier(string $name): void + { + if (!preg_match('/^[a-z_0-9]+$/i', $name)) { + throw new \InvalidArgumentException('invalid identifier'); + } + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php new file mode 100644 index 000000000..b76b2364f --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php @@ -0,0 +1,31 @@ + name => value of the stored settings */ + public function load(): array + { + return $this->query("SELECT `name`, `value` FROM `backup_setting`")->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** Stores the given values (name => value) */ + public function save(array $values): void + { + $this->transaction(function () use ($values): void { + foreach ($values as $name => $value) { + $this->query("INSERT INTO `backup_setting` (`name`, `value`) VALUES (:name, :value) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)", ['name' => (string)$name, 'value' => (string)$value]); + } + }); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php new file mode 100644 index 000000000..6a2e87e48 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php @@ -0,0 +1,31 @@ +query("SELECT " . self::COLUMNS . ", `content` FROM `backup_snapshot` WHERE `id` = :id OR `short_id` = :short_id ORDER BY `id` = :exact DESC LIMIT 1", + ['id' => $id, 'short_id' => $id, 'exact' => $id])->fetch(); + return is_array($row) ? Snapshot::fromRow($row) : null; + } + + /** @return Snapshot[] newest first; the content (the mailboxes in it) only with $withContent */ + public function all(bool $withContent = false): array + { + return array_map(fn(array $row): Snapshot => Snapshot::fromRow($row), + $this->query("SELECT " . self::COLUMNS . ($withContent ? ", `content`" : "") . " FROM `backup_snapshot` ORDER BY `time` DESC, `id`")->fetchAll()); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php b/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php new file mode 100644 index 000000000..99fac8dfa --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php @@ -0,0 +1,320 @@ +jobs->find($id); + if ($job === null || $job->type !== 'restore' || $job->state !== 'running') { + throw new UserError('backup_job_not_running'); + } + if (!$job->createsDomain() || ($job->params['requested_role'] ?? '') !== 'admin') { + throw new AccessDenied(); + } + $domain = strtolower((string)$job->domain); + if (!is_array($data) || !is_array($data['domain'] ?? null) || strtolower((string)($data['domain']['domain'] ?? '')) !== $domain) { + throw new UserError('backup_export_invalid'); + } + $result = $this->repo->locked($job->id . ':domain', fn(): array => $this->restoreDomain($job, $domain, $data)); + return is_array($result) ? ['domain' => $result] : throw new UserError('backup_job_busy'); + } + + private function restoreDomain(Job $job, string $domain, array $data): array + { + $result = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0]; + if (!empty($this->repo->rows('alias_domain', ['alias_domain' => $domain]))) { + $result['errors'][] = "$domain is an alias domain now"; + return $result; + } + if (!empty($this->repo->rows('domain', ['domain' => $domain]))) { + // created meanwhile: only what is missing is added + $result['skipped']++; + } else { + $this->createDomain($domain, $data, $result); + } + $this->repo->inTransaction(function () use ($domain, $data, &$result): void { + $this->aliasDomains($domain, $data, $result); + $this->domainSettings($domain, $data, $result); + }); + $this->redisSettings($domain, $data, $result); + $groupIds = $this->createGroups($domain, $data, $result); + $this->repo->inTransaction(function () use ($domain, $data, $groupIds, &$result): void { + $this->rules($domain, $data, 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id', $groupIds, $result); + $this->rules($domain, $data, 'retention_rules', 'retention_rule', 'retention_rule_member', 'rule_id', $groupIds, $result); + }); + return $result; + } + + /** The domain row, its tags and the effects of the legacy domain add */ + private function createDomain(string $domain, array $data, array &$result): void + { + // the domain keeps its creation time: domain admins may use the snapshots made after it + $row = array_diff_key(RestoreService::decode($data['domain']), array_flip(['modified'])); + $relayhost = (string)($row['relayhost'] ?? '0'); + if ($relayhost !== '0' && $relayhost !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$relayhost]))) { + $row['relayhost'] = '0'; + $result['errors'][] = 'relayhost: does not exist'; + } + $this->repo->inTransaction(function () use ($domain, $row, $data): void { + $this->repo->insert('domain', ['domain' => $domain] + $row); + $this->repo->dropExternalSenderAcl($domain); + foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', (array)($data['tags'] ?? [])), 'strlen')) as $tag) { + $this->repo->insert('tags_domain', ['domain' => $domain, 'tag_name' => $tag]); + } + }); + $result['added']++; + $result['created'] = 1; + $this->effect($result, 'domain map', fn() => $this->legacy->addDomainMap($domain)); + $error = $this->legacy->restartSogo(); + if ($error !== null) { + $result['errors'][] = 'SOGo restart failed: ' . $error; + } + } + + private function aliasDomains(string $domain, array $data, array &$result): void + { + foreach (self::rows($data, 'alias_domains') as $row) { + $alias = strtolower(trim((string)($row['alias_domain'] ?? ''))); + if ($alias === '' || $this->repo->domainExists($alias)) { + $result['skipped']++; + continue; + } + $this->repo->insert('alias_domain', ['alias_domain' => $alias, 'target_domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW))); + $this->repo->dropExternalSenderAcl($alias); + $this->effect($result, "domain map of $alias", fn() => $this->legacy->addDomainMap($alias)); + $result['added']++; + } + } + + /** Spam settings, BCC maps, footer, MTA-STS and domain admins of the domain that are missing */ + private function domainSettings(string $domain, array $data, array &$result): void + { + foreach (self::rows($data, 'filterconf') as $row) { + $option = (string)($row['option'] ?? ''); + $value = (string)($row['value'] ?? ''); + $current = $this->repo->rows('filterconf', ['object' => $domain]); + $present = in_array($option, ['whitelist_from', 'blacklist_from'], true) + ? array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true) && strtolower((string)$c['value']) === strtolower($value)) + : array_filter($current, fn(array $c): bool => $c['option'] === $option && (in_array($option, ['lowspamlevel', 'highspamlevel'], true) || (string)$c['value'] === $value)); + if ($option === '' || !empty($present)) { + $result['skipped']++; + continue; + } + $this->repo->insert('filterconf', ['object' => $domain, 'option' => $option, 'value' => $value]); + $result['added']++; + } + foreach (self::rows($data, 'bcc_maps') as $row) { + $localDest = strtolower((string)($row['local_dest'] ?? '')); + if (!in_array($localDest, [$domain, '@' . $domain], true) || !filter_var((string)($row['bcc_dest'] ?? ''), FILTER_VALIDATE_EMAIL) + || !in_array($row['type'] ?? '', ['sender', 'rcpt'], true)) { + $result['errors'][] = "BCC map $localDest: invalid"; + } elseif (!empty($this->repo->rows('bcc_maps', ['local_dest' => $localDest, 'type' => $row['type']]))) { + $result['skipped']++; + } else { + $this->repo->insert('bcc_maps', ['domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW))); + $result['added']++; + } + } + foreach (['footer' => 'domain_wide_footer', 'mta_sts' => 'mta_sts'] as $key => $table) { + if (!is_array($data[$key] ?? null)) { + continue; + } + if (!empty($this->repo->rows($table, ['domain' => $domain]))) { + $result['skipped']++; + continue; + } + $this->repo->insert($table, ['domain' => $domain] + array_diff_key(RestoreService::decode($data[$key]), array_flip(['created', 'modified']))); + $result['added']++; + } + foreach (self::rows($data, 'domain_admins') as $row) { + $username = (string)($row['username'] ?? ''); + if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0]))) { + $result['errors'][] = "domain admin $username: does not exist"; + } elseif (!empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => $domain]))) { + $result['skipped']++; + } else { + $this->repo->insert('domain_admins', ['username' => $username, 'domain' => $domain, 'active' => (int)($row['active'] ?? 1)]); + $result['added']++; + } + } + } + + /** DKIM keys of the domain and its alias domains, and the rate limit of the domain (Redis), when missing */ + private function redisSettings(string $domain, array $data, array &$result): void + { + $domains = array_merge([$domain], $this->repo->aliasDomainsOf($domain)); + foreach (self::rows($data, 'dkim') as $key) { + $name = strtolower((string)($key['domain'] ?? '')); + $selector = (string)($key['selector'] ?? ''); + if (!in_array($name, $domains, true)) { + continue; + } + if (!preg_match('/^[a-z0-9._-]{1,63}$/i', $selector) || (string)($key['pubkey'] ?? '') === '' || (string)($key['privkey'] ?? '') === '') { + $result['errors'][] = "DKIM key of $name: invalid"; + continue; + } + $this->effect($result, "DKIM key of $name", function () use ($name, $selector, $key, &$result): void { + if ($this->legacy->dkim($name) !== null) { + $result['skipped']++; + return; + } + $this->legacy->addDkim($name, $selector, (string)$key['pubkey'], (string)$key['privkey']); + $result['added']++; + }); + } + $rateLimit = $data['ratelimit'] ?? null; + if (is_string($rateLimit) && preg_match(self::RATE_LIMIT, $rateLimit)) { + $this->effect($result, 'rate limit', function () use ($domain, $rateLimit, &$result): void { + if (isset($this->legacy->rateLimits([$domain])[$domain])) { + $result['skipped']++; + return; + } + $this->legacy->setRateLimit($domain, $rateLimit); + $result['added']++; + }); + } + } + + /** + * Groups of the domain missing by name, through the group module (address, flags, priority, IdP link) + * + * @return array id in the backup => id now, of all groups found by name + */ + private function createGroups(string $domain, array $data, array &$result): array + { + $ids = []; + $admin = new Context('admin', 'backup', [], true); + foreach (self::rows($data, 'groups') as $row) { + $name = (string)($row['name'] ?? ''); + $current = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => $name])[0] ?? null; + if ($current !== null) { + $ids[(int)($row['id'] ?? 0)] = (int)$current['id']; + $result['skipped']++; + continue; + } + $input = ['domain' => $domain, 'name' => $name, 'description' => (string)($row['description'] ?? '')]; + foreach (['sender_allowed', 'sogo_visible', 'internal', 'active', 'priority'] as $key) { + if (isset($row[$key])) { + $input[$key] = (int)$row[$key]; + } + } + foreach (['address', 'idp_ref'] as $key) { + if (($row[$key] ?? null) !== null && $row[$key] !== '') { + $input[$key] = (string)$row[$key]; + } + } + try { + $ids[(int)($row['id'] ?? 0)] = $this->groups->create($admin, $input); + $result['added']++; + } catch (UserError $e) { + $result['errors'][] = "group $name: " . $e->key; + } + } + return $ids; + } + + /** + * Mail policies or retention rules missing by name, with their group assignments; group: entries of policies + * follow the groups' new ids + */ + private function rules(string $domain, array $data, string $key, string $table, string $member, string $column, array $groupIds, array &$result): void + { + foreach (self::rows($data, $key) as $row) { + $name = (string)($row['name'] ?? ''); + if (!empty($this->repo->rows($table, ['domain' => $domain, 'name' => $name]))) { + $result['skipped']++; + continue; + } + $values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_members'])); + foreach (self::POLICY_LISTS as $list) { + if (isset($values[$list])) { + $values[$list] = json_encode($this->remapGroups((string)$values[$list], $groupIds, "$name ($list)", $result)); + } + } + $id = $this->repo->insert($table, ['domain' => $domain, 'name' => $name] + $values); + foreach ((array)($row['group_members'] ?? []) as $groupName) { + $group = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => (string)$groupName])[0] ?? null; + if ($group === null) { + $result['errors'][] = "$name: group $groupName does not exist"; + continue; + } + $this->repo->insert($member, [$column => $id, 'group_id' => (int)$group['id']]); + } + $result['added']++; + } + } + + /** @return string[] entries of a JSON list with group: of the backup turned into the group's id now */ + private function remapGroups(string $json, array $groupIds, string $what, array &$result): array + { + $entries = []; + foreach ((array)json_decode($json, true) as $entry) { + if (!is_string($entry)) { + continue; + } + if (preg_match('/^group:(\d+)$/', $entry, $match)) { + if (!isset($groupIds[(int)$match[1]])) { + $result['errors'][] = "$what: $entry dropped (group does not exist)"; + continue; + } + $entry = 'group:' . $groupIds[(int)$match[1]]; + } + $entries[] = $entry; + } + return $entries; + } + + /** A Redis or dockerapi step: a failure is reported and the rest goes on */ + private function effect(array &$result, string $what, callable $work): void + { + try { + $work(); + } catch (Throwable) { + $result['errors'][] = "$what: not stored (Redis)"; + } + } + + private static function rows(array $data, string $key): array + { + return array_values(array_filter((array)($data[$key] ?? []), 'is_array')); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/ExportService.php b/data/web/inc/lib/mailcow/Backup/Service/ExportService.php new file mode 100644 index 000000000..42c5d7a38 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/ExportService.php @@ -0,0 +1,227 @@ +.json in a snapshot), in pages of mailboxes the runner + * merges into one file. The first page holds version, db_version, domain, domain_data (what recreating the domain + * needs), the aliases and `mailbox_aliases` (username => the aliases of any domain whose goto names the mailbox, + * without its own alias and group addresses; the runner moves them into the mailbox sections when it reads the file); + * every page a part of `mailboxes` and `next_after`, the last username of the page to ask for the next one with + * (keyset paging; null after the last page). + * + * Per mailbox every row a restore needs, with all columns. Password hashes, DKIM keys and the secrets of app + * passwords, 2FA and sync jobs are part of it on purpose; only the runner gets it (backup.php with the token). Binary + * values (fido2) are written as {"base64": "..."}. Beside the keys of the spec, a mailbox has `own_alias` (its alias + * row: forwarding with or without a copy), `aliases` (empty, see mailbox_aliases) and `ratelimit` (Redis). + */ +class ExportService +{ + public const VERSION = 1; + public const PAGE_SIZE = 500; + /** Lists of a mailbox: key => [table, column with the username] */ + private const MAILBOX_ROWS = [ + 'sender_acl' => ['sender_acl', 'logged_in_as'], + 'sender_acl_granted' => ['sender_acl', 'send_as'], + 'sieve_filters' => ['sieve_filters', 'username'], + 'filterconf' => ['filterconf', 'object'], + 'spamalias' => ['spamalias', 'goto'], + 'bcc_maps' => ['bcc_maps', 'local_dest'], + 'app_passwd' => ['app_passwd', 'mailbox'], + 'tfa' => ['tfa', 'username'], + // FIDO2 keys of mailbox users (passwordless login on the user page), like those of admins + 'fido2' => ['fido2', 'username'], + 'identity_link' => ['identity_link', 'username'], + ]; + /** Single rows of a mailbox: key => [table, column with the username] */ + private const MAILBOX_ROW = [ + 'user_acl' => ['user_acl', 'username'], + 'pushover' => ['pushover', 'username'], + ]; + + public function __construct(private ExportRepository $repo, private LegacyDomain $legacy) + { + } + + /** @return string[] all domains */ + public function domains(): array + { + return array_values(array_map('strval', $this->repo->domains())); + } + + /** + * @param ?string $after the last username of the previous page (keyset paging), null for the first page + * @throws UserError domain_not_found + */ + public function export(string $domain, ?string $after = null, int $pageSize = self::PAGE_SIZE): array + { + $domain = strtolower(trim($domain)); + $row = ($domain === '') ? null : ($this->repo->rows('domain', 'domain', $domain)[0] ?? null); + if ($row === null) { + throw new UserError('domain_not_found', [htmlspecialchars($domain)]); + } + $rows = $this->repo->mailboxesAfter($domain, strtolower($after ?? ''), $pageSize + 1); + $more = count($rows) > $pageSize; + $rows = array_slice($rows, 0, $pageSize); + $export = ['version' => self::VERSION, 'domain' => $domain]; + if ($after === null) { + $export += [ + 'db_version' => $this->repo->dbVersion(), + 'domain_data' => $this->domainData($domain, $row), + 'aliases' => array_map(fn(array $alias): array => self::encode($alias), $this->repo->aliases($domain)), + 'mailbox_aliases' => $this->mailboxAliases($domain) ?: new \stdClass(), + ]; + } + $export['mailboxes'] = $this->mailboxes($domain, $rows) ?: new \stdClass(); + $export['next_after'] = ($more && !empty($rows)) ? strtolower((string)end($rows)['username']) : null; + return $export; + } + + /** What recreating the domain needs: its row and settings, alias domains, DKIM keys, groups, policies, rules */ + private function domainData(string $domain, array $row): array + { + $aliasDomains = $this->repo->rows('alias_domain', 'target_domain', $domain); + $dkim = []; + foreach (array_merge([$domain], array_map(fn(array $alias): string => strtolower((string)$alias['alias_domain']), $aliasDomains)) as $name) { + $key = $this->legacy->dkim($name); + if ($key !== null) { + $dkim[] = ['domain' => $name] + $key; + } + } + return [ + 'domain' => self::encode($row), + 'tags' => array_map(fn(array $tag): string => (string)$tag['tag_name'], $this->repo->rows('tags_domain', 'domain', $domain)), + 'alias_domains' => $aliasDomains, + 'dkim' => $dkim, + 'ratelimit' => $this->legacy->rateLimits([$domain])[$domain] ?? null, + 'filterconf' => $this->repo->rows('filterconf', 'object', $domain), + 'bcc_maps' => $this->repo->domainBccMaps($domain), + 'footer' => $this->repo->rows('domain_wide_footer', 'domain', $domain)[0] ?? null, + 'mta_sts' => $this->repo->rows('mta_sts', 'domain', $domain)[0] ?? null, + 'domain_admins' => $this->repo->rows('domain_admins', 'domain', $domain), + 'groups' => $this->repo->rows('mailbox_group', 'domain', $domain), + 'mail_policies' => $this->repo->domainRules('mail_policy', 'mail_policy_member', 'policy_id', $domain), + 'retention_rules' => $this->repo->domainRules('retention_rule', 'retention_rule_member', 'rule_id', $domain), + ]; + } + + /** @return array username => its section */ + private function mailboxes(string $domain, array $rows): array + { + $mailboxes = []; + foreach ($rows as $row) { + $mailboxes[strtolower((string)$row['username'])] = [ + 'mailbox' => self::encode($row) + ['tags' => []], + 'user_acl' => null, + 'pushover' => null, + 'ratelimit' => null, + 'own_alias' => null, + 'aliases' => [], + 'groups' => [], + 'mail_policies' => [], + 'retention_rules' => [], + 'imapsync' => [], + ] + array_fill_keys(array_keys(self::MAILBOX_ROWS), []); + } + $usernames = array_map('strval', array_keys($mailboxes)); + if (empty($usernames)) { + return []; + } + // the mailbox of the page a row belongs to, null for anything else + $owner = fn(array $row, string $column): ?string => isset($mailboxes[strtolower((string)$row[$column])]) ? strtolower((string)$row[$column]) : null; + foreach ($this->repo->rowsOfMailboxes('tags_mailbox', 'username', $usernames) as $row) { + if (($username = $owner($row, 'username')) !== null) { + $mailboxes[$username]['mailbox']['tags'][] = (string)$row['tag_name']; + } + } + foreach (self::MAILBOX_ROW as $key => [$table, $column]) { + foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) { + if (($username = $owner($row, $column)) !== null) { + $mailboxes[$username][$key] = self::encode($row); + } + } + } + foreach (self::MAILBOX_ROWS as $key => [$table, $column]) { + foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) { + if (($username = $owner($row, $column)) !== null) { + $mailboxes[$username][$key][] = self::encode($row); + } + } + } + foreach ($this->legacy->rateLimits($usernames) as $username => $value) { + if (isset($mailboxes[strtolower((string)$username)])) { + $mailboxes[strtolower((string)$username)]['ratelimit'] = $value; + } + } + foreach ($this->repo->groupMemberships($usernames) as $row) { + if (($username = $owner($row, 'username')) !== null) { + $mailboxes[$username]['groups'][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain'], + 'source' => (string)$row['source']]; + } + } + foreach (['mail_policies' => ['mail_policy', 'mail_policy_member', 'policy_id'], 'retention_rules' => ['retention_rule', 'retention_rule_member', 'rule_id']] + as $key => [$table, $member, $column]) { + foreach ($this->repo->assignments($table, $member, $column, $usernames) as $row) { + if (($username = $owner($row, 'username')) !== null) { + $mailboxes[$username][$key][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain']]; + } + } + } + foreach ($this->repo->syncJobs($usernames) as $row) { + if (($username = $owner($row, 'user2')) !== null) { + $mailboxes[$username]['imapsync'][] = self::encode($row); + } + } + foreach ($this->repo->rowsOfMailboxes('alias', 'address', $usernames) as $row) { + if (($username = $owner($row, 'address')) !== null) { + $mailboxes[$username]['own_alias'] = self::encode($row); + } + } + return $mailboxes; + } + + /** + * @return array username => aliases of any domain whose goto names the mailbox (once per domain: the + * alias table is scanned, not once per page) + */ + private function mailboxAliases(string $domain): array + { + $usernames = array_fill_keys(array_map(fn(mixed $username): string => strtolower((string)$username), $this->repo->usernames($domain)), true); + $aliases = []; + foreach ($this->repo->aliasesPointingTo($domain) as $row) { + if ($row['group_id'] !== null) { + continue; + } + $address = strtolower((string)$row['address']); + foreach (array_unique(array_map('strtolower', self::gotos((string)$row['goto']))) as $goto) { + if ($goto !== $address && isset($usernames[$goto])) { + $aliases[$goto][] = self::encode($row); + } + } + } + return $aliases; + } + + /** @return string[] addresses of a goto list */ + public static function gotos(string $goto): array + { + return array_values(array_filter(array_map('trim', explode(',', $goto)), 'strlen')); + } + + /** Binary values (not UTF-8) as {"base64": "..."}, so the row survives JSON */ + public static function encode(array $row): array + { + foreach ($row as $column => $value) { + if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) { + $row[$column] = ['base64' => base64_encode($value)]; + } + } + return $row; + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php b/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php new file mode 100644 index 000000000..52d38db88 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php @@ -0,0 +1,48 @@ +service($context)->update($groupId, ['add_members' => [$username]]); + } + + /** + * Creates a group (restore of a deleted domain); members follow with the mailboxes. + * + * @throws UserError + */ + public function create(Context $context, array $input): int + { + return (int)$this->service($context)->create($input)->id; + } + + protected function service(Context $context): GroupService + { + return new GroupService($this->repo, $this->lookup, $this->templates, $this->effects, $this->sharing, $context); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/JobService.php b/data/web/inc/lib/mailcow/Backup/Service/JobService.php new file mode 100644 index 000000000..8ff4b5005 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/JobService.php @@ -0,0 +1,653 @@ + 'mailbox_groups', + 'mail_policies' => 'mail_policies', + 'retention_rules' => 'retention', + 'filters' => 'filters', + 'sieve_activate' => 'filters', + 'spam' => 'spam_policy', + 'bcc' => 'bcc_maps', + 'syncjobs' => 'syncjobs', + 'app_passwords' => 'app_passwds', + ]; + /** Components that only work together with another one */ + private const REQUIRES = ['sieve_activate' => 'filters']; + /** Domain admin ACLs the merge respects, stored with the job */ + private const RESTORE_ACL = ['filters', 'mailbox_groups', 'mail_policies', 'retention', 'spam_policy', 'bcc_maps', 'syncjobs', + 'app_passwds', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'sogo_access', 'pushover', 'extend_sender_acl', + 'alias_external_goto', 'ratelimit', 'tls_policy', 'quarantine', 'quarantine_notification', 'quarantine_category']; + private const MAX_MAILBOXES = 1000; + private const LIST_LIMIT = 100; + private const MAX_LIST = 1000; + /** Mailboxes a search returns at most */ + private const SEARCH_LIMIT = 500; + /** The runner reports every 10 seconds */ + private const RUNNER_TIMEOUT = 120; + /** @var array domain => [access of the domain admin, creation time now], per request */ + private array $domainState = []; + + public function __construct(private JobRepository $jobs, private SnapshotRepository $snapshots, private SettingsService $settings, + private Context $context, private string $timezone = 'UTC') + { + } + + /** + * @param mixed $type only jobs of this type ('' or null = all; an unknown type finds nothing) + * @return Job[] newest first, at most $limit (0 = LIST_LIMIT) + */ + public function list(int $limit = 0, mixed $type = null): array + { + $type = Input::text($type); + if ($type !== '' && !in_array($type, Job::TYPES, true)) { + return []; + } + return $this->jobs->visible($this->context, ($limit > 0) ? min($limit, self::MAX_LIST) : self::LIST_LIMIT, ($type === '') ? null : $type); + } + + /** + * Entry of the API. Domain admins get it restricted: e-mail addresses outside the job's domain and its alias domains + * and repository details (URLs, restic repository strings, paths of the repository) are shown as "…". + */ + public function view(Job $job, bool $withLog = false): array + { + $entry = $job->toArray($withLog); + if ($this->context->hasAdminRights()) { + return $entry; + } + $domains = array_merge([strtolower($job->domain)], $this->jobs->aliasDomainsOf(strtolower($job->domain))); + foreach (['result', 'error', 'log'] as $key) { + if (isset($entry[$key])) { + $entry[$key] = self::restricted($entry[$key], $domains); + } + } + return $entry; + } + + /** The job with its log, null when it does not exist or the caller may not see it */ + public function get(int $id): ?Job + { + $job = $this->jobs->find($id, true); + return ($job !== null && $this->visible($job)) ? $job : null; + } + + /** + * Queues a backup, check, prune or init job (admins). + * + * @throws UserError + */ + public function create(array $input): Job + { + $this->context->requireAdmin(); + $type = Input::text($input['type'] ?? null); + if (!in_array($type, Job::MANUAL_TYPES, true)) { + throw new UserError('backup_job_type_invalid', [htmlspecialchars((string)$type)]); + } + $settings = $this->settings->load(); + if (!$settings->configured()) { + throw new UserError('backup_not_configured'); + } + if ($type === 'backup' && $settings->get('password_confirmed') !== '1') { + throw new UserError('backup_password_not_confirmed'); + } + $job = new Job($type); + $job->requestedBy = (string)$this->context->username; + $this->jobs->insertExclusive($job, function (array $active) use ($type): void { + foreach ($active as $other) { + if ($other->type === $type) { + throw new UserError('backup_job_active', [$type]); + } + } + }); + return $job; + } + + /** + * Queues a restore: {snapshot, scope: mailbox|domain, mailboxes | domain, components, mail_target: folder|original}. + * All mailboxes belong to one domain. Mailboxes deleted since the snapshot are created from it (create_mailboxes); + * an admin may restore a deleted domain (create_domain). Scope domain restores the snapshot's mailboxes of the + * domain (the current ones for snapshots without content). + * + * @throws UserError + */ + public function restore(array $input): Job + { + $this->requireRestorer(); + if (!$this->settings->load()->configured()) { + throw new UserError('backup_not_configured'); + } + $snapshot = $this->snapshot($input['snapshot'] ?? null); + $scope = Input::text($input['scope'] ?? 'mailbox'); + [$domain, $mailboxes] = match ($scope) { + 'domain' => $this->domainScope($snapshot, $input['domain'] ?? null), + 'mailbox' => $this->mailboxScope($snapshot, $input['mailboxes'] ?? null, $input['domain'] ?? null), + default => throw new UserError('backup_scope_invalid'), + }; + $components = $this->components($input['components'] ?? null); + $missing = array_values(array_intersect($components, self::MAIL_COMPONENTS)); + if ($snapshot->mode() === 'config' && !empty($missing)) { + throw new UserError('backup_components_no_mails', [htmlspecialchars(implode(', ', $missing))]); + } + $mailTarget = Input::text($input['mail_target'] ?? null) ?: 'folder'; + if (!in_array($mailTarget, self::MAIL_TARGETS, true)) { + throw new UserError('backup_mail_target_invalid'); + } + $job = new Job('restore'); + $job->step = 'files'; + $job->domain = $domain; + $job->requestedBy = (string)$this->context->username; + $job->params = [ + 'snapshot' => $snapshot->id, + 'snapshot_time' => $snapshot->time, + 'scope' => $scope, + 'domain' => $domain, + 'mailboxes' => $mailboxes, + 'components' => $components, + 'mail_target' => $mailTarget, + 'requested_role' => $this->context->hasAdminRights() ? 'admin' : 'domainadmin', + 'create_domain' => (int)!$this->jobs->isDomain($domain), + 'create_mailboxes' => array_values(array_filter($mailboxes, fn(string $username): bool => $this->jobs->mailboxDomain($username) === null)), + ]; + if (!$this->context->hasAdminRights()) { + $job->params['acl'] = array_combine(self::RESTORE_ACL, array_map(fn(string $acl): int => (int)$this->context->hasAcl($acl), self::RESTORE_ACL)); + $job->params['allowed_domains'] = array_values(array_unique(array_merge([$domain], $this->jobs->aliasDomainsOf($domain)))); + } + $domainAdmin = !$this->context->hasAdminRights(); + $this->jobs->insertExclusive($job, function (array $active) use ($mailboxes, $domain, $domainAdmin): void { + foreach ($active as $other) { + if ($other->type !== 'restore') { + continue; + } + // a domain admin has one restore per domain at a time + if ($domainAdmin && strtolower($other->domain) === $domain) { + throw new UserError('backup_restore_domain_active', [htmlspecialchars($domain)]); + } + $both = array_intersect($mailboxes, $other->mailboxes()); + if (!empty($both)) { + throw new UserError('backup_restore_active', [htmlspecialchars((string)reset($both))]); + } + } + }); + return $job; + } + + /** + * Cancels a queued or running job (the restic runner stops at its next check): admins any, domain admins the + * restores of their domains that domain admins requested. A restore in the dovecot step (mails, mails_running) runs + * to its end. + * + * @throws UserError + */ + public function cancel(int $id): Job + { + if ($this->context->isReadOnlyApi()) { + throw new AccessDenied(); + } + $job = $this->jobs->find($id); + // domain admins cancel only restores domain admins requested + if ($job === null || !$this->visible($job) || (!$this->context->hasAdminRights() && ($job->params['requested_role'] ?? '') !== 'domainadmin')) { + throw new AccessDenied(); + } + if (!$job->isActive() || ($job->type === 'restore' && in_array($job->step, ['mails', 'mails_running'], true)) || !$this->jobs->cancel($id)) { + throw new UserError('backup_job_not_cancellable', [$id]); + } + $job->state = 'cancelled'; + return $job; + } + + /** + * Snapshots, newest first. Domain admins get those holding one of their domains (see mayUse()), with id, short_id, + * time and those domains. + */ + public function snapshots(): array + { + if ($this->context->hasAdminRights()) { + return array_map(fn(Snapshot $snapshot): array => $snapshot->toArray(), $this->snapshots->all()); + } + if (!$this->context->isDomainAdmin()) { + return []; + } + $snapshots = []; + // the domains with their creation time come from the snapshot row, the content is not needed here + foreach ($this->snapshots->all() as $snapshot) { + $domains = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain))); + if (!empty($domains)) { + $snapshots[] = ['id' => $snapshot->id, 'short_id' => $snapshot->shortId, 'time' => $snapshot->time, 'domains' => $domains, + 'mode' => $snapshot->mode()]; + } + } + return $snapshots; + } + + /** + * Domains of a snapshot with their state now: GET backup-snapshot-content/. `available` 0: the snapshot has + * no content (not made by this installation), the list comes from its manifest and only existing mailboxes can be restored. A domain + * of the manifest missing in the content (its export failed) lists its current mailboxes as well. + * + * @return array{snapshot: string, time: int, available: int, domains: array[]}|array{} [] when not found or not visible + */ + public function content(mixed $snapshotId): array + { + $snapshot = $this->visibleSnapshot($snapshotId); + if ($snapshot === null) { + return []; + } + $visible = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain))); + [$existing, $usernames] = $this->jobs->domainsAndUsernames($visible); + $domains = []; + foreach ($visible as $domain) { + $current = $usernames[$domain] ?? []; + $mailboxes = $snapshot->mailboxesOf($domain) ?? $current; + $domains[] = ['domain' => $domain, 'exists' => (int)isset($existing[$domain]), 'mailboxes' => count($mailboxes), + 'missing' => count(array_diff_key($mailboxes, $current))]; + } + return ['snapshot' => $snapshot->id, 'time' => $snapshot->time, 'available' => (int)($snapshot->content !== null), 'domains' => $domains]; + } + + /** + * Search in a snapshot: GET backup-snapshot-search//. Matches domain names and mailboxes by local + * part, address or name ("x@": local part, "@d": domain, "x@d": address), with the rules of content(); at most + * SEARCH_LIMIT mailboxes. A matching domain comes with all its mailboxes. + * + * @return array{domains: array[], truncated: int}|array{} [] when the snapshot is not found or not visible + */ + public function search(mixed $snapshotId, mixed $term): array + { + $snapshot = $this->visibleSnapshot($snapshotId); + $term = mb_strtolower((string)Input::text($term)); + if ($snapshot === null) { + return []; + } + if (trim($term, '@') === '') { + return ['domains' => [], 'truncated' => 0]; + } + $domains = []; + $hits = 0; + $truncated = 0; + [$local, $at, $domainTerm] = str_contains($term, '@') ? [strstr($term, '@', true), true, substr((string)strstr($term, '@'), 1)] : [$term, false, '']; + foreach ($snapshot->domainList() as $domain) { + if (!$this->mayUse($snapshot, $domain)) { + continue; + } + $domainMatch = $at ? ($domainTerm !== '' && str_contains($domain, $domainTerm)) : str_contains($domain, $term); + if ($at && $domainTerm !== '' && !$domainMatch) { + continue; + } + $current = $this->jobs->mailboxNames($domain); + $mailboxes = []; + foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) { + $username = (string)$username; + $part = (string)strstr($username, '@', true); + $match = match (true) { + // "x@d": the address, "x@": the local part, "@d": every mailbox of the matching domains + $at && $local !== '' && $domainTerm !== '' => str_contains($username, $term), + $at && $local !== '' => str_contains($part, $local), + $at => true, + default => $domainMatch || str_contains($username, $term) || str_contains(mb_strtolower($name), $term), + }; + if (!$match) { + continue; + } + if ($hits >= self::SEARCH_LIMIT) { + $truncated = 1; + break; + } + $hits++; + $mailboxes[] = ['username' => $username, 'name' => $name, 'exists' => (int)isset($current[$username])]; + } + // a domain without matching mailboxes only for a search by domain + if (!empty($mailboxes) || ($domainMatch && !($at && $local !== '') && !$truncated)) { + $domains[] = ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes]; + } + if ($truncated) { + break; + } + } + return ['domains' => $domains, 'truncated' => $truncated]; + } + + /** + * Mailboxes of a domain in a snapshot with their state now: GET backup-snapshot-content// + * + * @return array{domain: string, exists: int, mailboxes: array[]}|array{} [] when not found or not visible + */ + public function domainContent(mixed $snapshotId, mixed $domain): array + { + $snapshot = $this->visibleSnapshot($snapshotId); + $domain = strtolower((string)Input::text($domain)); + if ($snapshot === null || !$snapshot->hasDomain($domain) || !$this->mayUse($snapshot, $domain)) { + return []; + } + $current = $this->jobs->mailboxNames($domain); + $mailboxes = []; + foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) { + $mailboxes[] = ['username' => (string)$username, 'name' => $name, 'exists' => (int)isset($current[$username])]; + } + return ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes]; + } + + /** + * State of the backup for admins: settings state, runner heartbeat, repository, newest job of each type, queued and + * running jobs, the next scheduled run. [] for everybody else. + */ + public function status(?int $now = null): array + { + if (!$this->context->hasAdminRights()) { + return []; + } + $now ??= time(); + $settings = $this->settings->load(); + $seen = ($settings->get('state_runner_seen') === '') ? null : (int)$settings->get('state_runner_seen'); + $snapshots = $this->snapshots->all(); + $latest = $this->jobs->latestOfEachType(); + $stats = json_decode($settings->get('state_repo_stats'), true); + return [ + 'enabled' => (int)$settings->get('enabled'), + 'target' => $settings->get('target'), + 'configured' => (int)$settings->configured(), + 'password_confirmed' => (int)$settings->get('password_confirmed'), + 'initialized' => (int)$settings->get('state_initialized'), + // the runner cannot decrypt the stored credentials (e.g. a database restored on another server): enter them again + 'secrets_error' => ($settings->get('state_secrets_error') === '') ? null : $settings->get('state_secrets_error'), + // RESTIC_BACKUP_MODE (null until the runner reported it) + 'mode' => ($settings->get('state_backup_mode') === '') ? null : $settings->get('state_backup_mode'), + 'runner_seen' => $seen, + 'runner_age' => ($seen === null) ? null : max(0, $now - $seen), + 'runner_ok' => (int)($seen !== null && $now - $seen <= self::RUNNER_TIMEOUT), + 'last_success' => ($settings->get('state_last_success') === '') ? null : (int)$settings->get('state_last_success'), + 'repo_size' => ($settings->get('state_repo_size') === '') ? null : (int)$settings->get('state_repo_size'), + 'repo_stats' => is_array($stats) ? $stats : null, + 'snapshots' => count($snapshots), + 'latest_snapshot' => empty($snapshots) ? null : $snapshots[0]->time, + 'next_run' => $this->nextRun($settings, $now), + // the longest time between two scheduled backups (seconds): a later last success is overdue + 'schedule_gap' => $settings->scheduleGap(), + 'jobs' => array_map(fn(string $type): ?array => isset($latest[$type]) ? $latest[$type]->toArray() : null, + array_combine(Job::TYPES, Job::TYPES)), + 'active' => array_map(fn(Job $job): array => $job->toArray(), $this->jobs->active()), + ]; + } + + /** + * Unix time of the next scheduled backup, null while the schedule is off. Like the runner: the schedule is armed + * (state_schedule_since/state_schedule) when it is switched on or changed, a run is due at the first schedule time + * after that; a due run without a scheduled backup since its time is the next one (now). + */ + private function nextRun(Settings $settings, int $now): ?int + { + if ($settings->get('enabled') !== '1' || $settings->get('password_confirmed') !== '1' || !$settings->configured()) { + return null; + } + $slots = $settings->scheduleSlots(); + try { + $zone = new DateTimeZone($this->timezone); + } catch (\Exception) { + $zone = new DateTimeZone('UTC'); + } + $today = (new DateTimeImmutable('@' . $now))->setTimezone($zone); + // the last schedule time up to now, and the one after it + $due = self::slot($slots, $today, -1); + $next = self::slot($slots, $today, 1); + $since = $settings->get('state_schedule_since'); + if ($due === null || $since === '' || $settings->get('state_schedule') !== $settings->get('schedule') || (int)$since > $due) { + return $next; + } + return $this->jobs->scheduledSince($due) ? $next : $due; + } + + /** + * The newest schedule time up to $now ($direction -1) or the first one after it (1) as Unix time, null without + * slots ([weekday, hour, minute], sorted) + */ + private static function slot(array $slots, DateTimeImmutable $now, int $direction): ?int + { + for ($i = 0; $i <= 7; $i++) { + $day = $now->modify(sprintf('%+d day', $direction * $i)); + foreach (($direction < 0) ? array_reverse($slots) : $slots as [$weekday, $hour, $minute]) { + if ($weekday !== (int)$day->format('w')) { + continue; + } + $slot = $day->setTime($hour, $minute)->getTimestamp(); + if (($direction < 0) ? $slot <= $now->getTimestamp() : $slot > $now->getTimestamp()) { + return $slot; + } + } + } + return null; + } + + /** @throws AccessDenied unless the caller may request restores */ + private function requireRestorer(): void + { + if ($this->context->isReadOnlyApi() || !($this->context->hasAdminRights() || $this->context->isDomainAdmin())) { + throw new AccessDenied(); + } + } + + /** Admins every job; domain admins the restores of their domains requested after the domain was created */ + private function visible(Job $job): bool + { + if ($this->context->hasAdminRights()) { + return true; + } + if (!$this->context->isDomainAdmin() || $job->type !== 'restore' || $job->domain === '' || !$this->context->canAccessDomain($job->domain)) { + return false; + } + $created = $this->jobs->domainCreated(strtolower($job->domain)); + return $created !== null && (int)$job->created >= $created; + } + + /** + * A value of a job as a domain admin sees it: e-mail addresses outside $domains and repository details become "…" + * + * @param string[] $domains + */ + private static function restricted(mixed $value, array $domains): mixed + { + if (is_array($value)) { + $restricted = []; + foreach ($value as $key => $item) { + $restricted[is_string($key) ? (string)self::restricted($key, $domains) : $key] = self::restricted($item, $domains); + } + return $restricted; + } + if (!is_string($value)) { + return $value; + } + $value = preg_replace(['~\b(?:s3|sftp|rest|b2|azure|gs|swift|rclone):\S+~i', '~\bhttps?://\S+~i', '~/repository\S*~'], '…', $value); + return preg_replace_callback('/[^\s@<>"\'(),;:\[\]]+@([a-z0-9.-]+\.[a-z]{2,})/i', + fn(array $match): string => in_array(strtolower($match[1]), $domains, true) ? $match[0] : '…', (string)$value); + } + + /** @throws UserError backup_snapshot_not_found (also for snapshots a domain admin may not use) */ + private function snapshot(mixed $value): Snapshot + { + $id = (string)Input::text($value); + $snapshot = ($id === '') ? null : $this->snapshots->find($id); + if ($snapshot === null || (!$this->context->hasAdminRights() && empty(array_filter($snapshot->domainList(), + fn(string $domain): bool => $this->mayUse($snapshot, $domain))))) { + throw new UserError('backup_snapshot_not_found', [htmlspecialchars($id)]); + } + return $snapshot; + } + + private function visibleSnapshot(mixed $value): ?Snapshot + { + try { + return ($this->context->hasAdminRights() || $this->context->isDomainAdmin()) ? $this->snapshot($value) : null; + } catch (UserError) { + return null; + } + } + + /** + * Admins every domain of the snapshot; domain admins the domains they administer now, when the snapshot holds this + * domain and not a former one of the same name: its recorded creation time is the domain's (snapshots without it: + * made after the domain was created) + */ + private function mayUse(Snapshot $snapshot, string $domain): bool + { + if ($this->context->hasAdminRights()) { + return true; + } + if (!$this->context->isDomainAdmin()) { + return false; + } + $this->domainState[$domain] ??= [$this->context->canAccessDomain($domain), $this->jobs->domainCreated($domain)]; + [$access, $created] = $this->domainState[$domain]; + if (!$access) { + return false; + } + $recorded = $snapshot->createdOf($domain); + return $created !== null && (($recorded !== null) ? $recorded === $created : $snapshot->time > $created); + } + + /** + * @return array{0: string, 1: string[]} the domain and its mailboxes in the snapshot (now, for snapshots without content) + * @throws UserError + */ + private function domainScope(Snapshot $snapshot, mixed $value): array + { + $domain = strtolower((string)Input::text($value)); + $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)) { + throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]); + } + $this->requireDomain($snapshot, $domain); + $listed = $snapshot->mailboxesOf($domain); + $mailboxes = ($listed === null) ? $this->jobs->mailboxesOf($domain) : array_map('strval', array_keys($listed)); + // a deleted domain without mailboxes comes back with its aliases and settings + if (empty($mailboxes) && $exists) { + throw new UserError('backup_mailboxes_empty'); + } + return [$domain, $mailboxes]; + } + + /** + * @return array{0: string, 1: string[]} the domain of the mailboxes and the mailboxes + * @throws UserError + */ + private function mailboxScope(Snapshot $snapshot, mixed $value, mixed $domainValue): array + { + $mailboxes = array_values(array_unique(array_map('strtolower', self::values($value)))); + if (empty($mailboxes)) { + throw new UserError('backup_mailboxes_empty'); + } + if (count($mailboxes) > self::MAX_MAILBOXES) { + throw new UserError('backup_entries_exceeded', [self::MAX_MAILBOXES]); + } + $domain = strtolower((string)Input::text($domainValue)); + foreach ($mailboxes as $username) { + // 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) { + throw new UserError('backup_mailbox_not_found', [htmlspecialchars($username)]); + } + if ($domain !== '' && $mailboxDomain !== $domain) { + throw new UserError('backup_mailboxes_one_domain'); + } + $domain = $mailboxDomain; + } + if (!$this->jobs->isDomain($domain) && !$this->context->hasAdminRights()) { + throw new AccessDenied(); + } + $this->requireDomain($snapshot, $domain); + return [$domain, $mailboxes]; + } + + /** @throws UserError the snapshot does not hold the domain, or the caller may not restore into it */ + private function requireDomain(Snapshot $snapshot, string $domain): void + { + if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) { + throw new AccessDenied(); + } + if ($snapshot->knowsDomains() && !$snapshot->hasDomain($domain)) { + throw new UserError('backup_domain_not_in_snapshot', [htmlspecialchars($domain)]); + } + if (!$this->mayUse($snapshot, $domain)) { + throw new UserError('backup_snapshot_not_found', [htmlspecialchars($snapshot->shortId)]); + } + } + + /** + * @return string[] the requested components in their order + * @throws UserError + */ + private function components(mixed $value): array + { + $requested = array_values(array_unique(self::values($value))); + if (empty($requested)) { + throw new UserError('backup_components_invalid', ['']); + } + foreach ($requested as $component) { + if (!in_array($component, self::COMPONENTS, true)) { + throw new UserError('backup_components_invalid', [htmlspecialchars($component)]); + } + if (!$this->context->hasAdminRights() && isset(self::COMPONENT_ACL[$component]) && !$this->context->hasAcl(self::COMPONENT_ACL[$component])) { + throw new UserError('backup_component_denied', [$component]); + } + if (isset(self::REQUIRES[$component]) && !in_array(self::REQUIRES[$component], $requested, true)) { + throw new UserError('backup_component_requires', [$component, self::REQUIRES[$component]]); + } + } + return array_values(array_intersect(self::COMPONENTS, $requested)); + } + + private static function domainOf(string $address): string + { + return strtolower(substr((string)strrchr($address, '@'), 1)); + } + + /** + * Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces. + * + * @return string[] + */ + private static function values(mixed $value): array + { + $values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value)); + return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen')); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php new file mode 100644 index 000000000..deedd545e --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php @@ -0,0 +1,996 @@ + 'protocol_access', 'pop3_access' => 'protocol_access', 'smtp_access' => 'protocol_access', + 'sieve_access' => 'protocol_access', 'eas_access' => 'protocol_access', 'dav_access' => 'protocol_access', + 'relayhost' => 'mailbox_relayhost', 'sogo_access' => 'sogo_access', 'tls_enforce_in' => 'tls_policy', 'tls_enforce_out' => 'tls_policy', + 'quarantine_notification' => 'quarantine_notification', 'quarantine_category' => 'quarantine_category']; + /** Columns a restored row never takes from the backup */ + private const NEW_ROW = ['id', 'created', 'modified']; + /** @var array primary domain => aliases it may still get, counted once per request */ + private array $aliasesLeft = []; + private const MIB = 1048576; + + public function __construct(private RestoreRepository $repo, private JobRepository $jobs, private GroupMembers $groups, + private SogoService $sogo, private DovecotService $dovecot, private LegacyDomain $legacy, private bool $externalGotoDisabled = false) + { + } + + /** + * Action `restore`: one mailbox of a running restore job. A mailbox in params.create_mailboxes that does not exist + * is created from the backup first (result key `mailbox`); when that fails, nothing else is merged. + * + * @param mixed $data the mailbox's section of the domain export + * @return array per component of the job merged here + * @throws UserError + */ + public function merge(mixed $jobId, mixed $username, mixed $data): array + { + $job = $this->runningRestore($jobId); + $username = strtolower((string)Input::text($username)); + if ($username === '' || !in_array($username, array_map('strtolower', $job->mailboxes()), true)) { + throw new AccessDenied(); + } + if (!is_array($data) || !is_array($data['mailbox'] ?? null) || strtolower((string)($data['mailbox']['username'] ?? '')) !== $username) { + throw new UserError('backup_export_invalid'); + } + return $this->locked($job, $username, fn(): array => $this->mergeMailbox($job, $username, $data)); + } + + /** + * Action `restore_aliases`: a part of the aliases of the domain (scope domain), before its mailboxes. Missing aliases + * are created with their gotos that are valid now; existing ones are left to the mailboxes' merge. + * + * @return array{aliases: array{added: int, skipped: int, errors: string[]}} + * @throws UserError + */ + public function mergeAliases(mixed $jobId, mixed $aliases): array + { + $job = $this->runningRestore($jobId); + if (($job->params['scope'] ?? '') !== 'domain' || !in_array('aliases', $job->components(), true)) { + throw new AccessDenied(); + } + if (!is_array($aliases)) { + throw new UserError('backup_export_invalid'); + } + if (count($aliases) > self::MAX_ALIASES) { + throw new UserError('backup_entries_exceeded', [self::MAX_ALIASES]); + } + return $this->locked($job, '', function () use ($job, $aliases): array { + if ($this->permissionRevoked($job)) { + return ['aliases' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked']]]; + } + $domain = strtolower((string)$job->domain); + $run = new RestoreRun('', $domain, [], $job->params, [], $this->allowedDomains($job, $domain), $job->requestedBy); + $run->start('aliases'); + try { + $this->repo->inTransaction(fn() => $this->aliases($run, array_values(array_filter($aliases, 'is_array')))); + } catch (PDOException $e) { + $run->failed('aliases', 'database error ' . $e->getCode()); + } + $this->sogo->refreshStaticView($run->sogoUsers); + return $run->results; + }); + } + + /** The mailbox's merge under the lock: create it if needed, then the components */ + private function mergeMailbox(Job $job, string $username, array $data): array + { + if ($this->permissionRevoked($job)) { + return ['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked'], 'created' => 0]]; + } + $domain = strtolower((string)$job->domain); + $run = new RestoreRun($username, $domain, $this->mailbox($username) ?? [], $job->params, $data, $this->allowedDomains($job, $domain), $job->requestedBy); + $results = []; + if (in_array($username, array_map('strtolower', $job->createMailboxes()), true)) { + $created = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0]; + if (!empty($run->mailbox)) { + $created['skipped'] = 1; + } else { + $error = $this->createMailbox($run); + if ($error !== null) { + $created['errors'][] = $error; + return ['mailbox' => $created]; + } + $created = ['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1]; + $run = new RestoreRun($username, $domain, $this->mailbox($username) ?? [], $job->params, $data, $run->domains, $job->requestedBy); + $run->created = true; + $run->sogo($username); + $run->auth($username); + } + $results['mailbox'] = $created; + } + if (empty($run->mailbox)) { + throw new UserError('backup_mailbox_not_found', [htmlspecialchars($username)]); + } + $components = array_values(array_intersect(self::COMPONENTS, $job->components())); + $this->repo->inTransaction(function () use ($run, $components): void { + foreach (array_diff($components, ['groups']) as $component) { + $run->start($component); + try { + $this->repo->savepoint($component, fn() => $this->component($run, $component)); + } catch (PDOException $e) { + $run->failed($component, 'database error ' . $e->getCode()); + } + } + }); + if (in_array('groups', $components, true)) { + $run->start('groups'); + $this->groups($run); + } + $this->sogo->refreshStaticView($run->sogoUsers); + $this->dovecot->flushAuthCache($run->authUsers); + return $results + $run->results; + } + + /** + * Creates a mailbox deleted since the snapshot from its exported row, with the side effects of the legacy mailbox + * add (own alias, quota rows, user ACL, tags) and within the domain's limits + * + * @return ?string null on success, else why it was not created + */ + private function createMailbox(RestoreRun $run): ?string + { + $backup = self::decode($run->data['mailbox']); + $username = $run->username; + [$local, $domain] = array_pad(explode('@', $username, 2), 2, ''); + if (!filter_var($username, FILTER_VALIDATE_EMAIL) || $domain !== $run->domain) { + return 'invalid mailbox'; + } + $domainRow = $this->repo->rows('domain', ['domain' => $domain])[0] ?? null; + if ($domainRow === null) { + return 'the domain does not exist'; + } + $kind = $this->repo->addressKind($username); + // deleting a forwarding mailbox leaves its address as an alias to the forward targets: that one is taken back + $leftover = ($kind === 'alias') ? $this->leftoverAlias($run) : null; + if ($kind !== null && $leftover === null) { + return "the address is in use ($kind)"; + } + $quota = max(0, (int)($backup['quota'] ?? 0)); + if ((string)($backup['kind'] ?? '') === '') { + if ($this->repo->mailboxCount($domain) >= (int)($domainRow['mailboxes'] ?? 0)) { + return 'mailbox limit of the domain reached'; + } + $error = $this->quotaError($run, $quota); + if ($error !== null) { + return "quota: $error"; + } + } + $this->repo->inTransaction(function () use ($run, $backup, $username, $local, $domain, $leftover): void { + $this->repo->insert('mailbox', ['username' => $username, 'local_part' => $local, 'domain' => $domain] + + array_diff_key($backup, array_flip(['modified', 'tags']))); + $this->repo->resetQuota($username); + if ($leftover !== null) { + $this->repo->update('alias', ['id' => (int)$leftover['id']], ['goto' => $username, 'active' => (int)($backup['active'] ?? 1)]); + } elseif (empty($this->repo->rows('alias', ['address' => $username]))) { + $this->repo->insert('alias', ['address' => $username, 'goto' => $username, 'domain' => $domain, + 'active' => (int)($backup['active'] ?? 1)]); + } + // the user ACL of the backup (a domain admin's restore: defaults for what he may not grant), else the defaults + if (empty($this->repo->rows('user_acl', ['username' => $username]))) { + $acl = is_array($run->data['user_acl'] ?? null) ? self::decode($run->data['user_acl']) : []; + $this->repo->insert('user_acl', ['username' => $username] + array_diff_key($acl, array_flip($this->lockedUserAcl($run)))); + } + foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', + (array)($run->data['mailbox']['tags'] ?? [])), 'strlen')) as $tag) { + $this->repo->insert('tags_mailbox', ['username' => $username, 'tag_name' => $tag]); + } + }); + return null; + } + + /** + * A domain admin's restore stops when he lost what it was requested with: his account, the domain, or an ACL he had + * then (columns of da_acl) + */ + private function permissionRevoked(Job $job): bool + { + if (($job->params['requested_role'] ?? '') === 'admin') { + return false; + } + $username = $job->requestedBy; + if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0, 'active' => 1])) + || empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => strtolower($job->domain), 'active' => 1]))) { + return true; + } + $acl = $this->repo->rows('da_acl', ['username' => $username])[0] ?? []; + foreach ((array)($job->params['acl'] ?? []) as $name => $flag) { + if ((int)$flag === 1 && array_key_exists((string)$name, $acl) && (int)$acl[$name] !== 1) { + return true; + } + } + return false; + } + + /** + * User ACL columns a domain admin may not grant: those of da_acl he does not have himself (legacy acl('get', 'user')); + * none for admins + * + * @return string[] + */ + private function lockedUserAcl(RestoreRun $run): array + { + if ($run->byAdmin()) { + return []; + } + return array_values(array_filter(array_diff($this->repo->columns('da_acl'), ['username']), fn(string $column): bool => !$run->may($column))); + } + + /** @throws UserError backup_job_busy while another request merges the same mailbox of the job */ + private function locked(Job $job, string $username, callable $work): array + { + $result = $this->repo->locked($job->id . ':' . $username, $work); + return is_array($result) ? $result : throw new UserError('backup_job_busy'); + } + + /** @throws UserError backup_job_not_running */ + private function runningRestore(mixed $jobId): Job + { + $id = Input::intInRange($jobId, 1, PHP_INT_MAX); + $job = ($id === null) ? null : $this->jobs->find($id); + if ($job === null || $job->type !== 'restore' || $job->state !== 'running') { + throw new UserError('backup_job_not_running'); + } + return $job; + } + + /** @return string[] domains whose objects the restore may change: [] = all (admins), else the job's domain and its alias domains */ + private function allowedDomains(Job $job, string $domain): array + { + return (($job->params['requested_role'] ?? '') === 'admin') ? [] : array_values(array_unique(array_merge([$domain], $this->repo->aliasDomainsOf($domain)))); + } + + private function component(RestoreRun $run, string $component): void + { + match ($component) { + 'aliases' => $this->aliases($run, []), + 'sender_acl' => $this->senderAcl($run), + 'mail_policies' => $this->policies($run, 'mail_policies', 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id'), + 'retention_rules' => $this->policies($run, 'retention_rules', 'retention', 'retention_rule', 'retention_rule_member', 'rule_id'), + 'filters' => $this->filters($run), + 'spam' => $this->spam($run), + 'bcc' => $this->bcc($run), + 'syncjobs' => $this->syncJobs($run), + 'app_passwords' => $this->appPasswords($run), + 'tfa' => $this->tfa($run), + 'settings' => $this->settings($run), + 'password' => $this->password($run), + }; + } + + /** + * Aliases whose goto named the mailbox, or a part of the domain's aliases: a missing alias is created with its gotos + * that are valid now, an existing one gets the mailbox back into its goto. Group addresses belong to their group + * and are left alone. + */ + private function aliases(RestoreRun $run, array $domainAliases): void + { + if ($run->created) { + $this->ownAlias($run); + } + $seen = []; + $created = array_map('strtolower', (array)($run->params['create_mailboxes'] ?? [])); + foreach (array_merge($domainAliases, $run->rows('aliases')) as $row) { + $row = self::decode($row); + $address = strtolower(trim((string)($row['address'] ?? ''))); + $gotos = ExportService::gotos(strtolower((string)($row['goto'] ?? ''))); + // the own alias of a mailbox comes with the mailbox (ownAlias()) + if ($address === '' || isset($seen[$address]) || $gotos === [$address] || in_array($address, $created, true)) { + continue; + } + $seen[$address] = true; + if (($row['group_id'] ?? null) !== null) { + $run->skipped('aliases'); + continue; + } + $domain = self::domainOf($address); + if (!$run->mayChangeDomain($domain)) { + $run->error('aliases', "$address: outside the domain of the restore"); + continue; + } + $current = $this->repo->rows('alias', ['address' => $address])[0] ?? null; + if ($current !== null) { + $this->aliasGoto($run, $current, $gotos); + continue; + } + // naming itself: the own alias of a deleted mailbox (forwarding with a copy), as an alias it would block the mailbox + if (in_array($address, $gotos, true)) { + $run->skipped('aliases'); + continue; + } + $this->createAlias($run, $row, $address, $domain, $gotos); + } + } + + /** + * The alias at the address of a deleted mailbox when it is what the deletion left of the mailbox's forwarding: the + * forward targets of the backup's own alias, without the mailbox. Any other alias (e.g. one added since) blocks. + */ + private function leftoverAlias(RestoreRun $run): ?array + { + $backup = is_array($run->data['own_alias'] ?? null) ? self::decode($run->data['own_alias']) : []; + $forward = array_unique(array_diff(ExportService::gotos(strtolower((string)($backup['goto'] ?? ''))), [$run->username])); + $current = $this->repo->rows('alias', ['address' => $run->username])[0] ?? null; + if ($current === null || empty($forward) || ($current['group_id'] ?? null) !== null) { + return null; + } + $now = array_unique(ExportService::gotos(strtolower((string)$current['goto']))); + sort($forward); + sort($now); + return ($now === $forward) ? $current : null; + } + + /** + * The own alias of a mailbox the restore created gets the forwarding of the backup: the gotos that are valid now, with + * the mailbox itself when it kept a copy. Without a valid one it keeps delivering to the mailbox. + */ + private function ownAlias(RestoreRun $run): void + { + $backup = is_array($run->data['own_alias'] ?? null) ? self::decode($run->data['own_alias']) : []; + $gotos = ExportService::gotos(strtolower((string)($backup['goto'] ?? ''))); + $forward = array_values(array_diff($gotos, [$run->username])); + $current = $this->repo->rows('alias', ['address' => $run->username])[0] ?? null; + if (empty($forward) || $current === null) { + return; + } + $valid = $this->validGotos($run, $run->username, $forward); + if (empty($valid)) { + return; + } + $goto = in_array($run->username, $gotos, true) ? array_merge([$run->username], $valid) : $valid; + $this->repo->update('alias', ['id' => (int)$current['id']], ['goto' => implode(',', $goto)]); + $run->added('aliases'); + } + + /** The mailbox goes back into the goto of an existing alias (never into a group address or null/spam/ham) */ + private function aliasGoto(RestoreRun $run, array $current, array $backupGotos): void + { + $gotos = ExportService::gotos((string)$current['goto']); + $lower = array_map('strtolower', $gotos); + if ($run->username === '' || !in_array($run->username, $backupGotos, true) || in_array($run->username, $lower, true) || ($current['group_id'] ?? null) !== null + || !empty(array_intersect($lower, self::SPECIAL_GOTOS))) { + $run->skipped('aliases'); + return; + } + $gotos[] = $run->username; + $this->repo->update('alias', ['id' => (int)$current['id']], ['goto' => implode(',', $gotos)]); + $run->added('aliases'); + $run->sogo($run->username); + } + + /** Creates a missing alias with the checks of the legacy alias form */ + private function createAlias(RestoreRun $run, array $row, string $address, string $domain, array $backupGotos): void + { + if (!self::validAddress($address)) { + $run->error('aliases', "$address: invalid address"); + return; + } + $primary = $this->repo->primaryDomain($domain); + if ($primary === null) { + $run->error('aliases', "$address: domain does not exist"); + return; + } + $kind = str_starts_with($address, '@') ? null : $this->repo->addressKind($address); + if ($kind !== null) { + $run->error('aliases', "$address: address is in use ($kind)"); + return; + } + $this->aliasesLeft[$primary] ??= $this->repo->aliasesLeft($primary); + if ($this->aliasesLeft[$primary] <= 0) { + $run->error('aliases', "$address: alias limit of the domain reached"); + return; + } + $gotos = $this->validGotos($run, $address, $backupGotos); + if (empty($gotos)) { + $run->error('aliases', "$address: no valid goto"); + return; + } + $values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_id'])); + $this->repo->insert('alias', ['address' => $address, 'goto' => implode(',', $gotos), 'domain' => $domain] + $values); + $this->aliasesLeft[$primary]--; + $run->added('aliases'); + $run->sogo(...array_filter($gotos, fn(string $goto): bool => $this->repo->addressKind($goto) === 'mailbox')); + } + + /** + * Gotos of a new alias that are valid now: null/spam/ham alone, local addresses that exist or that the restore creates + * (no resources), external + * ones unless external gotos are disabled (globally or by the domain admin's ACL). Dropped ones are reported. + * + * @return string[] + */ + private function validGotos(RestoreRun $run, string $address, array $gotos): array + { + $special = array_intersect($gotos, self::SPECIAL_GOTOS); + if (!empty($special)) { + return (count($gotos) === 1) ? $gotos : []; + } + $valid = []; + foreach (array_unique($gotos) as $goto) { + if ($goto === $address || !filter_var($goto, FILTER_VALIDATE_EMAIL)) { + $run->error('aliases', "$address: goto $goto dropped (invalid)"); + continue; + } + if ($this->repo->isLocalDomain(self::domainOf($goto))) { + // mailboxes the restore creates count as existing (the domain's aliases come before its mailboxes) + if (!in_array($this->repo->addressKind($goto), ['mailbox', 'alias'], true) + && !in_array($goto, array_map('strtolower', (array)($run->params['create_mailboxes'] ?? [])), true)) { + $run->error('aliases', "$address: goto $goto dropped (no mailbox or alias)"); + continue; + } + } elseif ($this->externalGotoDisabled || !$run->may('alias_external_goto')) { + $run->error('aliases', "$address: goto $goto dropped (external gotos are not allowed)"); + continue; + } + $valid[] = $goto; + } + return $valid; + } + + /** + * Send-as rights of the mailbox and rights of others to send as it, for addresses and mailboxes that exist now. + * Rows of groups are rebuilt by the group module and left out. + */ + private function senderAcl(RestoreRun $run): void + { + foreach ($run->rows('sender_acl') as $row) { + if (($row['group_id'] ?? null) !== null) { + continue; + } + $sendAs = strtolower(trim((string)($row['send_as'] ?? ''))); + $external = (int)($row['external'] ?? 0); + $error = $this->sendAsError($run, $sendAs, $external); + if ($error !== null) { + $run->error('sender_acl', "$sendAs: $error"); + } elseif (!empty($this->repo->rows('sender_acl', ['logged_in_as' => $run->username, 'send_as' => $sendAs]))) { + $run->skipped('sender_acl'); + } else { + $this->repo->insert('sender_acl', ['logged_in_as' => $run->username, 'send_as' => $sendAs, 'external' => $external]); + $run->added('sender_acl'); + $run->sogo($run->username); + } + } + foreach ($run->rows('sender_acl_granted') as $row) { + if (($row['group_id'] ?? null) !== null) { + continue; + } + $login = strtolower(trim((string)($row['logged_in_as'] ?? ''))); + if (empty($this->repo->rows('mailbox', ['username' => $login]))) { + $run->error('sender_acl', "$login: mailbox does not exist"); + } elseif (!$run->mayChangeDomain(self::domainOf($login))) { + $run->error('sender_acl', "$login: outside the domain of the restore"); + } elseif (!empty($this->repo->rows('sender_acl', ['logged_in_as' => $login, 'send_as' => $run->username]))) { + $run->skipped('sender_acl'); + } else { + $this->repo->insert('sender_acl', ['logged_in_as' => $login, 'send_as' => $run->username, 'external' => 0]); + $run->added('sender_acl'); + $run->sogo($login); + } + } + } + + /** Why the mailbox may not send as this address (anymore), null when it may */ + private function sendAsError(RestoreRun $run, string $sendAs, int $external): ?string + { + if ($sendAs === '*') { + return $run->byAdmin() ? null : 'only admins grant *'; + } + $domain = self::domainOf($sendAs); + if (!self::validAddress($sendAs)) { + return 'invalid address'; + } + if ($external === 1) { + if (!$run->may('extend_sender_acl')) { + return 'not allowed'; + } + return $this->repo->domainExists($domain) ? 'domain is local now' : null; + } + if (!$run->mayChangeDomain($domain)) { + return 'outside the domain of the restore'; + } + if (str_starts_with($sendAs, '@')) { + return $this->repo->domainExists($domain) ? null : 'domain does not exist'; + } + return in_array($this->repo->addressKind($sendAs), ['mailbox', 'alias'], true) ? null : 'address does not exist'; + } + + /** + * Assignments to mail policies or retention rules of the mailbox's domain that exist now (by id, else by name) + * + * @param string $key the list in the backup and the component + * @param string $acl the domain admin ACL it needs + * @param string $member the member table and its $column with the id + */ + private function policies(RestoreRun $run, string $key, string $acl, string $table, string $member, string $column): void + { + $entries = $run->rows($key); + if (!empty($entries) && !$run->may($acl)) { + $run->error($key, 'not allowed'); + return; + } + foreach ($entries as $entry) { + $name = (string)($entry['name'] ?? ''); + $target = $this->repo->rows($table, ['id' => (int)($entry['id'] ?? 0), 'domain' => $run->domain])[0] + ?? $this->repo->rows($table, ['domain' => $run->domain, 'name' => $name])[0] ?? null; + if ($target === null) { + $run->error($key, "$name: does not exist"); + } elseif (!empty($this->repo->rows($member, [$column => (int)$target['id'], 'username' => $run->username]))) { + $run->skipped($key); + } else { + $this->repo->insert($member, [$column => (int)$target['id'], 'username' => $run->username]); + $run->added($key); + } + } + } + + /** + * mailcow filters (sieve_filters) missing with the same type and script; a restored filter is active only if it + * was and the mailbox has no active filter of its type now. + */ + private function filters(RestoreRun $run): void + { + foreach ($run->rows('sieve_filters') as $row) { + $type = (string)($row['filter_type'] ?? ''); + $script = (string)($row['script_data'] ?? ''); + $description = (string)($row['script_desc'] ?? ''); + if (!in_array($type, ['prefilter', 'postfilter'], true) || trim($script) === '') { + $run->error('filters', "$description: invalid filter"); + continue; + } + $current = $this->repo->rows('sieve_filters', ['username' => $run->username, 'filter_type' => $type]); + if (!empty(array_filter($current, fn(array $filter): bool => $filter['script_data'] === $script))) { + $run->skipped('filters'); + continue; + } + $active = ($row['script_name'] ?? '') === 'active' && empty(array_filter($current, fn(array $filter): bool => $filter['script_name'] === 'active')); + $this->repo->insert('sieve_filters', ['username' => $run->username, 'script_name' => $active ? 'active' : 'inactive'] + + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('filters'); + } + } + + /** + * Spam settings (filterconf): missing allow/deny list entries and spam score levels (an existing level stays); + * spam aliases that are still valid, whose address is free and in a domain of the mailbox. + */ + private function spam(RestoreRun $run): void + { + foreach ($run->rows('filterconf') as $row) { + $option = (string)($row['option'] ?? ''); + $value = (string)($row['value'] ?? ''); + $current = $this->repo->rows('filterconf', ['object' => $run->username]); + $present = match ($option) { + 'whitelist_from', 'blacklist_from' => array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true) + && strtolower((string)$c['value']) === strtolower($value)), + 'lowspamlevel', 'highspamlevel' => array_filter($current, fn(array $c): bool => $c['option'] === $option), + default => array_filter($current, fn(array $c): bool => $c['option'] === $option && (string)$c['value'] === $value), + }; + if ($option === '' || !empty($present)) { + $run->skipped('spam'); + continue; + } + $this->repo->insert('filterconf', ['object' => $run->username, 'option' => $option, 'value' => $value]); + $run->added('spam'); + } + foreach ($run->rows('spamalias') as $row) { + $address = strtolower(trim((string)($row['address'] ?? ''))); + if ((int)($row['permanent'] ?? 0) !== 1 && (int)($row['validity'] ?? 0) < time()) { + $run->skipped('spam'); + continue; + } + $current = $this->repo->rows('spamalias', ['address' => $address])[0] ?? null; + if ($current !== null && strtolower((string)$current['goto']) === $run->username) { + $run->skipped('spam'); + continue; + } + // like the legacy form: spam aliases in the mailbox's domain and its alias domains only + if (!filter_var($address, FILTER_VALIDATE_EMAIL) || !in_array(self::domainOf($address), + array_merge([$run->domain], $this->repo->aliasDomainsOf($run->domain)), true)) { + $run->error('spam', "$address: not a domain of the mailbox"); + continue; + } + $kind = $this->repo->addressKind($address); + if ($kind !== null) { + $run->error('spam', "$address: address is in use ($kind)"); + continue; + } + $this->repo->insert('spamalias', ['address' => $address, 'goto' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('spam'); + } + } + + /** BCC maps of the mailbox: one per type */ + private function bcc(RestoreRun $run): void + { + foreach ($run->rows('bcc_maps') as $row) { + $type = (string)($row['type'] ?? ''); + $destination = (string)($row['bcc_dest'] ?? ''); + if (!in_array($type, ['sender', 'rcpt'], true) || !filter_var($destination, FILTER_VALIDATE_EMAIL)) { + $run->error('bcc', "$destination: invalid BCC map"); + continue; + } + if (!empty($this->repo->rows('bcc_maps', ['local_dest' => $run->username, 'type' => $type]))) { + $run->skipped('bcc'); + continue; + } + $this->repo->insert('bcc_maps', ['local_dest' => $run->username, 'domain' => $run->domain] + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('bcc'); + } + } + + /** + * Sync jobs missing by user1, host and port. The source must exist now (same id with that host and port, else + * another source the mailbox may use). Restored jobs are inactive: an old migration must not start on its own. + */ + private function syncJobs(RestoreRun $run): void + { + $current = array_map(fn(array $job): string => self::syncKey((string)$job['user1'], (string)$job['host1'], (int)$job['port1']), + $this->repo->syncJobsOf($run->username)); + foreach ($run->rows('imapsync') as $row) { + $row = self::decode($row); + $user1 = (string)($row['user1'] ?? ''); + $host = (string)($row['source_host1'] ?? ''); + $port = (int)($row['source_port1'] ?? 0); + if (in_array(self::syncKey($user1, $host, $port), $current, true)) { + $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); + if ($sourceId === null || $user1 === '') { + $run->error('syncjobs', "$user1 ($host:$port): sync source does not exist"); + continue; + } + $values = array_diff_key($row, array_flip([...self::NEW_ROW, 'is_running', 'returned_text', 'last_run', 'success', 'exit_status', 'prio'])); + $this->repo->insert('imapsync', ['user2' => $run->username, 'source_id' => $sourceId, 'active' => 0] + $values); + $current[] = self::syncKey($user1, $host, $port); + $run->added('syncjobs'); + } + } + + /** App passwords missing by name */ + private function appPasswords(RestoreRun $run): void + { + foreach ($run->rows('app_passwd') as $row) { + $name = (string)($row['name'] ?? ''); + if ($name === '' || (string)($row['password'] ?? '') === '') { + $run->error('app_passwords', "$name: invalid app password"); + continue; + } + if (!empty($this->repo->rows('app_passwd', ['mailbox' => $run->username, 'name' => $name]))) { + $run->skipped('app_passwords'); + continue; + } + $this->repo->insert('app_passwd', ['mailbox' => $run->username, 'domain' => $run->domain] + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('app_passwords'); + $run->auth($run->username); + } + } + + /** + * 2FA entries missing (same key_id, keyHandle or secret = present) and FIDO2 keys of the passwordless login + * missing by credential id. + */ + private function tfa(RestoreRun $run): void + { + foreach ($run->rows('tfa') as $row) { + $current = $this->repo->rows('tfa', ['username' => $run->username]); + $present = array_filter($current, fn(array $tfa): bool => (string)$tfa['key_id'] === (string)($row['key_id'] ?? '') + || (($row['keyHandle'] ?? '') !== '' && ($row['keyHandle'] ?? null) !== null && $tfa['keyHandle'] === $row['keyHandle']) + || (($row['secret'] ?? '') !== '' && ($row['secret'] ?? null) !== null && $tfa['secret'] === $row['secret'])); + if (!empty($present)) { + $run->skipped('tfa'); + continue; + } + $this->repo->insert('tfa', ['username' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('tfa'); + } + foreach ($run->rows('fido2') as $row) { + $row = self::decode($row); + $credentialId = (string)($row['credentialId'] ?? ''); + if ($credentialId === '') { + $run->error('tfa', 'FIDO2 key without credential id'); + continue; + } + $present = array_filter($this->repo->rows('fido2', ['username' => $run->username]), fn(array $key): bool => $key['credentialId'] === $credentialId); + if (!empty($present)) { + $run->skipped('tfa'); + continue; + } + $this->repo->insert('fido2', ['username' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW))); + $run->added('tfa'); + } + } + + /** + * Overwrites quota (within the domain's limits), attributes (protocols, relayhost, ...), custom attributes, user + * ACL, Pushover settings and the rate limit; adds missing tags. Counts changed values as added, equal ones as skipped. + */ + private function settings(RestoreRun $run): void + { + $backup = self::decode($run->data['mailbox']); + $mailbox = $this->mailbox($run->username) ?? $run->mailbox; + $changes = []; + if (isset($backup['quota']) && is_numeric($backup['quota'])) { + $quota = (int)$backup['quota']; + $error = ($quota === (int)$mailbox['quota']) ? null : $this->quotaError($run, $quota); + if ($error !== null) { + $run->error('settings', "quota: $error"); + } elseif ($quota === (int)$mailbox['quota']) { + $run->skipped('settings'); + } else { + $changes['quota'] = $quota; + $run->added('settings'); + } + } + $attributes = self::json($mailbox['attributes'] ?? null); + $changed = false; + foreach (self::json($backup['attributes'] ?? null) as $key => $value) { + if (in_array($key, self::KEEP_ATTRIBUTES, true)) { + continue; + } + if (array_key_exists($key, $attributes) && $attributes[$key] === $value) { + $run->skipped('settings'); + continue; + } + if (isset(self::ATTRIBUTE_ACL[$key]) && !$run->may(self::ATTRIBUTE_ACL[$key])) { + $run->error('settings', "$key: not allowed"); + continue; + } + if ($key === 'relayhost' && (string)$value !== '0' && (string)$value !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$value]))) { + $run->error('settings', 'relayhost: does not exist'); + continue; + } + $attributes[$key] = $value; + $changed = true; + $run->added('settings'); + } + if ($changed) { + $changes['attributes'] = self::jsonText($attributes); + } + if (array_key_exists('custom_attributes', $backup)) { + $custom = self::json($mailbox['custom_attributes'] ?? null); + $restored = array_merge($custom, self::json($backup['custom_attributes'])); + if ($restored !== $custom) { + $changes['custom_attributes'] = self::jsonText($restored); + $run->added('settings'); + } + } + if (!empty($changes)) { + $this->repo->update('mailbox', ['username' => $run->username], $changes); + $run->auth($run->username); + $run->sogo($run->username); + } + $this->tags($run, (array)($run->data['mailbox']['tags'] ?? [])); + if (is_array($run->data['user_acl'] ?? null)) { + $this->overwriteRow($run, 'user_acl', self::decode($run->data['user_acl'])); + } + if (is_array($run->data['pushover'] ?? null)) { + if ($run->may('pushover')) { + $this->overwriteRow($run, 'pushover', self::decode($run->data['pushover'])); + } else { + $run->error('settings', 'pushover: not allowed'); + } + } + $rateLimit = $run->data['ratelimit'] ?? null; + if (is_string($rateLimit) && preg_match('/^\d+ \/ 1[smhd]$/', $rateLimit)) { + if (($this->legacy->rateLimits([$run->username])[$run->username] ?? null) === $rateLimit) { + $run->skipped('settings'); + } elseif ($run->may('ratelimit')) { + $this->legacy->setRateLimit($run->username, $rateLimit); + $run->added('settings'); + } else { + $run->error('settings', 'rate limit: not allowed'); + } + } + } + + /** Why the quota cannot be restored, null when it can (domain limits like the legacy mailbox form) */ + private function quotaError(RestoreRun $run, int $quota): ?string + { + if ($quota < 0) { + return 'invalid'; + } + if ($quota === 0) { + return $run->may('unlimited_quota') ? null : 'unlimited quota not allowed'; + } + $domain = $this->repo->rows('domain', ['domain' => $run->domain])[0] ?? null; + if ($domain === null) { + return 'domain does not exist'; + } + if ($quota > (int)$domain['maxquota'] * self::MIB) { + return 'exceeds the maximum quota of a mailbox in the domain'; + } + if ($quota > (int)$domain['quota'] * self::MIB - $this->repo->quotaInUse($run->domain, $run->username)) { + return 'exceeds the quota left in the domain'; + } + return null; + } + + /** @param array $tags tags of the backup: missing ones are added */ + private function tags(RestoreRun $run, array $tags): void + { + $current = array_map(fn(array $row): string => (string)$row['tag_name'], $this->repo->rows('tags_mailbox', ['username' => $run->username])); + foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen')) as $tag) { + if (in_array($tag, $current, true)) { + $run->skipped('settings'); + continue; + } + $this->repo->insert('tags_mailbox', ['username' => $run->username, 'tag_name' => $tag]); + $run->added('settings'); + } + } + + /** Overwrites the mailbox's row of user_acl or pushover with the backup's values (creates it if missing) */ + private function overwriteRow(RestoreRun $run, string $table, array $backup): void + { + $values = array_diff_key($backup, ['username' => true]); + $current = $this->repo->rows($table, ['username' => $run->username])[0] ?? null; + if ($table === 'user_acl') { + // a domain admin's restore keeps what he may not grant + foreach ($this->lockedUserAcl($run) as $column) { + if (array_key_exists($column, $values) && $current !== null && array_key_exists($column, $current) && (string)$current[$column] !== (string)$values[$column]) { + $run->error('settings', "user ACL $column: not allowed"); + } + unset($values[$column]); + } + } + if ($current === null) { + $this->repo->insert($table, ['username' => $run->username] + $values); + $run->added('settings'); + return; + } + $changed = array_filter($values, fn(mixed $value, string $column): bool => array_key_exists($column, $current) + && (string)$current[$column] !== (string)$value, ARRAY_FILTER_USE_BOTH); + $run->skipped('settings', count(array_intersect_key($values, $current)) - count($changed)); + if (!empty($changed)) { + $this->repo->update($table, ['username' => $run->username], $changed); + $run->added('settings', count($changed)); + } + } + + /** Overwrites the password hash (mailboxes that log in with mailcow passwords only) */ + private function password(RestoreRun $run): void + { + $backup = $run->data['mailbox']; + $hash = (string)($backup['password'] ?? ''); + $mailbox = $this->mailbox($run->username) ?? $run->mailbox; + if ($hash === '') { + $run->error('password', 'no password in the backup'); + } elseif (($mailbox['authsource'] ?? 'mailcow') !== 'mailcow') { + $run->error('password', 'the mailbox logs in through an identity provider'); + } elseif (hash_equals((string)$mailbox['password'], $hash)) { + $run->skipped('password'); + } else { + $attributes = self::json($mailbox['attributes'] ?? null); + $attributes['passwd_update'] = self::json($backup['attributes'] ?? null)['passwd_update'] ?? date('Y-m-d H:i:s'); + $this->repo->update('mailbox', ['username' => $run->username], ['password' => $hash, 'attributes' => self::jsonText($attributes)]); + $run->added('password'); + $run->auth($run->username); + } + } + + /** + * Manual memberships in groups of the mailbox's domain that exist now (by id, else by name), through the group + * module as the requester (a domain admin with his rights now: groups read-only for him are not allowed). + * IdP memberships are the IdP sync's. + */ + private function groups(RestoreRun $run): void + { + $context = $run->requester(); + foreach ($run->rows('groups') as $entry) { + $name = (string)($entry['name'] ?? ''); + if (($entry['source'] ?? 'manual') === 'idp') { + $run->skipped('groups'); + continue; + } + $group = $this->repo->rows('mailbox_group', ['id' => (int)($entry['id'] ?? 0), 'domain' => $run->domain])[0] + ?? $this->repo->rows('mailbox_group', ['domain' => $run->domain, 'name' => $name])[0] ?? null; + if ($group === null) { + $run->error('groups', "$name: does not exist"); + continue; + } + if (!empty($this->repo->rows('mailbox_group_member', ['group_id' => (int)$group['id'], 'username' => $run->username]))) { + $run->skipped('groups'); + continue; + } + try { + $this->groups->add($context, (int)$group['id'], $run->username); + $run->added('groups'); + } catch (AccessDenied) { + $run->error('groups', "$name: not allowed"); + } catch (UserError $e) { + $run->error('groups', "$name: " . $e->key); + } catch (PDOException $e) { + $run->error('groups', "$name: database error " . $e->getCode()); + } + } + } + + private function mailbox(string $username): ?array + { + return $this->repo->rows('mailbox', ['username' => $username])[0] ?? null; + } + + private static function syncKey(string $user1, string $host, int $port): string + { + return strtolower($user1) . '|' . strtolower($host) . '|' . $port; + } + + /** user@domain or @domain */ + private static function validAddress(string $address): bool + { + return str_starts_with($address, '@') ? (bool)preg_match('/^@[a-z0-9.-]+\.[a-z0-9-]+$/i', $address) + : filter_var($address, FILTER_VALIDATE_EMAIL) !== false; + } + + private static function domainOf(string $address): string + { + return strtolower(substr((string)strrchr($address, '@'), 1)); + } + + /** Values of a backup row: {"base64": ...} back to binary, other lists as JSON text */ + public static function decode(array $row): array + { + foreach ($row as $column => $value) { + if (is_array($value)) { + $row[$column] = (array_keys($value) === ['base64']) ? base64_decode((string)$value['base64'], true) : json_encode($value); + } + } + return $row; + } + + /** A JSON column as array ({} when empty or broken) */ + private static function json(mixed $value): array + { + $decoded = is_string($value) ? json_decode($value, true) : $value; + return is_array($decoded) ? $decoded : []; + } + + private static function jsonText(array $value): string + { + return (string)json_encode($value ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php b/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php new file mode 100644 index 000000000..bff233dc7 --- /dev/null +++ b/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php @@ -0,0 +1,327 @@ + [min, max] */ + private const RANGES = [ + 'sftp_port' => [1, 65535], + 'keep_hourly' => [0, 1000], + 'keep_daily' => [0, 1000], + 'keep_weekly' => [0, 1000], + 'keep_monthly' => [0, 1000], + 'keep_yearly' => [0, 1000], + 'prune_weekday' => [0, 6], + 'check_subset' => [0, 365], + 'limit_upload' => [0, 100000000], + ]; + private const MIN_PASSWORD = 8; + private const MAX_LENGTH = 255; + private const MAX_NOTIFY = 20; + /** Entries of the schedule */ + private const MAX_SCHEDULE = 24; + private const HOSTNAME = '/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i'; + + public function __construct(private SettingsRepository $repo, private Context $context) + { + } + + /** The settings for admins, null for everybody else */ + public function get(): ?Settings + { + return $this->context->hasAdminRights() ? $this->load() : null; + } + + /** The settings without permission check (status, job rules) */ + public function load(): Settings + { + return Settings::fromStored($this->repo->load()); + } + + /** + * Saves the given settings; missing ones keep their value, unknown and runner-owned ones are ignored. + * + * @throws UserError + */ + public function save(array $input): Settings + { + $this->context->requireAdmin(); + $current = $this->load(); + $values = $current->values; + foreach ($input as $name => $value) { + if (!is_string($name) || !array_key_exists($name, Settings::DEFAULTS) || in_array($name, Settings::RUNNER, true)) { + continue; + } + $value = $this->normalize($name, $value); + if (in_array($name, Settings::SECRETS, true)) { + if ($value === '') { + continue; + } + $value = self::seal($value, $current->get('state_secrets_pubkey')); + } + $values[$name] = $value; + } + if ($values['password'] !== $current->get('password') + && !(array_key_exists('password_confirmed', $input) && $values['password_confirmed'] === '1')) { + $values['password_confirmed'] = '0'; + } + foreach (array_merge(['target', 'password'], Settings::CONNECTION[$values['target']]) as $name) { + if ($values[$name] !== $current->get($name)) { + $values['state_initialized'] = '0'; + break; + } + } + if ($values['sftp_host'] !== $current->get('sftp_host') || $values['sftp_port'] !== $current->get('sftp_port')) { + $values['sftp_host_key'] = ''; + } + $settings = new Settings($values); + if ($values['enabled'] === '1' && !$settings->configured()) { + throw new UserError('backup_not_configured'); + } + // switched on without a time + if ($values['enabled'] === '1' && empty($settings->scheduleSlots())) { + throw self::invalid('schedule'); + } + $values['state_target_changed'] = $this->targetChange($current, $values); + $settings = new Settings($values); + $changed = array_diff_assoc($values, $current->values); + if (!empty($changed)) { + $this->repo->save($changed); + } + return $settings; + } + + /** + * state_target_changed after a save: when the target, its connection or the notify addresses change, what the target + * was, whom to tell (the notify addresses before the change) and what changed ("target", "notify_email"). An earlier + * change the runner has not mailed yet keeps its state and addresses and only gets the new kinds of change, so new + * addresses followed by a new target still go to the original addresses. Nothing is recorded for a target that was + * not configured or without notify addresses. + */ + private function targetChange(Settings $current, array $values): string + { + $pending = json_decode($current->get('state_target_changed'), true); + if (!$current->configured() && !is_array($pending)) { + return $current->get('state_target_changed'); + } + $old = $current->get('target'); + $watched = array_unique(array_merge(['target'], Settings::CONNECTION[$old] ?? [], Settings::CONNECTION[$values['target']] ?? [])); + $changed = []; + if (!empty(array_filter($watched, fn(string $name): bool => $values[$name] !== $current->get($name)))) { + $changed[] = 'target'; + } + if ($values['notify_email'] !== $current->get('notify_email')) { + $changed[] = 'notify_email'; + } + if (is_array($pending)) { + $pending['changed'] = array_values(array_unique(array_merge((array)($pending['changed'] ?? ['target']), $changed))); + return (string)json_encode($pending, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + $addresses = array_values(array_filter(explode(',', $current->get('notify_email')), 'strlen')); + if (empty($changed) || empty($addresses)) { + return ''; + } + $summary = ['target' => $old]; + foreach (array_diff(Settings::CONNECTION[$old] ?? [], Settings::SECRETS) as $name) { + $summary[$name] = $current->get($name); + } + return (string)json_encode(['old_target' => $summary, 'old_addresses' => $addresses, 'changed' => $changed, + 'changed_by' => (string)$this->context->username, 'time' => time()], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * A secret sealed for the runner: sealed:v1: + * + * @throws UserError backup_secrets_key_missing while the runner has not published its key + */ + private static function seal(string $secret, string $publicKey): string + { + $key = base64_decode($publicKey, true); + if ($key === false || strlen($key) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) { + throw new UserError('backup_secrets_key_missing'); + } + return Settings::SEALED . base64_encode(sodium_crypto_box_seal($secret, $key)); + } + + /** + * The value to store, '' for an empty optional value. + * + * @throws UserError backup_setting_invalid, backup_password_too_short + */ + private function normalize(string $name, mixed $value): string + { + if (in_array($name, self::FLAGS, true)) { + return self::flag($value) ?? throw self::invalid($name); + } + if ($name === 'notify_email' && is_string($value)) { + // one address per line is fine as well + $value = preg_replace('/[\r\n\t]+/', ',', $value); + } + if ($name === 'schedule') { + return self::schedule($value) ?? throw self::invalid($name); + } + // secrets as typed: spaces at the ends are part of a password + $text = (in_array($name, Settings::SECRETS, true) && is_string($value)) ? $value : Input::text($value); + if ($text === null || mb_strlen($text) > (($name === 'notify_email') ? self::MAX_LENGTH * self::MAX_NOTIFY : self::MAX_LENGTH) || preg_match('/[\x00-\x1f\x7f]/', $text) || !mb_check_encoding($text, 'UTF-8')) { + throw self::invalid($name); + } + // no scheduled prune (append-only or object-lock targets); a prune job still works + if ($name === 'prune_weekday' && $text === '') { + return ''; + } + if (isset(self::RANGES[$name])) { + $int = Input::intInRange($text, ...self::RANGES[$name]); + return ($int === null) ? throw self::invalid($name) : (string)$int; + } + $valid = match ($name) { + 'target' => in_array($text, Settings::TARGETS, true), + // like the runner: scheme, host and port only + 's3_endpoint' => self::url($text) && in_array(parse_url($text, PHP_URL_PATH) ?? '', ['', '/'], true), + 'rest_url' => $text === '' || self::url($text), + 's3_bucket' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{1,62}$/i', $text), + 's3_path' => $text === '' || self::path(trim($text, '/'), false), + 's3_region' => $text === '' || preg_match('/^[a-z0-9-]{1,64}$/i', $text), + 's3_access_key' => preg_match('/^[\x21-\x7e]*$/', $text), + 's3_secret_key', 'rest_password' => true, + 'sftp_host' => $text === '' || preg_match(self::HOSTNAME, $text) || filter_var($text, FILTER_VALIDATE_IP), + // the runner's character set (ssh -l) + 'sftp_user' => $text === '' || preg_match('/^[a-z0-9_][a-z0-9._-]{0,63}$/i', $text), + 'sftp_path' => $text === '' || self::path($text, true), + 'rest_user' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,63}$/i', $text), + // the runner's character set, below the repository folder + 'local_path' => preg_match('~^[a-z0-9._-]+(/[a-z0-9._-]+)*$~i', $text) && self::path($text, false), + 'password' => true, + 'max_unused' => preg_match('/^(\d{1,3})\s*%?$/', $text, $percent) && (int)$percent[1] <= 100, + 'notify_email' => true, + default => false, + }; + if (!$valid) { + throw self::invalid($name); + } + return match ($name) { + 's3_path' => trim($text, '/'), + 's3_endpoint' => rtrim($text, '/'), + 'sftp_path' => rtrim($text, '/'), + 'password' => self::password($text), + 'max_unused' => (int)$text . '%', + 'notify_email' => self::emails($text), + default => $text, + }; + } + + /** + * The schedule as stored: a list (or its JSON) of {days: weekdays 0 (Sunday) to 6, time: "H:MM"}, up to MAX_SCHEDULE + * entries, each with at least one weekday; days sorted, times as HH:MM, repeated entries dropped. Null when invalid. + */ + private static function schedule(mixed $value): ?string + { + if (is_string($value)) { + $value = json_decode($value, true); + } + if (!is_array($value) || !array_is_list($value) || count($value) > self::MAX_SCHEDULE) { + return null; + } + $entries = []; + foreach ($value as $entry) { + $days = $entry['days'] ?? null; + $time = is_array($entry) && is_string($entry['time'] ?? null) ? $entry['time'] : ''; + if (!is_array($days) || empty($days) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $time, $match)) { + return null; + } + foreach ($days as $day) { + if (Input::intInRange($day, 0, 6) === null) { + return null; + } + } + $days = array_values(array_unique(array_map('intval', $days))); + sort($days); + $entries[] = ['days' => $days, 'time' => sprintf('%02d:%02d', $match[1], $match[2])]; + } + return (string)json_encode(array_values(array_unique($entries, SORT_REGULAR))); + } + + /** http(s) URL without credentials, query or fragment (rest_user/rest_password hold the credentials) */ + private static function url(string $url): bool + { + $parts = parse_url($url); + return filter_var($url, FILTER_VALIDATE_URL) !== false && is_array($parts) && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) + && !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment']) + && (preg_match(self::HOSTNAME, (string)($parts['host'] ?? '')) || filter_var(trim((string)($parts['host'] ?? ''), '[]'), FILTER_VALIDATE_IP)); + } + + /** A path of plain names separated by /, without . and .. parts; a leading / only where $absolute is allowed */ + private static function path(string $path, bool $absolute): bool + { + if ($path === '' || (str_starts_with($path, '/') && !$absolute)) { + return false; + } + foreach (explode('/', trim($path, '/')) as $part) { + if ($part === '.' || $part === '..' || !preg_match('/^[a-z0-9_~+@][a-z0-9._~+@-]*$/i', $part)) { + return false; + } + } + return true; + } + + /** @throws UserError backup_password_too_short ('' keeps the stored password) */ + private static function password(string $password): string + { + if ($password !== '' && mb_strlen($password) < self::MIN_PASSWORD) { + throw new UserError('backup_password_too_short', [self::MIN_PASSWORD]); + } + return $password; + } + + /** @throws UserError for an invalid address or too many */ + private static function emails(string $text): string + { + $emails = array_values(array_unique(array_filter(preg_split('/[\s,;]+/', $text), 'strlen'))); + if (count($emails) > self::MAX_NOTIFY) { + throw self::invalid('notify_email'); + } + foreach ($emails as $email) { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw self::invalid('notify_email'); + } + } + return implode(',', $emails); + } + + /** Checkbox or API flag as '0'/'1'; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked; null = invalid */ + private static function flag(mixed $value): ?string + { + if (is_array($value)) { + $value = end($value); + } + if (!is_scalar($value) && $value !== null) { + return null; + } + $flag = filter_var($value ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return ($flag === null) ? null : (string)(int)$flag; + } + + private static function invalid(string $name): UserError + { + return new UserError('backup_setting_invalid', [$name]); + } +} diff --git a/data/web/inc/restore.inc.php b/data/web/inc/restore.inc.php new file mode 100644 index 000000000..e35214495 --- /dev/null +++ b/data/web/inc/restore.inc.php @@ -0,0 +1,18 @@ + Restore of admins and domain admins (admin/restore.php, domainadmin/restore.php set $role after protect_route): +// restores from the snapshots of the backup, also of deleted mailboxes (and domains for admins) +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; +$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; + +$js_minifier->add('/web/js/site/backup.js'); + +$template = 'restore.twig'; +$template_data = [ + 'acl' => $_SESSION['acl'], + 'role' => $role, + 'lang_datatables' => json_encode($lang['datatables']), + 'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(), + 'backup_timezone' => getenv('TZ') ?: 'UTC', +]; + +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; diff --git a/data/web/js/site/backup.js b/data/web/js/site/backup.js new file mode 100644 index 000000000..05af191c3 --- /dev/null +++ b/data/web/js/site/backup.js @@ -0,0 +1,1809 @@ +// Backup with restic. System > Configuration > Backup (admins): status, actions, settings, jobs and snapshots. +// E-Mail > Restore (admins, domain admins): snapshot, a tree of its domains and mailboxes (also deleted ones), the +// restore modal, the restores. Both pages share the job details modal. Texts from lang.backup. Loaded via $js_minifier. +var mailcowBackup = (function($) { + 'use strict'; + + var POLL_MS = 5000; + var COMPONENTS = ['mails', 'imap_acl', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', + 'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'sogo', 'settings', 'password']; + // unchecked for existing targets, preselected when everything to restore was deleted + var RISKY = ['tfa', 'app_passwords', 'retention_rules', 'sieve_activate']; + // overwrite current values; for deleted targets SOGo is a normal item and settings/password are not sent + var OVERWRITE = ['sogo', 'settings', 'password']; + // existing targets: only the mails by default + var EXISTING_DEFAULT = ['mails']; + // items that need the mails, not in snapshots without them (RESTIC_BACKUP_MODE=config, as JobService::MAIL_COMPONENTS) + var MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate']; + var BACKUP_MODES = ['all', 'data', 'config']; + // domain admin ACL an item needs (as JobService::COMPONENT_ACL) + var COMPONENT_ACL = {groups: 'mailbox_groups', mail_policies: 'mail_policies', retention_rules: 'retention', filters: 'filters', + sieve_activate: 'filters', spam: 'spam_policy', bcc: 'bcc_maps', syncjobs: 'syncjobs', app_passwords: 'app_passwds'}; + // the restore of the dovecot container (mails, IMAP ACLs, Sieve) cannot be cancelled while it runs + var DOVECOT_STEPS = ['mails', 'mails_running']; + var SECRETS = ['password', 's3_secret_key', 'rest_password']; + // value of a password field while its secret is stored: shown as dots, never sent + var SECRET_MASK = '************'; + var JOB_TYPES = ['backup', 'check', 'prune', 'init']; + var STATE_CLASS = {queued: 'bg-secondary', running: 'bg-info text-black', done: 'bg-success', failed: 'bg-danger', cancelled: 'bg-warning text-black'}; + var RESULT_COUNTS = ['added', 'skipped', 'errors']; + // a scheduled backup is overdue this long after the longest gap of the schedule (26 hours for one backup a day) + var STALE_MARGIN = 2 * 3600; + // entries of the schedule (as SettingsService::MAX_SCHEDULE) + var MAX_SCHEDULE = 24; + // weekdays in the order of the form, 0 = Sunday + var WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]; + // errors of a job that could not open the repository + var REPO_ERROR = /unable to open|wrong (repository )?password|does not exist|host key|connection refused|no such host|could not resolve|timed? ?out|permission denied|repository/i; + + // lang.backup of the page, '' if missing + function L(key) { + var src = (typeof lang !== 'undefined' && lang && lang.backup) ? lang.backup : {}; + return (src[key] !== undefined && src[key] !== null) ? src[key] : ''; + } + function esc(s) { + return escapeHtml(s === null || s === undefined ? '' : String(s)); + } + // replaces %s (and %1$s) one after the other, like vsprintf in the PHP alert box + function fill(text) { + var args = Array.prototype.slice.call(arguments, 1), next = 0; + return String(text).replace(/%(?:(\d+)\$)?s/g, function(match, pos) { + var value = pos ? args[pos - 1] : args[next++]; + return (value === undefined || value === null) ? '' : String(value); + }); + } + function pad(n) { + return (n < 10 ? '0' : '') + n; + } + function now() { + return Math.floor(Date.now() / 1000); + } + function pageLang() { + return document.documentElement.lang || undefined; + } + // the time zone of mailcow (TZ in mailcow.conf), so times match the schedule and the "Restored