app.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  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 os
  24. import shlex
  25. import socket
  26. import subprocess
  27. import logging
  28. import qubesadmin.base
  29. import qubesadmin.exc
  30. import qubesadmin.label
  31. import qubesadmin.storage
  32. import qubesadmin.utils
  33. import qubesadmin.vm
  34. import qubesadmin.config
  35. BUF_SIZE = 4096
  36. VM_ENTRY_POINT = 'qubesadmin.vm'
  37. class VMCollection(object):
  38. '''Collection of VMs objects'''
  39. def __init__(self, app):
  40. self.app = app
  41. self._vm_list = None
  42. self._vm_objects = {}
  43. def clear_cache(self):
  44. '''Clear cached list of VMs'''
  45. self._vm_list = None
  46. def refresh_cache(self, force=False):
  47. '''Refresh cached list of VMs'''
  48. if not force and self._vm_list is not None:
  49. return
  50. vm_list_data = self.app.qubesd_call(
  51. 'dom0',
  52. 'admin.vm.List'
  53. )
  54. new_vm_list = {}
  55. # FIXME: this will probably change
  56. for vm_data in vm_list_data.splitlines():
  57. vm_name, props = vm_data.decode('ascii').split(' ', 1)
  58. vm_name = str(vm_name)
  59. props = props.split(' ')
  60. new_vm_list[vm_name] = dict(
  61. [vm_prop.split('=', 1) for vm_prop in props])
  62. self._vm_list = new_vm_list
  63. for name, vm in list(self._vm_objects.items()):
  64. if vm.name not in self._vm_list:
  65. # VM no longer exists
  66. del self._vm_objects[name]
  67. elif vm.__class__.__name__ != self._vm_list[vm.name]['class']:
  68. # VM class have changed
  69. del self._vm_objects[name]
  70. # TODO: some generation ID, to detect VM re-creation
  71. elif name != vm.name:
  72. # renamed
  73. self._vm_objects[vm.name] = vm
  74. del self._vm_objects[name]
  75. def __getitem__(self, item):
  76. if item not in self:
  77. raise KeyError(item)
  78. if item not in self._vm_objects:
  79. cls = qubesadmin.utils.get_entry_point_one(VM_ENTRY_POINT,
  80. self._vm_list[item]['class'])
  81. self._vm_objects[item] = cls(self.app, item)
  82. return self._vm_objects[item]
  83. def __contains__(self, item):
  84. self.refresh_cache()
  85. return item in self._vm_list
  86. def __delitem__(self, key):
  87. self.app.qubesd_call(key, 'admin.vm.Remove')
  88. self.clear_cache()
  89. def __iter__(self):
  90. self.refresh_cache()
  91. for vm in sorted(self._vm_list):
  92. yield self[vm]
  93. def keys(self):
  94. '''Get list of VM names.'''
  95. self.refresh_cache()
  96. return self._vm_list.keys()
  97. class QubesBase(qubesadmin.base.PropertyHolder):
  98. '''Main Qubes application'''
  99. #: domains (VMs) collection
  100. domains = None
  101. #: labels collection
  102. labels = None
  103. #: storage pools
  104. pools = None
  105. #: type of qubesd connection: either 'socket' or 'qrexec'
  106. qubesd_connection_type = None
  107. #: logger
  108. log = None
  109. def __init__(self):
  110. super(QubesBase, self).__init__(self, 'admin.property.', 'dom0')
  111. self.domains = VMCollection(self)
  112. self.labels = qubesadmin.base.WrapperObjectsCollection(
  113. self, 'admin.label.List', qubesadmin.label.Label)
  114. self.pools = qubesadmin.base.WrapperObjectsCollection(
  115. self, 'admin.pool.List', qubesadmin.storage.Pool)
  116. #: cache for available storage pool drivers and options to create them
  117. self._pool_drivers = None
  118. self.log = logging.getLogger('app')
  119. def _refresh_pool_drivers(self):
  120. '''
  121. Refresh cached storage pool drivers and their parameters.
  122. :return: None
  123. '''
  124. if self._pool_drivers is None:
  125. pool_drivers_data = self.qubesd_call(
  126. 'dom0', 'admin.pool.ListDrivers', None, None)
  127. assert pool_drivers_data.endswith(b'\n')
  128. pool_drivers = {}
  129. for driver_line in pool_drivers_data.decode('ascii').splitlines():
  130. if not driver_line:
  131. continue
  132. driver_name, driver_options = driver_line.split(' ', 1)
  133. pool_drivers[driver_name] = driver_options.split(' ')
  134. self._pool_drivers = pool_drivers
  135. @property
  136. def pool_drivers(self):
  137. ''' Available storage pool drivers '''
  138. self._refresh_pool_drivers()
  139. return self._pool_drivers.keys()
  140. def pool_driver_parameters(self, driver):
  141. ''' Parameters to initialize storage pool using given driver '''
  142. self._refresh_pool_drivers()
  143. return self._pool_drivers[driver]
  144. def add_pool(self, name, driver, **kwargs):
  145. ''' Add a storage pool to config
  146. :param name: name of storage pool to create
  147. :param driver: driver to use, see :py:meth:`pool_drivers` for
  148. available drivers
  149. :param kwargs: configuration parameters for storage pool,
  150. see :py:meth:`pool_driver_parameters` for a list
  151. '''
  152. # sort parameters only to ease testing, not required by API
  153. payload = 'name={}\n'.format(name) + \
  154. ''.join('{}={}\n'.format(key, value)
  155. for key, value in sorted(kwargs.items()))
  156. self.qubesd_call('dom0', 'admin.pool.Add', driver,
  157. payload.encode('utf-8'))
  158. def remove_pool(self, name):
  159. ''' Remove a storage pool '''
  160. self.qubesd_call('dom0', 'admin.pool.Remove', name, None)
  161. def get_label(self, label):
  162. '''Get label as identified by index or name
  163. :throws KeyError: when label is not found
  164. '''
  165. # first search for name, verbatim
  166. try:
  167. return self.labels[label]
  168. except KeyError:
  169. pass
  170. # then search for index
  171. if label.isdigit():
  172. for i in self.labels:
  173. if i.index == int(label):
  174. return i
  175. raise KeyError(label)
  176. @staticmethod
  177. def get_vm_class(clsname):
  178. '''Find the class for a domain.
  179. Classes are registered as setuptools' entry points in ``qubes.vm``
  180. group. Any package may supply their own classes.
  181. :param str clsname: name of the class
  182. :return type: class
  183. '''
  184. try:
  185. return qubesadmin.utils.get_entry_point_one(
  186. VM_ENTRY_POINT, clsname)
  187. except KeyError:
  188. raise qubesadmin.exc.QubesException(
  189. 'no such VM class: {!r}'.format(clsname))
  190. # don't catch TypeError
  191. def add_new_vm(self, cls, name, label, template=None, pool=None,
  192. pools=None):
  193. '''Create new Virtual Machine
  194. Example usage with custom storage pools:
  195. >>> app = qubesadmin.Qubes()
  196. >>> pools = {'private': 'external'}
  197. >>> vm = app.add_new_vm('AppVM', 'my-new-vm', 'red',
  198. >>> 'my-template', pools=pools)
  199. >>> vm.netvm = app.domains['sys-whonix']
  200. :param str cls: name of VM class (`AppVM`, `TemplateVM` etc)
  201. :param str name: name of VM
  202. :param str label: label color for new VM
  203. :param str template: template to use (if apply for given VM class),
  204. can be also VM object; use None for default value
  205. :param str pool: storage pool to use instead of default one
  206. :param dict pools: storage pool for specific volumes
  207. :return new VM object
  208. '''
  209. if not isinstance(cls, str):
  210. cls = cls.__name__
  211. if template is not None:
  212. template = str(template)
  213. if pool and pools:
  214. raise ValueError('only one of pool= and pools= can be used')
  215. method_prefix = 'admin.vm.Create.'
  216. payload = 'name={} label={}'.format(name, label)
  217. if pool:
  218. payload += ' pool={}'.format(str(pool))
  219. method_prefix = 'admin.vm.CreateInPool.'
  220. if pools:
  221. payload += ''.join(' pool:{}={}'.format(vol, str(pool))
  222. for vol, pool in sorted(pools.items()))
  223. method_prefix = 'admin.vm.CreateInPool.'
  224. self.qubesd_call('dom0', method_prefix + cls, template,
  225. payload.encode('utf-8'))
  226. self.domains.clear_cache()
  227. return self.domains[name]
  228. def clone_vm(self, src_vm, new_name, pool=None, pools=None):
  229. '''Clone Virtual Machine
  230. Example usage with custom storage pools:
  231. >>> app = qubesadmin.Qubes()
  232. >>> pools = {'private': 'external'}
  233. >>> src_vm = app.domains['personal']
  234. >>> vm = app.clone_vm(src_vm, 'my-new-vm', pools=pools)
  235. >>> vm.label = app.labels['green']
  236. :param str cls: name of VM class (`AppVM`, `TemplateVM` etc)
  237. :param str name: name of VM
  238. :param str label: label color for new VM
  239. :param str template: template to use (if apply for given VM class),
  240. can be also VM object; use None for default value
  241. :param str pool: storage pool to use instead of default one
  242. :param dict pools: storage pool for specific volumes
  243. :return new VM object
  244. '''
  245. if pool and pools:
  246. raise ValueError('only one of pool= and pools= can be used')
  247. if not isinstance(src_vm, str):
  248. src_vm = str(src_vm)
  249. method = 'admin.vm.Clone'
  250. payload = 'name={}'.format(new_name)
  251. if pool:
  252. payload += ' pool={}'.format(str(pool))
  253. method = 'admin.vm.CloneInPool'
  254. if pools:
  255. payload += ''.join(' pool:{}={}'.format(vol, str(pool))
  256. for vol, pool in sorted(pools.items()))
  257. method = 'admin.vm.CloneInPool'
  258. self.qubesd_call(src_vm, method, None, payload.encode('utf-8'))
  259. return self.domains[new_name]
  260. def run_service(self, dest, service, filter_esc=False, user=None,
  261. localcmd=None, wait=True, **kwargs):
  262. '''Run qrexec service in a given destination
  263. *kwargs* are passed verbatim to :py:meth:`subprocess.Popen`.
  264. :param str dest: Destination - may be a VM name or empty
  265. string for default (for a given service)
  266. :param str service: service name
  267. :param bool filter_esc: filter escape sequences to protect terminal \
  268. emulator
  269. :param str user: username to run service as
  270. :param str localcmd: Command to connect stdin/stdout to
  271. :rtype: subprocess.Popen
  272. '''
  273. raise NotImplementedError
  274. class QubesLocal(QubesBase):
  275. '''Application object communicating through local socket.
  276. Used when running in dom0.
  277. '''
  278. qubesd_connection_type = 'socket'
  279. def qubesd_call(self, dest, method, arg=None, payload=None,
  280. payload_stream=None):
  281. '''
  282. Execute Admin API method.
  283. Only one of `payload` and `payload_stream` can be specified.
  284. :param dest: Destination VM name
  285. :param method: Full API method name ('admin...')
  286. :param arg: Method argument (if any)
  287. :param payload: Payload send to the method
  288. :param payload_stream: file-like object to read payload from
  289. :return: Data returned by qubesd (string)
  290. '''
  291. if payload and payload_stream:
  292. raise ValueError(
  293. 'Only one of payload and payload_stream can be used')
  294. if payload_stream:
  295. # payload_stream can be used for large amount of data,
  296. # so optimize for throughput, not latency: spawn actual qrexec
  297. # service implementation, which may use some optimization there (
  298. # see admin.vm.volume.Import - actual data handling is done with dd)
  299. method_path = os.path.join(
  300. qubesadmin.config.QREXEC_SERVICES_DIR, method)
  301. if not os.path.exists(method_path):
  302. raise qubesadmin.exc.QubesDaemonCommunicationError(
  303. '{} not found'.format(method_path))
  304. qrexec_call_env = os.environ.copy()
  305. qrexec_call_env['QREXEC_REMOTE_DOMAIN'] = 'dom0'
  306. qrexec_call_env['QREXEC_REQUESTED_TARGET'] = dest
  307. proc = subprocess.Popen([method_path, arg], stdin=payload_stream,
  308. stdout=subprocess.PIPE, env=qrexec_call_env)
  309. (return_data, _) = proc.communicate()
  310. return self._parse_qubesd_response(return_data)
  311. try:
  312. client_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  313. client_socket.connect(qubesadmin.config.QUBESD_SOCKET)
  314. except IOError:
  315. # TODO:
  316. raise
  317. # src, method, dest, arg
  318. for call_arg in ('dom0', method, dest, arg):
  319. if call_arg is not None:
  320. client_socket.sendall(call_arg.encode('ascii'))
  321. client_socket.sendall(b'\0')
  322. if payload is not None:
  323. client_socket.sendall(payload)
  324. client_socket.shutdown(socket.SHUT_WR)
  325. return_data = client_socket.makefile('rb').read()
  326. client_socket.close()
  327. return self._parse_qubesd_response(return_data)
  328. def run_service(self, dest, service, filter_esc=False, user=None,
  329. localcmd=None, wait=True, **kwargs):
  330. '''Run qrexec service in a given destination
  331. :param str dest: Destination - may be a VM name or empty
  332. string for default (for a given service)
  333. :param str service: service name
  334. :param bool filter_esc: filter escape sequences to protect terminal \
  335. emulator
  336. :param str user: username to run service as
  337. :param str localcmd: Command to connect stdin/stdout to
  338. :param bool wait: wait for remote process to finish
  339. :rtype: subprocess.Popen
  340. '''
  341. if not dest:
  342. raise ValueError('Empty destination name allowed only from a VM')
  343. if not wait and localcmd:
  344. raise ValueError('wait=False incompatible with localcmd')
  345. try:
  346. self.qubesd_call(dest, 'admin.vm.Start')
  347. except qubesadmin.exc.QubesVMNotHaltedError:
  348. pass
  349. qrexec_opts = ['-d', dest]
  350. if filter_esc:
  351. qrexec_opts.extend(['-t', '-T'])
  352. if localcmd:
  353. qrexec_opts.extend(['-l', localcmd])
  354. if user is None:
  355. user = 'DEFAULT'
  356. if not wait:
  357. qrexec_opts.extend(['-e'])
  358. kwargs.setdefault('stdin', subprocess.PIPE)
  359. kwargs.setdefault('stdout', subprocess.PIPE)
  360. kwargs.setdefault('stderr', subprocess.PIPE)
  361. proc = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT] +
  362. qrexec_opts + ['{}:QUBESRPC {} dom0'.format(user, service)],
  363. **kwargs)
  364. return proc
  365. class QubesRemote(QubesBase):
  366. '''Application object communicating through qrexec services.
  367. Used when running in VM.
  368. '''
  369. qubesd_connection_type = 'qrexec'
  370. def qubesd_call(self, dest, method, arg=None, payload=None,
  371. payload_stream=None):
  372. '''
  373. Execute Admin API method.
  374. Only one of `payload` and `payload_stream` can be specified.
  375. :param dest: Destination VM name
  376. :param method: Full API method name ('admin...')
  377. :param arg: Method argument (if any)
  378. :param payload: Payload send to the method
  379. :param payload_stream: file-like object to read payload from
  380. :return: Data returned by qubesd (string)
  381. '''
  382. if payload and payload_stream:
  383. raise ValueError(
  384. 'Only one of payload and payload_stream can be used')
  385. service_name = method
  386. if arg is not None:
  387. service_name += '+' + arg
  388. p = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT_VM,
  389. dest, service_name],
  390. stdin=(payload_stream or subprocess.PIPE),
  391. stdout=subprocess.PIPE,
  392. stderr=subprocess.PIPE)
  393. (stdout, stderr) = p.communicate(payload)
  394. if p.returncode != 0:
  395. # TODO: use dedicated exception
  396. raise qubesadmin.exc.QubesException('Service call error: %s',
  397. stderr.decode())
  398. return self._parse_qubesd_response(stdout)
  399. def run_service(self, dest, service, filter_esc=False, user=None,
  400. localcmd=None, wait=True, **kwargs):
  401. '''Run qrexec service in a given destination
  402. :param str dest: Destination - may be a VM name or empty
  403. string for default (for a given service)
  404. :param str service: service name
  405. :param bool filter_esc: filter escape sequences to protect terminal \
  406. emulator
  407. :param str user: username to run service as
  408. :param str localcmd: Command to connect stdin/stdout to
  409. :param bool wait: wait for process to finish
  410. :rtype: subprocess.Popen
  411. '''
  412. if filter_esc:
  413. raise NotImplementedError(
  414. 'filter_esc not implemented for calls from VM')
  415. if user:
  416. raise ValueError(
  417. 'non-default user not possible for calls from VM')
  418. if not wait and localcmd:
  419. raise ValueError('wait=False incompatible with localcmd')
  420. if not wait:
  421. # qrexec-client-vm can only request service calls, which are
  422. # started using MSG_EXEC_CMDLINE qrexec protocol message; this
  423. # message means "start the process, pipe its stdin/out/err,
  424. # and when it terminates, send exit code back".
  425. # According to the protocol qrexec-client-vm needs to wait for
  426. # MSG_DATA_EXIT_CODE, so implementing wait=False would require
  427. # some protocol change (or protocol violation).
  428. raise NotImplementedError(
  429. 'wait=False not implemented for calls from VM')
  430. kwargs.setdefault('stdin', subprocess.PIPE)
  431. kwargs.setdefault('stdout', subprocess.PIPE)
  432. kwargs.setdefault('stderr', subprocess.PIPE)
  433. proc = subprocess.Popen([qubesadmin.config.QREXEC_CLIENT_VM,
  434. dest or '', service] + (shlex.split(localcmd) if localcmd else []),
  435. **kwargs)
  436. return proc