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

View File

@ -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([

View File

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

View File

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