diff --git a/management/backup.py b/management/backup.py new file mode 100755 index 00000000..c63eb5c0 --- /dev/null +++ b/management/backup.py @@ -0,0 +1,54 @@ +#!/usr/bin/python3 + +# This script performs a backup of all user data: +# 1) System services are stopped while a copy of user data is made. +# 2) An incremental backup is made using rdiff-backup into the +# directory STORAGE_ROOT/backup/rdiff-history. This directory +# will contain the latest files plus a complete history for +# all prior backups. +# 3) The stopped services are restarted. +# 4) The backup directory is compressed into a single file using tar. +# 5) That file is encrypted with a long password stored in backup/secret_key.txt. + +import os, os.path, subprocess + +from utils import exclusive_process, load_environment + +env = load_environment() + +exclusive_process("backup") + +# Ensure the backup directory exists. +backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup') +rdiff_backup_dir = os.path.join(backup_dir, 'rdiff-history') +os.makedirs(backup_dir, exist_ok=True) + +# Stop services. +subprocess.check_call(["service", "dovecot", "stop"]) +subprocess.check_call(["service", "postfix", "stop"]) + +# Update the backup directory which stores increments. +try: + subprocess.check_call([ + "rdiff-backup", + "--exclude", backup_dir, + env["STORAGE_ROOT"], + rdiff_backup_dir]) +except subprocess.CalledProcessError: + pass + +# Start services. +subprocess.check_call(["service", "dovecot", "start"]) +subprocess.check_call(["service", "postfix", "start"]) + +# Tar the rdiff-backup directory into a single file encrypted using the backup private key. +os.system( + "tar -zcC %s . | openssl enc -aes-256-cbc -a -salt -in /dev/stdin -out %s -pass file:%s" + % + ( rdiff_backup_dir, + os.path.join(backup_dir, "latest.tgz.enc"), + os.path.join(backup_dir, "secret_key.txt"), + )) + +# The backup can be decrypted with: +# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z diff --git a/management/daemon.py b/management/daemon.py index 56849110..dfe93d6d 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -5,13 +5,11 @@ import os, os.path from flask import Flask, request, render_template app = Flask(__name__) -# Load settings from /etc/mailinabox.conf. -env = { } -for line in open("/etc/mailinabox.conf"): env.setdefault(*line.strip().split("=", 1)) -env["CONF_DIR"] = os.path.join(os.path.dirname(__file__), "../conf") - +import utils from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias +env = utils.load_environment() + @app.route('/') def index(): return render_template('index.html') diff --git a/management/utils.py b/management/utils.py new file mode 100644 index 00000000..f2ef19b5 --- /dev/null +++ b/management/utils.py @@ -0,0 +1,77 @@ +def load_environment(): + # Load settings from /etc/mailinabox.conf. + import os.path + env = { } + for line in open("/etc/mailinabox.conf"): env.setdefault(*line.strip().split("=", 1)) + env["CONF_DIR"] = os.path.join(os.path.dirname(__file__), "../conf") + return env + +def exclusive_process(name): + # Ensure that a process named `name` does not execute multiple + # times concurrently. + import os, sys, atexit + pidfile = '/var/run/mailinabox-%s.pid' % name + mypid = os.getpid() + + # Attempt to get a lock on ourself so that the concurrency check + # itself is not executed in parallel. + with open(__file__, 'r+') as flock: + # Try to get a lock. This blocks until a lock is acquired. The + # lock is held until the flock file is closed at the end of the + # with block. + os.lockf(flock.fileno(), os.F_LOCK, 0) + + # While we have a lock, look at the pid file. First attempt + # to write our pid to a pidfile if no file already exists there. + try: + with open(pidfile, 'x') as f: + # Successfully opened a new file. Since the file is new + # there is no concurrent process. Write our pid. + f.write(str(mypid)) + atexit.register(clear_my_pid, pidfile) + return + except FileExistsError: + # The pid file already exixts, but it may contain a stale + # pid of a terminated process. + with open(pidfile, 'r+') as f: + # Read the pid in the file. + existing_pid = None + try: + existing_pid = int(f.read().strip()) + except ValueError: + pass # No valid integer in the file. + + # Check if the pid in it is valid. + if existing_pid: + if is_pid_valid(existing_pid): + print("Another %s is already running (pid %d)." % (name, existing_pid), file=sys.stderr) + sys.exit(1) + + # Write our pid. + f.seek(0) + f.write(str(mypid)) + f.truncate() + atexit.register(clear_my_pid, pidfile) + + +def clear_my_pid(pidfile): + import os + os.unlink(pidfile) + + +def is_pid_valid(pid): + """Checks whether a pid is a valid process ID of a currently running process.""" + # adapted from http://stackoverflow.com/questions/568271/how-to-check-if-there-exists-a-process-with-a-given-pid + import os, errno + if pid <= 0: raise ValueError('Invalid PID.') + try: + os.kill(pid, 0) + except OSError as err: + if err.errno == errno.ESRCH: # No such process + return False + elif err.errno == errno.EPERM: # Not permitted to send signal + return True + else: # EINVAL + raise + else: + return True \ No newline at end of file diff --git a/setup/management.sh b/setup/management.sh index ea557708..2196903f 100755 --- a/setup/management.sh +++ b/setup/management.sh @@ -2,7 +2,13 @@ source setup/functions.sh -apt_install python3-flask links +apt_install python3-flask links rdiff-backup + +# Create a backup directory and a random key for encrypting backups. +mkdir -p $STORAGE_ROOT/backup +if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then + openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt +fi # Link the management server daemon into a well known location. rm -f /usr/bin/mailinabox-daemon