Backups: allow testing the connection of ssh locations

Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Michael Pimmer 2018-11-26 03:19:54 +00:00 committed by James Valleroy
parent b6d1237c0c
commit 4eeceaa695
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
8 changed files with 173 additions and 30 deletions

View File

@ -27,9 +27,10 @@ import subprocess
import sys import sys
import tarfile import tarfile
from plinth.errors import ActionError
from plinth.modules.backups import MANIFESTS_FOLDER, REPOSITORY from plinth.modules.backups import MANIFESTS_FOLDER, REPOSITORY
TIMEOUT = 5
def parse_arguments(): def parse_arguments():
"""Return parsed command line arguments as dictionary.""" """Return parsed command line arguments as dictionary."""
@ -38,7 +39,8 @@ def parse_arguments():
subparsers.add_parser( subparsers.add_parser(
'setup', help='Create repository if it does not already exist') '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') subparsers.add_parser('list', help='List repository contents')
create = subparsers.add_parser('create', help='Create archive') create = subparsers.add_parser('create', help='Create archive')
@ -94,9 +96,9 @@ def subcommand_setup(_):
subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY]) subprocess.run(['borg', 'init', '--encryption', 'none', REPOSITORY])
def subcommand_info(_): def subcommand_info(arguments):
"""Show repository information.""" """Show repository information."""
subprocess.run(['borg', 'info', '--json', REPOSITORY], check=True) run(['borg', 'info', '--json', arguments.repository])
def subcommand_list(_): def subcommand_list(_):
@ -189,7 +191,7 @@ def _get_apps_of_manifest(manifest):
elif type(manifest) is dict and 'apps' in manifest: elif type(manifest) is dict and 'apps' in manifest:
apps = manifest['apps'] apps = manifest['apps']
else: else:
raise ActionError('Unknown manifest format') raise RuntimeError('Unknown manifest format')
return apps return apps
@ -236,6 +238,29 @@ def subcommand_restore_exported_archive(arguments):
break 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(): def main():
"""Parse arguments and perform all duties.""" """Parse arguments and perform all duties."""
arguments = parse_arguments() arguments = parse_arguments()

View File

@ -59,8 +59,12 @@ def setup(helper, old_version=None):
helper.call('post', actions.superuser_run, 'backups', ['setup']) helper.call('post', actions.superuser_run, 'backups', ['setup'])
def get_info(): def get_info(repository, password=None):
output = actions.superuser_run('backups', ['info']) 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) return json.loads(output)

View File

@ -76,38 +76,52 @@ class RestoreForm(forms.Form):
class UploadForm(forms.Form): class UploadForm(forms.Form):
file = forms.FileField(label=_('Upload File'), required=True, file = forms.FileField(
validators=[FileExtensionValidator(['gz'], label=_('Upload File'),
'Backup files have to be in .tar.gz format')], required=True,
help_text=_('Select the backup file you want to upload')) 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): class CreateRepositoryForm(forms.Form):
repository = forms.CharField( repository = forms.CharField(
label=_('Repository path'), strip=True, label=_('SSH Repository Path'), strip=True,
help_text=_('Path of the new repository.')) help_text=_('Path of the new repository. Example: '
'<i>user@host/path/to/repo/</i>'))
ssh_password = forms.CharField(
label=_('SSH password'), strip=True,
help_text=_('Password of the SSH Server.<br />'
'If you have set up <a href="https://www.ssh.com/ssh/key/"'
'target="_blank">SSH key-based authentication</a> you can '
'omit the password.'),
required=False)
encryption = forms.ChoiceField( encryption = forms.ChoiceField(
label=_('Encryption'), label=_('Encryption'),
help_text=format_lazy(_('"Key in Repository" means that a ' help_text=format_lazy(
'password-protected key is stored with the backup. <br />' _('"Key in Repository" means that a '
'<b>You need this password to restore a backup!</b>')), 'password-protected key is stored with the backup. <br />'
'<b>You need this password to restore a backup!</b>')),
choices=[('repokey', 'Key in Repository'), ('none', 'None')] choices=[('repokey', 'Key in Repository'), ('none', 'None')]
) )
passphrase = forms.CharField( passphrase = forms.CharField(
label=_('Passphrase'), label=_('Passphrase'),
help_text=_('Passphrase; Only needed when using encryption.'), help_text=_('Passphrase; Only needed when using encryption.'),
widget=forms.PasswordInput() widget=forms.PasswordInput(),
required=False
) )
confirm_passphrase = forms.CharField( confirm_passphrase = forms.CharField(
label=_('Confirm Passphrase'), label=_('Confirm Passphrase'),
help_text=_('Repeat the passphrase.'), help_text=_('Repeat the passphrase.'),
widget=forms.PasswordInput() widget=forms.PasswordInput(),
required=False
) )
store_passphrase = forms.BooleanField( store_passphrase = forms.BooleanField(
label=_('Store passphrase on FreedomBox'), label=_('Store passphrase on FreedomBox'),
help_text=format_lazy(_('Store the passphrase on your {box_name}.' help_text=format_lazy(
'<br />You need to store the passphrase if you want to run ' _('Store the passphrase on your {box_name}.'
'recurrent backups.'), box_name=_(cfg.box_name)), '<br />You need to store the passphrase if you want to run '
'recurrent backups.'), box_name=_(cfg.box_name)),
required=False required=False
) )

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<form class="form" method="post" target="_blank">
{% csrf_token %}
{{ form|bootstrap }}
<input type="submit" class="btn btn-primary"
value="{% trans "Submit" %}"/>
<input type="submit" class="btn btn-secondary" value="Test Connection"
title="{% trans 'Test Connection to Repository' %}"
formaction="{% url 'backups:test-repository' %}" />
</form>
{% endblock %}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
{{ message }}
<div class="alert alert-warning" role="alert">
{{ error }}
</div>
{% endblock %}

View File

@ -22,7 +22,8 @@ from django.conf.urls import url
from .views import IndexView, CreateArchiveView, CreateRepositoryView, \ from .views import IndexView, CreateArchiveView, CreateRepositoryView, \
DeleteArchiveView, UploadArchiveView, ExportAndDownloadView, \ DeleteArchiveView, UploadArchiveView, ExportAndDownloadView, \
RepositoriesView, RestoreArchiveView, RestoreFromUploadView RepositoriesView, RestoreArchiveView, RestoreFromUploadView, \
TestRepositoryView
urlpatterns = [ urlpatterns = [
url(r'^sys/backups/$', IndexView.as_view(), name='index'), url(r'^sys/backups/$', IndexView.as_view(), name='index'),
@ -40,4 +41,6 @@ urlpatterns = [
RepositoriesView.as_view(), name='repositories'), RepositoriesView.as_view(), name='repositories'),
url(r'^sys/backups/repositories/create/$', url(r'^sys/backups/repositories/create/$',
CreateRepositoryView.as_view(), name='create-repository'), CreateRepositoryView.as_view(), name='create-repository'),
url(r'^sys/backups/repositories/test/$',
TestRepositoryView.as_view(), name='test-repository'),
] ]

View File

@ -40,10 +40,10 @@ from django.utils.translation import ugettext_lazy
from django.views.generic import View, FormView, TemplateView from django.views.generic import View, FormView, TemplateView
from plinth import actions, kvstore from plinth import actions, kvstore
from plinth.errors import PlinthError from plinth.errors import PlinthError, ActionError
from plinth.modules import backups, storage 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 from .decorators import delete_tmp_backup_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -73,7 +73,7 @@ class IndexView(TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['title'] = backups.name context['title'] = backups.name
context['description'] = backups.description context['description'] = backups.description
context['info'] = backups.get_info() context['info'] = backups.get_info(REPOSITORY)
context['archives'] = backups.list_archives() context['archives'] = backups.list_archives()
context['subsubmenu'] = subsubmenu context['subsubmenu'] = subsubmenu
apps = api.get_all_apps_for_backup() apps = api.get_all_apps_for_backup()
@ -282,12 +282,12 @@ class ExportAndDownloadView(View):
filename = "%s.tar.gz" % name filename = "%s.tar.gz" % name
args = ['export-tar', '--name', name] args = ['export-tar', '--name', name]
proc = actions.superuser_run('backups', args, run_in_background=True, proc = actions.superuser_run('backups', args, run_in_background=True,
bufsize=1) bufsize=1)
zipStream = ZipStream(proc.stdout, 'readline') zipStream = ZipStream(proc.stdout, 'readline')
response = StreamingHttpResponse(zipStream, response = StreamingHttpResponse(zipStream,
content_type="application/x-gzip") content_type="application/x-gzip")
response['Content-Disposition'] = 'attachment; filename="%s"' % \ response['Content-Disposition'] = 'attachment; filename="%s"' % \
filename filename
return response return response
@ -309,7 +309,7 @@ class CreateRepositoryView(SuccessMessageMixin, FormView):
"""View to create a new repository.""" """View to create a new repository."""
form_class = forms.CreateRepositoryForm form_class = forms.CreateRepositoryForm
prefix = 'backups' prefix = 'backups'
template_name = 'backups_form.html' template_name = 'backups_create_repository.html'
success_url = reverse_lazy('backups:repositories') success_url = reverse_lazy('backups:repositories')
success_message = _('Created new repository.') success_message = _('Created new repository.')
@ -335,3 +335,25 @@ class CreateRepositoryView(SuccessMessageMixin, FormView):
repositories.append(new_repo) repositories.append(new_repo)
kvstore.set('backups_repositories', json.dumps(repositories)) kvstore.set('backups_repositories', json.dumps(repositories))
return super().form_valid(form) 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)

View File

@ -146,7 +146,9 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
plinth.forms.LanguageSelectionFormMixin, forms.ModelForm): plinth.forms.LanguageSelectionFormMixin, forms.ModelForm):
"""When user info is changed, also updates LDAP user.""" """When user info is changed, also updates LDAP user."""
ssh_keys = forms.CharField( 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( help_text=ugettext_lazy(
'Setting an SSH public key will allow this user to ' 'Setting an SSH public key will allow this user to '
'securely log in to the system without using a ' 'securely log in to the system without using a '