From 3558a26b2f72c509eb097c7d1043fafc7540c891 Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Sun, 21 Dec 2025 12:01:23 -0500 Subject: [PATCH] backups: Use SSH key instead of password - After copying the SSH client public key to the remote host, replace the SSH password credential with keyfile. - Also use SSH key when checking that remote directory exists. Tests: - Add remote backup location "tester@localhost:~backups". Test various operations like create backup, download backup, unmount and mount. Confirm that SSH password is no longer present in plinth sqlite database. Signed-off-by: James Valleroy --- plinth/modules/backups/__init__.py | 19 +++++++++++++------ plinth/modules/backups/repository.py | 20 ++++++++++++-------- plinth/modules/backups/views.py | 9 +++++++-- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/plinth/modules/backups/__init__.py b/plinth/modules/backups/__init__.py index 52862b882..5991cbaaf 100644 --- a/plinth/modules/backups/__init__.py +++ b/plinth/modules/backups/__init__.py @@ -4,7 +4,7 @@ import json import logging import os -import pathlib +from pathlib import Path import re import subprocess @@ -132,14 +132,21 @@ def restore_from_upload(path, app_ids=None): create_subvolume=False, backup_file=path) -def get_known_hosts_path(): +def get_known_hosts_path() -> Path: """Return the path to the known hosts file.""" - return pathlib.Path(cfg.data_dir) / '.ssh' / 'known_hosts' + return Path(cfg.data_dir) / '.ssh' / 'known_hosts' + + +def get_ssh_client_auth_key_paths() -> tuple[Path, Path]: + """Return the paths to the SSH client public key and private key.""" + key_path = Path(cfg.data_dir) / '.ssh' / 'id_ed25519' + pubkey_path = key_path.with_suffix('.pub') + return pubkey_path, key_path def generate_ssh_client_auth_key(): """Generate SSH client authentication keypair, if needed.""" - key_path = pathlib.Path(cfg.data_dir) / '.ssh' / 'id_ed25519' + _, key_path = get_ssh_client_auth_key_paths() if not key_path.exists(): logger.info('Generating SSH client key %s for FreedomBox service', key_path) @@ -153,7 +160,7 @@ def generate_ssh_client_auth_key(): def get_ssh_client_public_key() -> str: """Get SSH client public key for FreedomBox service.""" - pubkey_path = pathlib.Path(cfg.data_dir) / '.ssh' / 'id_ed25519.pub' + pubkey_path, _ = get_ssh_client_auth_key_paths() with pubkey_path.open('r') as pubkey_file: pubkey = pubkey_file.read() @@ -166,7 +173,7 @@ def copy_ssh_client_public_key(hostname: str, username: str, Returns whether the copy was successful, and any error message. """ - pubkey_path = pathlib.Path(cfg.data_dir) / '.ssh' / 'id_ed25519.pub' + pubkey_path, _ = get_ssh_client_auth_key_paths() env = os.environ.copy() env['SSHPASS'] = password process = subprocess.run([ diff --git a/plinth/modules/backups/repository.py b/plinth/modules/backups/repository.py index b9c1b8fe4..22dbeccf9 100644 --- a/plinth/modules/backups/repository.py +++ b/plinth/modules/backups/repository.py @@ -13,7 +13,8 @@ from django.utils.translation import gettext_lazy as _ from plinth import cfg from plinth.utils import format_lazy -from . import (_backup_handler, api, errors, get_known_hosts_path, privileged, +from . import (_backup_handler, api, errors, get_known_hosts_path, + get_ssh_client_auth_key_paths, privileged, restore_archive_handler, split_path, store) from .schedule import Schedule @@ -344,6 +345,11 @@ class SshBorgRepository(BaseBorgRepository): """Return whether remote path is mounted locally.""" return privileged.is_mounted(self._mountpoint) + def replace_ssh_password_with_keyfile(self, keyfile_path: str): + """Add SSH keyfile credential and delete stored password.""" + self.credentials['ssh_keyfile'] = keyfile_path + self.credentials.pop('ssh_password', None) + def initialize(self): """Initialize the repository after mounting the target directory.""" self._ensure_remote_directory() @@ -405,16 +411,14 @@ class SshBorgRepository(BaseBorgRepository): if dir_path[0] == '~': dir_path = '.' + dir_path[1:] - password = self.credentials['ssh_password'] - # Ensure remote directory exists, check contents - env = {'SSHPASS': password} + _, key_path = get_ssh_client_auth_key_paths() known_hosts_path = str(get_known_hosts_path()) subprocess.run([ - 'sshpass', '-e', 'ssh', '-o', - f'UserKnownHostsFile={known_hosts_path}', f'{username}@{hostname}', - 'mkdir', '-p', dir_path - ], check=True, env=env) + 'ssh', '-i', + str(key_path), '-o', f'UserKnownHostsFile={known_hosts_path}', + f'{username}@{hostname}', 'mkdir', '-p', dir_path + ], check=True) def get_repositories(): diff --git a/plinth/modules/backups/views.py b/plinth/modules/backups/views.py index 23864f190..dd24ec65b 100644 --- a/plinth/modules/backups/views.py +++ b/plinth/modules/backups/views.py @@ -26,7 +26,8 @@ from plinth.views import AppView 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) + get_ssh_client_auth_key_paths, get_ssh_client_public_key, + is_ssh_hostkey_verified, privileged) from .decorators import delete_tmp_backup_file from .repository import (BorgRepository, SshBorgRepository, get_instance, get_repositories) @@ -446,11 +447,15 @@ class VerifySshHostkeyView(FormView): 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: logger.warning('Failed to copy SSH client public key: %s', message) - messages.error(self.request, 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: