qvm_template_postprocess.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2016 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. ''' Tool for importing rpm-installed template'''
  21. import asyncio
  22. import glob
  23. import os
  24. import pathlib
  25. import shutil
  26. import subprocess
  27. import sys
  28. import grp
  29. import qubesadmin
  30. import qubesadmin.exc
  31. import qubesadmin.tools
  32. try:
  33. # pylint: disable=wrong-import-position
  34. import qubesadmin.events.utils
  35. have_events = True
  36. except ImportError:
  37. have_events = False
  38. parser = qubesadmin.tools.QubesArgumentParser(
  39. description='Postprocess template package')
  40. parser.add_argument('--really', action='store_true', default=False,
  41. help='Really perform the action, YOU SHOULD REALLY KNOW WHAT YOU ARE DOING')
  42. parser.add_argument('--skip-start', action='store_true',
  43. help='Do not start the VM - do not retrieve menu entries etc.')
  44. parser.add_argument('--keep-source', action='store_true',
  45. help='Do not remove source data (*dir* directory) after import')
  46. parser.add_argument('--no-installed-by-rpm', action='store_true',
  47. help='Do not set installed_by_rpm')
  48. parser.add_argument('--allow-pv', action='store_true',
  49. help='Allow setting virt_mode to pv in configuration file.')
  50. parser.add_argument('--pool',
  51. help='Specify pool to store created VMs in.')
  52. parser.add_argument('action', choices=['post-install', 'pre-remove'],
  53. help='Action to perform')
  54. parser.add_argument('name', action='store',
  55. help='Template name')
  56. parser.add_argument('dir', action='store',
  57. help='Template directory')
  58. def get_root_img_size(source_dir):
  59. '''Extract size of root.img to be imported'''
  60. root_path = os.path.join(source_dir, 'root.img')
  61. # deal with both cases: split tar and non-split tar
  62. part_path = root_path + '.part.00'
  63. tar_path = root_path + '.tar'
  64. if os.path.exists(part_path) or os.path.exists(tar_path):
  65. # get just file root_size from the tar header
  66. path = part_path if os.path.exists(part_path) else tar_path
  67. p = subprocess.Popen(['tar', 'tvf', path],
  68. stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
  69. (stdout, _) = p.communicate()
  70. # -rw-r--r-- 0/0 1073741824 1970-01-01 01:00 root.img
  71. root_size = int(stdout.split()[2])
  72. elif os.path.exists(root_path):
  73. root_size = os.path.getsize(root_path)
  74. else:
  75. raise qubesadmin.exc.QubesException('root.img not found')
  76. return root_size
  77. def import_root_img(vm, source_dir):
  78. '''Import root.img into VM object'''
  79. # Try not break existing data in the volume in case of import failure. If
  80. # volume needs to be extended, do it before import, if reduced - after.
  81. root_size = get_root_img_size(source_dir)
  82. if vm.volumes['root'].size < root_size:
  83. vm.volumes['root'].resize(root_size)
  84. root_path = os.path.join(source_dir, 'root.img')
  85. if os.path.exists(root_path + '.part.00'):
  86. input_files = glob.glob(root_path + '.part.*')
  87. cat = subprocess.Popen(['cat'] + sorted(input_files),
  88. stdout=subprocess.PIPE)
  89. tar = subprocess.Popen(['tar', 'xSOf', '-'],
  90. stdin=cat.stdout,
  91. stdout=subprocess.PIPE)
  92. cat.stdout.close()
  93. vm.volumes['root'].import_data(stream=tar.stdout)
  94. if tar.wait() != 0:
  95. raise qubesadmin.exc.QubesException('root.img extraction failed')
  96. if cat.wait() != 0:
  97. raise qubesadmin.exc.QubesException('root.img extraction failed')
  98. elif os.path.exists(root_path + '.tar'):
  99. tar = subprocess.Popen(['tar', 'xSOf', root_path + '.tar'],
  100. stdout=subprocess.PIPE)
  101. vm.volumes['root'].import_data(stream=tar.stdout)
  102. if tar.wait() != 0:
  103. raise qubesadmin.exc.QubesException('root.img extraction failed')
  104. elif os.path.exists(root_path):
  105. if vm.app.qubesd_connection_type == 'socket':
  106. # check if root.img was already overwritten, i.e. if the source
  107. # and destination paths are the same
  108. vid = vm.volumes['root'].vid
  109. pool = vm.app.pools[vm.volumes['root'].pool]
  110. if (pool.driver in ('file', 'file-reflink')
  111. and root_path == os.path.join(pool.config['dir_path'],
  112. vid + '.img')):
  113. vm.log.info('root.img already in place, do not re-import')
  114. return
  115. with open(root_path, 'rb') as root_file:
  116. vm.volumes['root'].import_data(stream=root_file)
  117. if vm.volumes['root'].size > root_size:
  118. try:
  119. vm.volumes['root'].resize(root_size)
  120. except qubesadmin.exc.QubesException as err:
  121. vm.log.warning(
  122. 'Failed to resize root volume of {} from {} to {} after '
  123. 'import: {}'.format(vm.name, vm.volumes['root'].size,
  124. root_size, str(err)))
  125. def reset_private_img(vm):
  126. '''Clear private volume'''
  127. vm.volumes['private'].clear_data()
  128. def import_appmenus(vm, source_dir, skip_generate=True):
  129. """Import appmenus settings into VM object (later: GUI VM)
  130. :param vm: QubesVM object of just imported template
  131. :param source_dir: directory with source files
  132. :param skip_generate: do not generate actual menu entries,
  133. only set item lists
  134. """
  135. if os.getuid() == 0:
  136. try:
  137. qubes_group = grp.getgrnam('qubes')
  138. user = qubes_group.gr_mem[0]
  139. cmd_prefix = ['runuser', '-u', user, '--', 'env', 'DISPLAY=:0']
  140. except KeyError as e:
  141. vm.log.warning('Default user not found, not importing appmenus: ' +
  142. str(e))
  143. return
  144. else:
  145. cmd_prefix = []
  146. # store the whitelists in VM features
  147. # separated by spaces should be ok as there should be no spaces in the file
  148. # name according to the FreeDesktop spec
  149. source_dir = pathlib.Path(source_dir)
  150. try:
  151. with open(source_dir / 'vm-whitelisted-appmenus.list', 'r') as fd:
  152. vm.features['default-menu-items'] = \
  153. ' '.join([x.rstrip() for x in fd])
  154. except FileNotFoundError as e:
  155. vm.log.warning('Cannot set default-menu-items, %s not found',
  156. e.filename)
  157. try:
  158. with open(source_dir / 'whitelisted-appmenus.list', 'r') as fd:
  159. vm.features['menu-items'] = ' '.join([x.rstrip() for x in fd])
  160. except FileNotFoundError as e:
  161. vm.log.warning('Cannot set menu-items, %s not found',
  162. e.filename)
  163. try:
  164. with open(source_dir / 'netvm-whitelisted-appmenus.list', 'r') as fd:
  165. vm.features['netvm-menu-items'] = ' '.join([x.rstrip() for x in fd])
  166. except FileNotFoundError as e:
  167. vm.log.warning('Cannot set netvm-menu-items, %s not found',
  168. e.filename)
  169. if skip_generate:
  170. return
  171. # TODO: change this to qrexec calls to GUI VM, when GUI VM will be
  172. # implemented
  173. try:
  174. subprocess.check_call(cmd_prefix + ['qvm-appmenus',
  175. '--set-default-whitelist={!s}'.format(
  176. source_dir / 'vm-whitelisted-appmenus.list'), vm.name])
  177. subprocess.check_call(cmd_prefix + ['qvm-appmenus',
  178. '--set-whitelist={!s}'.format(
  179. source_dir / 'whitelisted-appmenus.list'), vm.name])
  180. except subprocess.CalledProcessError as e:
  181. vm.log.warning('Failed to set default application list: %s', e)
  182. def parse_template_config(path):
  183. '''Parse template.conf from template package. (KEY=VALUE format)'''
  184. with open(path, 'r') as fd:
  185. return dict(line.rstrip('\n').split('=', 1) for line in fd)
  186. @asyncio.coroutine
  187. def call_postinstall_service(vm):
  188. '''Call qubes.PostInstall service
  189. And adjust related settings (netvm, features).
  190. '''
  191. # just created, so no need to save previous value - we know what it was
  192. vm.netvm = None
  193. # temporarily enable qrexec feature - so vm.start() will wait for it;
  194. # if start fails, rollback it
  195. vm.features['qrexec'] = True
  196. try:
  197. vm.start()
  198. except qubesadmin.exc.QubesException:
  199. del vm.features['qrexec']
  200. else:
  201. try:
  202. vm.run_service_for_stdio('qubes.PostInstall')
  203. except subprocess.CalledProcessError:
  204. vm.log.error('qubes.PostInstall service failed')
  205. vm.shutdown()
  206. if have_events:
  207. try:
  208. # pylint: disable=no-member
  209. yield from asyncio.wait_for(
  210. qubesadmin.events.utils.wait_for_domain_shutdown([vm]),
  211. qubesadmin.config.defaults['shutdown_timeout'])
  212. except asyncio.TimeoutError:
  213. vm.kill()
  214. else:
  215. timeout = qubesadmin.config.defaults['shutdown_timeout']
  216. while timeout >= 0:
  217. if vm.is_halted():
  218. break
  219. yield from asyncio.sleep(1)
  220. timeout -= 1
  221. if not vm.is_halted():
  222. try:
  223. vm.kill()
  224. except qubesadmin.exc.QubesVMNotStartedError:
  225. pass
  226. finally:
  227. vm.netvm = qubesadmin.DEFAULT
  228. def validate_ip(ip):
  229. """Check if given string has a valid IP address syntax"""
  230. try:
  231. return all(0 <= int(part) <= 255 for part in ip.split('.', 3))
  232. except ValueError:
  233. return False
  234. @asyncio.coroutine
  235. def post_install(args):
  236. '''Handle post-installation tasks'''
  237. app = args.app
  238. vm_created = False
  239. # reinstall and running in dom0, using the same directory as qubes core
  240. local_reinstall = False
  241. try:
  242. # reinstall
  243. vm = app.domains[args.name]
  244. if app.qubesd_connection_type == 'socket' and \
  245. args.dir == '/var/lib/qubes/vm-templates/' + args.name:
  246. # VM exists and use use the same directory as target vm - on
  247. # final cleanup remove only some files, not the whole directory
  248. local_reinstall = True
  249. except KeyError:
  250. if app.qubesd_connection_type == 'socket' and \
  251. args.dir == '/var/lib/qubes/vm-templates/' + args.name:
  252. # vm.create_on_disk() need to create the directory on its own,
  253. # move it away from its way
  254. tmp_sourcedir = os.path.join('/var/lib/qubes/vm-templates',
  255. 'tmp-' + args.name)
  256. shutil.move(args.dir, tmp_sourcedir)
  257. args.dir = tmp_sourcedir
  258. vm = app.add_new_vm('TemplateVM',
  259. name=args.name,
  260. label=qubesadmin.config.defaults['template_label'],
  261. pool=args.pool)
  262. vm_created = True
  263. vm.log.info('Importing data')
  264. try:
  265. import_root_img(vm, args.dir)
  266. except:
  267. # if data import fails, remove half-created VM
  268. if vm_created:
  269. del app.domains[vm.name]
  270. raise
  271. if not vm_created:
  272. vm.log.info('Clearing private volume')
  273. reset_private_img(vm)
  274. vm.installed_by_rpm = not args.no_installed_by_rpm
  275. # do not generate actual menu entries, if post-install service will be
  276. # executed anyway
  277. import_appmenus(vm, args.dir, skip_generate=not args.skip_start)
  278. conf_path = os.path.join(args.dir, 'template.conf')
  279. if os.path.exists(conf_path):
  280. import_template_config(args, conf_path, vm)
  281. if not args.skip_start:
  282. yield from call_postinstall_service(vm)
  283. if not args.keep_source:
  284. if local_reinstall:
  285. # remove only imported root img
  286. root_path = os.path.join(args.dir, 'root.img')
  287. for root_part in glob.glob(root_path + '.part.*'):
  288. os.unlink(root_part)
  289. else:
  290. shutil.rmtree(args.dir)
  291. # if running as root, tell underlying storage layer about just freed
  292. # data blocks
  293. if os.getuid() == 0:
  294. subprocess.call(['sync', '-f', os.path.dirname(args.dir)])
  295. subprocess.call(['fstrim', os.path.dirname(args.dir)])
  296. return 0
  297. def import_template_config(args, conf_path, vm):
  298. """
  299. Parse template.conf and apply its content to the just installed TemplateVM
  300. :param args: arguments for qvm-template-postprocess (used for --allow-pv
  301. option and possibly some other in the future)
  302. :param conf_path: path to the template.conf
  303. :param vm: Template to operate on
  304. :return:
  305. """
  306. conf = parse_template_config(conf_path)
  307. # Import qvm-feature tags
  308. for key in (
  309. 'no-monitor-layout',
  310. 'pci-e820-host',
  311. 'linux-stubdom',
  312. 'gui',
  313. 'gui-emulated',
  314. 'qrexec'):
  315. if key in conf:
  316. if conf[key] == '1':
  317. vm.features[key] = conf[key]
  318. else:
  319. vm.log.warning(
  320. 'ignoring boolean config flags that are not \'1\'')
  321. for key in (
  322. 'net.fake-ip',
  323. 'net.fake-gateway',
  324. 'net.fake-netmask'):
  325. if key in conf:
  326. if validate_ip(conf[key]):
  327. vm.features[key] = conf[key]
  328. else:
  329. vm.log.warning(
  330. 'ignoring invalid value for \'%s\'', key)
  331. if 'virt-mode' in conf:
  332. if conf['virt-mode'] == 'pv' and args.allow_pv:
  333. vm.virt_mode = 'pv'
  334. elif conf['virt-mode'] == 'pv':
  335. vm.log.warning(
  336. '--allow-pv not set, ignoring request to change virt-mode')
  337. elif conf['virt-mode'] in ('pvh', 'hvm'):
  338. vm.virt_mode = conf['virt-mode']
  339. else:
  340. vm.log.warning('ignoring invalid value for virt-mode')
  341. if 'kernel' in conf:
  342. if conf['kernel'] == '':
  343. vm.kernel = ''
  344. else:
  345. vm.log.warning(
  346. 'Currently only supports setting kernel to (none)')
  347. def pre_remove(args):
  348. '''Handle pre-removal tasks'''
  349. app = args.app
  350. try:
  351. tpl = app.domains[args.name]
  352. except KeyError:
  353. parser.error('No Qube with this name exists')
  354. for appvm in tpl.appvms:
  355. parser.error('Qube {} uses this template'.format(appvm.name))
  356. tpl.installed_by_rpm = False
  357. del app.domains[args.name]
  358. return 0
  359. def is_chroot():
  360. '''Detect if running inside chroot'''
  361. try:
  362. stat_root = os.stat('/')
  363. stat_init_root = os.stat('/proc/1/root/.')
  364. return (
  365. stat_root.st_dev != stat_init_root.st_dev or
  366. stat_root.st_ino != stat_init_root.st_ino)
  367. except IOError:
  368. return False
  369. def main(args=None, app=None):
  370. '''Main function of qvm-template-postprocess'''
  371. args = parser.parse_args(args, app=app)
  372. if is_chroot():
  373. print('Running in chroot, ignoring request. Import template with:',
  374. file=sys.stderr)
  375. print(' '.join(sys.argv), file=sys.stderr)
  376. return
  377. if not args.really:
  378. parser.error('Do not call this tool directly.')
  379. if args.action == 'post-install':
  380. loop = asyncio.get_event_loop()
  381. try:
  382. loop.run_until_complete(post_install(args))
  383. loop.stop()
  384. loop.run_forever()
  385. finally:
  386. loop.close()
  387. elif args.action == 'pre-remove':
  388. pre_remove(args)
  389. else:
  390. parser.error('Unknown action')
  391. return 0
  392. if __name__ == '__main__':
  393. sys.exit(main())