mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-26 02:57:04 +00:00
merge - duplicity configuration in the control panel and disabling backups
Merges branch 'ponychicken-backup'.
This commit is contained in:
commit
c2e5f5cb2b
@ -10,20 +10,27 @@
|
|||||||
|
|
||||||
import os, os.path, shutil, glob, re, datetime
|
import os, os.path, shutil, glob, re, datetime
|
||||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||||
|
import rtyaml
|
||||||
|
|
||||||
from utils import exclusive_process, load_environment, shell, wait_for_service
|
from utils import exclusive_process, load_environment, shell, wait_for_service
|
||||||
|
|
||||||
# Destroy backups when the most recent increment in the chain
|
|
||||||
# that depends on it is this many days old.
|
|
||||||
keep_backups_for_days = 3
|
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
# What is the current status of backups?
|
# Root folder
|
||||||
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
# get a list of all of the backups taken and sum up file sizes to
|
|
||||||
# see how large the storage is.
|
|
||||||
|
|
||||||
|
# What is the current status of backups?
|
||||||
|
# Query duplicity to get a list of all backups.
|
||||||
|
# Use the number of volumes to estimate the size.
|
||||||
|
config = get_backup_config(env)
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
|
|
||||||
|
# Are backups dissbled?
|
||||||
|
if config["target"] == "off":
|
||||||
|
return { }
|
||||||
|
|
||||||
|
backups = { }
|
||||||
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
|
||||||
def reldate(date, ref, clip):
|
def reldate(date, ref, clip):
|
||||||
if ref < date: return clip
|
if ref < date: return clip
|
||||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||||
@ -34,27 +41,38 @@ def backup_status(env):
|
|||||||
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||||
|
|
||||||
backups = { }
|
# Get duplicity collection status and parse for a list of backups.
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
def parse_line(line):
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
keys = line.strip().split()
|
||||||
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
|
date = dateutil.parser.parse(keys[1])
|
||||||
for fn in os.listdir(backup_dir):
|
return {
|
||||||
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)
|
"date": keys[1],
|
||||||
if not m: raise ValueError(fn)
|
|
||||||
|
|
||||||
key = m.group("date")
|
|
||||||
if key not in backups:
|
|
||||||
date = dateutil.parser.parse(m.group("date"))
|
|
||||||
backups[key] = {
|
|
||||||
"date": m.group("date"),
|
|
||||||
"date_str": date.strftime("%x %X"),
|
"date_str": date.strftime("%x %X"),
|
||||||
"date_delta": reldate(date, now, "the future?"),
|
"date_delta": reldate(date, now, "the future?"),
|
||||||
"full": m.group("incbase") is None,
|
"full": keys[0] == "full",
|
||||||
"previous": m.group("incbase"),
|
"size": 0, # collection-status doesn't give us the size
|
||||||
"size": 0,
|
"volumes": keys[2], # number of archive volumes for this backup (not really helpful)
|
||||||
}
|
}
|
||||||
|
collection_status = shell('check_output', [
|
||||||
|
"/usr/bin/duplicity",
|
||||||
|
"collection-status",
|
||||||
|
"--archive-dir", backup_cache_dir,
|
||||||
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
|
"--log-fd", "1",
|
||||||
|
config["target"],
|
||||||
|
],
|
||||||
|
get_env(env))
|
||||||
|
for line in collection_status.split('\n'):
|
||||||
|
if line.startswith(" full") or line.startswith(" inc"):
|
||||||
|
backup = parse_line(line)
|
||||||
|
backups[backup["date"]] = backup
|
||||||
|
|
||||||
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
|
# Look at the target to get the sizes of each of the backups. There is more than one file per backup.
|
||||||
|
for fn, size in list_target_files(config):
|
||||||
|
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)
|
||||||
|
if not m: continue # not a part of a current backup chain
|
||||||
|
key = m.group("date")
|
||||||
|
backups[key]["size"] += size
|
||||||
|
|
||||||
# Ensure the rows are sorted reverse chronologically.
|
# Ensure the rows are sorted reverse chronologically.
|
||||||
# This is relied on by should_force_full() and the next step.
|
# This is relied on by should_force_full() and the next step.
|
||||||
@ -79,11 +97,11 @@ def backup_status(env):
|
|||||||
# when the threshold is met.
|
# when the threshold is met.
|
||||||
deleted_in = None
|
deleted_in = None
|
||||||
if incremental_count > 0 and first_full_size is not None:
|
if incremental_count > 0 and first_full_size is not None:
|
||||||
deleted_in = "approx. %d days" % round(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
deleted_in = "approx. %d days" % round(config["min_age_in_days"] + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
|
||||||
|
|
||||||
# When will a backup be deleted?
|
# When will a backup be deleted?
|
||||||
saw_full = False
|
saw_full = False
|
||||||
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
days_ago = now - datetime.timedelta(days=config["min_age_in_days"])
|
||||||
for bak in backups:
|
for bak in backups:
|
||||||
if deleted_in:
|
if deleted_in:
|
||||||
# Subsequent backups are deleted when the most recent increment
|
# Subsequent backups are deleted when the most recent increment
|
||||||
@ -99,8 +117,6 @@ def backup_status(env):
|
|||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"directory": backup_dir,
|
|
||||||
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
|
|
||||||
"tz": now.tzname(),
|
"tz": now.tzname(),
|
||||||
"backups": backups,
|
"backups": backups,
|
||||||
}
|
}
|
||||||
@ -124,15 +140,48 @@ def should_force_full(env):
|
|||||||
# (I love for/else blocks. Here it's just to show off.)
|
# (I love for/else blocks. Here it's just to show off.)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_passphrase(env):
|
||||||
|
# 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.
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
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!")
|
||||||
|
|
||||||
|
return passphrase
|
||||||
|
|
||||||
|
def get_env(env):
|
||||||
|
config = get_backup_config(env)
|
||||||
|
|
||||||
|
env = { "PASSPHRASE" : get_passphrase(env) }
|
||||||
|
|
||||||
|
if get_target_type(config) == 's3':
|
||||||
|
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
|
||||||
|
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
def get_target_type(config):
|
||||||
|
protocol = config["target"].split(":")[0]
|
||||||
|
return protocol
|
||||||
|
|
||||||
def perform_backup(full_backup):
|
def perform_backup(full_backup):
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
|
||||||
exclusive_process("backup")
|
exclusive_process("backup")
|
||||||
|
config = get_backup_config(env)
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
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
|
# In an older version of this script, duplicity was called
|
||||||
# such that it did not encrypt the backups it created (in
|
# such that it did not encrypt the backups it created (in
|
||||||
# backup/duplicity), and instead openssl was called separately
|
# backup/duplicity), and instead openssl was called separately
|
||||||
@ -169,17 +218,6 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "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 }
|
|
||||||
|
|
||||||
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
|
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
|
||||||
# --allow-source-mismatch is needed in case the box's hostname is changed
|
# --allow-source-mismatch is needed in case the box's hostname is changed
|
||||||
# after the first backup. See #396.
|
# after the first backup. See #396.
|
||||||
@ -192,10 +230,10 @@ def perform_backup(full_backup):
|
|||||||
"--volsize", "250",
|
"--volsize", "250",
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||||
@ -210,12 +248,12 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"remove-older-than",
|
"remove-older-than",
|
||||||
"%dD" % keep_backups_for_days,
|
"%dD" % config["min_age_in_days"],
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
"file://" + backup_dir
|
config["target"]
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
|
|
||||||
# From duplicity's manual:
|
# From duplicity's manual:
|
||||||
# "This should only be necessary after a duplicity session fails or is
|
# "This should only be necessary after a duplicity session fails or is
|
||||||
@ -227,12 +265,13 @@ def perform_backup(full_backup):
|
|||||||
"cleanup",
|
"cleanup",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
"file://" + backup_dir
|
config["target"]
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
|
|
||||||
# Change ownership of backups to the user-data user, so that the after-bcakup
|
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||||
# script can access them.
|
# script can access them.
|
||||||
|
if get_target_type(config) == 'file':
|
||||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# Execute a post-backup script that does the copying to a remote server.
|
||||||
@ -241,7 +280,7 @@ def perform_backup(full_backup):
|
|||||||
post_script = os.path.join(backup_root, 'after-backup')
|
post_script = os.path.join(backup_root, 'after-backup')
|
||||||
if os.path.exists(post_script):
|
if os.path.exists(post_script):
|
||||||
shell('check_call',
|
shell('check_call',
|
||||||
['su', env['STORAGE_USER'], '-c', post_script],
|
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
|
||||||
env=env)
|
env=env)
|
||||||
|
|
||||||
# Our nightly cron job executes system status checks immediately after this
|
# Our nightly cron job executes system status checks immediately after this
|
||||||
@ -254,9 +293,9 @@ def perform_backup(full_backup):
|
|||||||
def run_duplicity_verification():
|
def run_duplicity_verification():
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
config = get_backup_config(env)
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
|
||||||
env_with_passphrase = { "PASSPHRASE" : open(os.path.join(backup_root, 'secret_key.txt')).read() }
|
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"--verbosity", "info",
|
"--verbosity", "info",
|
||||||
@ -264,9 +303,113 @@ def run_duplicity_verification():
|
|||||||
"--compare-data",
|
"--compare-data",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
], env_with_passphrase)
|
], get_env(env))
|
||||||
|
|
||||||
|
def list_target_files(config):
|
||||||
|
import urllib.parse
|
||||||
|
try:
|
||||||
|
p = urllib.parse.urlparse(config["target"])
|
||||||
|
except ValueError:
|
||||||
|
return "invalid target"
|
||||||
|
|
||||||
|
if p.scheme == "file":
|
||||||
|
return [(fn, os.path.getsize(os.path.join(p.path, fn))) for fn in os.listdir(p.path)]
|
||||||
|
|
||||||
|
elif p.scheme == "s3":
|
||||||
|
# match to a Region
|
||||||
|
import boto.s3
|
||||||
|
from boto.exception import BotoServerError
|
||||||
|
for region in boto.s3.regions():
|
||||||
|
if region.endpoint == p.hostname:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid S3 region/host.")
|
||||||
|
|
||||||
|
bucket = p.path[1:].split('/')[0]
|
||||||
|
path = '/'.join(p.path[1:].split('/')[1:]) + '/'
|
||||||
|
if bucket == "":
|
||||||
|
raise ValueError("Enter an S3 bucket name.")
|
||||||
|
|
||||||
|
# connect to the region & bucket
|
||||||
|
try:
|
||||||
|
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
|
||||||
|
bucket = conn.get_bucket(bucket)
|
||||||
|
except BotoServerError as e:
|
||||||
|
if e.status == 403:
|
||||||
|
raise ValueError("Invalid S3 access key or secret access key.")
|
||||||
|
elif e.status == 404:
|
||||||
|
raise ValueError("Invalid S3 bucket name.")
|
||||||
|
elif e.status == 301:
|
||||||
|
raise ValueError("Incorrect region for this bucket.")
|
||||||
|
raise ValueError(e.reason)
|
||||||
|
|
||||||
|
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(config["target"])
|
||||||
|
|
||||||
|
|
||||||
|
def backup_set_custom(env, target, target_user, target_pass, min_age):
|
||||||
|
config = get_backup_config(env, for_save=True)
|
||||||
|
|
||||||
|
# min_age must be an int
|
||||||
|
if isinstance(min_age, str):
|
||||||
|
min_age = int(min_age)
|
||||||
|
|
||||||
|
config["target"] = target
|
||||||
|
config["target_user"] = target_user
|
||||||
|
config["target_pass"] = target_pass
|
||||||
|
config["min_age_in_days"] = min_age
|
||||||
|
|
||||||
|
# Validate.
|
||||||
|
try:
|
||||||
|
if config["target"] not in ("off", "local"):
|
||||||
|
# these aren't supported by the following function, which expects a full url in the target key,
|
||||||
|
# which is what is there except when loading the config prior to saving
|
||||||
|
list_target_files(config)
|
||||||
|
except ValueError as e:
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
write_backup_config(env, config)
|
||||||
|
|
||||||
|
return "Updated backup config"
|
||||||
|
|
||||||
|
def get_backup_config(env, for_save=False):
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
|
||||||
|
# Defaults.
|
||||||
|
config = {
|
||||||
|
"min_age_in_days": 3,
|
||||||
|
"target": "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge in anything written to custom.yaml.
|
||||||
|
try:
|
||||||
|
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
|
||||||
|
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
||||||
|
config.update(custom_config)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# When updating config.yaml, don't do any further processing on what we find.
|
||||||
|
if for_save:
|
||||||
|
return config
|
||||||
|
|
||||||
|
# helper fields for the admin
|
||||||
|
config["file_target_directory"] = os.path.join(backup_root, 'encrypted')
|
||||||
|
config["enc_pw_file"] = os.path.join(backup_root, 'secret_key.txt')
|
||||||
|
if config["target"] == "local":
|
||||||
|
# Expand to the full URL.
|
||||||
|
config["target"] = "file://" + config["file_target_directory"]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def write_backup_config(env, newconfig):
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f:
|
||||||
|
f.write(rtyaml.dump(newconfig))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
@ -274,6 +417,7 @@ if __name__ == "__main__":
|
|||||||
# Run duplicity's verification command to check a) the backup files
|
# Run duplicity's verification command to check a) the backup files
|
||||||
# are readable, and b) report if they are up to date.
|
# are readable, and b) report if they are up to date.
|
||||||
run_duplicity_verification()
|
run_duplicity_verification()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Perform a backup. Add --full to force a full backup rather than
|
# Perform a backup. Add --full to force a full backup rather than
|
||||||
# possibly performing an incremental backup.
|
# possibly performing an incremental backup.
|
||||||
|
@ -90,13 +90,19 @@ def json_response(data):
|
|||||||
def index():
|
def index():
|
||||||
# Render the control panel. This route does not require user authentication
|
# Render the control panel. This route does not require user authentication
|
||||||
# so it must be safe!
|
# so it must be safe!
|
||||||
|
|
||||||
no_users_exist = (len(get_mail_users(env)) == 0)
|
no_users_exist = (len(get_mail_users(env)) == 0)
|
||||||
no_admins_exist = (len(get_admins(env)) == 0)
|
no_admins_exist = (len(get_admins(env)) == 0)
|
||||||
|
|
||||||
|
import boto.s3
|
||||||
|
backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
hostname=env['PRIMARY_HOSTNAME'],
|
hostname=env['PRIMARY_HOSTNAME'],
|
||||||
storage_root=env['STORAGE_ROOT'],
|
storage_root=env['STORAGE_ROOT'],
|
||||||
no_users_exist=no_users_exist,
|
no_users_exist=no_users_exist,
|
||||||
no_admins_exist=no_admins_exist,
|
no_admins_exist=no_admins_exist,
|
||||||
|
backup_s3_hosts=backup_s3_hosts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
@ -402,6 +408,23 @@ def backup_status():
|
|||||||
from backup import backup_status
|
from backup import backup_status
|
||||||
return json_response(backup_status(env))
|
return json_response(backup_status(env))
|
||||||
|
|
||||||
|
@app.route('/system/backup/config', methods=["GET"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def backup_get_custom():
|
||||||
|
from backup import get_backup_config
|
||||||
|
return json_response(get_backup_config(env))
|
||||||
|
|
||||||
|
@app.route('/system/backup/config', methods=["POST"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def backup_set_custom():
|
||||||
|
from backup import backup_set_custom
|
||||||
|
return json_response(backup_set_custom(env,
|
||||||
|
request.form.get('target', ''),
|
||||||
|
request.form.get('target_user', ''),
|
||||||
|
request.form.get('target_pass', ''),
|
||||||
|
request.form.get('min_age', '')
|
||||||
|
))
|
||||||
|
|
||||||
# MUNIN
|
# MUNIN
|
||||||
|
|
||||||
@app.route('/munin/')
|
@app.route('/munin/')
|
||||||
@ -432,4 +455,3 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
||||||
app.run(port=10222)
|
app.run(port=10222)
|
||||||
|
|
||||||
|
@ -5,17 +5,77 @@
|
|||||||
|
|
||||||
<h2>Backup Status</h2>
|
<h2>Backup Status</h2>
|
||||||
|
|
||||||
<h3>Copying Backup Files</h3>
|
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can also have it stored on Amazon S3</p>
|
||||||
|
|
||||||
<p>The box makes an incremental backup each night. The backup is stored on the machine itself. You are responsible for copying the backup files off of the machine.</p>
|
<h3>Configuration</h3>
|
||||||
|
|
||||||
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
|
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="backup-target-type" class="col-sm-2 control-label">Backup to:</label>
|
||||||
|
<div class="col-sm-2">
|
||||||
|
<select class="form-control" rows="1" id="backup-target-type" onchange="toggle_form()">
|
||||||
|
<option value="off">Nowhere (Disable Backups)</option>
|
||||||
|
<option value="local">{{hostname}}</option>
|
||||||
|
<option value="s3">Amazon S3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-local">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<div>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt id="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<div>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-local backup-target-s3">
|
||||||
|
<label for="min-age" class="col-sm-2 control-label">How many days should backups be kept?</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="number" class="form-control" rows="1" id="min-age">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Region</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<select class="form-control" rows="1" id="backup-target-s3-host">
|
||||||
|
{% for name, host in backup_s3_hosts %}
|
||||||
|
<option value="{{host}}">{{name}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-user" class="col-sm-2 control-label">S3 Access Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-pass" class="col-sm-2 control-label">S3 Secret Access Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-11">
|
||||||
|
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrypted, so they are safe to store anywhere. Copy the encryption password from <tt id="backup-encpassword-file"></tt> also but keep it in a safe location.</p>
|
<p> Copy the encryption password from <tt id="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</div></p>
|
||||||
|
|
||||||
<h3>Current Backups</h3>
|
<h3>Available Backups</h3>
|
||||||
|
|
||||||
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
|
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id="backup-total-size"></span>.</p>
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@ -27,8 +87,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
function toggle_form() {
|
||||||
|
var target_type = $("#backup-target-type").val();
|
||||||
|
$(".backup-target-local, .backup-target-s3").hide();
|
||||||
|
$(".backup-target-" + target_type).show();
|
||||||
|
}
|
||||||
|
|
||||||
function nice_size(bytes) {
|
function nice_size(bytes) {
|
||||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -46,19 +112,22 @@ function nice_size(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show_system_backup() {
|
function show_system_backup() {
|
||||||
|
show_custom_backup()
|
||||||
|
|
||||||
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/system/backup/status",
|
"/system/backup/status",
|
||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(r) {
|
function(r) {
|
||||||
$('#backup-location').text(r.directory);
|
|
||||||
$('#backup-encpassword-file').text(r.encpwfile);
|
|
||||||
|
|
||||||
$('#backup-status tbody').html("");
|
$('#backup-status tbody').html("");
|
||||||
var total_disk_size = 0;
|
var total_disk_size = 0;
|
||||||
|
|
||||||
if (r.backups.length == 0) {
|
if (typeof r.backups == "undefined") {
|
||||||
|
var tr = $('<tr><td colspan="3">Backups are turned off.</td></tr>');
|
||||||
|
$('#backup-status tbody').append(tr);
|
||||||
|
return;
|
||||||
|
} else if (r.backups.length == 0) {
|
||||||
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
}
|
}
|
||||||
@ -83,4 +152,62 @@ function show_system_backup() {
|
|||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function show_custom_backup() {
|
||||||
|
$(".backup-target-local, .backup-target-s3").hide();
|
||||||
|
api(
|
||||||
|
"/system/backup/config",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
if (r.target == "file://" + r.file_target_directory) {
|
||||||
|
$("#backup-target-type").val("local");
|
||||||
|
} else if (r.target == "off") {
|
||||||
|
$("#backup-target-type").val("off");
|
||||||
|
} else if (r.target.substring(0, 5) == "s3://") {
|
||||||
|
$("#backup-target-type").val("s3");
|
||||||
|
var hostpath = r.target.substring(5).split('/');
|
||||||
|
var host = hostpath.shift();
|
||||||
|
$("#backup-target-s3-host").val(host);
|
||||||
|
$("#backup-target-s3-path").val(hostpath.join('/'));
|
||||||
|
}
|
||||||
|
$("#backup-target-user").val(r.target_user);
|
||||||
|
$("#backup-target-pass").val(r.target_pass);
|
||||||
|
$("#min-age").val(r.min_age_in_days);
|
||||||
|
$('#backup-location').text(r.file_target_directory);
|
||||||
|
$('#backup-encpassword-file').text(r.enc_pw_file);
|
||||||
|
toggle_form()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_custom_backup() {
|
||||||
|
var target_type = $("#backup-target-type").val();
|
||||||
|
var target_user = $("#backup-target-user").val();
|
||||||
|
var target_pass = $("#backup-target-pass").val();
|
||||||
|
|
||||||
|
var target;
|
||||||
|
if (target_type == "local" || target_type == "off")
|
||||||
|
target = target_type;
|
||||||
|
else if (target_type == "s3")
|
||||||
|
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
||||||
|
|
||||||
|
var min_age = $("#min-age").val();
|
||||||
|
api(
|
||||||
|
"/system/backup/config",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
target: target,
|
||||||
|
target_user: target_user,
|
||||||
|
target_pass: target_pass,
|
||||||
|
min_age: min_age
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
// Responses are multiple lines of pre-formatted text.
|
||||||
|
show_modal_error("Backup configuration", $("<pre/>").text(r), function() { show_system_backup(); }); // refresh after modal
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
show_modal_error("Backup configuration (error)", r);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -4,9 +4,13 @@ source setup/functions.sh
|
|||||||
|
|
||||||
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
|
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
|
||||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
|
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
|
||||||
build-essential libssl-dev libffi-dev python3-dev
|
build-essential libssl-dev libffi-dev python3-dev python-pip
|
||||||
hide_output pip3 install --upgrade rtyaml email_validator idna cryptography
|
hide_output pip3 install --upgrade rtyaml email_validator idna cryptography boto
|
||||||
# email_validator is repeated in setup/questions.sh
|
|
||||||
|
# duplicity uses python 2 so we need to use the python 2 package of boto
|
||||||
|
hide_output pip install --upgrade boto
|
||||||
|
|
||||||
|
# email_validator is repeated in setup/questions.sh
|
||||||
|
|
||||||
# Create a backup directory and a random key for encrypting backups.
|
# Create a backup directory and a random key for encrypting backups.
|
||||||
mkdir -p $STORAGE_ROOT/backup
|
mkdir -p $STORAGE_ROOT/backup
|
||||||
|
Loading…
Reference in New Issue
Block a user