Example #1
0
def perform_backup(full_backup):
    env = load_environment()

    exclusive_process("backup")
    config = get_backup_config(env)
    backup_root = os.path.join(env["STORAGE_ROOT"], "backup")
    backup_cache_dir = os.path.join(backup_root, "cache")
    backup_dir = os.path.join(backup_root, "encrypted")

    # Are backups dissbled?
    if config["target"] == "off":
        return

        # In an older version of this script, duplicity was called
        # such that it did not encrypt the backups it created (in
        # backup/duplicity), and instead openssl was called separately
        # after each backup run, creating AES256 encrypted copies of
        # each file created by duplicity in backup/encrypted.
        #
        # We detect the transition by the presence of backup/duplicity
        # and handle it by 'dupliception': we move all the old *un*encrypted
        # duplicity files up out of the backup/duplicity directory (as
        # backup/ is excluded from duplicity runs) in order that it is
        # included in the next run, and we delete backup/encrypted (which
        # duplicity will output files directly to, post-transition).
    old_backup_dir = os.path.join(backup_root, "duplicity")
    migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
    if os.path.isdir(old_backup_dir):
        # Move the old unencrypted files to a new location outside of
        # the backup root so they get included in the next (new) backup.
        # Then we'll delete them. Also so that they do not get in the
        # way of duplicity doing a full backup on the first run after
        # we take care of this.
        shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)

        # The backup_dir (backup/encrypted) now has a new purpose.
        # Clear it out.
        shutil.rmtree(backup_dir)

        # On the first run, always do a full backup. Incremental
        # will fail. Otherwise do a full backup when the size of
        # the increments since the most recent full backup are
        # large.
    try:
        full_backup = full_backup or should_force_full(config, env)
    except Exception as e:
        # This was the first call to duplicity, and there might
        # be an error already.
        print(e)
        sys.exit(1)

        # Stop services.

    def service_command(service, command, quit=None):
        # Execute silently, but if there is an error then display the output & exit.
        code, ret = shell("check_output", ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
        if code != 0:
            print(ret)
            if quit:
                sys.exit(code)

    service_command("php5-fpm", "stop", quit=True)
    service_command("postfix", "stop", quit=True)
    service_command("dovecot", "stop", quit=True)

    # Execute a pre-backup script that copies files outside the homedir.
    # Run as the STORAGE_USER user, not as root. Pass our settings in
    # environment variables so the script has access to STORAGE_ROOT.
    pre_script = os.path.join(backup_root, "before-backup")
    if os.path.exists(pre_script):
        shell("check_call", ["su", env["STORAGE_USER"], "-c", pre_script, config["target"]], env=env)

        # Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
        # --allow-source-mismatch is needed in case the box's hostname is changed
        # after the first backup. See #396.
    try:
        shell(
            "check_call",
            [
                "/usr/bin/duplicity",
                "full" if full_backup else "incr",
                "--verbosity",
                "warning",
                "--no-print-statistics",
                "--archive-dir",
                backup_cache_dir,
                "--exclude",
                backup_root,
                "--volsize",
                "250",
                "--gpg-options",
                "--cipher-algo=AES256",
                env["STORAGE_ROOT"],
                config["target"],
                "--allow-source-mismatch",
            ],
            get_env(env),
        )
    finally:
        # Start services again.
        service_command("dovecot", "start", quit=False)
        service_command("postfix", "start", quit=False)
        service_command("php5-fpm", "start", quit=False)

        # Once the migrated backup is included in a new backup, it can be deleted.
    if os.path.isdir(migrated_unencrypted_backup_dir):
        shutil.rmtree(migrated_unencrypted_backup_dir)

        # Remove old backups. This deletes all backup data no longer needed
        # from more than 3 days ago.
    shell(
        "check_call",
        [
            "/usr/bin/duplicity",
            "remove-older-than",
            "%dD" % config["min_age_in_days"],
            "--verbosity",
            "error",
            "--archive-dir",
            backup_cache_dir,
            "--force",
            config["target"],
        ],
        get_env(env),
    )

    # From duplicity's manual:
    # "This should only be necessary after a duplicity session fails or is
    # aborted prematurely."
    # That may be unlikely here but we may as well ensure we tidy up if
    # that does happen - it might just have been a poorly timed reboot.
    shell(
        "check_call",
        [
            "/usr/bin/duplicity",
            "cleanup",
            "--verbosity",
            "error",
            "--archive-dir",
            backup_cache_dir,
            "--force",
            config["target"],
        ],
        get_env(env),
    )

    # Change ownership of backups to the user-data user, so that the after-bcakup
    # script can access them.
    if get_target_type(config) == "file":
        shell("check_call", ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])

        # Execute a post-backup script that does the copying to a remote server.
        # Run as the STORAGE_USER user, not as root. Pass our settings in
        # environment variables so the script has access to STORAGE_ROOT.
    post_script = os.path.join(backup_root, "after-backup")
    if os.path.exists(post_script):
        shell("check_call", ["su", env["STORAGE_USER"], "-c", post_script, config["target"]], env=env)

        # Our nightly cron job executes system status checks immediately after this
        # backup. Since it checks that dovecot and postfix are running, block for a
        # bit (maximum of 10 seconds each) to give each a chance to finish restarting
        # before the status checks might catch them down. See #381.
    wait_for_service(25, True, env, 10)
    wait_for_service(993, True, env, 10)
Example #2
0
def perform_backup(full_backup):
	env = load_environment()

	exclusive_process("backup")

	# Ensure the backup directory exists.
	backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
	backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
	os.makedirs(backup_duplicity_dir, exist_ok=True)

	# On the first run, always do a full backup. Incremental
	# will fail. Otherwise do a full backup when the size of
	# the increments since the most recent full backup are
	# large.
	full_backup = full_backup or should_force_full(env)

	# Stop services.
	shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
	shell('check_call', ["/usr/sbin/service", "postfix", "stop"])

	# Update the backup mirror directory which mirrors the current
	# STORAGE_ROOT (but excluding the backups themselves!).
	try:
		shell('check_call', [
			"/usr/bin/duplicity",
			"full" if full_backup else "incr",
			"--no-encryption",
			"--archive-dir", "/tmp/duplicity-archive-dir",
			"--name", "mailinabox",
			"--exclude", backup_dir,
			"--volsize", "100",
			"--verbosity", "warning",
			env["STORAGE_ROOT"],
			"file://" + backup_duplicity_dir
			])
	finally:
		# Start services again.
		shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
		shell('check_call', ["/usr/sbin/service", "postfix", "start"])

	# Remove old backups. This deletes all backup data no longer needed
	# from more than 31 days ago. Must do this before destroying the
	# cache directory or else this command will re-create it.
	shell('check_call', [
		"/usr/bin/duplicity",
		"remove-older-than",
		"%dD" % keep_backups_for_days,
		"--archive-dir", "/tmp/duplicity-archive-dir",
		"--name", "mailinabox",
		"--force",
		"--verbosity", "warning",
		"file://" + backup_duplicity_dir
		])

	# Remove duplicity's cache directory because it's redundant with our backup directory.
	shutil.rmtree("/tmp/duplicity-archive-dir")

	# Encrypt all of the new files.
	backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
	os.makedirs(backup_encrypted_dir, exist_ok=True)
	for fn in os.listdir(backup_duplicity_dir):
		fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
		if os.path.exists(fn2): continue

		# Encrypt the backup using the backup private key.
		shell('check_call', [
			"/usr/bin/openssl",
			"enc",
			"-aes-256-cbc",
			"-a",
			"-salt",
			"-in", os.path.join(backup_duplicity_dir, fn),
			"-out", fn2,
			"-pass", "file:%s" % 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

	# Remove encrypted backups that are no longer needed.
	for fn in os.listdir(backup_encrypted_dir):
		fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
		if os.path.exists(fn2): continue
		os.unlink(os.path.join(backup_encrypted_dir, fn))

	# Execute a post-backup script that does the copying to a remote server.
	# Run as the STORAGE_USER user, not as root. Pass our settings in
	# environment variables so the script has access to STORAGE_ROOT.
	post_script = os.path.join(backup_dir, 'after-backup')
	if os.path.exists(post_script):
		shell('check_call',
			['su', env['STORAGE_USER'], '-c', post_script],
			env=env)
Example #3
0
def provision_certificates_cmdline():
	import sys
	from utils import load_environment, exclusive_process

	exclusive_process("update_tls_certificates")
	env = load_environment()

	verbose = False
	headless = False
	force_domains = None
	show_extended_problems = True
	
	args = list(sys.argv)
	args.pop(0) # program name
	if args and args[0] == "-v":
		verbose = True
		args.pop(0)
	if args and args[0] == "q":
		show_extended_problems = False
		args.pop(0)
	if args and args[0] == "--headless":
		headless = True
		args.pop(0)
	if args and args[0] == "--force":
		force_domains = "ALL"
		args.pop(0)
	else:
		force_domains = args

	agree_to_tos_url = None
	while True:
		# Run the provisioning script. This installs certificates. If there are
		# a very large number of domains on this box, it issues separate
		# certificates for groups of domains. We have to check the result for
		# each group.
		def my_logger(message):
			if verbose:
				print(">", message)
		status = provision_certificates(env, agree_to_tos_url=agree_to_tos_url, logger=my_logger, force_domains=force_domains, show_extended_problems=show_extended_problems)
		agree_to_tos_url = None # reset to prevent infinite looping

		if not status["requests"]:
			# No domains need certificates.
			if not headless or verbose:
				if len(status["problems"]) == 0:
					print("No domains hosted on this box need a new TLS certificate at this time.")
				elif len(status["problems"]) > 0:
					print("No TLS certificates could be provisoned at this time:")
					print()
					for domain in sort_domains(status["problems"], env):
						print("%s: %s" % (domain, status["problems"][domain]))

			sys.exit(0)

		# What happened?
		wait_until = None
		wait_domains = []
		for request in status["requests"]:
			if request["result"] == "agree-to-tos":
				# We may have asked already in a previous iteration.
				if agree_to_tos_url is not None:
					continue

				# Can't ask the user a question in this mode. Warn the user that something
				# needs to be done.
				if headless:
					print(", ".join(request["domains"]) + " need a new or renewed TLS certificate.")
					print()
					print("This box can't do that automatically for you until you agree to Let's Encrypt's")
					print("Terms of Service agreement. Use the Mail-in-a-Box control panel to provision")
					print("certificates for these domains.")
					sys.exit(1)

				print("""
I'm going to provision a TLS certificate (formerly called a SSL certificate)
for you from Let's Encrypt (letsencrypt.org).

TLS certificates are cryptographic keys that ensure communication between
you and this box are secure when getting and sending mail and visiting
websites hosted on this box. Let's Encrypt is a free provider of TLS
certificates.

Please open this document in your web browser:

%s

It is Let's Encrypt's terms of service agreement. If you agree, I can
provision that TLS certificate. If you don't agree, you will have an
opportunity to install your own TLS certificate from the Mail-in-a-Box
control panel.

Do you agree to the agreement? Type Y or N and press <ENTER>: """
				 % request["url"], end='', flush=True)
			
				if sys.stdin.readline().strip().upper() != "Y":
					print("\nYou didn't agree. Quitting.")
					sys.exit(1)

				# Okay, indicate agreement on next iteration.
				agree_to_tos_url = request["url"]

			if request["result"] == "wait":
				# Must wait. We'll record until when. The wait occurs below.
				if wait_until is None:
					wait_until = request["until"]
				else:
					wait_until = max(wait_until, request["until"])
				wait_domains += request["domains"]

			if request["result"] == "error":
				print(", ".join(request["domains"]) + ":")
				print(request["message"])

			if request["result"] == "installed":
				print("A TLS certificate was successfully installed for " + ", ".join(request["domains"]) + ".")

		if wait_until:
			# Wait, then loop.
			import time, datetime
			print()
			print("A TLS certificate was requested for: " + ", ".join(wait_domains) + ".")
			first = True
			while wait_until > datetime.datetime.now():
				if not headless or first:
					print ("We have to wait", int(round((wait_until - datetime.datetime.now()).total_seconds())), "seconds for the certificate to be issued...")
				time.sleep(10)
				first = False

			continue # Loop!

		if agree_to_tos_url:
			# The user agrees to the TOS. Loop to try again by agreeing.
			continue # Loop!

		# Unless we were instructed to wait, or we just agreed to the TOS,
		# we're done for now.
		break

	# And finally show the domains with problems.
	if len(status["problems"]) > 0:
		print("TLS certificates could not be provisoned for:")
		for domain in sort_domains(status["problems"], env):
			print("%s: %s" % (domain, status["problems"][domain]))
Example #4
0
def perform_backup(full_backup):
    env = load_environment()

    exclusive_process("backup")
    config = get_backup_config(env)
    backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
    backup_cache_dir = os.path.join(backup_root, 'cache')
    backup_dir = os.path.join(backup_root, 'encrypted')

    # Are backups dissbled?
    if config["target"] == "off":
        return

    # In an older version of this script, duplicity was called
    # such that it did not encrypt the backups it created (in
    # backup/duplicity), and instead openssl was called separately
    # after each backup run, creating AES256 encrypted copies of
    # each file created by duplicity in backup/encrypted.
    #
    # We detect the transition by the presence of backup/duplicity
    # and handle it by 'dupliception': we move all the old *un*encrypted
    # duplicity files up out of the backup/duplicity directory (as
    # backup/ is excluded from duplicity runs) in order that it is
    # included in the next run, and we delete backup/encrypted (which
    # duplicity will output files directly to, post-transition).
    old_backup_dir = os.path.join(backup_root, 'duplicity')
    migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
    if os.path.isdir(old_backup_dir):
        # Move the old unencrypted files to a new location outside of
        # the backup root so they get included in the next (new) backup.
        # Then we'll delete them. Also so that they do not get in the
        # way of duplicity doing a full backup on the first run after
        # we take care of this.
        shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)

        # The backup_dir (backup/encrypted) now has a new purpose.
        # Clear it out.
        shutil.rmtree(backup_dir)

    # On the first run, always do a full backup. Incremental
    # will fail. Otherwise do a full backup when the size of
    # the increments since the most recent full backup are
    # large.
    try:
        full_backup = full_backup or should_force_full(config, env)
    except Exception as e:
        # This was the first call to duplicity, and there might
        # be an error already.
        print(e)
        sys.exit(1)

    # Stop services.
    def service_command(service, command, quit=None):
        # Execute silently, but if there is an error then display the output & exit.
        code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
        if code != 0:
            print(ret)
            if quit:
                sys.exit(code)

    service_command("postfix", "stop", quit=True)
    service_command("dovecot", "stop", quit=True)

    # Execute a pre-backup script that copies files outside the homedir.
    # Run as the STORAGE_USER user, not as root. Pass our settings in
    # environment variables so the script has access to STORAGE_ROOT.
    pre_script = os.path.join(backup_root, 'before-backup')
    if os.path.exists(pre_script):
        shell('check_call',
              ['su', env['STORAGE_USER'], '-c', pre_script, config["target"]],
              env=env)

    # Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
    # --allow-source-mismatch is needed in case the box's hostname is changed
    # after the first backup. See #396.
    try:
        shell('check_call', [
            "/usr/bin/duplicity",
            "full" if full_backup else "incr",
            "--verbosity", "warning", "--no-print-statistics",
            "--archive-dir", backup_cache_dir,
            "--exclude", backup_root,
            "--volsize", "250",
            "--gpg-options", "--cipher-algo=AES256",
            env["STORAGE_ROOT"],
            config["target"],
            "--allow-source-mismatch"
        ],
              get_env(env))
    finally:
        # Start services again.
        service_command("dovecot", "start", quit=False)
        service_command("postfix", "start", quit=False)

    # Once the migrated backup is included in a new backup, it can be deleted.
    if os.path.isdir(migrated_unencrypted_backup_dir):
        shutil.rmtree(migrated_unencrypted_backup_dir)

    # Remove old backups. This deletes all backup data no longer needed
    # from more than 3 days ago.
    shell('check_call', [
        "/usr/bin/duplicity",
        "remove-older-than",
        "%dD" % config["min_age_in_days"],
        "--verbosity", "error",
        "--archive-dir", backup_cache_dir,
        "--force",
        config["target"]
    ],
          get_env(env))

    # From duplicity's manual:
    # "This should only be necessary after a duplicity session fails or is
    # aborted prematurely."
    # That may be unlikely here but we may as well ensure we tidy up if
    # that does happen - it might just have been a poorly timed reboot.
    shell('check_call', [
        "/usr/bin/duplicity",
        "cleanup",
        "--verbosity", "error",
        "--archive-dir", backup_cache_dir,
        "--force",
        config["target"]
    ],
          get_env(env))

    # Change ownership of backups to the user-data user, so that the after-bcakup
    # script can access them.
    if get_target_type(config) == 'file':
        shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])

    # Execute a post-backup script that does the copying to a remote server.
    # Run as the STORAGE_USER user, not as root. Pass our settings in
    # environment variables so the script has access to STORAGE_ROOT.
    post_script = os.path.join(backup_root, 'after-backup')
    if os.path.exists(post_script):
        shell('check_call',
              ['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
              env=env)

    # Our nightly cron job executes system status checks immediately after this
    # backup. Since it checks that dovecot and postfix are running, block for a
    # bit (maximum of 10 seconds each) to give each a chance to finish restarting
    # before the status checks might catch them down. See #381.
    wait_for_service(25, True, env, 10)
    wait_for_service(993, True, env, 10)
Example #5
0
def perform_backup(full_backup):
	env = load_environment()

	exclusive_process("backup")

	backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
	backup_cache_dir = os.path.join(backup_root, 'cache')
	backup_dir = os.path.join(backup_root, 'encrypted')

	# In an older version of this script, duplicity was called
	# such that it did not encrypt the backups it created (in
	# backup/duplicity), and instead openssl was called separately
	# after each backup run, creating AES256 encrypted copies of
	# each file created by duplicity in backup/encrypted.
	#
	# We detect the transition by the presence of backup/duplicity
	# and handle it by 'dupliception': we move all the old *un*encrypted
	# duplicity files up out of the backup/duplicity directory (as
	# backup/ is excluded from duplicity runs) in order that it is
	# included in the next run, and we delete backup/encrypted (which
	# duplicity will output files directly to, post-transition).
	old_backup_dir = os.path.join(backup_root, 'duplicity')
	migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
	if os.path.isdir(old_backup_dir):
		# Move the old unencrpyted files to a new location outside of
		# the backup root so they get included in the next (new) backup.
		# Then we'll delete them. Also so that they do not get in the
		# way of duplicity doing a full backup on the first run after
		# we take care of this.
		shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)

		# The backup_dir (backup/encrypted) now has a new purpose.
		# Clear it out.
		shutil.rmtree(backup_dir)

	# On the first run, always do a full backup. Incremental
	# will fail. Otherwise do a full backup when the size of
	# the increments since the most recent full backup are
	# large.
	full_backup = full_backup or should_force_full(env)

	# Stop services.
	shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
	shell('check_call', ["/usr/sbin/service", "postfix", "stop"])

	# Get the encryption passphrase. secret_key.txt is 2048 random
	# bits base64-encoded and with line breaks every 65 characters.
	# gpg will only take the first line of text, so sanity check that
	# that line is long enough to be a reasonable passphrase. It
	# only needs to be 43 base64-characters to match AES256's key
	# length of 32 bytes.
	with open(os.path.join(backup_root, 'secret_key.txt')) as f:
		passphrase = f.readline().strip()
	if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
	env_with_passphrase = { "PASSPHRASE" : passphrase }

	# Update the backup mirror directory which mirrors the current
	# STORAGE_ROOT (but excluding the backups themselves!).
	try:
		shell('check_call', [
			"/usr/bin/duplicity",
			"full" if full_backup else "incr",
			"--archive-dir", backup_cache_dir,
			"--exclude", backup_root,
			"--volsize", "250",
			"--gpg-options", "--cipher-algo=AES256",
			env["STORAGE_ROOT"],
			"file://" + backup_dir
			],
			env_with_passphrase)
	finally:
		# Start services again.
		shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
		shell('check_call', ["/usr/sbin/service", "postfix", "start"])

	# Once the migrated backup is included in a new backup, it can be deleted.
	if os.path.isdir(migrated_unencrypted_backup_dir):
		shutil.rmtree(migrated_unencrypted_backup_dir)

	# Remove old backups. This deletes all backup data no longer needed
	# from more than 3 days ago.
	shell('check_call', [
		"/usr/bin/duplicity",
		"remove-older-than",
		"%dD" % keep_backups_for_days,
		"--archive-dir", backup_cache_dir,
		"--force",
		"file://" + backup_dir
		],
		env_with_passphrase)

	# From duplicity's manual:
	# "This should only be necessary after a duplicity session fails or is
	# aborted prematurely."
	# That may be unlikely here but we may as well ensure we tidy up if
	# that does happen - it might just have been a poorly timed reboot.
	shell('check_call', [
		"/usr/bin/duplicity",
		"cleanup",
		"--archive-dir", backup_cache_dir,
		"--force",
		"file://" + backup_dir
		],
		env_with_passphrase)

	# Change ownership of backups to the user-data user, so that the after-bcakup
	# script can access them.
	shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])

	# Execute a post-backup script that does the copying to a remote server.
	# Run as the STORAGE_USER user, not as root. Pass our settings in
	# environment variables so the script has access to STORAGE_ROOT.
	post_script = os.path.join(backup_root, 'after-backup')
	if os.path.exists(post_script):
		shell('check_call',
			['su', env['STORAGE_USER'], '-c', post_script],
			env=env)
Example #6
0
#    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 sys, os, os.path, shutil, glob

from utils import exclusive_process, load_environment, shell

# settings
full_backup = "--full" in sys.argv
keep_backups_for = "31D" # destroy backups older than 31 days except the most recent full backup

env = load_environment()

exclusive_process("backup")

# Ensure the backup directory exists.
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
os.makedirs(backup_dir, exist_ok=True)

# On the first run, always do a full backup. Incremental
# will fail.
if len(os.listdir(backup_duplicity_dir)) == 0:
	full_backup = True
else:
	# When the size of incremental backups exceeds the size of existing
	# full backups, take a new full backup. We want to avoid full backups
	# because they are costly to synchronize off-site.
	full_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-full.*'))
Example #7
0
def perform_backup(full_backup):
	env = load_environment()

	exclusive_process("backup")

	# Ensure the backup directory exists.
	backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
	backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
	os.makedirs(backup_duplicity_dir, exist_ok=True)

	# On the first run, always do a full backup. Incremental
	# will fail. Otherwise do a full backup when the size of
	# the increments since the most recent full backup are
	# large.
	full_backup = full_backup or should_force_full(env)

	# Stop services.
	shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
	shell('check_call', ["/usr/sbin/service", "postfix", "stop"])

	# Update the backup mirror directory which mirrors the current
	# STORAGE_ROOT (but excluding the backups themselves!).
	try:
		shell('check_call', [
			"/usr/bin/duplicity",
			"full" if full_backup else "incr",
			"--no-encryption",
			"--archive-dir", "/tmp/duplicity-archive-dir",
			"--name", "mailinabox",
			"--exclude", backup_dir,
			"--volsize", "100",
			"--verbosity", "warning",
			env["STORAGE_ROOT"],
			"file://" + backup_duplicity_dir
			])
	finally:
		# Start services again.
		shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
		shell('check_call', ["/usr/sbin/service", "postfix", "start"])

	# Remove old backups. This deletes all backup data no longer needed
	# from more than 31 days ago. Must do this before destroying the
	# cache directory or else this command will re-create it.
	shell('check_call', [
		"/usr/bin/duplicity",
		"remove-older-than",
		"%dD" % keep_backups_for_days,
		"--archive-dir", "/tmp/duplicity-archive-dir",
		"--name", "mailinabox",
		"--force",
		"--verbosity", "warning",
		"file://" + backup_duplicity_dir
		])

	# Remove duplicity's cache directory because it's redundant with our backup directory.
	shutil.rmtree("/tmp/duplicity-archive-dir")

	# Encrypt all of the new files.
	backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
	os.makedirs(backup_encrypted_dir, exist_ok=True)
	for fn in os.listdir(backup_duplicity_dir):
		fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
		if os.path.exists(fn2): continue

		# Encrypt the backup using the backup private key.
		shell('check_call', [
			"/usr/bin/openssl",
			"enc",
			"-aes-256-cbc",
			"-a",
			"-salt",
			"-in", os.path.join(backup_duplicity_dir, fn),
			"-out", fn2,
			"-pass", "file:%s" % 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

	# Remove encrypted backups that are no longer needed.
	for fn in os.listdir(backup_encrypted_dir):
		fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
		if os.path.exists(fn2): continue
		os.unlink(os.path.join(backup_encrypted_dir, fn))

	# Execute a post-backup script that does the copying to a remote server.
	# Run as the STORAGE_USER user, not as root. Pass our settings in
	# environment variables so the script has access to STORAGE_ROOT.
	post_script = os.path.join(backup_dir, 'after-backup')
	if os.path.exists(post_script):
		shell('check_call',
			['su', env['STORAGE_USER'], '-c', post_script],
			env=env)
Example #8
0
def perform_backup(full_backup):
    env = load_environment()

    exclusive_process("backup")

    backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
    backup_cache_dir = os.path.join(backup_root, 'cache')
    backup_dir = os.path.join(backup_root, 'encrypted')

    # In an older version of this script, duplicity was called
    # such that it did not encrypt the backups it created (in
    # backup/duplicity), and instead openssl was called separately
    # after each backup run, creating AES256 encrypted copies of
    # each file created by duplicity in backup/encrypted.
    #
    # We detect the transition by the presence of backup/duplicity
    # and handle it by 'dupliception': we move all the old *un*encrypted
    # duplicity files up out of the backup/duplicity directory (as
    # backup/ is excluded from duplicity runs) in order that it is
    # included in the next run, and we delete backup/encrypted (which
    # duplicity will output files directly to, post-transition).
    old_backup_dir = os.path.join(backup_root, 'duplicity')
    migrated_unencrypted_backup_dir = os.path.join(
        env["STORAGE_ROOT"], "migrated_unencrypted_backup")
    if os.path.isdir(old_backup_dir):
        # Move the old unencrypted files to a new location outside of
        # the backup root so they get included in the next (new) backup.
        # Then we'll delete them. Also so that they do not get in the
        # way of duplicity doing a full backup on the first run after
        # we take care of this.
        shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)

        # The backup_dir (backup/encrypted) now has a new purpose.
        # Clear it out.
        shutil.rmtree(backup_dir)

    # On the first run, always do a full backup. Incremental
    # will fail. Otherwise do a full backup when the size of
    # the increments since the most recent full backup are
    # large.
    full_backup = full_backup or should_force_full(env)

    # Stop services.
    shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
    shell('check_call', ["/usr/sbin/service", "postfix", "stop"])

    # Get the encryption passphrase. secret_key.txt is 2048 random
    # bits base64-encoded and with line breaks every 65 characters.
    # gpg will only take the first line of text, so sanity check that
    # that line is long enough to be a reasonable passphrase. It
    # only needs to be 43 base64-characters to match AES256's key
    # length of 32 bytes.
    with open(os.path.join(backup_root, 'secret_key.txt')) as f:
        passphrase = f.readline().strip()
    if len(passphrase) < 43:
        raise Exception("secret_key.txt's first line is too short!")
    env_with_passphrase = {"PASSPHRASE": passphrase}

    # Update the backup mirror directory which mirrors the current
    # STORAGE_ROOT (but excluding the backups themselves!).
    try:
        shell('check_call', [
            "/usr/bin/duplicity", "full" if full_backup else "incr",
            "--archive-dir", backup_cache_dir, "--exclude", backup_root,
            "--volsize", "250", "--gpg-options", "--cipher-algo=AES256",
            env["STORAGE_ROOT"], "file://" + backup_dir
        ], env_with_passphrase)
    finally:
        # Start services again.
        shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
        shell('check_call', ["/usr/sbin/service", "postfix", "start"])

    # Once the migrated backup is included in a new backup, it can be deleted.
    if os.path.isdir(migrated_unencrypted_backup_dir):
        shutil.rmtree(migrated_unencrypted_backup_dir)

    # Remove old backups. This deletes all backup data no longer needed
    # from more than 3 days ago.
    shell('check_call', [
        "/usr/bin/duplicity", "remove-older-than",
        "%dD" % keep_backups_for_days, "--archive-dir", backup_cache_dir,
        "--force", "file://" + backup_dir
    ], env_with_passphrase)

    # From duplicity's manual:
    # "This should only be necessary after a duplicity session fails or is
    # aborted prematurely."
    # That may be unlikely here but we may as well ensure we tidy up if
    # that does happen - it might just have been a poorly timed reboot.
    shell('check_call', [
        "/usr/bin/duplicity", "cleanup", "--archive-dir", backup_cache_dir,
        "--force", "file://" + backup_dir
    ], env_with_passphrase)

    # Change ownership of backups to the user-data user, so that the after-bcakup
    # script can access them.
    shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])

    # Execute a post-backup script that does the copying to a remote server.
    # Run as the STORAGE_USER user, not as root. Pass our settings in
    # environment variables so the script has access to STORAGE_ROOT.
    post_script = os.path.join(backup_root, 'after-backup')
    if os.path.exists(post_script):
        shell('check_call', ['su', env['STORAGE_USER'], '-c', post_script],
              env=env)

    # Our nightly cron job executes system status checks immediately after this
    # backup. Since it checks that dovecot and postfix are running, block for a
    # bit (maximum of 10 seconds each) to give each a chance to finish restarting
    # before the status checks might catch them down. See #381.
    wait_for_service(25, True, env, 10)
    wait_for_service(993, True, env, 10)
Example #9
0
# 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