dispvm.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. #
  2. # The Qubes OS Project, http://www.qubes-os.org
  3. #
  4. # Copyright (C) 2019 Marek Marczykowski-Górecki
  5. # <marmarek@invisiblethingslab.com>
  6. #
  7. # This program is free software; you can redistribute it and/or modify
  8. # it under the terms of the GNU Lesser General Public License as published by
  9. # the Free Software Foundation; either version 2.1 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Lesser General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Lesser General Public License along
  18. # with this program; if not, write to the Free Software Foundation, Inc.,
  19. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  20. """Handle backup extraction using DisposableVM"""
  21. import collections
  22. import datetime
  23. import itertools
  24. import logging
  25. import os
  26. import string
  27. import subprocess
  28. #import typing
  29. import qubesadmin
  30. import qubesadmin.exc
  31. import qubesadmin.utils
  32. import qubesadmin.vm
  33. LOCKFILE = '/var/run/qubes/backup-paranoid-restore.lock'
  34. Option = collections.namedtuple('Option', ('opts', 'handler'))
  35. # Convenient functions for 'handler' value of Option object
  36. # (see RestoreInDisposableVM.arguments):
  37. def handle_store_true(option, value):
  38. """Handle argument enabling an option (action="store_true")"""
  39. if value:
  40. return [option.opts[0]]
  41. return []
  42. def handle_store_false(option, value):
  43. """Handle argument disabling an option (action="false")"""
  44. if not value:
  45. return [option.opts[0]]
  46. return []
  47. def handle_verbose(option, value):
  48. """Handle argument --quiet / --verbose options (action="count")"""
  49. if option.opts[0] == '--verbose':
  50. value -= 1 # verbose defaults to 1
  51. return [option.opts[0]] * value
  52. def handle_store(option, value):
  53. """Handle argument with arbitrary string value (action="store")"""
  54. if value:
  55. return [option.opts[0], str(value)]
  56. return []
  57. def handle_append(option, value):
  58. """Handle argument with a list of values (action="append")"""
  59. return itertools.chain(*([option.opts[0], v] for v in value))
  60. def skip(_option, _value):
  61. """Skip argument"""
  62. return []
  63. class RestoreInDisposableVM:
  64. """Perform backup restore with actual archive extraction isolated
  65. within DisposableVM"""
  66. #dispvm: typing.Optional[qubesadmin.vm.QubesVM]
  67. #: map of args attr -> original option
  68. arguments = {
  69. 'quiet': Option(('--quiet', '-q'), handle_verbose),
  70. 'verbose': Option(('--verbose', '-v'), handle_verbose),
  71. 'verify_only': Option(('--verify-only',), handle_store_true),
  72. 'skip_broken': Option(('--skip-broken',), handle_store_true),
  73. 'ignore_missing': Option(('--ignore-missing',), handle_store_true),
  74. 'skip_conflicting': Option(('--skip-conflicting',), handle_store_true),
  75. 'rename_conflicting': Option(('--rename-conflicting',),
  76. handle_store_true),
  77. 'exclude': Option(('--exclude', '-x'), handle_append),
  78. 'dom0_home': Option(('--skip-dom0-home',), handle_store_false),
  79. 'ignore_username_mismatch': Option(('--ignore-username-mismatch',),
  80. handle_store_true),
  81. 'ignore_size_limit': Option(('--ignore-size-limit',),
  82. handle_store_true),
  83. 'compression': Option(('--compression-filter', '-Z'), handle_store),
  84. 'appvm': Option(('--dest-vm', '-d'), handle_store),
  85. 'pass_file': Option(('--passphrase-file', '-p'), handle_store),
  86. 'location_is_service': Option(('--location-is-service',),
  87. handle_store_true),
  88. 'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip),
  89. 'auto_close': Option(('--auto-close',), skip),
  90. # make the verification easier, those don't really matter
  91. 'help': Option(('--help', '-h'), skip),
  92. 'force_root': Option(('--force-root',), skip),
  93. }
  94. def __init__(self, app, args):
  95. """
  96. :param app: Qubes() instance
  97. :param args: namespace instance as with qvm-backup-restore arguments
  98. parsed. See :py:module:`qubesadmin.tools.qvm_backup_restore`.
  99. """
  100. self.app = app
  101. self.args = args
  102. # only one backup restore is allowed at the time, use constant names
  103. #: name of DisposableVM using to extract the backup
  104. self.dispvm_name = 'disp-backup-restore'
  105. #: tag given to this DisposableVM - qrexec policy is configured for it
  106. self.dispvm_tag = 'backup-restore-mgmt'
  107. #: tag automatically added to restored VMs
  108. self.restored_tag = 'backup-restore-in-progress'
  109. #: tag added to a VM storing the backup archive
  110. self.storage_tag = 'backup-restore-storage'
  111. # FIXME: make it random, collision free
  112. # (when considering non-disposable case)
  113. self.backup_log_path = '/var/tmp/backup-restore.log'
  114. self.terminal_app = ('xterm', '-hold', '-title', 'Backup restore', '-e',
  115. '/bin/sh', '-c',
  116. '("$0" "$@" 2>&1; echo exit code: $?) | tee {}'.
  117. format(self.backup_log_path))
  118. if args.auto_close:
  119. # filter-out '-hold'
  120. self.terminal_app = tuple(a for a in self.terminal_app
  121. if a != '-hold')
  122. self.dispvm = None
  123. if args.appvm:
  124. self.backup_storage_vm = self.app.domains[args.appvm]
  125. else:
  126. self.backup_storage_vm = self.app.domains['dom0']
  127. self.storage_access_proc = None
  128. self.storage_access_id = None
  129. self.log = logging.getLogger('qubesadmin.backup.dispvm')
  130. def clear_old_tags(self):
  131. """Remove tags from old restore operation"""
  132. for domain in self.app.domains:
  133. domain.tags.discard(self.restored_tag)
  134. domain.tags.discard(self.dispvm_tag)
  135. domain.tags.discard(self.storage_tag)
  136. def create_dispvm(self):
  137. """Create DisposableVM used to restore"""
  138. self.dispvm = self.app.add_new_vm('DispVM', self.dispvm_name, 'red',
  139. template=self.app.management_dispvm)
  140. self.dispvm.auto_cleanup = True
  141. self.dispvm.features['tag-created-vm-with'] = self.restored_tag
  142. def transfer_pass_file(self, path):
  143. """Copy passhprase file to the DisposableVM"""
  144. subprocess.check_call(
  145. ['qvm-copy-to-vm', self.dispvm_name, path],
  146. stdout=subprocess.DEVNULL,
  147. stderr=subprocess.DEVNULL)
  148. return '/home/{}/QubesIncoming/{}/{}'.format(
  149. self.dispvm.default_user,
  150. os.uname()[1],
  151. os.path.basename(path)
  152. )
  153. def register_backup_source(self):
  154. """Tell backup archive holding VM we want this content.
  155. This function registers a backup source, receives a token needed to
  156. access it (stored in *storage_access_id* attribute). The access is
  157. revoked when connection referenced in *storage_access_proc* attribute
  158. is closed.
  159. """
  160. self.storage_access_proc = self.backup_storage_vm.run_service(
  161. 'qubes.RegisterBackupLocation', stdin=subprocess.PIPE,
  162. stdout=subprocess.PIPE)
  163. self.storage_access_proc.stdin.write(
  164. (self.args.backup_location.
  165. replace("\r", "").replace("\n", "") + "\n").encode())
  166. self.storage_access_proc.stdin.flush()
  167. storage_access_id = self.storage_access_proc.stdout.readline().strip()
  168. allowed_chars = (string.ascii_letters + string.digits).encode()
  169. if not storage_access_id or \
  170. not all(c in allowed_chars for c in storage_access_id):
  171. if self.storage_access_proc.returncode == 127:
  172. raise qubesadmin.exc.QubesException(
  173. 'Backup source registration failed - qubes-core-agent '
  174. 'package too old?')
  175. raise qubesadmin.exc.QubesException(
  176. 'Backup source registration failed - got invalid id')
  177. self.storage_access_id = storage_access_id.decode('ascii')
  178. # keep connection open, closing it invalidates the access
  179. self.backup_storage_vm.tags.add(self.storage_tag)
  180. def invalidate_backup_access(self):
  181. """Revoke access to backup archive"""
  182. self.backup_storage_vm.tags.discard(self.storage_tag)
  183. self.storage_access_proc.stdin.close()
  184. self.storage_access_proc.wait()
  185. def prepare_inner_args(self):
  186. """Prepare arguments for inner (in-DispVM) qvm-backup-restore command"""
  187. new_options = []
  188. new_positional_args = []
  189. for attr, opt in self.arguments.items():
  190. if not hasattr(self.args, attr):
  191. continue
  192. new_options.extend(opt.handler(opt, getattr(self.args, attr)))
  193. new_options.append('--location-is-service')
  194. # backup location, replace by qrexec service to be called
  195. new_positional_args.append(
  196. 'qubes.RestoreById+' + self.storage_access_id)
  197. if self.args.vms:
  198. new_positional_args.extend(self.args.vms)
  199. return new_options + new_positional_args
  200. def finalize_tags(self):
  201. """Make sure all the restored VMs are marked with
  202. restored-from-backup-xxx tag, then remove backup-restore-in-progress
  203. tag"""
  204. self.app.domains.clear_cache()
  205. for domain in self.app.domains:
  206. if 'backup-restore-in-progress' not in domain.tags:
  207. continue
  208. if not any(t.startswith('restored-from-backup-')
  209. for t in domain.tags):
  210. self.log.warning('Restored domain %s was not tagged with '
  211. 'restored-from-backup-* tag',
  212. domain.name)
  213. # add fallback tag
  214. domain.tags.add('restored-from-backup-at-{}'.format(
  215. datetime.date.strftime(datetime.date.today(), '%F')))
  216. domain.tags.discard('backup-restore-in-progress')
  217. @staticmethod
  218. def sanitize_log(untrusted_log):
  219. """Replace characters potentially dangerouns to terminal in
  220. a backup log"""
  221. allowed_set = set(range(0x20, 0x7e))
  222. allowed_set.update({0x0a})
  223. return bytes(c if c in allowed_set else ord('.') for c in untrusted_log)
  224. def extract_log(self):
  225. """Extract restore log from the DisposableVM"""
  226. untrusted_backup_log, _ = self.dispvm.run_with_args(
  227. 'cat', self.backup_log_path,
  228. stdout=subprocess.PIPE,
  229. stderr=subprocess.DEVNULL)
  230. backup_log = self.sanitize_log(untrusted_backup_log)
  231. return backup_log
  232. def run(self):
  233. """Run the backup restore operation"""
  234. lock = qubesadmin.utils.LockFile(LOCKFILE, True)
  235. lock.acquire()
  236. try:
  237. self.create_dispvm()
  238. self.clear_old_tags()
  239. self.register_backup_source()
  240. self.dispvm.start()
  241. self.dispvm.run_service_for_stdio('qubes.WaitForSession')
  242. if self.args.pass_file:
  243. self.args.pass_file = self.transfer_pass_file(
  244. self.args.pass_file)
  245. args = self.prepare_inner_args()
  246. self.dispvm.tags.add(self.dispvm_tag)
  247. self.dispvm.run_with_args(*self.terminal_app,
  248. 'qvm-backup-restore', *args,
  249. stdout=subprocess.DEVNULL,
  250. stderr=subprocess.DEVNULL)
  251. backup_log = self.extract_log()
  252. last_line = backup_log.splitlines()[-1]
  253. if not last_line.startswith(b'exit code:'):
  254. raise qubesadmin.exc.BackupRestoreError(
  255. 'qvm-backup-restore did not reported exit code',
  256. backup_log=backup_log)
  257. try:
  258. exit_code = int(last_line.split()[-1])
  259. except ValueError:
  260. raise qubesadmin.exc.BackupRestoreError(
  261. 'qvm-backup-restore reported unexpected exit code',
  262. backup_log=backup_log)
  263. if exit_code == 127:
  264. raise qubesadmin.exc.QubesException(
  265. 'qvm-backup-restore tool '
  266. 'missing in {} template, install qubes-core-admin-client '
  267. 'package there'.format(
  268. getattr(self.dispvm.template,
  269. 'template',
  270. self.dispvm.template).name)
  271. )
  272. if exit_code != 0:
  273. raise qubesadmin.exc.BackupRestoreError(
  274. 'qvm-backup-restore failed with {}'.format(exit_code),
  275. backup_log=backup_log)
  276. return backup_log
  277. except subprocess.CalledProcessError as e:
  278. if e.returncode == 127:
  279. raise qubesadmin.exc.QubesException(
  280. '{} missing in {} template, install it there '
  281. 'package there'.format(self.terminal_app[0],
  282. self.dispvm.template.template.name)
  283. )
  284. try:
  285. backup_log = self.extract_log()
  286. except: # pylint: disable=bare-except
  287. backup_log = None
  288. raise qubesadmin.exc.BackupRestoreError(
  289. 'qvm-backup-restore failed with {}'.format(e.returncode),
  290. backup_log=backup_log)
  291. finally:
  292. if self.dispvm is not None:
  293. # first revoke permission, then cleanup
  294. self.dispvm.tags.discard(self.dispvm_tag)
  295. # autocleanup removes the VM
  296. try:
  297. self.dispvm.kill()
  298. except qubesadmin.exc.QubesVMNotStartedError:
  299. # delete it manually
  300. del self.app.domains[self.dispvm]
  301. self.finalize_tags()
  302. lock.release()