app.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  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 program is free software; you can redistribute it and/or modify
  9. # it under the terms of the GNU Lesser General Public License as published by
  10. # the Free Software Foundation; either version 2.1 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This program 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
  16. # GNU Lesser General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public License along
  19. # with this program; if not, see <http://www.gnu.org/licenses/>.
  20. '''
  21. Main Qubes() class and related classes.
  22. '''
  23. import shlex
  24. import socket
  25. import subprocess
  26. import logging
  27. import qubesadmin.base
  28. import qubesadmin.exc
  29. import qubesadmin.label
  30. import qubesadmin.storage
  31. import qubesadmin.utils
  32. import qubesadmin.vm
  33. import qubesadmin.config
  34. BUF_SIZE = 4096
  35. VM_ENTRY_POINT = 'qubesadmin.vm'
  36. class VMCollection(object):
  37. '''Collection of VMs objects'''
  38. def __init__(self, app):
  39. self.app = app
  40. self._vm_list = None
  41. self._vm_objects = {}
  42. def clear_cache(self):
  43. '''Clear cached list of VMs'''
  44. self._vm_list = None
  45. def refresh_cache(self, force=False):
  46. '''Refresh cached list of VMs'''
  47. if not force and self._vm_list is not None:
  48. return
  49. vm_list_data = self.app.qubesd_call(
  50. 'dom0',
  51. 'mgmt.vm.List'
  52. )
  53. new_vm_list = {}
  54. # FIXME: this will probably change
  55. for vm_data in vm_list_data.splitlines():
  56. vm_name, props = vm_data.decode('ascii').split(' ', 1)
  57. vm_name = str(vm_name)
  58. props = props.split(' ')
  59. new_vm_list[vm_name] = dict(
  60. [vm_prop.split('=', 1) for vm_prop in props])
  61. self._vm_list = new_vm_list
  62. for name, vm in list(self._vm_objects.items()):
  63. if vm.name not in self._vm_list:
  64. # VM no longer exists
  65. del self._vm_objects[name]
  66. elif vm.__class__.__name__ != self._vm_list[vm.name]['class']:
  67. # VM class have changed
  68. del self._vm_objects[name]
  69. # TODO: some generation ID, to detect VM re-creation
  70. elif name != vm.name:
  71. # renamed
  72. self._vm_objects[vm.name] = vm
  73. del self._vm_objects[name]
  74. def __getitem__(self, item):
  75. if item not in self:
  76. raise KeyError(item)
  77. if item not in self._vm_objects:
  78. cls = qubesadmin.utils.get_entry_point_one(VM_ENTRY_POINT,
  79. self._vm_list[item]['class'])
  80. self._vm_objects[item] = cls(self.app, item)
  81. return self._vm_objects[item]
  82. def __contains__(self, item):
  83. self.refresh_cache()
  84. return item in self._vm_list
  85. def __delitem__(self, key):
  86. self.app.qubesd_call(key, 'mgmt.vm.Remove')
  87. self.clear_cache()
  88. def __iter__(self):
  89. self.refresh_cache()
  90. for vm in self._vm_list:
  91. yield self[vm]
  92. def keys(self):
  93. '''Get list of VM names.'''
  94. self.refresh_cache()
  95. return self._vm_list.keys()
  96. class QubesBase(qubesadmin.base.PropertyHolder):
  97. '''Main Qubes application'''
  98. #: domains (VMs) collection
  99. domains = None
  100. #: labels collection
  101. labels = None
  102. #: storage pools
  103. pools = None
  104. #: type of qubesd connection: either 'socket' or 'qrexec'
  105. qubesd_connection_type = None
  106. #: logger
  107. log = None
  108. def __init__(self):
  109. super(QubesBase, self).__init__(self, 'mgmt.property.', 'dom0')
  110. self.domains = VMCollection(self)
  111. self.labels = qubesadmin.base.WrapperObjectsCollection(
  112. self, 'mgmt.label.List', qubesadmin.label.Label)
  113. self.pools = qubesadmin.base.WrapperObjectsCollection(
  114. self, 'mgmt.pool.List', qubesadmin.storage.Pool)
  115. #: cache for available storage pool drivers and options to create them
  116. self._pool_drivers = None
  117. self.log = logging.getLogger('app')
  118. def _refresh_pool_drivers(self):
  119. '''
  120. Refresh cached storage pool drivers and their parameters.
  121. :return: None
  122. '''
  123. if self._pool_drivers is None:
  124. pool_drivers_data = self.qubesd_call(
  125. 'dom0', 'mgmt.pool.ListDrivers', None, None)
  126. assert pool_drivers_data.endswith(b'\n')
  127. pool_drivers = {}
  128. for driver_line in pool_drivers_data.decode('ascii').splitlines():
  129. if not driver_line:
  130. continue
  131. driver_name, driver_options = driver_line.split(' ', 1)
  132. pool_drivers[driver_name] = driver_options.split(' ')
  133. self._pool_drivers = pool_drivers
  134. @property
  135. def pool_drivers(self):
  136. ''' Available storage pool drivers '''
  137. self._refresh_pool_drivers()
  138. return self._pool_drivers.keys()
  139. def pool_driver_parameters(self, driver):
  140. ''' Parameters to initialize storage pool using given driver '''
  141. self._refresh_pool_drivers()
  142. return self._pool_drivers[driver]
  143. def add_pool(self, name, driver, **kwargs):
  144. ''' Add a storage pool to config
  145. :param name: name of storage pool to create
  146. :param driver: driver to use, see :py:meth:`pool_drivers` for
  147. available drivers
  148. :param kwargs: configuration parameters for storage pool,
  149. see :py:meth:`pool_driver_parameters` for a list
  150. '''
  151. # sort parameters only to ease testing, not required by API
  152. payload = 'name={}\n'.format(name) + \
  153. ''.join('{}={}\n'.format(key, value)
  154. for key, value in sorted(kwargs.items()))
  155. self.qubesd_call('dom0', 'mgmt.pool.Add', driver,
  156. payload.encode('utf-8'))
  157. def remove_pool(self, name):
  158. ''' Remove a storage pool '''
  159. self.qubesd_call('dom0', 'mgmt.pool.Remove', name, None)
  160. def get_label(self, label):
  161. '''Get label as identified by index or name
  162. :throws KeyError: when label is not found
  163. '''
  164. # first search for name, verbatim
  165. try:
  166. return self.labels[label]
  167. except KeyError:
  168. pass
  169. # then search for index
  170. if label.isdigit():
  171. for i in self.labels:
  172. if i.index == int(label):
  173. return i
  174. raise KeyError(label)
  175. @staticmethod
  176. def get_vm_class(clsname):
  177. '''Find the class for a domain.
  178. Classes are registered as setuptools' entry points in ``qubes.vm``
  179. group. Any package may supply their own classes.
  180. :param str clsname: name of the class
  181. :return type: class
  182. '''
  183. try:
  184. return qubesadmin.utils.get_entry_point_one(
  185. VM_ENTRY_POINT, clsname)
  186. except KeyError:
  187. raise qubesadmin.exc.QubesException(
  188. 'no such VM class: {!r}'.format(clsname))
  189. # don't catch TypeError
  190. def add_new_vm(self, cls, name, label, template=None, pool=None,
  191. pools=None):
  192. '''Create new Virtual Machine
  193. Example usage with custom storage pools:
  194. >>> app = qubesadmin.Qubes()
  195. >>> pools = {'private': 'external'}
  196. >>> vm = app.add_new_vm('AppVM', 'my-new-vm', 'red',
  197. >>> 'my-template', pools=pools)
  198. >>> vm.netvm = app.domains['sys-whonix']
  199. :param str cls: name of VM class (`AppVM`, `TemplateVM` etc)
  200. :param str name: name of VM
  201. :param str label: label color for new VM
  202. :param str template: template to use (if apply for given VM class),
  203. can be also VM object; use None for default value
  204. :param str pool: storage pool to use instead of default one
  205. :param dict pools: storage pool for specific volumes
  206. :return new VM object
  207. '''
  208. if not isinstance(cls, str):
  209. cls = cls.__name__
  210. if template is not None:
  211. template = str(template)
  212. if pool and pools:
  213. raise ValueError('only one of pool= and pools= can be used')
  214. method_prefix = 'mgmt.vm.Create.'
  215. payload = 'name={} label={}'.format(name, label)
  216. if pool:
  217. payload += ' pool={}'.format(str(pool))
  218. method_prefix = 'mgmt.vm.CreateInPool.'
  219. if pools:
  220. payload += ''.join(' pool:{}={}'.format(vol, str(pool))
  221. for vol, pool in sorted(pools.items()))
  222. method_prefix = 'mgmt.vm.CreateInPool.'
  223. self.qubesd_call('dom0', method_prefix + cls, template,
  224. payload.encode('utf-8'))
  225. return self.domains[name]
  226. def clone_vm(self, src_vm, new_name, pool=None, pools=None):
  227. '''Clone Virtual Machine
  228. Example usage with custom storage pools:
  229. >>> app = qubesadmin.Qubes()
  230. >>> pools = {'private': 'external'}
  231. >>> src_vm = app.domains['personal']
  232. >>> vm = app.clone_vm(src_vm, 'my-new-vm', pools=pools)
  233. >>> vm.label = app.labels['green']
  234. :param str cls: name of VM class (`AppVM`, `TemplateVM` etc)
  235. :param str name: name of VM
  236. :param str label: label color for new VM
  237. :param str template: template to use (if apply for given VM class),
  238. can be also VM object; use None for default value
  239. :param str pool: storage pool to use instead of default one
  240. :param dict pools: storage pool for specific volumes
  241. :return new VM object
  242. '''
  243. if pool and pools:
  244. raise ValueError('only one of pool= and pools= can be used')
  245. if not isinstance(src_vm, str):
  246. src_vm = str(src_vm)
  247. method = 'mgmt.vm.Clone'
  248. payload = 'name={}'.format(new_name)
  249. if pool:
  250. payload += ' pool={}'.format(str(pool))
  251. method = 'mgmt.vm.CloneInPool'
  252. if pools:
  253. payload += ''.join(' pool:{}={}'.format(vol, str(pool))
  254. for vol, pool in sorted(pools.items()))
  255. method = 'mgmt.vm.CloneInPool'
  256. self.qubesd_call(src_vm, method, None, payload.encode('utf-8'))
  257. return self.domains[new_name]
  258. def run_service(self, dest, service, filter_esc=False, user=None,
  259. localcmd=None, **kwargs):
  260. '''Run qrexec service in a given destination
  261. *kwargs* are passed verbatim to :py:meth:`subprocess.Popen`.
  262. :param str dest: Destination - may be a VM name or empty
  263. string for default (for a given service)
  264. :param str service: service name
  265. :param bool filter_esc: filter escape sequences to protect terminal \
  266. emulator
  267. :param str user: username to run service as
  268. :param str localcmd: Command to connect stdin/stdout to
  269. :rtype: subprocess.Popen
  270. '''
  271. raise NotImplementedError
  272. class QubesLocal(QubesBase):
  273. '''Application object communicating through local socket.
  274. Used when running in dom0.
  275. '''
  276. qubesd_connection_type = 'socket'
  277. def qubesd_call(self, dest, method, arg=None, payload=None):
  278. try:
  279. client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  280. client_socket.connect(qubesadmin.config.QUBESD_SOCKET)
  281. except IOError:
  282. # TODO:
  283. raise
  284. # src, method, dest, arg
  285. for call_arg in ('dom0', method, dest, arg):
  286. if call_arg is not None:
  287. client_socket.sendall(call_arg.encode('ascii'))
  288. client_socket.sendall(b'\0')
  289. if payload is not None:
  290. client_socket.sendall(payload)
  291. client_socket.shutdown(socket.SHUT_WR)
  292. return_data = client_socket.makefile('rb').read()
  293. client_socket.close()
  294. return self._parse_qubesd_response(return_data)
  295. def run_service(self, dest, service, filter_esc=False, user=None,
  296. localcmd=None, **kwargs):
  297. '''Run qrexec service in a given destination
  298. :param str dest: Destination - may be a VM name or empty
  299. string for default (for a given service)
  300. :param str service: service name
  301. :param bool filter_esc: filter escape sequences to protect terminal \
  302. emulator
  303. :param str user: username to run service as
  304. :param str localcmd: Command to connect stdin/stdout to
  305. :rtype: subprocess.Popen
  306. '''
  307. if not dest:
  308. raise ValueError('Empty destination name allowed only from a VM')
  309. try:
  310. self.qubesd_call(dest, 'mgmt.vm.Start')
  311. except qubesadmin.exc.QubesVMNotHaltedError:
  312. pass
  313. qrexec_opts = ['-d', dest]
  314. if filter_esc:
  315. qrexec_opts.extend(['-t', '-T'])
  316. if localcmd:
  317. qrexec_opts.extend(['-l', localcmd])
  318. if user is None:
  319. user = 'DEFAULT'
  320. kwargs.setdefault('stdin', subprocess.PIPE)
  321. kwargs.setdefault('stdout', subprocess.PIPE)
  322. kwargs.setdefault('stderr', subprocess.PIPE)
  323. proc = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT] +
  324. qrexec_opts + ['{}:QUBESRPC {} dom0'.format(user, service)],
  325. **kwargs)
  326. return proc
  327. class QubesRemote(QubesBase):
  328. '''Application object communicating through qrexec services.
  329. Used when running in VM.
  330. '''
  331. qubesd_connection_type = 'qrexec'
  332. def qubesd_call(self, dest, method, arg=None, payload=None):
  333. service_name = method
  334. if arg is not None:
  335. service_name += '+' + arg
  336. p = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT_VM,
  337. dest, service_name],
  338. stdin=subprocess.PIPE, stdout=subprocess.PIPE,
  339. stderr=subprocess.PIPE)
  340. (stdout, stderr) = p.communicate(payload)
  341. if p.returncode != 0:
  342. # TODO: use dedicated exception
  343. raise qubesadmin.exc.QubesException('Service call error: %s',
  344. stderr.decode())
  345. return self._parse_qubesd_response(stdout)
  346. def run_service(self, dest, service, filter_esc=False, user=None,
  347. localcmd=None, **kwargs):
  348. '''Run qrexec service in a given destination
  349. :param str dest: Destination - may be a VM name or empty
  350. string for default (for a given service)
  351. :param str service: service name
  352. :param bool filter_esc: filter escape sequences to protect terminal \
  353. emulator
  354. :param str user: username to run service as
  355. :param str localcmd: Command to connect stdin/stdout to
  356. :rtype: subprocess.Popen
  357. '''
  358. if filter_esc:
  359. raise NotImplementedError(
  360. 'filter_esc not implemented for calls from VM')
  361. if user:
  362. raise ValueError(
  363. 'non-default user not possible for calls from VM')
  364. kwargs.setdefault('stdin', subprocess.PIPE)
  365. kwargs.setdefault('stdout', subprocess.PIPE)
  366. kwargs.setdefault('stderr', subprocess.PIPE)
  367. proc = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT_VM,
  368. dest or '', service] + (shlex.split(localcmd) if localcmd else []),
  369. **kwargs)
  370. return proc