Browse Source

backup/restore: add option for unattended restore and extracting log

Allow running unattended, with qvm-backup-restore --passphrase-file.
This require few modifications:
 - copy the passphrase file into the DisposableVM (that VM knows the
         passphrase anyway, so there is no extra data leak)
 - close the terminal when operation finishes

Closing the terminal would eliminate almost all the feedback (operation
log, errors, warnings etc), so write it into a file in DisposableVM and
later extract it and show on the stdout. Similar to qvm-run, color it
red as a content coming from a VM.

QubesOS/qubes-issues#5310
Marek Marczykowski-Górecki 4 years ago
parent
commit
7d6cb655f8

+ 7 - 0
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 <joanna at invisiblethingslab dot com>

+ 58 - 13
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

+ 6 - 0
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'''

+ 25 - 1
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