PageRenderTime 106ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 1ms

/management/backup.py

https://gitlab.com/oytunistrator/mailinabox
Python | 176 lines | 117 code | 24 blank | 35 comment | 19 complexity | f8db4466529349fad334795ed74b92cb MD5 | raw file
  1. #!/usr/bin/python3
  2. # This script performs a backup of all user data:
  3. # 1) System services are stopped while a copy of user data is made.
  4. # 2) An incremental backup is made using duplicity into the
  5. # directory STORAGE_ROOT/backup/duplicity.
  6. # 3) The stopped services are restarted.
  7. # 4) The backup files are encrypted with a long password (stored in
  8. # backup/secret_key.txt) to STORAGE_ROOT/backup/encrypted.
  9. # 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
  10. import os, os.path, shutil, glob, re, datetime
  11. import dateutil.parser, dateutil.relativedelta, dateutil.tz
  12. from utils import exclusive_process, load_environment, shell
  13. # settings
  14. keep_backups_for = "31D" # destroy backups older than 31 days except the most recent full backup
  15. def backup_status(env):
  16. # What is the current status of backups?
  17. # Loop through all of the files in STORAGE_ROOT/backup/duplicity to
  18. # get a list of all of the backups taken and sum up file sizes to
  19. # see how large the storage is.
  20. now = datetime.datetime.now(dateutil.tz.tzlocal())
  21. def reldate(date):
  22. rd = dateutil.relativedelta.relativedelta(now, date)
  23. if rd.days >= 7: return "%d days" % rd.days
  24. if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
  25. if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
  26. return "%d hours, %d minutes" % (rd.hours, rd.minutes)
  27. backups = { }
  28. basedir = os.path.join(env['STORAGE_ROOT'], 'backup/duplicity/')
  29. encdir = os.path.join(env['STORAGE_ROOT'], 'backup/encrypted/')
  30. for fn in os.listdir(basedir):
  31. m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
  32. if not m: raise ValueError(fn)
  33. key = m.group("date")
  34. if key not in backups:
  35. date = dateutil.parser.parse(m.group("date"))
  36. backups[key] = {
  37. "date": m.group("date"),
  38. "date_str": date.strftime("%x %X"),
  39. "date_delta": reldate(date),
  40. "full": m.group("incbase") is None,
  41. "previous": m.group("incbase") is None,
  42. "size": 0,
  43. "encsize": 0,
  44. }
  45. backups[key]["size"] += os.path.getsize(os.path.join(basedir, fn))
  46. # Also check encrypted size.
  47. encfn = os.path.join(encdir, fn + ".enc")
  48. if os.path.exists(encfn):
  49. backups[key]["encsize"] += os.path.getsize(encfn)
  50. backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
  51. return {
  52. "directory": basedir,
  53. "encpwfile": os.path.join(env['STORAGE_ROOT'], 'backup/secret_key.txt'),
  54. "encdirectory": encdir,
  55. "tz": now.tzname(),
  56. "backups": backups,
  57. }
  58. def perform_backup(full_backup):
  59. env = load_environment()
  60. exclusive_process("backup")
  61. # Ensure the backup directory exists.
  62. backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
  63. backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
  64. os.makedirs(backup_duplicity_dir, exist_ok=True)
  65. # On the first run, always do a full backup. Incremental
  66. # will fail.
  67. if len(os.listdir(backup_duplicity_dir)) == 0:
  68. full_backup = True
  69. else:
  70. # When the size of incremental backups exceeds the size of existing
  71. # full backups, take a new full backup. We want to avoid full backups
  72. # because they are costly to synchronize off-site.
  73. full_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-full.*'))
  74. inc_sz = sum(os.path.getsize(f) for f in glob.glob(backup_duplicity_dir + '/*-inc.*'))
  75. # (n.b. not counting size of new-signatures files because they are relatively small)
  76. if inc_sz > full_sz * 1.5:
  77. full_backup = True
  78. # Stop services.
  79. shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
  80. shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
  81. # Update the backup mirror directory which mirrors the current
  82. # STORAGE_ROOT (but excluding the backups themselves!).
  83. try:
  84. shell('check_call', [
  85. "/usr/bin/duplicity",
  86. "full" if full_backup else "incr",
  87. "--no-encryption",
  88. "--archive-dir", "/tmp/duplicity-archive-dir",
  89. "--name", "mailinabox",
  90. "--exclude", backup_dir,
  91. "--volsize", "100",
  92. "--verbosity", "warning",
  93. env["STORAGE_ROOT"],
  94. "file://" + backup_duplicity_dir
  95. ])
  96. finally:
  97. # Start services again.
  98. shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
  99. shell('check_call', ["/usr/sbin/service", "postfix", "start"])
  100. # Remove old backups. This deletes all backup data no longer needed
  101. # from more than 31 days ago. Must do this before destroying the
  102. # cache directory or else this command will re-create it.
  103. shell('check_call', [
  104. "/usr/bin/duplicity",
  105. "remove-older-than",
  106. keep_backups_for,
  107. "--archive-dir", "/tmp/duplicity-archive-dir",
  108. "--name", "mailinabox",
  109. "--force",
  110. "--verbosity", "warning",
  111. "file://" + backup_duplicity_dir
  112. ])
  113. # Remove duplicity's cache directory because it's redundant with our backup directory.
  114. shutil.rmtree("/tmp/duplicity-archive-dir")
  115. # Encrypt all of the new files.
  116. backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
  117. os.makedirs(backup_encrypted_dir, exist_ok=True)
  118. for fn in os.listdir(backup_duplicity_dir):
  119. fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
  120. if os.path.exists(fn2): continue
  121. # Encrypt the backup using the backup private key.
  122. shell('check_call', [
  123. "/usr/bin/openssl",
  124. "enc",
  125. "-aes-256-cbc",
  126. "-a",
  127. "-salt",
  128. "-in", os.path.join(backup_duplicity_dir, fn),
  129. "-out", fn2,
  130. "-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
  131. ])
  132. # The backup can be decrypted with:
  133. # openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z
  134. # Remove encrypted backups that are no longer needed.
  135. for fn in os.listdir(backup_encrypted_dir):
  136. fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
  137. if os.path.exists(fn2): continue
  138. os.unlink(os.path.join(backup_encrypted_dir, fn))
  139. # Execute a post-backup script that does the copying to a remote server.
  140. # Run as the STORAGE_USER user, not as root. Pass our settings in
  141. # environment variables so the script has access to STORAGE_ROOT.
  142. post_script = os.path.join(backup_dir, 'after-backup')
  143. if os.path.exists(post_script):
  144. shell('check_call',
  145. ['su', env['STORAGE_USER'], '-c', post_script],
  146. env=env)
  147. if __name__ == "__main__":
  148. import sys
  149. full_backup = "--full" in sys.argv
  150. perform_backup(full_backup)