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
This commit is contained in:
Marek Marczykowski-Górecki 2019-10-18 06:02:04 +02:00
parent e9120e3196
commit 7d6cb655f8
No known key found for this signature in database
GPG Key ID: 063938BA42CFA724
4 changed files with 96 additions and 14 deletions

View File

@ -101,6 +101,13 @@ Options
- dom0 home directory (desktop environment settings) - dom0 home directory (desktop environment settings)
- PCI devices assignments - 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 Authors
======= =======
| Joanna Rutkowska <joanna at invisiblethingslab dot com> | Joanna Rutkowska <joanna at invisiblethingslab dot com>

View File

@ -19,8 +19,11 @@
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Handle backup extraction using DisposableVM""" """Handle backup extraction using DisposableVM"""
import collections
import datetime import datetime
import itertools
import logging import logging
import os
import string import string
import subprocess import subprocess
@ -74,14 +77,6 @@ def skip(_option, _value):
return [] 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: class RestoreInDisposableVM:
"""Perform backup restore with actual archive extraction isolated """Perform backup restore with actual archive extraction isolated
within DisposableVM""" within DisposableVM"""
@ -105,10 +100,11 @@ class RestoreInDisposableVM:
handle_store_true), handle_store_true),
'compression': Option(('--compression-filter', '-Z'), handle_store), 'compression': Option(('--compression-filter', '-Z'), handle_store),
'appvm': Option(('--dest-vm', '-d'), 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',), 'location_is_service': Option(('--location-is-service',),
handle_store_true), handle_store_true),
'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip), 'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip),
'auto_close': Option(('--auto-close',), skip),
# make the verification easier, those don't really matter # make the verification easier, those don't really matter
'help': Option(('--help', '-h'), skip), 'help': Option(('--help', '-h'), skip),
'force_root': Option(('--force-root',), skip), 'force_root': Option(('--force-root',), skip),
@ -134,7 +130,17 @@ class RestoreInDisposableVM:
#: tag added to a VM storing the backup archive #: tag added to a VM storing the backup archive
self.storage_tag = 'backup-restore-storage' 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 self.dispvm = None
@ -161,6 +167,18 @@ class RestoreInDisposableVM:
self.dispvm.auto_cleanup = True self.dispvm.auto_cleanup = True
self.dispvm.features['tag-created-vm-with'] = self.restored_tag 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): def register_backup_source(self):
"""Tell backup archive holding VM we want this content. """Tell backup archive holding VM we want this content.
@ -235,6 +253,23 @@ class RestoreInDisposableVM:
datetime.date.strftime(datetime.date.today(), '%F'))) datetime.date.strftime(datetime.date.today(), '%F')))
domain.tags.discard('backup-restore-in-progress') 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): def run(self):
"""Run the backup restore operation""" """Run the backup restore operation"""
lock = qubesadmin.utils.LockFile(LOCKFILE, True) lock = qubesadmin.utils.LockFile(LOCKFILE, True)
@ -243,14 +278,19 @@ class RestoreInDisposableVM:
self.create_dispvm() self.create_dispvm()
self.clear_old_tags() self.clear_old_tags()
self.register_backup_source() self.register_backup_source()
args = self.prepare_inner_args()
self.dispvm.start() self.dispvm.start()
self.dispvm.run_service_for_stdio('qubes.WaitForSession') 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.tags.add(self.dispvm_tag)
# TODO: better error detection
self.dispvm.run_with_args(*self.terminal_app, self.dispvm.run_with_args(*self.terminal_app,
'qvm-backup-restore', *args, 'qvm-backup-restore', *args,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) stderr=subprocess.DEVNULL)
return self.extract_log()
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if e.returncode == 127: if e.returncode == 127:
raise qubesadmin.exc.QubesException( raise qubesadmin.exc.QubesException(
@ -259,8 +299,13 @@ class RestoreInDisposableVM:
'package there'.format(self.terminal_app[0], 'package there'.format(self.terminal_app[0],
self.dispvm.template.template.name) self.dispvm.template.template.name)
) )
raise qubesadmin.exc.QubesException( try:
'qvm-backup-restore failed with {}'.format(e.returncode)) 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: finally:
if self.dispvm is not None: if self.dispvm is not None:
# first revoke permission, then cleanup # first revoke permission, then cleanup

View File

@ -154,6 +154,12 @@ class DeviceAlreadyAttached(QubesException, KeyError):
return QubesException.__str__(self) 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 # pylint: disable=too-many-ancestors
class QubesDaemonNoResponseError(QubesDaemonCommunicationError): class QubesDaemonNoResponseError(QubesDaemonCommunicationError):
'''Got empty response from qubesd''' '''Got empty response from qubesd'''

View File

@ -21,6 +21,7 @@
'''Console frontend for backup restore code''' '''Console frontend for backup restore code'''
import getpass import getpass
import os
import sys import sys
from qubesadmin.backup.restore import BackupRestore from qubesadmin.backup.restore import BackupRestore
@ -89,6 +90,10 @@ parser.add_argument("-p", "--passphrase-file", action="store",
dest="pass_file", default=None, dest="pass_file", default=None,
help="Read passphrase from file, or use '-' to read from stdin") 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", parser.add_argument("--location-is-service", action="store_true",
help="Interpret backup location as a qrexec service name," help="Interpret backup location as a qrexec service name,"
"possibly with an argument separated by +.Requires -d option.") "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 " "files should be copied or moved out of the new "
"directory before using them.") "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): def main(args=None, app=None):
'''Main function of qvm-backup-restore''' '''Main function of qvm-backup-restore'''
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
@ -228,7 +245,14 @@ def main(args=None, app=None):
"manually.") "manually.")
restore_in_dispvm = RestoreInDisposableVM(args.app, args) restore_in_dispvm = RestoreInDisposableVM(args.app, args)
try: 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: except qubesadmin.exc.QubesException as e:
parser.error_runtime(str(e)) parser.error_runtime(str(e))
return 1 return 1