diff --git a/doc/manpages/qvm-backup-restore.rst b/doc/manpages/qvm-backup-restore.rst index 0e171b7..3791583 100644 --- a/doc/manpages/qvm-backup-restore.rst +++ b/doc/manpages/qvm-backup-restore.rst @@ -101,6 +101,13 @@ Options - dom0 home directory (desktop environment settings) - PCI devices assignments +.. option:: --auto-close + + When running with --paranoid-mode (see above), automatically close restore + progress window after the restore process is finished and display restore log + on the standard output. The log will be colored red if the standard output is + a terminal. + Authors ======= | Joanna Rutkowska diff --git a/qubesadmin/backup/dispvm.py b/qubesadmin/backup/dispvm.py index 724bec2..1ee5f2a 100644 --- a/qubesadmin/backup/dispvm.py +++ b/qubesadmin/backup/dispvm.py @@ -19,8 +19,11 @@ # 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 @@ -74,14 +77,6 @@ def skip(_option, _value): 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""" @@ -105,10 +100,11 @@ class RestoreInDisposableVM: 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), + '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), @@ -134,7 +130,17 @@ class RestoreInDisposableVM: #: tag added to a VM storing the backup archive self.storage_tag = 'backup-restore-storage' - self.terminal_app = ('xterm', '-hold', '-title', 'Backup restore', '-e') + # 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', + 'exec "$0" "$@" 2>&1 | 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 @@ -161,6 +167,18 @@ class RestoreInDisposableVM: 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. @@ -235,6 +253,23 @@ class RestoreInDisposableVM: 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) @@ -243,14 +278,19 @@ class RestoreInDisposableVM: 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') + 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) + # TODO: better error detection self.dispvm.run_with_args(*self.terminal_app, 'qvm-backup-restore', *args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return self.extract_log() except subprocess.CalledProcessError as e: if e.returncode == 127: raise qubesadmin.exc.QubesException( @@ -259,8 +299,13 @@ class RestoreInDisposableVM: 'package there'.format(self.terminal_app[0], self.dispvm.template.template.name) ) - raise qubesadmin.exc.QubesException( - 'qvm-backup-restore failed with {}'.format(e.returncode)) + 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 diff --git a/qubesadmin/exc.py b/qubesadmin/exc.py index 7256b95..b566964 100644 --- a/qubesadmin/exc.py +++ b/qubesadmin/exc.py @@ -154,6 +154,12 @@ class DeviceAlreadyAttached(QubesException, KeyError): return QubesException.__str__(self) +class BackupRestoreError(QubesException): + '''Restoring a backup failed''' + def __init__(self, msg, backup_log=None): + super(BackupRestoreError, self).__init__(msg) + self.backup_log = backup_log + # pylint: disable=too-many-ancestors class QubesDaemonNoResponseError(QubesDaemonCommunicationError): '''Got empty response from qubesd''' diff --git a/qubesadmin/tools/qvm_backup_restore.py b/qubesadmin/tools/qvm_backup_restore.py index ca45659..d7e3449 100644 --- a/qubesadmin/tools/qvm_backup_restore.py +++ b/qubesadmin/tools/qvm_backup_restore.py @@ -21,6 +21,7 @@ '''Console frontend for backup restore code''' import getpass +import os import sys from qubesadmin.backup.restore import BackupRestore @@ -89,6 +90,10 @@ parser.add_argument("-p", "--passphrase-file", action="store", dest="pass_file", default=None, help="Read passphrase from file, or use '-' to read from stdin") +parser.add_argument('--auto-close', action="store_true", + help="Auto-close restore window and display log on the stdout " + "(applies to --paranoid-mode)") + 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.") @@ -206,6 +211,18 @@ def handle_broken(app, args, restore_info): "files should be copied or moved out of the new " "directory before using them.") + +def print_backup_log(backup_log): + """Print a log on stdout, coloring it red if it's a terminal""" + if os.isatty(sys.stdout.fileno()): + sys.stdout.write('\033[0;31m') + sys.stdout.flush() + sys.stdout.buffer.write(backup_log) + if os.isatty(sys.stdout.fileno()): + sys.stdout.write('\033[0m') + sys.stdout.flush() + + def main(args=None, app=None): '''Main function of qvm-backup-restore''' # pylint: disable=too-many-return-statements @@ -228,7 +245,14 @@ def main(args=None, app=None): "manually.") restore_in_dispvm = RestoreInDisposableVM(args.app, args) try: - restore_in_dispvm.run() + backup_log = restore_in_dispvm.run() + if args.auto_close: + print_backup_log(backup_log) + except qubesadmin.exc.BackupRestoreError as e: + if e.backup_log is not None: + print_backup_log(e.backup_log) + parser.error_runtime(str(e)) + return 1 except qubesadmin.exc.QubesException as e: parser.error_runtime(str(e)) return 1