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)
- 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>

View File

@ -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

View File

@ -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'''

View File

@ -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