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 <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2025-12-12 07:14:39 -05:00 committed by Sunil Mohan Adapa
parent 7fb41313cd
commit f689e1b3cf
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
3 changed files with 61 additions and 11 deletions

View File

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

View File

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

View File

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