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:
parent
e9120e3196
commit
7d6cb655f8
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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'''
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user