block.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. # -*- encoding: utf8 -*-
  2. #
  3. # The Qubes OS Project, http://www.qubes-os.org
  4. #
  5. # Copyright (C) 2017 Marek Marczykowski-Górecki
  6. # <marmarek@invisiblethingslab.com>
  7. #
  8. # This library is free software; you can redistribute it and/or
  9. # modify it under the terms of the GNU Lesser General Public
  10. # License as published by the Free Software Foundation; either
  11. # version 2.1 of the License, or (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  16. # Lesser General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public
  19. # License along with this library; if not, see <https://www.gnu.org/licenses/>.
  20. ''' Qubes block devices extensions '''
  21. import re
  22. import string
  23. import lxml.etree
  24. import qubes.devices
  25. import qubes.ext
  26. name_re = re.compile(r"^[a-z0-9-]{1,12}$")
  27. device_re = re.compile(r"^[a-z0-9/-]{1,64}$")
  28. # FIXME: any better idea of desc_re?
  29. desc_re = re.compile(r"^.{1,255}$")
  30. mode_re = re.compile(r"^[rw]$")
  31. # all frontends, prefer xvdi
  32. # TODO: get this from libvirt driver?
  33. AVAILABLE_FRONTENDS = ['xvd'+c for c in
  34. string.ascii_lowercase[8:]+string.ascii_lowercase[:8]]
  35. SYSTEM_DISKS = ('xvda', 'xvdb', 'xvdc')
  36. # xvdd is considered system disk only if vm.kernel is set
  37. SYSTEM_DISKS_DOM0_KERNEL = SYSTEM_DISKS + ('xvdd',)
  38. class BlockDevice(qubes.devices.DeviceInfo):
  39. def __init__(self, backend_domain, ident):
  40. super().__init__(backend_domain=backend_domain,
  41. ident=ident)
  42. self._description = None
  43. self._mode = None
  44. self._size = None
  45. @property
  46. def description(self):
  47. '''Human readable device description'''
  48. if self._description is None:
  49. if not self.backend_domain.is_running():
  50. return self.ident
  51. safe_set = {ord(c) for c in
  52. string.ascii_letters + string.digits + '()+,-.:=_/ '}
  53. untrusted_desc = self.backend_domain.untrusted_qdb.read(
  54. '/qubes-block-devices/{}/desc'.format(self.ident))
  55. if not untrusted_desc:
  56. return ''
  57. desc = ''.join((chr(c) if c in safe_set else '_')
  58. for c in untrusted_desc)
  59. self._description = desc
  60. return self._description
  61. @property
  62. def mode(self):
  63. '''Device mode, either 'w' for read-write, or 'r' for read-only'''
  64. if self._mode is None:
  65. if not self.backend_domain.is_running():
  66. return 'w'
  67. untrusted_mode = self.backend_domain.untrusted_qdb.read(
  68. '/qubes-block-devices/{}/mode'.format(self.ident))
  69. if untrusted_mode is None:
  70. self._mode = 'w'
  71. elif untrusted_mode not in (b'w', b'r'):
  72. self.backend_domain.log.warning(
  73. 'Device {} has invalid mode'.format(self.ident))
  74. self._mode = 'w'
  75. else:
  76. self._mode = untrusted_mode.decode()
  77. return self._mode
  78. @property
  79. def size(self):
  80. '''Device size in bytes'''
  81. if self._size is None:
  82. if not self.backend_domain.is_running():
  83. return None
  84. untrusted_size = self.backend_domain.untrusted_qdb.read(
  85. '/qubes-block-devices/{}/size'.format(self.ident))
  86. if untrusted_size is None:
  87. self._size = 0
  88. elif not untrusted_size.isdigit():
  89. self.backend_domain.log.warning(
  90. 'Device {} has invalid size'.format(self.ident))
  91. self._size = 0
  92. else:
  93. self._size = int(untrusted_size)
  94. return self._size
  95. @property
  96. def device_node(self):
  97. '''Device node in backend domain'''
  98. return '/dev/' + self.ident.replace('_', '/')
  99. class BlockDeviceExtension(qubes.ext.Extension):
  100. @qubes.ext.handler('domain-init', 'domain-load')
  101. def on_domain_init_load(self, vm, event):
  102. '''Initialize watching for changes'''
  103. # pylint: disable=unused-argument,no-self-use
  104. vm.watch_qdb_path('/qubes-block-devices')
  105. @qubes.ext.handler('domain-qdb-change:/qubes-block-devices')
  106. def on_qdb_change(self, vm, event, path):
  107. '''A change in QubesDB means a change in device list'''
  108. # pylint: disable=unused-argument,no-self-use
  109. vm.fire_event('device-list-change:block')
  110. def device_get(self, vm, ident):
  111. # pylint: disable=no-self-use
  112. '''Read information about device from QubesDB
  113. :param vm: backend VM object
  114. :param ident: device identifier
  115. :returns BlockDevice'''
  116. untrusted_qubes_device_attrs = vm.untrusted_qdb.list(
  117. '/qubes-block-devices/{}/'.format(ident))
  118. if not untrusted_qubes_device_attrs:
  119. return None
  120. return BlockDevice(vm, ident)
  121. @qubes.ext.handler('device-list:block')
  122. def on_device_list_block(self, vm, event):
  123. # pylint: disable=unused-argument,no-self-use
  124. if not vm.is_running():
  125. return
  126. untrusted_qubes_devices = vm.untrusted_qdb.list('/qubes-block-devices/')
  127. untrusted_idents = set(untrusted_path.split('/', 3)[2]
  128. for untrusted_path in untrusted_qubes_devices)
  129. for untrusted_ident in untrusted_idents:
  130. if not name_re.match(untrusted_ident):
  131. msg = ("%s vm's device path name contains unsafe characters. "
  132. "Skipping it.")
  133. vm.log.warning(msg % vm.name)
  134. continue
  135. ident = untrusted_ident
  136. device_info = self.device_get(vm, ident)
  137. if device_info:
  138. yield device_info
  139. @qubes.ext.handler('device-get:block')
  140. def on_device_get_block(self, vm, event, ident):
  141. # pylint: disable=unused-argument,no-self-use
  142. if not vm.is_running():
  143. return
  144. if not vm.app.vmm.offline_mode:
  145. device_info = self.device_get(vm, ident)
  146. if device_info:
  147. yield device_info
  148. @qubes.ext.handler('device-list-attached:block')
  149. def on_device_list_attached(self, vm, event, **kwargs):
  150. # pylint: disable=unused-argument,no-self-use
  151. if not vm.is_running():
  152. return
  153. system_disks = SYSTEM_DISKS
  154. if getattr(vm, 'kernel', None):
  155. system_disks = SYSTEM_DISKS_DOM0_KERNEL
  156. xml_desc = lxml.etree.fromstring(vm.libvirt_domain.XMLDesc())
  157. for disk in xml_desc.findall('devices/disk'):
  158. if disk.get('type') != 'block':
  159. continue
  160. dev_path_node = disk.find('source')
  161. if dev_path_node is None:
  162. continue
  163. dev_path = dev_path_node.get('dev')
  164. target_node = disk.find('target')
  165. if target_node is not None:
  166. frontend_dev = target_node.get('dev')
  167. if not frontend_dev:
  168. continue
  169. if frontend_dev in system_disks:
  170. continue
  171. else:
  172. continue
  173. backend_domain_node = disk.find('backenddomain')
  174. if backend_domain_node is not None:
  175. backend_domain = vm.app.domains[backend_domain_node.get('name')]
  176. else:
  177. backend_domain = vm.app.domains[0]
  178. options = {}
  179. read_only_node = disk.find('readonly')
  180. if read_only_node is not None:
  181. options['read-only'] = 'yes'
  182. else:
  183. options['read-only'] = 'no'
  184. options['frontend-dev'] = frontend_dev
  185. if disk.get('device') != 'disk':
  186. options['devtype'] = disk.get('device')
  187. if dev_path.startswith('/dev/'):
  188. ident = dev_path[len('/dev/'):]
  189. else:
  190. ident = dev_path
  191. ident = ident.replace('/', '_')
  192. yield (BlockDevice(backend_domain, ident), options)
  193. def find_unused_frontend(self, vm, devtype='disk'):
  194. # pylint: disable=no-self-use
  195. '''Find unused block frontend device node for <target dev=.../>
  196. parameter'''
  197. assert vm.is_running()
  198. xml = vm.libvirt_domain.XMLDesc()
  199. parsed_xml = lxml.etree.fromstring(xml)
  200. used = [target.get('dev', None) for target in
  201. parsed_xml.xpath("//domain/devices/disk/target")]
  202. if devtype == 'cdrom' and 'xvdd' not in used:
  203. # prefer 'xvdd' for CDROM if available; only first 4 disks are
  204. # emulated in HVM, which means only those are bootable
  205. return 'xvdd'
  206. for dev in AVAILABLE_FRONTENDS:
  207. if dev not in used:
  208. return dev
  209. return None
  210. @qubes.ext.handler('device-pre-attach:block')
  211. def on_device_pre_attached_block(self, vm, event, device, options):
  212. # pylint: disable=unused-argument
  213. # validate options
  214. for option, value in options.items():
  215. if option == 'frontend-dev':
  216. if not value.startswith('xvd') and not value.startswith('sd'):
  217. raise qubes.exc.QubesValueError(
  218. 'Invalid frontend-dev option value: ' + value)
  219. elif option == 'read-only':
  220. options[option] = (
  221. 'yes' if qubes.property.bool(None, None, value) else 'no')
  222. elif option == 'devtype':
  223. if value not in ('disk', 'cdrom'):
  224. raise qubes.exc.QubesValueError(
  225. 'devtype option can only have '
  226. '\'disk\' or \'cdrom\' value')
  227. else:
  228. raise qubes.exc.QubesValueError(
  229. 'Unsupported option {}'.format(option))
  230. if 'read-only' not in options:
  231. options['read-only'] = 'yes' if device.mode == 'r' else 'no'
  232. if options.get('read-only', 'no') == 'no' and device.mode == 'r':
  233. raise qubes.exc.QubesValueError(
  234. 'This device can be attached only read-only')
  235. if not vm.is_running():
  236. return
  237. if not device.backend_domain.is_running():
  238. raise qubes.exc.QubesVMNotRunningError(device.backend_domain,
  239. 'Domain {} needs to be running to attach device from '
  240. 'it'.format(device.backend_domain.name))
  241. if 'frontend-dev' not in options:
  242. options['frontend-dev'] = self.find_unused_frontend(
  243. vm, options.get('devtype', 'disk'))
  244. vm.libvirt_domain.attachDevice(
  245. vm.app.env.get_template('libvirt/devices/block.xml').render(
  246. device=device, vm=vm, options=options))
  247. @qubes.ext.handler('device-pre-detach:block')
  248. def on_device_pre_detached_block(self, vm, event, device):
  249. # pylint: disable=unused-argument,no-self-use
  250. if not vm.is_running():
  251. return
  252. # need to enumerate attached device to find frontend_dev option (at
  253. # least)
  254. for attached_device, options in self.on_device_list_attached(vm, event):
  255. if attached_device == device:
  256. vm.libvirt_domain.detachDevice(
  257. vm.app.env.get_template('libvirt/devices/block.xml').render(
  258. device=device, vm=vm, options=options))
  259. break