backups: Fix issue with verifying SSH hosts with RSA key

- In current stable and testing, verifying SSH remote hosts using RSA is not
working. After selecting the verified RSA fingerprint, paramiko fails to connect

- A change introduced in paramiko 2.9 lead to failures when connecting to hosts
that have a verified RSA host key[1][2][3]. To fix the issue,
disabled_algorithms must be used to drop some of the other algorithms supported
by the server to force paramiko behavior. A better solution to the problem was
introduced in paramiko 3.2. Both these solutions require careful update to the
code. Considering the utility paramiko provides, the regression annoyance,
effort required for this fix, and the security implications (it is an completely
independent SSH implementation), the library does not seem to be worth the
effort in our case.

- Switch to using sshpass command line utility instead of paramiko library. The
only reason to use paramiko seems that 'ssh' command by default does not allow
us to input password easily while paramiko does.

- Another place where paramiko is being used is to check if a host is already
verified in the known_hosts file. This has been trivially replaced with
'ssh-keygen -F'.

- Exit codes provided by sshpass can replace the specific exception raised by
paramiko.

Links:

1) https://www.paramiko.org/changelog.html
2) https://github.com/paramiko/paramiko/issues/2017
3) https://github.com/paramiko/paramiko/issues/1984

Tests:

- Add a remote backup repository with and without encryption.

- Add remote backup repository with all three types of algorithms.

- Add a remote repository again with wrong password. Authentication error is
properly shown.

- Add a remote backup repository and remove it. Host remains verified. Add a
repository again.

- Add a remote backup repository and remove it. Host remains verified. Change
the fingerprint the /var/lib/plinth/.ssh/known_hosts file. Add a repository
again. A proper error is shown that remote host could not be verified.

- Add a remote backup repository and remove it. Host remains verified. Stop SSH
server on the remote host. A generic error is shown that ssh command on remote
host failed.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2024-12-20 19:11:17 -08:00 committed by James Valleroy
parent 54538ed891
commit c2007d0f6d
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
5 changed files with 27 additions and 39 deletions

2
debian/control vendored
View File

@ -38,7 +38,6 @@ Build-Depends:
python3-markupsafe,
python3-mypy,
python3-pampy,
python3-paramiko,
python3-pexpect,
python3-pip,
python3-psutil,
@ -114,7 +113,6 @@ Depends:
python3-gi,
python3-markupsafe,
python3-pampy,
python3-paramiko,
python3-pexpect,
python3-psutil,
python3-requests,

View File

@ -211,7 +211,6 @@ autodoc_mock_imports = [
'gi',
'markupsafe',
'pam',
'paramiko',
'psutil',
'pytest',
'requests',

View File

@ -6,8 +6,8 @@ import logging
import os
import pathlib
import re
import subprocess
import paramiko
from django.utils.text import get_valid_filename
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop
@ -51,7 +51,8 @@ class BackupsApp(app_module.App):
order=20)
self.add(menu_item)
packages = Packages('packages-backups', ['borgbackup', 'sshfs'])
packages = Packages('packages-backups',
['borgbackup', 'sshfs', 'sshpass'])
self.add(packages)
@staticmethod
@ -143,9 +144,13 @@ def is_ssh_hostkey_verified(hostname):
if not known_hosts_path.exists():
return False
known_hosts = paramiko.hostkeys.HostKeys(str(known_hosts_path))
host_keys = known_hosts.lookup(hostname)
return host_keys is not None
try:
subprocess.run(
['ssh-keygen', '-F', hostname, '-f',
str(known_hosts_path)], check=True)
return True
except subprocess.CalledProcessError:
return False
def split_path(path):

View File

@ -7,9 +7,9 @@ import io
import logging
import os
import re
import subprocess
from uuid import uuid1
import paramiko
from django.utils.translation import gettext_lazy as _
from plinth import cfg
@ -493,28 +493,13 @@ class SshBorgRepository(BaseBorgRepository):
password = self.credentials['ssh_password']
# Ensure remote directory exists, check contents
# TODO Test with IPv6 connection
with _ssh_connection(hostname, username, password) as ssh_client:
with ssh_client.open_sftp() as sftp_client:
try:
sftp_client.listdir(dir_path)
except FileNotFoundError:
logger.info('Directory %s does not exist, creating.',
dir_path)
sftp_client.mkdir(dir_path)
@contextlib.contextmanager
def _ssh_connection(hostname, username, password):
"""Context manager to create and close an SSH connection."""
ssh_client = paramiko.SSHClient()
ssh_client.load_host_keys(str(get_known_hosts_path()))
try:
ssh_client.connect(hostname, username=username, password=password)
yield ssh_client
finally:
ssh_client.close()
env = {'SSHPASS': password}
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)
def get_repositories():

View File

@ -5,10 +5,10 @@ Views for the backups app.
import logging
import os
import subprocess
from datetime import datetime
from urllib.parse import unquote
import paramiko
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404, StreamingHttpResponse
@ -427,13 +427,14 @@ def _save_repository(request, repository):
repository.verified = True
repository.save()
return True
except paramiko.BadHostKeyException:
message = _('SSH host public key could not be verified.')
except paramiko.AuthenticationException:
message = _('Authentication to remote server failed.')
except paramiko.SSHException as exception:
message = _('Error establishing connection to server: {}').format(
str(exception))
except subprocess.CalledProcessError as exception:
if exception.returncode in (6, 7):
message = _('SSH host public key could not be verified.')
elif exception.returncode == 5:
message = _('Authentication to remote server failed.')
else:
message = _('Error establishing connection to server: {}').format(
str(exception))
except Exception as exception:
message = str(exception)
logger.exception('Error adding repository: %s', exception)