Add "paranoid restore" mode
Having Admin API, it is possible to do this properly now: - create DisposableVM - assign it proper permissions to create VMs and control those created VMs - run restore process inside - cleanup DisposableVM afterwards Since the RestoreInDisposableVM class contains de facto reverse parser for qvm-backup-restore command line, add a test that will spot when it gets out of sync. This feature depends on modifications in various other components, including: - linux-utils and core-agent-linux for update qfile-unpacker - core-admin for qrexec policy modification QubesOS/qubes-issues#5310
This commit is contained in:
		
							parent
							
								
									2089e9e730
								
							
						
					
					
						commit
						cc71dd5876
					
				@ -92,6 +92,14 @@ Options
 | 
			
		||||
   Provided backup location is a qrexec service name (optionally with an
 | 
			
		||||
   argument, separated by ``+``), instead of file path or a command.
 | 
			
		||||
 | 
			
		||||
.. option:: --paranoid-mode, --plan-b
 | 
			
		||||
 | 
			
		||||
  Isolate restore process in a DisposableVM, defend against potentially
 | 
			
		||||
  compromised backup. In this mode some parts of the backup are skipped,
 | 
			
		||||
  specifically:
 | 
			
		||||
 | 
			
		||||
    - dom0 home directory (desktop environment settings)
 | 
			
		||||
    - PCI devices assignments
 | 
			
		||||
 | 
			
		||||
Authors
 | 
			
		||||
=======
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										275
									
								
								qubesadmin/backup/dispvm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								qubesadmin/backup/dispvm.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,275 @@
 | 
			
		||||
#
 | 
			
		||||
# The Qubes OS Project, http://www.qubes-os.org
 | 
			
		||||
#
 | 
			
		||||
# Copyright (C) 2019 Marek Marczykowski-Górecki
 | 
			
		||||
#                               <marmarek@invisiblethingslab.com>
 | 
			
		||||
#
 | 
			
		||||
# This program is free software; you can redistribute it and/or modify
 | 
			
		||||
# it under the terms of the GNU Lesser General Public License as published by
 | 
			
		||||
# the Free Software Foundation; either version 2.1 of the License, or
 | 
			
		||||
# (at your option) any later version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful,
 | 
			
		||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
# GNU Lesser General Public License for more details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU Lesser General Public License along
 | 
			
		||||
# with this program; if not, write to the Free Software Foundation, Inc.,
 | 
			
		||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 | 
			
		||||
 | 
			
		||||
"""Handle backup extraction using DisposableVM"""
 | 
			
		||||
import datetime
 | 
			
		||||
import logging
 | 
			
		||||
import string
 | 
			
		||||
 | 
			
		||||
import subprocess
 | 
			
		||||
#import typing
 | 
			
		||||
import qubesadmin
 | 
			
		||||
import qubesadmin.exc
 | 
			
		||||
import qubesadmin.utils
 | 
			
		||||
import qubesadmin.vm
 | 
			
		||||
 | 
			
		||||
LOCKFILE = '/var/run/qubes/backup-paranoid-restore.lock'
 | 
			
		||||
 | 
			
		||||
Option = collections.namedtuple('Option', ('opts', 'handler'))
 | 
			
		||||
 | 
			
		||||
# Convenient functions for 'handler' value of Option object
 | 
			
		||||
#  (see RestoreInDisposableVM.arguments):
 | 
			
		||||
 | 
			
		||||
def handle_store_true(option, value):
 | 
			
		||||
    """Handle argument enabling an option (action="store_true")"""
 | 
			
		||||
    if value:
 | 
			
		||||
        return [option.opts[0]]
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_store_false(option, value):
 | 
			
		||||
    """Handle argument disabling an option (action="false")"""
 | 
			
		||||
    if not value:
 | 
			
		||||
        return [option.opts[0]]
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
def handle_verbose(option, value):
 | 
			
		||||
    """Handle argument --quiet / --verbose options (action="count")"""
 | 
			
		||||
    if option.opts[0] == '--verbose':
 | 
			
		||||
        value -= 1  # verbose defaults to 1
 | 
			
		||||
    return [option.opts[0]] * value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_store(option, value):
 | 
			
		||||
    """Handle argument with arbitrary string value (action="store")"""
 | 
			
		||||
    if value:
 | 
			
		||||
        return [option.opts[0], str(value)]
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_append(option, value):
 | 
			
		||||
    """Handle argument with a list of values (action="append")"""
 | 
			
		||||
    return itertools.chain(*([option.opts[0], v] for v in value))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def skip(_option, _value):
 | 
			
		||||
    """Skip argument"""
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_unsupported(option, value):
 | 
			
		||||
    """Reject argument as unsupported"""
 | 
			
		||||
    if value:
 | 
			
		||||
        raise NotImplementedError(
 | 
			
		||||
            '{} option is not supported with --paranoid-mode'.format(
 | 
			
		||||
                option.opts[0]))
 | 
			
		||||
    return []
 | 
			
		||||
 | 
			
		||||
class RestoreInDisposableVM:
 | 
			
		||||
    """Perform backup restore with actual archive extraction isolated
 | 
			
		||||
    within DisposableVM"""
 | 
			
		||||
    #dispvm: typing.Optional[qubesadmin.vm.QubesVM]
 | 
			
		||||
 | 
			
		||||
    #: map of args attr -> original option
 | 
			
		||||
    arguments = {
 | 
			
		||||
        'quiet': Option(('--quiet', '-q'), handle_verbose),
 | 
			
		||||
        'verbose': Option(('--verbose', '-v'), handle_verbose),
 | 
			
		||||
        'verify_only': Option(('--verify-only',), handle_store_true),
 | 
			
		||||
        'skip_broken': Option(('--skip-broken',), handle_store_true),
 | 
			
		||||
        'ignore_missing': Option(('--ignore-missing',), handle_store_true),
 | 
			
		||||
        'skip_conflicting': Option(('--skip-conflicting',), handle_store_true),
 | 
			
		||||
        'rename_conflicting': Option(('--rename-conflicting',),
 | 
			
		||||
            handle_store_true),
 | 
			
		||||
        'exclude': Option(('--exclude', '-x'), handle_append),
 | 
			
		||||
        'dom0_home': Option(('--skip-dom0-home',), handle_store_false),
 | 
			
		||||
        'ignore_username_mismatch': Option(('--ignore-username-mismatch',),
 | 
			
		||||
            handle_store_true),
 | 
			
		||||
        'ignore_size_limit': Option(('--ignore-size-limit',),
 | 
			
		||||
            handle_store_true),
 | 
			
		||||
        'compression': Option(('--compression-filter', '-Z'), handle_store),
 | 
			
		||||
        'appvm': Option(('--dest-vm', '-d'), handle_store),
 | 
			
		||||
        'pass_file': Option(('--passphrase-file', '-p'), handle_unsupported),
 | 
			
		||||
        'location_is_service': Option(('--location-is-service',),
 | 
			
		||||
            handle_store_true),
 | 
			
		||||
        'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip),
 | 
			
		||||
        # make the verification easier, those don't really matter
 | 
			
		||||
        'help': Option(('--help', '-h'), skip),
 | 
			
		||||
        'force_root': Option(('--force-root',), skip),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def __init__(self, app, args):
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        :param app: Qubes() instance
 | 
			
		||||
        :param args: namespace instance as with qvm-backup-restore arguments
 | 
			
		||||
        parsed. See :py:module:`qubesadmin.tools.qvm_backup_restore`.
 | 
			
		||||
        """
 | 
			
		||||
        self.app = app
 | 
			
		||||
        self.args = args
 | 
			
		||||
 | 
			
		||||
        # only one backup restore is allowed at the time, use constant names
 | 
			
		||||
        #: name of DisposableVM using to extract the backup
 | 
			
		||||
        self.dispvm_name = 'disp-backup-restore'
 | 
			
		||||
        #: tag given to this DisposableVM - qrexec policy is configured for it
 | 
			
		||||
        self.dispvm_tag = 'backup-restore-mgmt'
 | 
			
		||||
        #: tag automatically added to restored VMs
 | 
			
		||||
        self.restored_tag = 'backup-restore-in-progress'
 | 
			
		||||
        #: tag added to a VM storing the backup archive
 | 
			
		||||
        self.storage_tag = 'backup-restore-storage'
 | 
			
		||||
 | 
			
		||||
        self.terminal_app = ('xterm', '-hold', '-title', 'Backup restore', '-e')
 | 
			
		||||
 | 
			
		||||
        self.dispvm = None
 | 
			
		||||
 | 
			
		||||
        if args.appvm:
 | 
			
		||||
            self.backup_storage_vm = self.app.domains[args.appvm]
 | 
			
		||||
        else:
 | 
			
		||||
            self.backup_storage_vm = self.app.domains['dom0']
 | 
			
		||||
 | 
			
		||||
        self.storage_access_proc = None
 | 
			
		||||
        self.storage_access_id = None
 | 
			
		||||
        self.log = logging.getLogger('qubesadmin.backup.dispvm')
 | 
			
		||||
 | 
			
		||||
    def clear_old_tags(self):
 | 
			
		||||
        """Remove tags from old restore operation"""
 | 
			
		||||
        for domain in self.app.domains:
 | 
			
		||||
            domain.tags.discard(self.restored_tag)
 | 
			
		||||
            domain.tags.discard(self.dispvm_tag)
 | 
			
		||||
            domain.tags.discard(self.storage_tag)
 | 
			
		||||
 | 
			
		||||
    def create_dispvm(self):
 | 
			
		||||
        """Create DisposableVM used to restore"""
 | 
			
		||||
        self.dispvm = self.app.add_new_vm('DispVM', self.dispvm_name, 'red',
 | 
			
		||||
                                          template=self.app.management_dispvm)
 | 
			
		||||
        self.dispvm.auto_cleanup = True
 | 
			
		||||
        self.dispvm.features['tag-created-vm-with'] = self.restored_tag
 | 
			
		||||
 | 
			
		||||
    def register_backup_source(self):
 | 
			
		||||
        """Tell backup archive holding VM we want this content.
 | 
			
		||||
 | 
			
		||||
        This function registers a backup source, receives a token needed to
 | 
			
		||||
        access it (stored in *storage_access_id* attribute). The access is
 | 
			
		||||
        revoked when connection referenced in *storage_access_proc* attribute
 | 
			
		||||
        is closed.
 | 
			
		||||
        """
 | 
			
		||||
        self.storage_access_proc = self.backup_storage_vm.run_service(
 | 
			
		||||
            'qubes.RegisterBackupLocation', stdin=subprocess.PIPE,
 | 
			
		||||
            stdout=subprocess.PIPE)
 | 
			
		||||
        self.storage_access_proc.stdin.write(
 | 
			
		||||
            (self.args.backup_location.
 | 
			
		||||
             replace("\r", "").replace("\n", "") + "\n").encode())
 | 
			
		||||
        self.storage_access_proc.stdin.flush()
 | 
			
		||||
        storage_access_id = self.storage_access_proc.stdout.readline().strip()
 | 
			
		||||
        allowed_chars = (string.ascii_letters + string.digits).encode()
 | 
			
		||||
        if not storage_access_id or \
 | 
			
		||||
                not all(c in allowed_chars for c in storage_access_id):
 | 
			
		||||
            if self.storage_access_proc.returncode == 127:
 | 
			
		||||
                raise qubesadmin.exc.QubesException(
 | 
			
		||||
                    'Backup source registration failed - qubes-core-agent '
 | 
			
		||||
                    'package too old?')
 | 
			
		||||
            raise qubesadmin.exc.QubesException(
 | 
			
		||||
                'Backup source registration failed - got invalid id')
 | 
			
		||||
        self.storage_access_id = storage_access_id.decode('ascii')
 | 
			
		||||
        # keep connection open, closing it invalidates the access
 | 
			
		||||
 | 
			
		||||
        self.backup_storage_vm.tags.add(self.storage_tag)
 | 
			
		||||
 | 
			
		||||
    def invalidate_backup_access(self):
 | 
			
		||||
        """Revoke access to backup archive"""
 | 
			
		||||
        self.backup_storage_vm.tags.discard(self.storage_tag)
 | 
			
		||||
        self.storage_access_proc.stdin.close()
 | 
			
		||||
        self.storage_access_proc.wait()
 | 
			
		||||
 | 
			
		||||
    def prepare_inner_args(self):
 | 
			
		||||
        """Prepare arguments for inner (in-DispVM) qvm-backup-restore command"""
 | 
			
		||||
        new_options = []
 | 
			
		||||
        new_positional_args = []
 | 
			
		||||
 | 
			
		||||
        for attr, opt in self.arguments.items():
 | 
			
		||||
            if not hasattr(self.args, attr):
 | 
			
		||||
                continue
 | 
			
		||||
            new_options.extend(opt.handler(opt, getattr(self.args, attr)))
 | 
			
		||||
 | 
			
		||||
        new_options.append('--location-is-service')
 | 
			
		||||
 | 
			
		||||
        # backup location, replace by qrexec service to be called
 | 
			
		||||
        new_positional_args.append(
 | 
			
		||||
            'qubes.RestoreById+' + self.storage_access_id)
 | 
			
		||||
        if self.args.vms:
 | 
			
		||||
            new_positional_args.extend(self.args.vms)
 | 
			
		||||
 | 
			
		||||
        return new_options + new_positional_args
 | 
			
		||||
 | 
			
		||||
    def finalize_tags(self):
 | 
			
		||||
        """Make sure all the restored VMs are marked with
 | 
			
		||||
        restored-from-backup-xxx tag, then remove backup-restore-in-progress
 | 
			
		||||
        tag"""
 | 
			
		||||
        self.app.domains.clear_cache()
 | 
			
		||||
        for domain in self.app.domains:
 | 
			
		||||
            if 'backup-restore-in-progress' not in domain.tags:
 | 
			
		||||
                continue
 | 
			
		||||
            if not any(t.startswith('restored-from-backup-')
 | 
			
		||||
                       for t in domain.tags):
 | 
			
		||||
                self.log.warning('Restored domain %s was not tagged with '
 | 
			
		||||
                                 'restored-from-backup-* tag',
 | 
			
		||||
                                 domain.name)
 | 
			
		||||
                # add fallback tag
 | 
			
		||||
                domain.tags.add('restored-from-backup-at-{}'.format(
 | 
			
		||||
                    datetime.date.strftime(datetime.date.today(), '%F')))
 | 
			
		||||
            domain.tags.discard('backup-restore-in-progress')
 | 
			
		||||
 | 
			
		||||
    def run(self):
 | 
			
		||||
        """Run the backup restore operation"""
 | 
			
		||||
        lock = qubesadmin.utils.LockFile(LOCKFILE, True)
 | 
			
		||||
        lock.acquire()
 | 
			
		||||
        try:
 | 
			
		||||
            self.create_dispvm()
 | 
			
		||||
            self.clear_old_tags()
 | 
			
		||||
            self.register_backup_source()
 | 
			
		||||
            args = self.prepare_inner_args()
 | 
			
		||||
            self.dispvm.start()
 | 
			
		||||
            self.dispvm.run_service_for_stdio('qubes.WaitForSession')
 | 
			
		||||
            self.dispvm.tags.add(self.dispvm_tag)
 | 
			
		||||
            self.dispvm.run_with_args(*self.terminal_app,
 | 
			
		||||
                                      'qvm-backup-restore', *args,
 | 
			
		||||
                                      stdout=subprocess.DEVNULL,
 | 
			
		||||
                                      stderr=subprocess.DEVNULL)
 | 
			
		||||
        except subprocess.CalledProcessError as e:
 | 
			
		||||
            if e.returncode == 127:
 | 
			
		||||
                raise qubesadmin.exc.QubesException(
 | 
			
		||||
                    'qvm-backup-restore tool or {} '
 | 
			
		||||
                    'missing in {} template, install qubes-core-admin-client '
 | 
			
		||||
                    'package there'.format(self.terminal_app[0],
 | 
			
		||||
                                           self.dispvm.template.template.name)
 | 
			
		||||
                )
 | 
			
		||||
            raise qubesadmin.exc.QubesException(
 | 
			
		||||
                'qvm-backup-restore failed with {}'.format(e.returncode))
 | 
			
		||||
        finally:
 | 
			
		||||
            if self.dispvm is not None:
 | 
			
		||||
                # first revoke permission, then cleanup
 | 
			
		||||
                self.dispvm.tags.discard(self.dispvm_tag)
 | 
			
		||||
                # autocleanup removes the VM
 | 
			
		||||
                try:
 | 
			
		||||
                    self.dispvm.kill()
 | 
			
		||||
                except qubesadmin.exc.QubesVMNotStartedError:
 | 
			
		||||
                    # delete it manually
 | 
			
		||||
                    del self.app.domains[self.dispvm]
 | 
			
		||||
            self.finalize_tags()
 | 
			
		||||
            lock.release()
 | 
			
		||||
@ -17,12 +17,15 @@
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU Lesser General Public License along
 | 
			
		||||
# with this program; if not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
import itertools
 | 
			
		||||
 | 
			
		||||
import qubesadmin.tests
 | 
			
		||||
import qubesadmin.tests.tools
 | 
			
		||||
import qubesadmin.tools.qvm_backup_restore
 | 
			
		||||
from unittest import mock
 | 
			
		||||
from qubesadmin.backup import BackupVM
 | 
			
		||||
from qubesadmin.backup.restore import BackupRestore
 | 
			
		||||
from qubesadmin.backup.dispvm import RestoreInDisposableVM
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TC_00_qvm_backup_restore(qubesadmin.tests.QubesTestCase):
 | 
			
		||||
@ -231,3 +234,14 @@ class TC_00_qvm_backup_restore(qubesadmin.tests.QubesTestCase):
 | 
			
		||||
                qubesadmin.tools.qvm_backup_restore.handle_broken(
 | 
			
		||||
                    self.app, mock_args, mock_restore_info)
 | 
			
		||||
            self.assertAppropriateLogging('NetVM', 'error')
 | 
			
		||||
 | 
			
		||||
    def test_100_restore_in_dispvm_parser(self):
 | 
			
		||||
        """Verify if qvm-backup-restore tool options matches un-parser
 | 
			
		||||
        for paranoid restore mode"""
 | 
			
		||||
        parser = qubesadmin.tools.qvm_backup_restore.parser
 | 
			
		||||
        actions = parser._get_optional_actions()
 | 
			
		||||
        options_tool = set(itertools.chain(*(a.option_strings for a in actions)))
 | 
			
		||||
 | 
			
		||||
        options_parser = set(itertools.chain(
 | 
			
		||||
            *(o.opts for o in RestoreInDisposableVM.arguments.values())))
 | 
			
		||||
        self.assertEqual(options_tool, options_parser)
 | 
			
		||||
 | 
			
		||||
@ -24,12 +24,17 @@ import getpass
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
from qubesadmin.backup.restore import BackupRestore
 | 
			
		||||
from qubesadmin.backup.dispvm import RestoreInDisposableVM
 | 
			
		||||
import qubesadmin.exc
 | 
			
		||||
import qubesadmin.tools
 | 
			
		||||
import qubesadmin.utils
 | 
			
		||||
 | 
			
		||||
parser = qubesadmin.tools.QubesArgumentParser()
 | 
			
		||||
 | 
			
		||||
# WARNING:
 | 
			
		||||
# When adding options, update/verify also
 | 
			
		||||
# qubeadmin.restore.dispvm.RestoreInDisposableVM.arguments
 | 
			
		||||
#
 | 
			
		||||
parser.add_argument("--verify-only", action="store_true",
 | 
			
		||||
    dest="verify_only", default=False,
 | 
			
		||||
    help="Verify backup integrity without restoring any "
 | 
			
		||||
@ -88,6 +93,10 @@ parser.add_argument("--location-is-service", action="store_true",
 | 
			
		||||
    help="Interpret backup location as a qrexec service name,"
 | 
			
		||||
         "possibly with an argument separated by +.Requires -d option.")
 | 
			
		||||
 | 
			
		||||
parser.add_argument('--paranoid-mode', '--plan-b', action="store_true",
 | 
			
		||||
    help="Isolate restore process in a DispVM, defend against untrusted backup;"
 | 
			
		||||
         "implies --skip-dom0-home")
 | 
			
		||||
 | 
			
		||||
parser.add_argument('backup_location', action='store',
 | 
			
		||||
    help="Backup directory name, or command to pipe from")
 | 
			
		||||
 | 
			
		||||
@ -212,6 +221,19 @@ def main(args=None, app=None):
 | 
			
		||||
    if args.location_is_service and not args.appvm:
 | 
			
		||||
        parser.error('--location-is-service option requires -d')
 | 
			
		||||
 | 
			
		||||
    if args.paranoid_mode:
 | 
			
		||||
        args.dom0_home = False
 | 
			
		||||
        args.app.log.info("Starting restore process in a DisposableVM...")
 | 
			
		||||
        args.app.log.info("When operation completes, close its window "
 | 
			
		||||
                          "manually.")
 | 
			
		||||
        restore_in_dispvm = RestoreInDisposableVM(args.app, args)
 | 
			
		||||
        try:
 | 
			
		||||
            restore_in_dispvm.run()
 | 
			
		||||
        except qubesadmin.exc.QubesException as e:
 | 
			
		||||
            parser.error_runtime(str(e))
 | 
			
		||||
            return 1
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if args.pass_file is not None:
 | 
			
		||||
        pass_f = open(args.pass_file) if args.pass_file != "-" else sys.stdin
 | 
			
		||||
        passphrase = pass_f.readline().rstrip()
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user