diff --git a/plinth/modules/backups/forms.py b/plinth/modules/backups/forms.py
index f7ccea780..3ccb62956 100644
--- a/plinth/modules/backups/forms.py
+++ b/plinth/modules/backups/forms.py
@@ -251,15 +251,34 @@ class AddRemoteRepositoryForm(EncryptedBackupsMixin, forms.Form):
help_text=_('Path of a new or existing repository. Example: '
'user@host:~/path/to/repo/'),
validators=[repository_validator])
+ ssh_auth_type = forms.ChoiceField(
+ label=_('SSH Authentication Type'),
+ help_text=_('How to authenticate to the remote SSH server.
'
+ 'If Key-based Authentication is selected, then the '
+ "FreedomBox service's SSH client public key must be added"
+ " to the authorized keys list on the remote server.
"
+ 'If Password-based Authentication is selected, then '
+ 'FreedomBox will attempt to copy its SSH client public '
+ 'key to the remote server.'), widget=forms.RadioSelect(),
+ choices=[('key_auth', _('Key-based Authentication')),
+ ('password_auth', _('Password-based Authentication'))])
ssh_password = forms.CharField(
- label=_('SSH server password'), strip=True,
- help_text=_('Password of the SSH Server.
'
- 'Either provide a password, or add the FreedomBox '
- "service's SSH client public key (listed above) to the "
- 'authorized keys list on the remote machine.'),
- widget=forms.PasswordInput(), required=False)
+ label=_('SSH server password'), widget=forms.PasswordInput(),
+ strip=True, help_text=_(
+ 'Password of the SSH Server. Required only for Password-based '
+ 'Authentication.'), required=False)
- field_order = ['repository', 'ssh_password'] + encryption_fields
+ field_order = ['repository', 'ssh_auth_type', 'ssh_password'
+ ] + encryption_fields
+
+ def clean(self):
+ super().clean()
+ ssh_password = self.cleaned_data.get('ssh_password')
+ if self.cleaned_data.get(
+ 'ssh_auth_type') == 'password_auth' and not ssh_password:
+ raise forms.ValidationError(
+ _('SSH password is needed for password-based authentication.'))
+ return self.cleaned_data
def clean_repository(self):
"""Validate repository form field."""
diff --git a/plinth/modules/backups/repository.py b/plinth/modules/backups/repository.py
index 22dbeccf9..53dc0ee8c 100644
--- a/plinth/modules/backups/repository.py
+++ b/plinth/modules/backups/repository.py
@@ -335,6 +335,14 @@ class SshBorgRepository(BaseBorgRepository):
return None
+ @property
+ def ssh_keyfile(self) -> str | None:
+ """Return path to SSH client key if stored, otherwise None."""
+ if 'ssh_keyfile' in self.credentials:
+ return self.credentials['ssh_keyfile']
+
+ return None
+
@property
def _mountpoint(self):
"""Return the local mount point where repository is to be mounted."""
diff --git a/plinth/modules/backups/static/backups_add_remote_repository.js b/plinth/modules/backups/static/backups_add_remote_repository.js
new file mode 100644
index 000000000..3377259e3
--- /dev/null
+++ b/plinth/modules/backups/static/backups_add_remote_repository.js
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+/**
+ * @licstart The following is the entire license notice for the JavaScript
+ * code in this page.
+ *
+ * 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 .
+ *
+ * @licend The above is the entire license notice for the JavaScript code
+ * in this page.
+ */
+
+document.addEventListener('DOMContentLoaded', () => {
+ const keyAuth = document.getElementById('id_ssh_auth_type_0');
+ const passwordAuth = document.getElementById('id_ssh_auth_type_1');
+ const sshPasswordField = document.getElementById('id_ssh_password');
+ const encryptionType = document.getElementById('id_encryption');
+ const encryptionPassphraseField = document.getElementById('id_encryption_passphrase');
+ const encryptionConfirmPassphraseField = document.getElementById('id_confirm_encryption_passphrase');
+
+ function handleAuthTypeChange() {
+ if (keyAuth.checked) {
+ sshPasswordField.value = "";
+ sshPasswordField.disabled = true;
+ } else {
+ sshPasswordField.disabled = false;
+ }
+ }
+
+ function handleEncryptionTypeChange() {
+ if (encryptionType.value === "repokey") {
+ encryptionPassphraseField.disabled = false;
+ encryptionConfirmPassphraseField.disabled = false;
+ } else {
+ encryptionPassphraseField.value = "";
+ encryptionPassphraseField.disabled = true;
+ encryptionConfirmPassphraseField.value = "";
+ encryptionConfirmPassphraseField.disabled = true;
+ }
+ }
+
+ keyAuth.addEventListener('change', handleAuthTypeChange);
+ passwordAuth.addEventListener('change', handleAuthTypeChange);
+ encryptionType.addEventListener('change', handleEncryptionTypeChange);
+
+ handleAuthTypeChange();
+ handleEncryptionTypeChange();
+});
diff --git a/plinth/modules/backups/templates/backups_add_remote_repository.html b/plinth/modules/backups/templates/backups_add_remote_repository.html
index d996be073..939195d3c 100644
--- a/plinth/modules/backups/templates/backups_add_remote_repository.html
+++ b/plinth/modules/backups/templates/backups_add_remote_repository.html
@@ -5,6 +5,12 @@
{% load bootstrap %}
{% load i18n %}
+{% load static %}
+
+{% block page_js %}
+
+{% endblock %}
{% block content %}
diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py
index dd24ec65b..52bfe4255 100644
--- a/plinth/modules/backups/views.py
+++ b/plinth/modules/backups/views.py
@@ -385,10 +385,13 @@ class AddRemoteRepositoryView(FormView):
if form.cleaned_data.get('encryption') == 'none':
encryption_passphrase = None
- credentials = {
- 'ssh_password': form.cleaned_data.get('ssh_password'),
- 'encryption_passphrase': encryption_passphrase
- }
+ credentials = {'encryption_passphrase': encryption_passphrase}
+ if form.cleaned_data.get('ssh_auth_type') == 'password_auth':
+ credentials['ssh_password'] = form.cleaned_data.get('ssh_password')
+ else:
+ _pubkey_path, key_path = get_ssh_client_auth_key_paths()
+ credentials['ssh_keyfile'] = str(key_path)
+
with handle_common_errors(self.request):
repository = SshBorgRepository(path, credentials)
repository.verfied = False
@@ -437,42 +440,70 @@ class VerifySshHostkeyView(FormView):
with known_hosts_path.open('a', encoding='utf-8') as known_hosts_file:
known_hosts_file.write(ssh_public_key + '\n')
+ def _check_save_repository(self):
+ """Save the repository and redirect according to the result."""
+ if _save_repository(self.request, self._get_repository()):
+ return redirect(reverse_lazy('backups:index'))
+
+ return redirect(reverse_lazy('backups:add-remote-repository'))
+
def _check_copy_ssh_client_public_key(self):
- """ Try to copy FreedomBox's SSH client public key to the host."""
+ """Try to copy FreedomBox's SSH client public key to the host."""
repo = self._get_repository()
- result, message = copy_ssh_client_public_key(repo.hostname,
- repo.username,
- repo.ssh_password)
- if result:
- logger.info(
- "Copied SSH client public key to remote host's authorized "
- "keys.")
- _pubkey_path, key_path = get_ssh_client_auth_key_paths()
- repo.replace_ssh_password_with_keyfile(str(key_path))
- if _save_repository(self.request, repo):
- return redirect(reverse_lazy('backups:index'))
- else:
+ ssh_password = repo.ssh_password
+ if ssh_password:
+ result, message = copy_ssh_client_public_key(
+ repo.hostname, repo.username, repo.ssh_password)
+ if result:
+ logger.info("Copied SSH client public key to remote host's "
+ "authorized keys.")
+ _pubkey_path, key_path = get_ssh_client_auth_key_paths()
+ repo.replace_ssh_password_with_keyfile(str(key_path))
+ return self._check_save_repository()
+
logger.warning('Failed to copy SSH client public key: %s', message)
messages.error(
self.request,
_('Failed to copy SSH client public key: %s') % message)
- # Remove the repository so that the user can have another go at
- # creating it.
- try:
- repo.remove()
- messages.error(self.request, _('Repository removed.'))
- except KeyError:
- pass
+
+ else:
+ logger.error(
+ 'SSH password is required to copy SSH client public key.')
+ messages.error(
+ self.request,
+ _('SSH password is required to copy SSH public key.'))
+
+ # Remove the repository so that the user can have another go at
+ # creating it.
+ try:
+ repo.remove()
+ messages.error(self.request, _('Repository removed.'))
+ except KeyError:
+ pass
return redirect(reverse_lazy('backups:add-remote-repository'))
+ def _check_next_step(self):
+ """Check whether we need to copy the SSH client public key.
+
+ Otherwise, save the repository and redirect.
+ """
+ if self._get_repository().ssh_keyfile:
+ # SSH keyfile credential is stored. Assume it is already copied to
+ # the remote host. Check the connection.
+ logger.info('Check connection using SSH keyfile...')
+ return self._check_save_repository()
+
+ logger.info('Copy SSH client public key to remote host...')
+ return self._check_copy_ssh_client_public_key()
+
def get(self, *args, **kwargs):
"""Skip this view if host is already verified."""
if not is_ssh_hostkey_verified(self._get_repository().hostname):
return super().get(*args, **kwargs)
messages.success(self.request, _('SSH host already verified.'))
- return self._check_copy_ssh_client_public_key()
+ return self._check_next_step()
def form_valid(self, form):
"""Create and store the repository."""
@@ -480,7 +511,8 @@ class VerifySshHostkeyView(FormView):
with handle_common_errors(self.request):
self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.'))
- return self._check_copy_ssh_client_public_key()
+
+ return self._check_next_step()
def _save_repository(request, repository):