123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- #
- # 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 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()
|