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):