mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +00:00
Backups: allow testing the connection of ssh locations
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
b6d1237c0c
commit
4eeceaa695
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 '
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user