From f689e1b3cf469cb2326c43f57a4ce511bf3dbf69 Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Fri, 12 Dec 2025 07:14:39 -0500 Subject: [PATCH] backups: Copy SSH client public key to remote Tests: - In development VM, add a remote backup location of "tester@localhost:~/backups". Verify the SSH host key. plinth@freedombox key is listed in /home/tester/.ssh/authorized_keys. - Remove the remote backup location, and delete /home/tester/.ssh/authorized_keys. Add the same remote backup location again. plinth@freedombox key is again listed in /home/tester/.ssh/authorized_keys. Signed-off-by: James Valleroy --- plinth/modules/backups/__init__.py | 17 ++++++++++++ plinth/modules/backups/repository.py | 16 +++++++++++- plinth/modules/backups/views.py | 39 +++++++++++++++++++++------- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index b1f725de2..52862b882 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -160,6 +160,23 @@ def get_ssh_client_public_key() -> str: return pubkey +def copy_ssh_client_public_key(hostname: str, username: str, + password: str) -> tuple[bool, str]: + """Copy the SSH client public key to the remote server. + + Returns whether the copy was successful, and any error message. + """ + pubkey_path = pathlib.Path(cfg.data_dir) / '.ssh' / 'id_ed25519.pub' + env = os.environ.copy() + env['SSHPASS'] = password + process = subprocess.run([ + 'sshpass', '-e', 'ssh-copy-id', '-i', + str(pubkey_path), f'{username}@{hostname}' + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, env=env) + error_message = process.stderr.decode() if process.returncode else '' + return (process.returncode == 0, error_message) + + def is_ssh_hostkey_verified(hostname): """Check whether SSH Hostkey has already been verified. diff --git a/plinth/modules/backups/repository.py b/plinth/modules/backups/repository.py index ec40a3ed1..b9c1b8fe4 100644 --- a/plinth/modules/backups/repository.py +++ b/plinth/modules/backups/repository.py @@ -315,11 +315,25 @@ class SshBorgRepository(BaseBorgRepository): self._umount_ignore_errors() @property - def hostname(self): + def hostname(self) -> str: """Return hostname from the remote path.""" _, hostname, _ = split_path(self._path) return hostname.split('%')[0] # XXX: Likely incorrect to split + @property + def username(self) -> str: + """Return username from the remote path.""" + username, _, _ = split_path(self._path) + return username + + @property + def ssh_password(self) -> str | None: + """Return SSH password if it is stored, otherwise None.""" + if 'ssh_password' in self.credentials: + return self.credentials['ssh_password'] + + return None + @property def _mountpoint(self): """Return the local mount point where repository is to be mounted.""" diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index 73d30e749..23864f190 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -24,8 +24,8 @@ from plinth.errors import PlinthError from plinth.modules import backups, storage from plinth.views import AppView -from . import (SESSION_PATH_VARIABLE, api, errors, forms, - generate_ssh_client_auth_key, get_known_hosts_path, +from . import (SESSION_PATH_VARIABLE, api, copy_ssh_client_public_key, errors, + forms, generate_ssh_client_auth_key, get_known_hosts_path, get_ssh_client_public_key, is_ssh_hostkey_verified, privileged) from .decorators import delete_tmp_backup_file from .repository import (BorgRepository, SshBorgRepository, get_instance, @@ -436,16 +436,38 @@ 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_copy_ssh_client_public_key(self): + """ 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.") + if _save_repository(self.request, repo): + return redirect(reverse_lazy('backups:index')) + else: + logger.warning('Failed to copy SSH client public key: %s', message) + messages.error(self.request, 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 + + return redirect(reverse_lazy('backups:add-remote-repository')) + 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.')) - if _save_repository(self.request, self._get_repository()): - return redirect(reverse_lazy('backups:index')) - - return redirect(reverse_lazy('backups:add-remote-repository')) + return self._check_copy_ssh_client_public_key() def form_valid(self, form): """Create and store the repository.""" @@ -453,10 +475,7 @@ class VerifySshHostkeyView(FormView): with handle_common_errors(self.request): self._add_ssh_hostkey(ssh_public_key) messages.success(self.request, _('SSH host verified.')) - if _save_repository(self.request, self._get_repository()): - return redirect(reverse_lazy('backups:index')) - - return redirect(reverse_lazy('backups:add-remote-repository')) + return self._check_copy_ssh_client_public_key() def _save_repository(request, repository):