Verify SSH hostkey before mounting

Signed-off-by: Joseph Nuthalapati <njoseph@thoughtworks.com>
This commit is contained in:
Joseph Nuthalapati 2019-06-13 14:39:45 +05:30
parent 7684814a5c
commit 3a6dcbe7a7
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
8 changed files with 146 additions and 120 deletions

View File

@ -294,7 +294,7 @@ def get_env(arguments, use_credentials=False):
password = read_password()
if password:
env['SSHPASS'] = password
env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=no'
env['BORG_RSH'] = 'sshpass -e ssh -o StrictHostKeyChecking=yes'
else:
raise ValueError('could not find credentials')

View File

@ -44,6 +44,9 @@ def parse_arguments():
required=True)
mount.add_argument('--ssh-keyfile', help='Path of private ssh key',
default=None, required=False)
mount.add_argument('--user-known-hosts-file',
help='Path to a custom known_hosts file',
default='/dev/null')
umount = subparsers.add_parser('umount', help='unmount an ssh filesystem')
umount.add_argument('--mountpoint', help='Mountpoint to unmount',
required=True)
@ -69,7 +72,8 @@ def subcommand_mount(arguments):
remote_path = remote_path.replace('~/', '').replace('~', '')
cmd = [
'sshfs', remote_path, arguments.mountpoint, '-o',
'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no'
f'UserKnownHostsFile={arguments.user_known_hosts_file}', '-o',
'StrictHostKeyChecking=yes'
]
if arguments.ssh_keyfile:
cmd += ['-o', 'IdentityFile=' + arguments.ssh_keyfile]

View File

@ -21,6 +21,7 @@ FreedomBox app to manage backup archives.
import json
import os
import paramiko
from django.utils.text import get_valid_filename
from django.utils.translation import ugettext_lazy as _
@ -142,3 +143,16 @@ def restore_from_upload(path, apps=None):
"""Restore files from an uploaded .tar.gz backup file"""
api.restore_apps(_restore_exported_archive_handler, app_names=apps,
create_subvolume=False, backup_file=path)
def is_ssh_hostkey_verified(hostname):
"""Check whether SSH Hostkey has already been verified.
hostname: Domain name or IP address of the host
"""
known_hosts_path = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
if not os.path.exists(known_hosts_path):
return False
known_hosts = paramiko.hostkeys.HostKeys(known_hosts_path)
host_keys = known_hosts.lookup(hostname)
return host_keys is not None

View File

@ -54,7 +54,8 @@ def _get_repository_choices():
choices = [('root', ROOT_REPOSITORY_NAME)]
storages = network_storage.get_storages()
for storage in storages.values():
choices += [(storage['uuid'], storage['path'])]
if storage['verified']:
choices += [(storage['uuid'], storage['path'])]
return choices
@ -119,18 +120,18 @@ class AddRepositoryForm(forms.Form):
def clean(self):
super(AddRepositoryForm, self).clean()
passphrase = self.cleaned_data.get("encryption_passphrase")
passphrase = self.cleaned_data.get('encryption_passphrase')
confirm_passphrase = self.cleaned_data.get(
"confirm_encryption_passphrase")
'confirm_encryption_passphrase')
if passphrase != confirm_passphrase:
raise forms.ValidationError(
_("The entered encryption passphrases do not match"))
_('The entered encryption passphrases do not match'))
return self.cleaned_data
def clean_repository(self):
path = self.cleaned_data.get("repository")
path = self.cleaned_data.get('repository')
# Avoid creation of duplicate ssh remotes
self._check_if_duplicate_remote(path)
return path

View File

@ -26,11 +26,12 @@ from uuid import uuid1
from django.utils.translation import ugettext_lazy as _
from plinth import actions
from plinth import actions, cfg
from plinth.errors import ActionError
from . import (ROOT_REPOSITORY, ROOT_REPOSITORY_NAME, ROOT_REPOSITORY_UUID,
_backup_handler, api, network_storage, restore_archive_handler)
_backup_handler, api, is_ssh_hostkey_verified, network_storage,
restore_archive_handler)
from .errors import BorgError, BorgRepositoryDoesNotExistError, SshfsError
logger = logging.getLogger(__name__)
@ -236,7 +237,7 @@ class SshBorgRepository(BorgRepository):
storage_type = 'ssh'
uuid = None
def __init__(self, uuid=None, path=None, credentials=None, automount=True,
def __init__(self, uuid=None, path=None, credentials=None, automount=False,
**kwargs):
"""
Instanciate a new repository.
@ -259,7 +260,8 @@ class SshBorgRepository(BorgRepository):
self._load_from_kvstore()
if automount:
self.mount()
if is_ssh_hostkey_verified(path):
self.mount()
@property
def repo_path(self):
@ -328,8 +330,10 @@ class SshBorgRepository(BorgRepository):
def mount(self):
if self.is_mounted:
return
known_hosts_path = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
arguments = [
'mount', '--mountpoint', self.mountpoint, '--path', self._path
'mount', '--mountpoint', self.mountpoint, '--path', self._path,
'--user-known-hosts-file', known_hosts_path
]
arguments, kwargs = self._append_sshfs_arguments(
arguments, self.credentials)
@ -382,7 +386,7 @@ def get_ssh_repositories():
"""Get all SSH Repositories including the archive content"""
repositories = {}
for storage in network_storage.get_storages().values():
repository = SshBorgRepository(automount=False, **storage)
repository = SshBorgRepository(**storage)
repositories[storage['uuid']] = repository.get_view_content()
return repositories

View File

@ -26,10 +26,9 @@ import uuid
import pytest
from plinth import actions
from plinth.modules import backups
from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
from plinth import actions
from plinth.tests import config as test_config
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg')
@ -164,8 +163,7 @@ def test_sshfs_mount_password():
credentials = _get_credentials()
ssh_path = test_config.backups_ssh_path
repository = SshBorgRepository(path=ssh_path, credentials=credentials,
automount=False)
repository = SshBorgRepository(path=ssh_path, credentials=credentials)
repository.mount()
assert repository.is_mounted
repository.umount()
@ -178,8 +176,7 @@ def test_sshfs_mount_keyfile():
credentials = _get_credentials()
ssh_path = test_config.backups_ssh_path
repository = SshBorgRepository(path=ssh_path, credentials=credentials,
automount=False)
repository = SshBorgRepository(path=ssh_path, credentials=credentials)
repository.mount()
assert repository.is_mounted
repository.umount()
@ -189,8 +186,8 @@ def test_sshfs_mount_keyfile():
def test_access_nonexisting_url():
"""Test accessing a non-existent URL."""
repo_url = "user@%s.com.au:~/repo" % str(uuid.uuid1())
repository = SshBorgRepository(
path=repo_url, credentials=_dummy_credentials, automount=False)
repository = SshBorgRepository(path=repo_url,
credentials=_dummy_credentials)
with pytest.raises(backups.errors.BorgRepositoryDoesNotExistError):
repository.get_info()
@ -198,8 +195,8 @@ def test_access_nonexisting_url():
def test_inaccessible_repo_url():
"""Test accessing an existing URL with wrong credentials."""
repo_url = 'user@heise.de:~/repo'
repository = SshBorgRepository(
path=repo_url, credentials=_dummy_credentials, automount=False)
repository = SshBorgRepository(path=repo_url,
credentials=_dummy_credentials)
with pytest.raises(backups.errors.BorgError):
repository.get_info()

View File

@ -18,12 +18,12 @@
Views for the backups app.
"""
import json
import logging
import os
import re
import subprocess
import tempfile
from contextlib import contextmanager
from datetime import datetime
from urllib.parse import unquote
@ -32,20 +32,19 @@ from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.forms import ValidationError
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy
from django.views.generic import FormView, TemplateView, View
from paramiko.hostkeys import HostKeys
from plinth import cfg
from plinth.errors import PlinthError
from plinth.modules import backups, storage
from . import (ROOT_REPOSITORY, SESSION_PATH_VARIABLE, api, forms,
network_storage)
is_ssh_hostkey_verified, network_storage)
from .decorators import delete_tmp_backup_file
from .errors import BorgRepositoryDoesNotExistError
from .repository import (BorgRepository, SshBorgRepository, get_repository,
@ -196,6 +195,10 @@ class BaseRestoreView(SuccessMessageMixin, FormView):
context['uuid'] = self.kwargs.get('uuid', None)
return context
def _get_included_apps(self):
"""To be overridden."""
raise NotImplementedError
class RestoreFromUploadView(BaseRestoreView):
"""View to restore files from an (uploaded) exported archive."""
@ -260,12 +263,10 @@ class DownloadArchiveView(View):
class AddRepositoryView(SuccessMessageMixin, FormView):
"""View to verify the SSH Hostkey of the server and save the
new SSH repository."""
"""View to verify the SSH Hostkey of the server and save repository."""
form_class = forms.AddRepositoryForm
template_name = 'backups_repository_add.html'
success_url = reverse_lazy('backups:index')
success_message = _('Added new remote ssh repository.')
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
@ -274,51 +275,20 @@ class AddRepositoryView(SuccessMessageMixin, FormView):
context['subsubmenu'] = subsubmenu
return context
def post(self, request, *args, **kwargs):
form = self.form_class(data=request.POST)
repository = None
if form.is_valid():
path = form.cleaned_data.get("repository")
_, hostname, _ = re.split('[@:]', path)
credentials = _get_credentials(form.cleaned_data)
if not self._is_ssh_hostkey_verified(hostname):
# Cannot mount at this point because we cannot connect
# and validate the directory.
repository = SshBorgRepository(
path=path, credentials=credentials, automount=False)
# Save for now, verify in the next view
repository.save(verified=False)
uuid = repository.uuid
url = reverse('backups:verify-ssh-hostkey', args=[uuid])
return redirect(url)
else:
try:
repository = _validate_remote_repository(path, credentials)
except ValidationError as err:
messages.error(request, err.message)
context_data = self.get_context_data()
context_data['form'] = form
return render(request, self.template_name, context_data)
def form_valid(self, form):
"""Create and save Borg repository.
_create_borg_repository(repository,
form.cleaned_data['encryption'])
return redirect(self.success_url)
else:
context_data = self.get_context_data()
context_data['form'] = form
return render(request, self.template_name, context_data)
def _is_ssh_hostkey_verified(self, hostname):
"""Check whether SSH Hostkey has already been verified.
hostname: Domain name or IP address of the host
Present the Host key verification form if necessary.
"""
KNOWN_HOSTS = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
if os.path.exists(KNOWN_HOSTS):
known_hosts = HostKeys(KNOWN_HOSTS)
host_keys = known_hosts.lookup(hostname)
return host_keys is not None
else:
return False
super().form_valid(form)
path = form.cleaned_data.get('repository')
_, hostname, _ = re.split('[@:]', path)
credentials = _get_credentials(form.cleaned_data)
repository = SshBorgRepository(path=path, credentials=credentials)
repository.save(verified=False)
url = reverse('backups:verify-ssh-hostkey', args=[repository.uuid])
return redirect(url)
class VerifySshHostkeyView(SuccessMessageMixin, FormView):
@ -326,7 +296,6 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
form_class = forms.VerifySshHostkeyForm
template_name = 'verify_ssh_hostkey.html'
success_url = reverse_lazy('backups:index')
success_message = _('Added new remote ssh repository.')
repo_data = {}
def get_form_kwargs(self):
@ -355,28 +324,45 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
_, hostname, _ = re.split('[@:]', self._get_repo_data()['path'])
return hostname
def _add_ssh_hostkey(self, hostname, key_type):
@staticmethod
def _add_ssh_hostkey(hostname, key_type):
"""Add the given SSH key to known_hosts."""
KNOWN_HOSTS = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
if not os.path.exists(KNOWN_HOSTS):
os.makedirs(KNOWN_HOSTS.rsplit('/', maxsplit=1)[0])
open(KNOWN_HOSTS, 'w').close()
with open(KNOWN_HOSTS, 'a') as known_hosts_file:
known_hosts_path = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
if not os.path.exists(known_hosts_path):
os.makedirs(known_hosts_path.rsplit('/', maxsplit=1)[0])
open(known_hosts_path, 'w').close()
with open(known_hosts_path, 'a') as known_hosts_file:
key_line = subprocess.run(
['ssh-keyscan', '-t', key_type, hostname],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
check=True).stdout.decode().strip()
known_hosts_file.write('\n')
known_hosts_file.write(key_line)
known_hosts_file.write('\n')
def get(self, *args, **kwargs):
if is_ssh_hostkey_verified(self._get_hostname()):
self._add_remote_repository()
messages.success(self.request,
_('Added new remote ssh repository.'))
return redirect(reverse_lazy('backups:index'))
else:
return super().get(*args, **kwargs)
def form_valid(self, form):
"""Create and store the repository."""
key_type = form.cleaned_data['ssh_public_key']
self._add_ssh_hostkey(self._get_hostname(), key_type)
self._add_remote_repository()
messages.success(self.request, _('Added new remote ssh repository.'))
return super().form_valid(form)
def _add_remote_repository(self):
repo_data = self._get_repo_data()
path = repo_data.get("path")
path = repo_data['path']
credentials = repo_data['credentials']
uuid = self.kwargs['uuid']
try:
repository = _validate_remote_repository(path, credentials,
uuid=uuid)
@ -386,12 +372,14 @@ class VerifySshHostkeyView(SuccessMessageMixin, FormView):
# so that the user can have another go at creating it.
network_storage.delete(uuid)
return redirect(reverse_lazy('backups:repository-add'))
_create_borg_repository(repository, repo_data.get(
'encryption', 'none'))
return super().form_valid(form)
def _create_borg_repository(repository, encryption='none'):
if not repository.is_mounted:
repository.mount()
try:
repository.get_info()
except BorgRepositoryDoesNotExistError:
@ -410,40 +398,32 @@ def _get_credentials(data):
def _validate_remote_repository(path, credentials, uuid=None):
"""
Validation of SSH remote
"""Validation of SSH remote
* Create empty directory if not exists
* Check if the directory is empty
- if not empty, check if it's an existing backup repository
- else throw an error
"""
KNOWN_HOSTS = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
username, hostname, dir_path = re.split('[@:]', path)
dir_path = dir_path.replace('~', f'/home/{username}')
dir_path = dir_path.replace('~', '.')
password = credentials['ssh_password']
ssh_client = paramiko.SSHClient()
ssh_client.load_host_keys(KNOWN_HOSTS)
repository = None
try:
ssh_client.connect(hostname, username=username, password=password)
except Exception as err:
msg = _(f'Accessing the remote repository failed. Details: {err}')
raise ValidationError(msg, params={'err': str(err)})
else:
sftp_client = ssh_client.open_sftp()
try:
dir_contents = sftp_client.listdir(dir_path)
except FileNotFoundError:
logger.info(_(f"Directory {dir_path} doesn't exist. Creating ..."))
sftp_client.mkdir(dir_path)
repository = SshBorgRepository(uuid=uuid, path=path,
credentials=credentials)
else:
with _ssh_connection(hostname, username, password) as ssh_client:
with _sftp_client(ssh_client) as sftp_client:
dir_contents = None
try:
dir_contents = sftp_client.listdir(dir_path)
except FileNotFoundError:
logger.info(
_(f"Directory {dir_path} doesn't exist. Creating..."))
sftp_client.mkdir(dir_path)
if dir_contents:
try:
repository = SshBorgRepository(uuid=uuid, path=path,
credentials=credentials)
repository.mount()
repository.get_info()
except BorgRepositoryDoesNotExistError:
msg = _(f'Directory {dir_path} is neither empty nor '
@ -452,12 +432,36 @@ def _validate_remote_repository(path, credentials, uuid=None):
else:
repository = SshBorgRepository(uuid=uuid, path=path,
credentials=credentials)
finally:
sftp_client.close()
return repository
@contextmanager
def _ssh_connection(hostname, username, password):
"""Context manager to create and close an SSH connection."""
ssh_client = paramiko.SSHClient()
known_hosts_path = os.path.join(cfg.data_dir, '.ssh', 'known_hosts')
ssh_client.load_host_keys(known_hosts_path)
try:
ssh_client.connect(hostname, username=username, password=password)
yield ssh_client
except Exception as err:
msg = _('Accessing the remote repository failed. Details: %(err)s')
raise ValidationError(msg, params={'err': str(err)})
finally:
ssh_client.close()
return repository
@contextmanager
def _sftp_client(ssh_client):
"""Context manager to create and close an SFTP client."""
sftp_client = ssh_client.open_sftp()
try:
yield sftp_client
finally:
sftp_client.close()
class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
@ -468,12 +472,12 @@ class RemoveRepositoryView(SuccessMessageMixin, TemplateView):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['title'] = _('Remove Repository')
context['repository'] = SshBorgRepository(uuid=uuid, automount=False)
context['repository'] = SshBorgRepository(uuid=uuid)
return context
def post(self, request, uuid):
"""Delete the archive."""
repository = SshBorgRepository(uuid, automount=False)
repository = SshBorgRepository(uuid)
repository.remove_repository()
messages.success(
request,
@ -490,7 +494,10 @@ def umount_repository(request, uuid):
def mount_repository(request, uuid):
repository = SshBorgRepository(uuid=uuid, automount=False)
# Do not mount unverified ssh repositories. Prompt for verification.
if not network_storage.get(uuid).get('verified'):
return redirect('backups:verify-ssh-hostkey', uuid=uuid)
repository = SshBorgRepository(uuid=uuid)
try:
repository.mount()
except Exception as err:

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Django settings for test modules.
"""
@ -31,18 +30,18 @@ DATABASES = {
}
INSTALLED_APPS = [
'captcha',
'bootstrapform',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages',
'stronghold',
'plinth',
]
'captcha',
'bootstrapform',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages',
'stronghold',
'plinth',
]
# These are included here solely to suppress Django warnings
# during testing setup
MIDDLEWARE_CLASSES = (
MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',