# # The Qubes OS Project, http://www.qubes-os.org # # Copyright (C) 2019 Marek Marczykowski-Górecki # # # 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 collections import datetime import itertools import logging import os 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 [] 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_store), 'location_is_service': Option(('--location-is-service',), handle_store_true), 'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip), 'auto_close': Option(('--auto-close',), 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' # FIXME: make it random, collision free # (when considering non-disposable case) self.backup_log_path = '/var/tmp/backup-restore.log' self.terminal_app = ('xterm', '-hold', '-title', 'Backup restore', '-e', '/bin/sh', '-c', '("$0" "$@" 2>&1; echo exit code: $?) | tee {}'. format(self.backup_log_path)) if args.auto_close: # filter-out '-hold' self.terminal_app = tuple(a for a in self.terminal_app if a != '-hold') 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 transfer_pass_file(self, path): """Copy passhprase file to the DisposableVM""" subprocess.check_call( ['qvm-copy-to-vm', self.dispvm_name, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return '/home/{}/QubesIncoming/{}/{}'.format( self.dispvm.default_user, os.uname()[1], os.path.basename(path) ) 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') @staticmethod def sanitize_log(untrusted_log): """Replace characters potentially dangerouns to terminal in a backup log""" allowed_set = set(range(0x20, 0x7e)) allowed_set.update({0x0a}) return bytes(c if c in allowed_set else ord('.') for c in untrusted_log) def extract_log(self): """Extract restore log from the DisposableVM""" untrusted_backup_log, _ = self.dispvm.run_with_args( 'cat', self.backup_log_path, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) backup_log = self.sanitize_log(untrusted_backup_log) return backup_log 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() self.dispvm.start() self.dispvm.run_service_for_stdio('qubes.WaitForSession') if self.args.pass_file: self.args.pass_file = self.transfer_pass_file( self.args.pass_file) args = self.prepare_inner_args() 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) backup_log = self.extract_log() last_line = backup_log.splitlines()[-1] if not last_line.startswith(b'exit code:'): raise qubesadmin.exc.BackupRestoreError( 'qvm-backup-restore did not reported exit code', backup_log=backup_log) try: exit_code = int(last_line.split()[-1]) except ValueError: raise qubesadmin.exc.BackupRestoreError( 'qvm-backup-restore reported unexpected exit code', backup_log=backup_log) if exit_code == 127: raise qubesadmin.exc.QubesException( 'qvm-backup-restore tool ' 'missing in {} template, install qubes-core-admin-client ' 'package there'.format( getattr(self.dispvm.template, 'template', self.dispvm.template).name) ) if exit_code != 0: raise qubesadmin.exc.BackupRestoreError( 'qvm-backup-restore failed with {}'.format(exit_code), backup_log=backup_log) return backup_log except subprocess.CalledProcessError as e: if e.returncode == 127: raise qubesadmin.exc.QubesException( '{} missing in {} template, install it there ' 'package there'.format(self.terminal_app[0], self.dispvm.template.template.name) ) try: backup_log = self.extract_log() except: # pylint: disable=bare-except backup_log = None raise qubesadmin.exc.BackupRestoreError( 'qvm-backup-restore failed with {}'.format(e.returncode), backup_log=backup_log) 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()