diff --git a/actions/backups b/actions/backups index cb539e7c0..a4d612948 100755 --- a/actions/backups +++ b/actions/backups @@ -27,9 +27,10 @@ import subprocess import sys import tarfile -from plinth.errors import ActionError from plinth.modules.backups import MANIFESTS_FOLDER, REPOSITORY +TIMEOUT = 5 + def parse_arguments(): """Return parsed command line arguments as dictionary.""" @@ -38,7 +39,8 @@ def parse_arguments(): subparsers.add_parser( 'setup', help='Create repository if it does not already exist') - subparsers.add_parser('info', help='Show repository information') + info = subparsers.add_parser('info', help='Show repository information') + info.add_argument('--repository', help='Repository path', required=True) subparsers.add_parser('list', help='List repository contents') create = subparsers.add_parser('create', help='Create archive') @@ -94,9 +96,9 @@ def subcommand_setup(_): subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY]) -def subcommand_info(_): +def subcommand_info(arguments): """Show repository information.""" - subprocess.run(['borg', 'info', '--json', REPOSITORY], check=True) + run(['borg', 'info', '--json', arguments.repository]) def subcommand_list(_): @@ -189,7 +191,7 @@ def _get_apps_of_manifest(manifest): elif type(manifest) is dict and 'apps' in manifest: apps = manifest['apps'] else: - raise ActionError('Unknown manifest format') + raise RuntimeError('Unknown manifest format') return apps @@ -236,6 +238,29 @@ def subcommand_restore_exported_archive(arguments): break +def read_password(): + """Read the password from stdin.""" + if sys.stdin.isatty(): + return '' + else: + 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() + 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. + timeout = TIMEOUT + subprocess.run(cmd, check=True, env=env, timeout=timeout) + + def main(): """Parse arguments and perform all duties.""" arguments = parse_arguments() diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index 5f5095bc2..d7f3b0a7a 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -59,8 +59,12 @@ def setup(helper, old_version=None): helper.call('post', actions.superuser_run, 'backups', ['setup']) -def get_info(): - output = actions.superuser_run('backups', ['info']) +def get_info(repository, password=None): + args = ['backups', ['info', '--repository', repository]] + kwargs = {} + if password is not None: + kwargs['input'] = password.encode() + output = actions.superuser_run(*args, **kwargs) return json.loads(output) diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py index 6d61aa85e..0a42ff07b 100644 --- a/plinth/modules/backups/forms.py +++ b/plinth/modules/backups/forms.py @@ -76,38 +76,52 @@ class RestoreForm(forms.Form): class UploadForm(forms.Form): - file = forms.FileField(label=_('Upload File'), required=True, - validators=[FileExtensionValidator(['gz'], - 'Backup files have to be in .tar.gz format')], - help_text=_('Select the backup file you want to upload')) + file = forms.FileField( + label=_('Upload File'), + required=True, + validators=[FileExtensionValidator(['gz'], + 'Backup files have to be in .tar.gz format')], + help_text=_('Select the backup file you want to upload')) class CreateRepositoryForm(forms.Form): repository = forms.CharField( - label=_('Repository path'), strip=True, - help_text=_('Path of the new repository.')) + label=_('SSH Repository Path'), strip=True, + help_text=_('Path of the new repository. Example: ' + '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.'), + required=False) encryption = forms.ChoiceField( label=_('Encryption'), - help_text=format_lazy(_('"Key in Repository" means that a ' - 'password-protected key is stored with the backup.
' - 'You need this password to restore a backup!')), + help_text=format_lazy( + _('"Key in Repository" means that a ' + 'password-protected key is stored with the backup.
' + 'You need this password to restore a backup!')), choices=[('repokey', 'Key in Repository'), ('none', 'None')] ) passphrase = forms.CharField( label=_('Passphrase'), help_text=_('Passphrase; Only needed when using encryption.'), - widget=forms.PasswordInput() + widget=forms.PasswordInput(), + required=False ) confirm_passphrase = forms.CharField( label=_('Confirm Passphrase'), help_text=_('Repeat the passphrase.'), - widget=forms.PasswordInput() + widget=forms.PasswordInput(), + required=False ) store_passphrase = forms.BooleanField( label=_('Store passphrase on FreedomBox'), - help_text=format_lazy(_('Store the passphrase on your {box_name}.' - '
You need to store the passphrase if you want to run ' - 'recurrent backups.'), box_name=_(cfg.box_name)), + help_text=format_lazy( + _('Store the passphrase on your {box_name}.' + '
You need to store the passphrase if you want to run ' + 'recurrent backups.'), box_name=_(cfg.box_name)), required=False ) diff --git a/plinth/modules/backups/templates/backups_create_repository.html b/plinth/modules/backups/templates/backups_create_repository.html new file mode 100644 index 000000000..0b4be62eb --- /dev/null +++ b/plinth/modules/backups/templates/backups_create_repository.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% comment %} +# +# 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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + + +
+ +{% endblock %} diff --git a/plinth/modules/backups/templates/backups_test_repository.html b/plinth/modules/backups/templates/backups_test_repository.html new file mode 100644 index 000000000..da26fd960 --- /dev/null +++ b/plinth/modules/backups/templates/backups_test_repository.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% comment %} +# +# 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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} + +

{{ title }}

+ + {{ message }} + + +{% endblock %} diff --git a/plinth/modules/backups/urls.py b/plinth/modules/backups/urls.py index 7e0090c03..9168922cd 100644 --- a/plinth/modules/backups/urls.py +++ b/plinth/modules/backups/urls.py @@ -22,7 +22,8 @@ from django.conf.urls import url from .views import IndexView, CreateArchiveView, CreateRepositoryView, \ DeleteArchiveView, UploadArchiveView, ExportAndDownloadView, \ - RepositoriesView, RestoreArchiveView, RestoreFromUploadView + RepositoriesView, RestoreArchiveView, RestoreFromUploadView, \ + TestRepositoryView urlpatterns = [ url(r'^sys/backups/$', IndexView.as_view(), name='index'), @@ -40,4 +41,6 @@ urlpatterns = [ RepositoriesView.as_view(), name='repositories'), url(r'^sys/backups/repositories/create/$', CreateRepositoryView.as_view(), name='create-repository'), + url(r'^sys/backups/repositories/test/$', + TestRepositoryView.as_view(), name='test-repository'), ] diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index 4ecfa2ff5..9a51efc49 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -40,10 +40,10 @@ 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 +from plinth.errors import PlinthError, ActionError from plinth.modules import backups, storage -from . import api, forms, SESSION_PATH_VARIABLE +from . import api, forms, SESSION_PATH_VARIABLE, REPOSITORY from .decorators import delete_tmp_backup_file logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ class IndexView(TemplateView): context = super().get_context_data(**kwargs) context['title'] = backups.name context['description'] = backups.description - context['info'] = backups.get_info() + context['info'] = backups.get_info(REPOSITORY) context['archives'] = backups.list_archives() context['subsubmenu'] = subsubmenu apps = api.get_all_apps_for_backup() @@ -282,12 +282,12 @@ class ExportAndDownloadView(View): filename = "%s.tar.gz" % name args = ['export-tar', '--name', name] proc = actions.superuser_run('backups', args, run_in_background=True, - bufsize=1) + bufsize=1) zipStream = ZipStream(proc.stdout, 'readline') response = StreamingHttpResponse(zipStream, - content_type="application/x-gzip") + content_type="application/x-gzip") response['Content-Disposition'] = 'attachment; filename="%s"' % \ - filename + filename return response @@ -309,7 +309,7 @@ class CreateRepositoryView(SuccessMessageMixin, FormView): """View to create a new repository.""" form_class = forms.CreateRepositoryForm prefix = 'backups' - template_name = 'backups_form.html' + template_name = 'backups_create_repository.html' success_url = reverse_lazy('backups:repositories') success_message = _('Created new repository.') @@ -335,3 +335,25 @@ class CreateRepositoryView(SuccessMessageMixin, FormView): repositories.append(new_repo) kvstore.set('backups_repositories', json.dumps(repositories)) return super().form_valid(form) + + +class TestRepositoryView(TemplateView): + """View to create a new repository.""" + template_name = 'backups_test_repository.html' + + def post(self, request): + 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 + + return self.render_to_response(context) diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index f3bfef69b..16e9807de 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -146,7 +146,9 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, plinth.forms.LanguageSelectionFormMixin, forms.ModelForm): """When user info is changed, also updates LDAP user.""" ssh_keys = forms.CharField( - label=ugettext_lazy('SSH Keys'), required=False, widget=forms.Textarea, + label=ugettext_lazy('Authorized SSH Keys'), + required=False, + widget=forms.Textarea, help_text=ugettext_lazy( 'Setting an SSH public key will allow this user to ' 'securely log in to the system without using a '