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: ' help_text=_('Path of a new or existing repository. Example: '
'<i>user@host:~/path/to/repo/</i>'), '<i>user@host:~/path/to/repo/</i>'),
validators=[repository_validator]) 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( ssh_password = forms.CharField(
label=_('SSH server password'), strip=True, label=_('SSH server password'), widget=forms.PasswordInput(),
help_text=_('Password of the SSH Server.<br />' strip=True, help_text=_(
'Either provide a password, or add the FreedomBox ' 'Password of the SSH Server. Required only for Password-based '
"service's SSH client public key (listed above) to the " 'Authentication.'), required=False)
'authorized keys list on the remote machine.'),
widget=forms.PasswordInput(), 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): def clean_repository(self):
"""Validate repository form field.""" """Validate repository form field."""

View File

@ -335,6 +335,14 @@ class SshBorgRepository(BaseBorgRepository):
return None 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 @property
def _mountpoint(self): def _mountpoint(self):
"""Return the local mount point where repository is to be mounted.""" """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 bootstrap %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'backups/backups_add_remote_repository.js' %}"
defer></script>
{% endblock %}
{% block content %} {% block content %}

View File

@ -385,10 +385,13 @@ class AddRemoteRepositoryView(FormView):
if form.cleaned_data.get('encryption') == 'none': if form.cleaned_data.get('encryption') == 'none':
encryption_passphrase = None encryption_passphrase = None
credentials = { credentials = {'encryption_passphrase': encryption_passphrase}
'ssh_password': form.cleaned_data.get('ssh_password'), if form.cleaned_data.get('ssh_auth_type') == 'password_auth':
'encryption_passphrase': encryption_passphrase 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): with handle_common_errors(self.request):
repository = SshBorgRepository(path, credentials) repository = SshBorgRepository(path, credentials)
repository.verfied = False repository.verfied = False
@ -437,42 +440,70 @@ class VerifySshHostkeyView(FormView):
with known_hosts_path.open('a', encoding='utf-8') as known_hosts_file: with known_hosts_path.open('a', encoding='utf-8') as known_hosts_file:
known_hosts_file.write(ssh_public_key + '\n') 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): 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() repo = self._get_repository()
result, message = copy_ssh_client_public_key(repo.hostname, ssh_password = repo.ssh_password
repo.username, if ssh_password:
repo.ssh_password) result, message = copy_ssh_client_public_key(
if result: repo.hostname, repo.username, repo.ssh_password)
logger.info( if result:
"Copied SSH client public key to remote host's authorized " logger.info("Copied SSH client public key to remote host's "
"keys.") "authorized keys.")
_pubkey_path, key_path = get_ssh_client_auth_key_paths() _pubkey_path, key_path = get_ssh_client_auth_key_paths()
repo.replace_ssh_password_with_keyfile(str(key_path)) repo.replace_ssh_password_with_keyfile(str(key_path))
if _save_repository(self.request, repo): return self._check_save_repository()
return redirect(reverse_lazy('backups:index'))
else:
logger.warning('Failed to copy SSH client public key: %s', message) logger.warning('Failed to copy SSH client public key: %s', message)
messages.error( messages.error(
self.request, self.request,
_('Failed to copy SSH client public key: %s') % message) _('Failed to copy SSH client public key: %s') % message)
# Remove the repository so that the user can have another go at
# creating it. else:
try: logger.error(
repo.remove() 'SSH password is required to copy SSH client public key.')
messages.error(self.request, _('Repository removed.')) messages.error(
except KeyError: self.request,
pass _('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')) 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): def get(self, *args, **kwargs):
"""Skip this view if host is already verified.""" """Skip this view if host is already verified."""
if not is_ssh_hostkey_verified(self._get_repository().hostname): if not is_ssh_hostkey_verified(self._get_repository().hostname):
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
messages.success(self.request, _('SSH host already verified.')) 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): def form_valid(self, form):
"""Create and store the repository.""" """Create and store the repository."""
@ -480,7 +511,8 @@ class VerifySshHostkeyView(FormView):
with handle_common_errors(self.request): with handle_common_errors(self.request):
self._add_ssh_hostkey(ssh_public_key) self._add_ssh_hostkey(ssh_public_key)
messages.success(self.request, _('SSH host verified.')) 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): def _save_repository(request, repository):