#!/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=*', '-c', 'core.fsmonitor=false', '--no-optional-locks', '-C', install_dir] + list(args), capture_output=True, text=True, timeout=300) return result.stdout if result.returncode == 0 else None 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]} # imported: what the dovecot runner imports into /var/vmail afterwards, keeping disk_reserve() free like import_space_ok() def free_space(job, path, need, what, imported=0): usage = shutil.disk_usage(path) required = need + space_reserve if imported: vmail = shutil.disk_usage('/var/vmail') if os.stat(path).st_dev == os.stat('/var/vmail').st_dev: required = need + imported * 11 // 10 + disk_reserve(usage.total) elif imported * 11 // 10 + disk_reserve(vmail.total) > vmail.free: raise JobError('not enough free disk space for the import of the mails: %s needed, %s free' % ( mib(imported * 11 // 10 + disk_reserve(vmail.total)), mib(vmail.free))) if required > usage.free: raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(required), mib(usage.free))) # Sizes of the files below the given directories of a snapshot (bytes per directory); listed directories that do not # exist are missing in the result 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, imported=0): free_space(job, restore_dir, need, what, imported) 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 parts = ['created'] if (result.get('mailbox') or {}).get('created') else [] restored = [component for component in sorted(components) if component in result] if restored: parts.append(', '.join(restored) + ' restored') job.log('%s: %s' % (username, '; '.join(parts) or 'nothing to restore')) except Exception as e: for component in components: result[component] = component_error('the restore request failed: %s' % http_error(e)) 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) # also without files: the dovecot runner then reports "no mails" instead of missing files make_dir(batch_dir) if includes: job.step('files') started = time.monotonic() restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore', imported=need) job.log('files of %d mailboxes restored in %d s (batch %d of %d)' % (len(ready), time.monotonic() - started, number, len(batches))) detached = False 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()