backups: Use selected SSH credential for remote

- Use javascript to disable or enable password fields.

- If SSH key auth is selected, then try the connection.

- If SSH password auth is selected, then copy the key.

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2025-12-22 19:57:33 -05:00 committed by Sunil Mohan Adapa
parent 3558a26b2f
commit 043bd44dec
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
5 changed files with 158 additions and 33 deletions

View File

@ -251,15 +251,34 @@ class AddRemoteRepositoryForm(EncryptedBackupsMixin, forms.Form):
help_text=_('Path of a new or existing repository. Example: '
'<i>user@host:~/path/to/repo/</i>'),
validators=[repository_validator])
ssh_auth_type = forms.ChoiceField(
label=_('SSH Authentication Type'),
help_text=_('How to authenticate to the remote SSH server.<br />'
'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.<br />"
'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.<br />'
'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."""

View File

@ -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."""

View File

@ -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 <http://www.gnu.org/licenses/>.
*
* @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();
});

View File

@ -5,6 +5,12 @@
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'backups/backups_add_remote_repository.js' %}"
defer></script>
{% endblock %}
{% block content %}

View File

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