From 0e2489ec23c75896fc3d9b5e6a57935ea8fae71d Mon Sep 17 00:00:00 2001 From: Michael Pimmer Date: Tue, 27 Nov 2018 03:03:58 +0000 Subject: [PATCH] Backups, remote repositories: implement init, info and some test - added functionality to use remote repositories - added some tests Reviewed-by: James Valleroy --- actions/backups | 92 ++++++++++++++++++++---------- plinth/modules/backups/__init__.py | 76 ++++++++++++++++++++++-- plinth/modules/backups/errors.py | 28 +++++++++ plinth/modules/backups/forms.py | 29 +++++----- plinth/modules/backups/views.py | 46 +++++++++------ plinth/tests/test_backups.py | 83 +++++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 65 deletions(-) create mode 100644 plinth/modules/backups/errors.py create mode 100644 plinth/tests/test_backups.py diff --git a/actions/backups b/actions/backups index a4d612948..4ea00ab31 100755 --- a/actions/backups +++ b/actions/backups @@ -17,7 +17,7 @@ # along with this program. If not, see . # """ -Configuration helper for backups. +Wrapper to handle backups using borg-backups. """ import argparse @@ -41,6 +41,10 @@ def parse_arguments(): 'setup', help='Create repository if it does not already exist') info = subparsers.add_parser('info', help='Show repository information') info.add_argument('--repository', help='Repository path', required=True) + init = subparsers.add_parser('init', help='Initialize a repository') + init.add_argument('--repository', help='Repository path', required=True) + init.add_argument('--encryption', help='Enryption of the repository', + required=True) subparsers.add_parser('list', help='List repository contents') create = subparsers.add_parser('create', help='Create archive') @@ -51,10 +55,17 @@ def parse_arguments(): delete = subparsers.add_parser('delete', help='Delete archive') delete.add_argument('--name', help='Archive name', required=True) - export_help='Export archive contents as tar on stdout' + export_help = 'Export archive contents as tar on stdout' export_tar = subparsers.add_parser('export-tar', help=export_help) export_tar.add_argument('--name', help='Archive name)', - required=True) + required=True) + # TODO: add parameters to missing commands (list, create, delete, export) + for cmd in [info, init]: + cmd.add_argument('--ssh-keyfile', help='Path of private ssh key', + default=None) + cmd.add_argument('--encryption-passphrase', + help='Encryption passphrase', + default=None) get_exported_archive_apps = subparsers.add_parser( 'get-exported-archive-apps', @@ -71,14 +82,14 @@ def parse_arguments(): restore_exported_archive = subparsers.add_parser( 'restore-exported-archive', help='Restore files from an exported archive') - restore_exported_archive.add_argument('--path', - help='Tarball file path', required=True) + restore_exported_archive.add_argument('--path', help='Tarball file path', + required=True) restore_archive = subparsers.add_parser( 'restore-archive', help='Restore files from an archive') restore_archive.add_argument('--path', help='Archive path', required=True) restore_archive.add_argument('--destination', help='Destination', - required=True) + required=True) subparsers.required = True return parser.parse_args() @@ -93,34 +104,65 @@ def subcommand_setup(_): if not os.path.exists(path): os.makedirs(path) - subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY]) + run(['borg', 'init', '--encryption', 'none', REPOSITORY]) + + +def init(repository, encryption, env=None): + """Initialize a local or remote borg repository""" + # TODO: verify that the repository does not exist? + # TODO: does remote borg also create folders if they don't exist? + if encryption != 'none' and 'BORG_PASSPHRASE' not in env: + raise ValueError('No encryption passphrase provided') + cmd = ['borg', 'init', '--encryption', encryption, repository] + run(cmd, env=env) + + +def get_env(arguments): + """Create encryption and ssh kwargs out of given arguments""" + env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes') + if arguments.encryption_passphrase: + env['BORG_PASSPHRASE'] = arguments.encryption_passphrase + if arguments.ssh_keyfile: + env['BORG_RSH'] = "ssh -i %s" % arguments.ssh_keyfile + else: + password = read_password() + if password: + env['SSHPASS'] = password + env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=no' + return env + + +def subcommand_init(arguments): + env = get_env(arguments) + init(arguments.repository, arguments.encryption, env=env) def subcommand_info(arguments): """Show repository information.""" - run(['borg', 'info', '--json', arguments.repository]) + env = get_env(arguments) + cmd = ['borg', 'info', '--json', arguments.repository] + run(cmd, env=env) def subcommand_list(_): """List repository contents.""" - subprocess.run(['borg', 'list', '--json', REPOSITORY], check=True) + run(['borg', 'list', '--json', REPOSITORY]) def subcommand_create(arguments): """Create archive.""" paths = filter(os.path.exists, arguments.paths) - subprocess.run([ + run([ 'borg', 'create', '--json', REPOSITORY + '::' + arguments.name, - ] + list(paths), check=True) + ] + list(paths)) def subcommand_delete(arguments): """Delete archive.""" - subprocess.run(['borg', 'delete', REPOSITORY + '::' + arguments.name], - check=True) + run(['borg', 'delete', REPOSITORY + '::' + arguments.name]) def _extract(archive_path, destination, locations=None): @@ -150,9 +192,7 @@ def _extract(archive_path, destination, locations=None): def subcommand_export_tar(arguments): """Export archive contents as tar stream on stdout.""" - subprocess.run([ - 'borg', 'export-tar', REPOSITORY + '::' + arguments.name, '-'], - check=True) + run(['borg', 'export-tar', REPOSITORY + '::' + arguments.name, '-']) def _read_archive_file(archive, filepath): @@ -165,7 +205,7 @@ def subcommand_get_archive_apps(arguments): """Get list of apps included in archive.""" manifest_folder = os.path.relpath(MANIFESTS_FOLDER, '/') borg_call = ['borg', 'list', arguments.path, manifest_folder, - '--format', '{path}{NEWLINE}'] + '--format', '{path}{NEWLINE}'] try: manifest_path = subprocess.check_output(borg_call).decode().strip() except subprocess.CalledProcessError: @@ -173,8 +213,7 @@ def subcommand_get_archive_apps(arguments): manifest = None if manifest_path: - manifest_data = _read_archive_file(arguments.path, - manifest_path) + manifest_data = _read_archive_file(arguments.path, manifest_path) manifest = json.loads(manifest_data) if manifest: for app in _get_apps_of_manifest(manifest): @@ -246,17 +285,12 @@ def read_password(): return ''.join(sys.stdin) -def run(cmd): - """Pass provided passwords on to borg""" - env = dict(os.environ, BORG_RELOCATED_REPO_ACCESS_IS_OK='yes') - password = read_password() +def run(cmd, env=None): + """Wrap the command with ssh password or keyfile authentication""" + # If the remote server asks for a password but no password is + # provided, we get stuck at asking the password. timeout = None - if password: - env['SSHPASS'] = password - env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=no' - else: - # When no password is given ssh might ask for a password and get stuck. - # Use timeout to abort early. + if 'BORG_RSH' in env and 'SSHPASS' not in env: timeout = TIMEOUT subprocess.run(cmd, check=True, env=env, timeout=timeout) diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index d7f3b0a7a..182752d1d 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -26,6 +26,8 @@ from django.utils.translation import ugettext_lazy as _ from plinth import actions from plinth.menu import main_menu +from plinth.errors import ActionError +from .errors import BorgError, BorgRepositoryDoesNotExistError from . import api @@ -45,6 +47,23 @@ MANIFESTS_FOLDER = '/var/lib/plinth/backups-manifests/' REPOSITORY = '/var/lib/freedombox/borgbackup' # session variable name that stores when a backup file should be deleted SESSION_PATH_VARIABLE = 'fbx-backups-upload-path' +# known errors that come up when remotely accessing a borg repository +# 'errors' are error strings to look for in the stacktrace. +KNOWN_ERRORS = [{ + "errors": ["subprocess.TimeoutExpired"], + "message": _("Server not reachable - try providing a password."), + "raise_as": BorgError, + }, + { + "errors": ["Connection refused"], + "message": _("Connection refused"), + "raise_as": BorgError, + }, + { + "errors": ["not a valid repository", "does not exist"], + "message": _("Connection works - Repository does not exist"), + "raise_as": BorgRepositoryDoesNotExistError, + }] def init(): @@ -59,12 +78,17 @@ def setup(helper, old_version=None): helper.call('post', actions.superuser_run, 'backups', ['setup']) -def get_info(repository, password=None): - args = ['backups', ['info', '--repository', repository]] +def get_info(repository, encryption_passphrase=None, ssh_password=None, + ssh_keyfile=None): + args = ['info', '--repository', repository] kwargs = {} - if password is not None: - kwargs['input'] = password.encode() - output = actions.superuser_run(*args, **kwargs) + if ssh_password is not None: + kwargs['input'] = ssh_password.encode() + if ssh_keyfile is not None: + args += ['--ssh-keyfile', ssh_keyfile] + if encryption_passphrase is not None: + args += ['--encryption-passphrase', encryption_passphrase] + output = actions.superuser_run('backups', args, **kwargs) return json.loads(output) @@ -81,6 +105,30 @@ def get_archive(name): return None +def test_connection(repository, encryption_passphrase=None, ssh_password=None, + ssh_keyfile=None): + """ + Test connecting to a local or remote borg repository. + Tries to detect (and throw) some known ssh or borg errors. + Returns 'borg info' information otherwise. + """ + try: + # TODO: instead of passing encryption_passphrase, ssh_password and + # ssh_keyfile around all the time, try using an 'options' dict. + message = get_info(repository, + encryption_passphrase=encryption_passphrase, + ssh_password=ssh_password, ssh_keyfile=ssh_keyfile) + return message + except ActionError as err: + caught_error = str(err) + for known_error in KNOWN_ERRORS: + for error in known_error["errors"]: + if error in caught_error: + raise known_error["raise_as"](known_error["message"]) + else: + raise + + def _backup_handler(packet): """Performs backup operation on packet.""" if not os.path.exists(MANIFESTS_FOLDER): @@ -108,6 +156,24 @@ def create_archive(name, app_names): api.backup_apps(_backup_handler, app_names, name) +def create_repository(repository, encryption, encryption_passphrase=None, + ssh_keyfile=None, ssh_password=None): + cmd = ['init', '--repository', repository, '--encryption', encryption] + if ssh_keyfile: + cmd += ['--ssh-keyfile', ssh_keyfile] + if encryption_passphrase: + cmd += ['--encryption-passphrase', encryption_passphrase] + + kwargs = {} + if ssh_password: + kwargs['input'] = ssh_password.encode() + + output = actions.superuser_run('backups', cmd, **kwargs) + if output: + output = json.loads(output) + return output + + def delete_archive(name): actions.superuser_run('backups', ['delete', '--name', name]) diff --git a/plinth/modules/backups/errors.py b/plinth/modules/backups/errors.py new file mode 100644 index 000000000..6635e4c8e --- /dev/null +++ b/plinth/modules/backups/errors.py @@ -0,0 +1,28 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +from plinth.errors import PlinthError + + +class BorgError(PlinthError): + """Generic class for borg errors that """ + pass + + +class BorgRepositoryDoesNotExistError(BorgError): + """Borg access to a repository works but the repository does not exist""" + pass diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py index 0a42ff07b..0da605950 100644 --- a/plinth/modules/backups/forms.py +++ b/plinth/modules/backups/forms.py @@ -88,13 +88,12 @@ class CreateRepositoryForm(forms.Form): repository = forms.CharField( label=_('SSH Repository Path'), strip=True, help_text=_('Path of the new repository. Example: ' - 'user@host/path/to/repo/')) + 'user@host:~/path/to/repo/')) ssh_password = forms.CharField( - label=_('SSH password'), strip=True, - help_text=_('Password of the SSH Server.
' - 'If you have set up SSH key-based authentication you can ' - 'omit the password.'), + label=_('SSH server password'), strip=True, + help_text=_('Password of the SSH Server.
' + 'SSH key-based authentication is not yet possible.'), + widget=forms.PasswordInput(), required=False) encryption = forms.ChoiceField( label=_('Encryption'), @@ -104,33 +103,33 @@ class CreateRepositoryForm(forms.Form): 'You need this password to restore a backup!')), choices=[('repokey', 'Key in Repository'), ('none', 'None')] ) - passphrase = forms.CharField( + encryption_passphrase = forms.CharField( label=_('Passphrase'), help_text=_('Passphrase; Only needed when using encryption.'), widget=forms.PasswordInput(), required=False ) - confirm_passphrase = forms.CharField( + confirm_encryption_passphrase = forms.CharField( label=_('Confirm Passphrase'), help_text=_('Repeat the passphrase.'), widget=forms.PasswordInput(), required=False ) - store_passphrase = forms.BooleanField( - label=_('Store passphrase on FreedomBox'), + store_passwords = forms.BooleanField( + label=_('Store passwords on FreedomBox'), help_text=format_lazy( - _('Store the passphrase on your {box_name}.' - '
You need to store the passphrase if you want to run ' + _('Store the passwords on your {box_name}.' + '
You need to store passwords if you want to run ' 'recurrent backups.'), box_name=_(cfg.box_name)), required=False ) def clean(self): cleaned_data = super(CreateRepositoryForm, self).clean() - passphrase = cleaned_data.get("passphrase") - confirm_passphrase = cleaned_data.get("confirm_passphrase") + passphrase = cleaned_data.get("encryption_passphrase") + confirm_passphrase = cleaned_data.get("confirm_encryption_passphrase") if passphrase != confirm_passphrase: raise forms.ValidationError( - "passphrase and confirm_passphrase do not match" + "The entered encryption passphrases do not match" ) diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index 9a51efc49..53c835e4d 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -40,11 +40,12 @@ from django.utils.translation import ugettext_lazy from django.views.generic import View, FormView, TemplateView from plinth import actions, kvstore -from plinth.errors import PlinthError, ActionError +from plinth.errors import PlinthError from plinth.modules import backups, storage from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY from .decorators import delete_tmp_backup_file +from .errors import BorgRepositoryDoesNotExistError logger = logging.getLogger(__name__) @@ -316,22 +317,41 @@ class CreateRepositoryView(SuccessMessageMixin, FormView): def get_context_data(self, **kwargs): """Return additional context for rendering the template.""" context = super().get_context_data(**kwargs) - context['title'] = _('Create new repository') + context['title'] = _('Create remote backup repository') context['subsubmenu'] = subsubmenu return context def form_valid(self, form): """Restore files from the archive on valid form submission.""" + repository = form.cleaned_data['repository'] + encryption = form.cleaned_data['encryption'] + encryption_passphrase = form.cleaned_data['encryption_passphrase'] + ssh_password = form.cleaned_data['ssh_password'] + ssh_keyfile = form.cleaned_data['ssh_keyfile'] + + # TODO: create borg repository if it doesn't exist + import pdb; pdb.set_trace() + try: + backups.test_connection(repository, ssh_password) + except BorgRepositoryDoesNotExistError: + kwargs = {} + if encryption_passphrase: + kwargs['encryption_passphrase'] = encryption_passphrase + if ssh_keyfile: + kwargs['ssh_keyfile'] = ssh_keyfile + backups.create_repository(repository, encryption, **kwargs) + repositories = kvstore.get_default('backups_repositories', []) if repositories: repositories = json.loads(repositories) new_repo = { 'uuid': str(uuid1()), - 'repository': form.cleaned_data['repository'], - 'encryption': form.cleaned_data['encryption'], + 'repository': repository, + 'encryption': encryption, } - if form.cleaned_data['store_passphrase']: - new_repo['passphrase'] = form.cleaned_data['passphrase'] + if form.cleaned_data['store_passwords']: + new_repo['encryption_passphrase'] = \ + form.cleaned_data['encryption_passphrase'] repositories.append(new_repo) kvstore.set('backups_repositories', json.dumps(repositories)) return super().form_valid(form) @@ -342,18 +362,12 @@ class TestRepositoryView(TemplateView): template_name = 'backups_test_repository.html' def post(self, request): + # TODO: add support for borg encryption context = self.get_context_data() repository = request.POST['backups-repository'] ssh_password = request.POST['backups-ssh_password'] - try: - info = backups.get_info(repository, password=ssh_password) - except ActionError as err: - if "subprocess.TimeoutExpired" in str(err): - msg = _("Server not reachable - try providing a password.") - context["error"] = msg - else: - context["error"] = str(err) - else: - context["message"] = info + (error, message) = backups.test_connection(repository, ssh_password) + context["message"] = message + context["error"] = error return self.render_to_response(context) diff --git a/plinth/tests/test_backups.py b/plinth/tests/test_backups.py new file mode 100644 index 000000000..b2b18ff50 --- /dev/null +++ b/plinth/tests/test_backups.py @@ -0,0 +1,83 @@ +# +# This file is part of FreedomBox. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Test the backups action script. +""" + +import os +import shutil +import tempfile +import unittest + +from plinth import cfg +from plinth.modules import backups + +euid = os.geteuid() + + +class TestBackups(unittest.TestCase): + """Test creating, reading and deleting a repository""" + + @classmethod + def setUpClass(cls): + """Initial setup for all the classes.""" + cls.action_directory = tempfile.TemporaryDirectory() + cls.backup_directory = tempfile.TemporaryDirectory() + cfg.actions_dir = cls.action_directory.name + actions_dir = os.path.join(os.path.dirname(__file__), '..', '..', + 'actions') + shutil.copy(os.path.join(actions_dir, 'backups'), cfg.actions_dir) + + @classmethod + def tearDownClass(cls): + """Cleanup after all the tests are completed.""" + cls.action_directory.cleanup() + cls.backup_directory.cleanup() + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_nonexisting_repository(self): + nonexisting_dir = os.path.join(self.backup_directory.name, + 'does_not_exist') + with self.assertRaises(backups.errors.BorgRepositoryDoesNotExistError): + backups.test_connection(nonexisting_dir) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_empty_dir(self): + empty_dir = os.path.join(self.backup_directory.name, 'empty_dir') + os.mkdir(empty_dir) + with self.assertRaises(backups.errors.BorgRepositoryDoesNotExistError): + backups.test_connection(empty_dir) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_create_unencrypted_repository(self): + repo_path = os.path.join(self.backup_directory.name, 'borgbackup') + backups.create_repository(repo_path, 'none') + info = backups.get_info(repo_path) + assert 'encryption' in info + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_create_encrypted_repository(self): + repo_path = os.path.join(self.backup_directory.name, + 'borgbackup_encrypted') + passphrase = '12345' + # create_repository is supposed to create the folder automatically + # if it does not exist + backups.create_repository(repo_path, 'repokey', + encryption_passphrase=passphrase) + assert backups.get_info(repo_path, encryption_passphrase=passphrase) + assert backups.test_connection(repo_path, + encryption_passphrase=passphrase)