mgmt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2017 Wojtek Porczyk <woju@invisiblethingslab.com>
  5. #
  6. # This program is free software; you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation; either version 2 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License along
  17. # with this program; if not, write to the Free Software Foundation, Inc.,
  18. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  19. #
  20. '''
  21. Qubes OS Management API
  22. '''
  23. import asyncio
  24. import functools
  25. import string
  26. import pkg_resources
  27. import qubes.vm
  28. import qubes.vm.qubesvm
  29. import qubes.storage
  30. class ProtocolError(AssertionError):
  31. '''Raised when something is wrong with data received'''
  32. pass
  33. class PermissionDenied(Exception):
  34. '''Raised deliberately by handlers when we decide not to cooperate'''
  35. pass
  36. def api(name, *, no_payload=False):
  37. '''Decorator factory for methods intended to appear in API.
  38. The decorated method can be called from public API using a child of
  39. :py:class:`AbstractQubesMgmt` class. The method becomes "public", and can be
  40. called using remote management interface.
  41. :param str name: qrexec rpc method name
  42. :param bool no_payload: if :py:obj:`True`, will barf on non-empty payload; \
  43. also will not pass payload at all to the method
  44. The expected function method should have one argument (other than usual
  45. *self*), ``untrusted_payload``, which will contain the payload.
  46. .. warning::
  47. This argument has to be named such, to remind the programmer that the
  48. content of this variable is indeed untrusted.
  49. If *no_payload* is true, then the method is called with no arguments.
  50. '''
  51. # TODO regexp for vm/dev classess; supply regexp groups as untrusted_ kwargs
  52. def decorator(func):
  53. if no_payload:
  54. # the following assignment is needed for how closures work in Python
  55. _func = func
  56. @functools.wraps(_func)
  57. def wrapper(self, untrusted_payload):
  58. if untrusted_payload != b'':
  59. raise ProtocolError('unexpected payload')
  60. return _func(self)
  61. func = wrapper
  62. func._rpcname = name # pylint: disable=protected-access
  63. return func
  64. return decorator
  65. class AbstractQubesMgmt(object):
  66. '''Common code for Qubes Management Protocol handling
  67. Different interfaces can expose different API call sets, however they share
  68. common protocol and common implementation framework. This class is the
  69. latter.
  70. To implement a new interface, inherit from this class and write at least one
  71. method and decorate it with :py:func:`api` decorator. It will have access to
  72. pre-defined attributes: :py:attr:`app`, :py:attr:`src`, :py:attr:`dest`,
  73. :py:attr:`arg` and :py:attr:`method`.
  74. There are also two helper functions for firing events associated with API
  75. calls.
  76. '''
  77. def __init__(self, app, src, method, dest, arg, send_event=None):
  78. #: :py:class:`qubes.Qubes` object
  79. self.app = app
  80. #: source qube
  81. self.src = self.app.domains[src.decode('ascii')]
  82. #: destination qube
  83. self.dest = self.app.domains[dest.decode('ascii')]
  84. #: argument
  85. self.arg = arg.decode('ascii')
  86. #: name of the method
  87. self.method = method.decode('ascii')
  88. #: callback for sending events if applicable
  89. self.send_event = send_event
  90. #: is this operation cancellable?
  91. self.cancellable = False
  92. untrusted_candidates = []
  93. for attr in dir(self):
  94. untrusted_func = getattr(self, attr)
  95. if not callable(untrusted_func):
  96. continue
  97. try:
  98. # pylint: disable=protected-access
  99. if untrusted_func._rpcname != self.method:
  100. continue
  101. except AttributeError:
  102. continue
  103. untrusted_candidates.append(untrusted_func)
  104. if not untrusted_candidates:
  105. raise ProtocolError('no such method: {!r}'.format(self.method))
  106. assert len(untrusted_candidates) == 1, \
  107. 'multiple candidates for method {!r}'.format(self.method)
  108. #: the method to execute
  109. self._handler = untrusted_candidates[0]
  110. self._running_handler = None
  111. del untrusted_candidates
  112. def execute(self, *, untrusted_payload):
  113. '''Execute management operation.
  114. This method is a coroutine.
  115. '''
  116. self._running_handler = asyncio.ensure_future(self._handler(
  117. untrusted_payload=untrusted_payload))
  118. return self._running_handler
  119. def cancel(self):
  120. '''If operation is cancellable, interrupt it'''
  121. if self.cancellable and self._running_handler is not None:
  122. self._running_handler.cancel()
  123. def fire_event_for_permission(self, **kwargs):
  124. '''Fire an event on the source qube to check for permission'''
  125. return self.src.fire_event_pre('mgmt-permission:{}'.format(self.method),
  126. dest=self.dest, arg=self.arg, **kwargs)
  127. def fire_event_for_filter(self, iterable, **kwargs):
  128. '''Fire an event on the source qube to filter for permission'''
  129. for selector in self.fire_event_for_permission(**kwargs):
  130. iterable = filter(selector, iterable)
  131. return iterable
  132. class QubesMgmt(AbstractQubesMgmt):
  133. '''Implementation of Qubes Management API calls
  134. This class contains all the methods available in the main API.
  135. .. seealso::
  136. https://www.qubes-os.org/doc/mgmt1/
  137. '''
  138. @api('mgmt.vmclass.List', no_payload=True)
  139. @asyncio.coroutine
  140. def vmclass_list(self):
  141. '''List all VM classes'''
  142. assert not self.arg
  143. assert self.dest.name == 'dom0'
  144. entrypoints = self.fire_event_for_filter(
  145. pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT))
  146. return ''.join('{}\n'.format(ep.name)
  147. for ep in entrypoints)
  148. @api('mgmt.vm.List', no_payload=True)
  149. @asyncio.coroutine
  150. def vm_list(self):
  151. '''List all the domains'''
  152. assert not self.arg
  153. if self.dest.name == 'dom0':
  154. domains = self.fire_event_for_filter(self.app.domains)
  155. else:
  156. domains = self.fire_event_for_filter([self.dest])
  157. return ''.join('{} class={} state={}\n'.format(
  158. vm.name,
  159. vm.__class__.__name__,
  160. vm.get_power_state())
  161. for vm in sorted(domains))
  162. @api('mgmt.vm.property.List', no_payload=True)
  163. @asyncio.coroutine
  164. def vm_property_list(self):
  165. '''List all properties on a qube'''
  166. assert not self.arg
  167. properties = self.fire_event_for_filter(self.dest.property_list())
  168. return ''.join('{}\n'.format(prop.__name__) for prop in properties)
  169. @api('mgmt.vm.property.Get', no_payload=True)
  170. @asyncio.coroutine
  171. def vm_property_get(self):
  172. '''Get a value of one property'''
  173. assert self.arg in self.dest.property_list()
  174. self.fire_event_for_permission()
  175. property_def = self.dest.property_get_def(self.arg)
  176. # explicit list to be sure that it matches protocol spec
  177. if isinstance(property_def, qubes.vm.VMProperty):
  178. property_type = 'vm'
  179. elif property_def.type is int:
  180. property_type = 'int'
  181. elif property_def.type is bool:
  182. property_type = 'bool'
  183. elif self.arg == 'label':
  184. property_type = 'label'
  185. else:
  186. property_type = 'str'
  187. try:
  188. value = getattr(self.dest, self.arg)
  189. except AttributeError:
  190. return 'default=True type={} '.format(property_type)
  191. else:
  192. return 'default={} type={} {}'.format(
  193. str(self.dest.property_is_default(self.arg)),
  194. property_type,
  195. str(value) if value is not None else '')
  196. @api('mgmt.vm.property.Set')
  197. @asyncio.coroutine
  198. def vm_property_set(self, untrusted_payload):
  199. assert self.arg in self.dest.property_list()
  200. property_def = self.dest.property_get_def(self.arg)
  201. newvalue = property_def.sanitize(untrusted_newvalue=untrusted_payload)
  202. self.fire_event_for_permission(newvalue=newvalue)
  203. setattr(self.dest, self.arg, newvalue)
  204. self.app.save()
  205. @api('mgmt.vm.property.Help', no_payload=True)
  206. @asyncio.coroutine
  207. def vm_property_help(self):
  208. '''Get help for one property'''
  209. assert self.arg in self.dest.property_list()
  210. self.fire_event_for_permission()
  211. try:
  212. doc = self.dest.property_get_def(self.arg).__doc__
  213. except AttributeError:
  214. return ''
  215. return qubes.utils.format_doc(doc)
  216. @api('mgmt.vm.property.Reset', no_payload=True)
  217. @asyncio.coroutine
  218. def vm_property_reset(self):
  219. '''Reset a property to a default value'''
  220. assert self.arg in self.dest.property_list()
  221. self.fire_event_for_permission()
  222. delattr(self.dest, self.arg)
  223. self.app.save()
  224. @api('mgmt.vm.volume.List', no_payload=True)
  225. @asyncio.coroutine
  226. def vm_volume_list(self):
  227. assert not self.arg
  228. volume_names = self.fire_event_for_filter(self.dest.volumes.keys())
  229. return ''.join('{}\n'.format(name) for name in volume_names)
  230. @api('mgmt.vm.volume.Info', no_payload=True)
  231. @asyncio.coroutine
  232. def vm_volume_info(self):
  233. assert self.arg in self.dest.volumes.keys()
  234. self.fire_event_for_permission()
  235. volume = self.dest.volumes[self.arg]
  236. # properties defined in API
  237. volume_properties = [
  238. 'pool', 'vid', 'size', 'usage', 'rw', 'internal', 'source',
  239. 'save_on_stop', 'snap_on_start']
  240. return ''.join('{}={}\n'.format(key, getattr(volume, key)) for key in
  241. volume_properties)
  242. @api('mgmt.vm.volume.ListSnapshots', no_payload=True)
  243. @asyncio.coroutine
  244. def vm_volume_listsnapshots(self):
  245. assert self.arg in self.dest.volumes.keys()
  246. volume = self.dest.volumes[self.arg]
  247. revisions = [revision for revision in volume.revisions]
  248. revisions = self.fire_event_for_filter(revisions)
  249. return ''.join('{}\n'.format(revision) for revision in revisions)
  250. @api('mgmt.vm.volume.Revert')
  251. @asyncio.coroutine
  252. def vm_volume_revert(self, untrusted_payload):
  253. assert self.arg in self.dest.volumes.keys()
  254. untrusted_revision = untrusted_payload.decode('ascii').strip()
  255. del untrusted_payload
  256. volume = self.dest.volumes[self.arg]
  257. snapshots = volume.revisions
  258. assert untrusted_revision in snapshots
  259. revision = untrusted_revision
  260. self.fire_event_for_permission(revision=revision)
  261. self.dest.storage.get_pool(volume).revert(revision)
  262. self.app.save()
  263. @api('mgmt.vm.volume.Resize')
  264. @asyncio.coroutine
  265. def vm_volume_resize(self, untrusted_payload):
  266. assert self.arg in self.dest.volumes.keys()
  267. untrusted_size = untrusted_payload.decode('ascii').strip()
  268. del untrusted_payload
  269. assert untrusted_size.isdigit() # only digits, forbid '-' too
  270. assert len(untrusted_size) <= 20 # limit to about 2^64
  271. size = int(untrusted_size)
  272. self.fire_event_for_permission(size=size)
  273. self.dest.storage.resize(self.arg, size)
  274. self.app.save()
  275. @api('mgmt.pool.List', no_payload=True)
  276. @asyncio.coroutine
  277. def pool_list(self):
  278. assert not self.arg
  279. assert self.dest.name == 'dom0'
  280. pools = self.fire_event_for_filter(self.app.pools)
  281. return ''.join('{}\n'.format(pool) for pool in pools)
  282. @api('mgmt.pool.ListDrivers', no_payload=True)
  283. @asyncio.coroutine
  284. def pool_listdrivers(self):
  285. assert self.dest.name == 'dom0'
  286. assert not self.arg
  287. drivers = self.fire_event_for_filter(qubes.storage.pool_drivers())
  288. return ''.join('{} {}\n'.format(
  289. driver,
  290. ' '.join(qubes.storage.driver_parameters(driver)))
  291. for driver in drivers)
  292. @api('mgmt.pool.Info', no_payload=True)
  293. @asyncio.coroutine
  294. def pool_info(self):
  295. assert self.dest.name == 'dom0'
  296. assert self.arg in self.app.pools.keys()
  297. pool = self.app.pools[self.arg]
  298. self.fire_event_for_permission(pool=pool)
  299. return ''.join('{}={}\n'.format(prop, val)
  300. for prop, val in sorted(pool.config.items()))
  301. @api('mgmt.pool.Add')
  302. @asyncio.coroutine
  303. def pool_add(self, untrusted_payload):
  304. assert self.dest.name == 'dom0'
  305. drivers = qubes.storage.pool_drivers()
  306. assert self.arg in drivers
  307. untrusted_pool_config = untrusted_payload.decode('ascii').splitlines()
  308. del untrusted_payload
  309. assert all(('=' in line) for line in untrusted_pool_config)
  310. # pairs of (option, value)
  311. untrusted_pool_config = [line.split('=', 1)
  312. for line in untrusted_pool_config]
  313. # reject duplicated options
  314. assert len(set(x[0] for x in untrusted_pool_config)) == \
  315. len([x[0] for x in untrusted_pool_config])
  316. # and convert to dict
  317. untrusted_pool_config = dict(untrusted_pool_config)
  318. assert 'name' in untrusted_pool_config
  319. untrusted_pool_name = untrusted_pool_config.pop('name')
  320. allowed_chars = string.ascii_letters + string.digits + '-_.'
  321. assert all(c in allowed_chars for c in untrusted_pool_name)
  322. pool_name = untrusted_pool_name
  323. assert pool_name not in self.app.pools
  324. driver_parameters = qubes.storage.driver_parameters(self.arg)
  325. assert all(key in driver_parameters for key in untrusted_pool_config)
  326. pool_config = untrusted_pool_config
  327. self.fire_event_for_permission(name=pool_name,
  328. pool_config=pool_config)
  329. self.app.add_pool(name=pool_name, driver=self.arg, **pool_config)
  330. self.app.save()
  331. @api('mgmt.pool.Remove', no_payload=True)
  332. @asyncio.coroutine
  333. def pool_remove(self):
  334. assert self.dest.name == 'dom0'
  335. assert self.arg in self.app.pools.keys()
  336. self.fire_event_for_permission()
  337. self.app.remove_pool(self.arg)
  338. self.app.save()
  339. @api('mgmt.label.List', no_payload=True)
  340. @asyncio.coroutine
  341. def label_list(self):
  342. assert self.dest.name == 'dom0'
  343. assert not self.arg
  344. labels = self.fire_event_for_filter(self.app.labels.values())
  345. return ''.join('{}\n'.format(label.name) for label in labels)
  346. @api('mgmt.label.Get', no_payload=True)
  347. @asyncio.coroutine
  348. def label_get(self):
  349. assert self.dest.name == 'dom0'
  350. try:
  351. label = self.app.get_label(self.arg)
  352. except KeyError:
  353. raise qubes.exc.QubesValueError
  354. self.fire_event_for_permission(label=label)
  355. return label.color
  356. @api('mgmt.label.Index', no_payload=True)
  357. @asyncio.coroutine
  358. def label_index(self):
  359. assert self.dest.name == 'dom0'
  360. try:
  361. label = self.app.get_label(self.arg)
  362. except KeyError:
  363. raise qubes.exc.QubesValueError
  364. self.fire_event_for_permission(label=label)
  365. return str(label.index)
  366. @api('mgmt.label.Create')
  367. @asyncio.coroutine
  368. def label_create(self, untrusted_payload):
  369. assert self.dest.name == 'dom0'
  370. # don't confuse label name with label index
  371. assert not self.arg.isdigit()
  372. allowed_chars = string.ascii_letters + string.digits + '-_.'
  373. assert all(c in allowed_chars for c in self.arg)
  374. try:
  375. self.app.get_label(self.arg)
  376. except KeyError:
  377. # ok, no such label yet
  378. pass
  379. else:
  380. raise qubes.exc.QubesValueError('label already exists')
  381. untrusted_payload = untrusted_payload.decode('ascii').strip()
  382. assert len(untrusted_payload) == 8
  383. assert untrusted_payload.startswith('0x')
  384. # besides prefix, only hex digits are allowed
  385. assert all(x in string.hexdigits for x in untrusted_payload[2:])
  386. # SEE: #2732
  387. color = untrusted_payload
  388. self.fire_event_for_permission(color=color)
  389. # allocate new index, but make sure it's outside of default labels set
  390. new_index = max(
  391. qubes.config.max_default_label, *self.app.labels.keys()) + 1
  392. label = qubes.Label(new_index, color, self.arg)
  393. self.app.labels[new_index] = label
  394. self.app.save()
  395. @api('mgmt.label.Remove', no_payload=True)
  396. @asyncio.coroutine
  397. def label_remove(self):
  398. assert self.dest.name == 'dom0'
  399. try:
  400. label = self.app.get_label(self.arg)
  401. except KeyError:
  402. raise qubes.exc.QubesValueError
  403. # don't allow removing default labels
  404. assert label.index > qubes.config.max_default_label
  405. # FIXME: this should be in app.add_label()
  406. for vm in self.app.domains:
  407. if vm.label == label:
  408. raise qubes.exc.QubesException('label still in use')
  409. self.fire_event_for_permission(label=label)
  410. del self.app.labels[label.index]
  411. self.app.save()
  412. @api('mgmt.vm.Start', no_payload=True)
  413. @asyncio.coroutine
  414. def vm_start(self):
  415. assert not self.arg
  416. self.fire_event_for_permission()
  417. yield from self.dest.start()
  418. @api('mgmt.vm.Shutdown', no_payload=True)
  419. @asyncio.coroutine
  420. def vm_shutdown(self):
  421. assert not self.arg
  422. self.fire_event_for_permission()
  423. yield from self.dest.shutdown()
  424. @api('mgmt.vm.Pause', no_payload=True)
  425. @asyncio.coroutine
  426. def vm_pause(self):
  427. assert not self.arg
  428. self.fire_event_for_permission()
  429. yield from self.dest.pause()
  430. @api('mgmt.vm.Unpause', no_payload=True)
  431. @asyncio.coroutine
  432. def vm_unpause(self):
  433. assert not self.arg
  434. self.fire_event_for_permission()
  435. yield from self.dest.unpause()
  436. @api('mgmt.vm.Kill', no_payload=True)
  437. @asyncio.coroutine
  438. def vm_kill(self):
  439. assert not self.arg
  440. self.fire_event_for_permission()
  441. yield from self.dest.kill()
  442. @api('mgmt.Events', no_payload=True)
  443. @asyncio.coroutine
  444. def events(self):
  445. assert not self.arg
  446. # run until client connection is terminated
  447. self.cancellable = True
  448. wait_for_cancel = asyncio.get_event_loop().create_future()
  449. # cache event filters, to not call an event each time an event arrives
  450. event_filters = self.fire_event_for_permission()
  451. def handler(subject, event, **kwargs):
  452. if self.dest.name != 'dom0' and subject != self.dest:
  453. return
  454. if event.startswith('mgmt-permission:'):
  455. return
  456. for selector in event_filters:
  457. if not selector((subject, event, kwargs)):
  458. return
  459. self.send_event(subject, event, **kwargs)
  460. if self.dest.name == 'dom0':
  461. type(self.app).add_handler('*', handler)
  462. qubes.vm.BaseVM.add_handler('*', handler)
  463. # send artificial event as a confirmation that connection is established
  464. self.send_event(self.app, 'connection-established')
  465. try:
  466. yield from wait_for_cancel
  467. except asyncio.CancelledError:
  468. # the above waiting was already interrupted, this is all we need
  469. pass
  470. if self.dest.name == 'dom0':
  471. type(self.app).remove_handler('*', handler)
  472. qubes.vm.BaseVM.remove_handler('*', handler)
  473. @api('mgmt.vm.feature.List', no_payload=True)
  474. @asyncio.coroutine
  475. def vm_feature_list(self):
  476. assert not self.arg
  477. features = self.fire_event_for_filter(self.dest.features.keys())
  478. return ''.join('{}\n'.format(feature) for feature in features)
  479. @api('mgmt.vm.feature.Get', no_payload=True)
  480. @asyncio.coroutine
  481. def vm_feature_get(self):
  482. # validation of self.arg done by qrexec-policy is enough
  483. self.fire_event_for_permission()
  484. try:
  485. value = self.dest.features[self.arg]
  486. except KeyError:
  487. raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
  488. return value
  489. @api('mgmt.vm.feature.CheckWithTemplate', no_payload=True)
  490. @asyncio.coroutine
  491. def vm_feature_checkwithtemplate(self):
  492. # validation of self.arg done by qrexec-policy is enough
  493. self.fire_event_for_permission()
  494. try:
  495. value = self.dest.features.check_with_template(self.arg)
  496. except KeyError:
  497. raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
  498. return value
  499. @api('mgmt.vm.feature.Remove', no_payload=True)
  500. @asyncio.coroutine
  501. def vm_feature_remove(self):
  502. # validation of self.arg done by qrexec-policy is enough
  503. self.fire_event_for_permission()
  504. try:
  505. del self.dest.features[self.arg]
  506. except KeyError:
  507. raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
  508. self.app.save()
  509. @api('mgmt.vm.feature.Set')
  510. @asyncio.coroutine
  511. def vm_feature_set(self, untrusted_payload):
  512. # validation of self.arg done by qrexec-policy is enough
  513. value = untrusted_payload.decode('ascii', errors='strict')
  514. del untrusted_payload
  515. self.fire_event_for_permission(value=value)
  516. self.dest.features[self.arg] = value
  517. self.app.save()