callback.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. #
  2. # The Qubes OS Project, https://www.qubes-os.org/
  3. #
  4. # Copyright (C) 2020 David Hobach <david@hobach.de>
  5. #
  6. # This library is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU Lesser General Public
  8. # License as published by the Free Software Foundation; either
  9. # version 2.1 of the License, or (at your option) any later version.
  10. #
  11. # This library 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 GNU
  14. # Lesser General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Lesser General Public
  17. # License along with this library; if not, see <https://www.gnu.org/licenses/>.
  18. #
  19. import logging
  20. import subprocess
  21. import importlib
  22. import json
  23. from shlex import quote
  24. import qubes.storage
  25. class CallbackPool(qubes.storage.Pool):
  26. ''' Proxy storage pool driver adding callback functionality to other pool drivers.
  27. This way, users can extend storage pool drivers with custom functionality using the programming language of their choice.
  28. All configuration for this pool driver must be done in `/etc/qubes_callback.json`. Each configuration ID `conf_id` can be used
  29. to create a callback pool with e.g. `qvm-pool -o conf_id=your_conf_id -a pool_name callback`.
  30. Check the `qubes_callback.json.example` for an overview of the available options.
  31. Example applications of this driver:
  32. - custom pool mounts
  33. - encryption
  34. - debugging
  35. **Integration tests**:
  36. (all of these tests assume the `qubes_callback.json.example` configuration)
  37. Tests that should **fail**:
  38. ```
  39. qvm-pool -a test callback
  40. qvm-pool -o conf_id=non-existing -a test callback
  41. qvm-pool -o conf_id=conf_id -a test callback
  42. qvm-pool -o conf_id=testing-fail-missing-all -a test callback
  43. qvm-pool -o conf_id=testing-fail-missing-bdriver-args -a test callback
  44. ```
  45. Tests that should **work**:
  46. ```
  47. qvm-pool -o conf_id=testing-succ-file-01 -a test callback
  48. qvm-pool
  49. ls /mnt/test01
  50. qvm-pool -r test && sudo rm -rf /mnt/test01
  51. echo '#!/bin/bash'$'\n''i=0 ; for arg in "$@" ; do echo "$i: $arg" >> /tmp/callback.log ; (( i++)) ; done ; exit 0' > /usr/bin/testCbLogArgs && chmod +x /usr/bin/testCbLogArgs
  52. rm -f /tmp/callback.log
  53. qvm-pool -o conf_id=testing-succ-file-02 -a test callback
  54. qvm-pool
  55. ls /mnt/test02
  56. less /tmp/callback.log (on_ctor & on_setup should be there and in that order)
  57. qvm-create -l red -P test test-vm
  58. cat /tmp/callback.log (2x on_volume_create should be added)
  59. qvm-start test-vm
  60. qvm-volume | grep test-vm
  61. grep test-vm /var/lib/qubes/qubes.xml
  62. ls /mnt/test02/appvms/
  63. cat /tmp/callback.log (2x on_volume_start should be added)
  64. qvm-shutdown test-vm
  65. cat /tmp/callback.log (2x on_volume_stop should be added)
  66. #reboot
  67. cat /tmp/callback.log (only (!) on_ctor should be there)
  68. qvm-start test-vm
  69. cat /tmp/callback.log (on_sinit & 2x on_volume_start should be added)
  70. qvm-shutdown --wait test-vm && qvm-remove test-vm
  71. qvm-pool -r test && sudo rm -rf /mnt/test02
  72. less /tmp/callback.log (2x on_volume_stop, 2x on_volume_remove, on_destroy should be added)
  73. qvm-pool -o conf_id=testing-succ-file-03 -a test callback
  74. qvm-pool
  75. ls /mnt/test03
  76. less /tmp/callback.log (on_ctor & on_setup should be there, no more arguments)
  77. qvm-pool -r test && sudo rm -rf /mnt/test03
  78. less /tmp/callback.log (nothing should have been added)
  79. #luks pool test:
  80. #(make sure /mnt/test.key & /mnt/test.luks don't exist)
  81. qvm-pool -o conf_id=testing-succ-file-luks -a tluks callback
  82. ls /mnt/
  83. qvm-pool
  84. sudo cryptsetup status test-luks
  85. sudo mount | grep test_luks
  86. ls /mnt/test_luks/
  87. qvm-create -l red -P tluks test-luks (journalctl -b0 should show two on_volume_create callbacks)
  88. ls /mnt/test_luks/appvms/test-luks/
  89. qvm-volume | grep test-luks
  90. qvm-start test-luks
  91. #reboot
  92. grep luks /var/lib/qubes/qubes.xml
  93. sudo cryptsetup status test-luks (should be inactive due to late on_sinit!)
  94. qvm-start test-luks
  95. sudo mount | grep test_luks
  96. qvm-shutdown --wait test-luks
  97. qvm-remove test-luks
  98. qvm-pool -r tluks
  99. sudo cryptsetup status test-luks
  100. ls -l /mnt/
  101. #ephemeral luks pool test (key in RAM / lost on reboot):
  102. qvm-pool -o conf_id=testing-succ-file-luks-eph -a teph callback (executes setup() twice due to signal_back)
  103. ls /mnt/
  104. ls /mnt/ram
  105. md5sum /mnt/ram/teph.key (1)
  106. sudo mount|grep -E 'ram|test'
  107. sudo cryptsetup status test-eph
  108. qvm-create -l red -P teph test-eph (should execute two on_volume_create callbacks)
  109. qvm-volume | grep test-eph
  110. ls /mnt/test_eph/appvms
  111. qvm-start test-eph
  112. #reboot
  113. ls /mnt/ram (should be empty)
  114. ls /mnt/
  115. sudo mount|grep -E 'ram|test' (should be empty)
  116. qvm-ls | grep eph (should still have test-eph)
  117. grep eph /var/lib/qubes/qubes.xml (should still have test-eph)
  118. qvm-remove test-eph (should create a new encrypted pool backend)
  119. sudo cryptsetup status test-eph
  120. grep eph /var/lib/qubes/qubes.xml (only the pool should be left)
  121. ls /mnt/test_eph/ (should have the appvms directory etc.)
  122. qvm-create -l red -P teph test-eph2
  123. ls /mnt/test_eph/appvms/
  124. ls /mnt/ram
  125. qvm-start test-eph2
  126. md5sum /mnt/ram/teph.key ((2), different than in (1))
  127. qvm-shutdown --wait test-eph2
  128. systemctl restart qubesd
  129. qvm-start test-eph2 (trigger storage re-init)
  130. md5sum /mnt/ram/teph.key (same as in (2))
  131. qvm-shutdown test-eph2
  132. sudo umount /mnt/test_eph
  133. qvm-create -l red -P teph test-eph-fail (must fail with error in journalctl)
  134. ls /mnt/test_eph/ (should be empty)
  135. systemctl restart qubesd
  136. qvm-remove test-eph2
  137. qvm-create -l red -P teph test-eph3
  138. md5sum /mnt/ram/teph.key (same as in (2))
  139. sudo mount|grep -E 'ram|test'
  140. ls /mnt/test_eph/appvms/test_eph3
  141. qvm-remove test-eph3
  142. qvm-ls | grep test-eph
  143. qvm-pool -r teph
  144. grep eph /var/lib/qubes/qubes.xml (nothing should be left)
  145. qvm-pool
  146. ls /mnt/
  147. ls /mnt/ram/ (should be empty)
  148. ```
  149. ''' # pylint: disable=protected-access
  150. driver = 'callback'
  151. config_path='/etc/qubes_callback.json'
  152. def __init__(self, *, name, conf_id):
  153. '''Constructor.
  154. :param conf_id: Identifier as found inside the user-controlled configuration at `/etc/qubes_callback.json`.
  155. Non-ASCII, non-alphanumeric characters may be disallowed.
  156. **Security Note**: Depending on your RPC policy (admin.pool.Add) this constructor and its parameters
  157. may be called from an untrusted VM (not by default though). In those cases it may be security-relevant
  158. not to pick easily guessable `conf_id` values for your configuration as untrusted VMs may otherwise
  159. execute callbacks meant for other pools.
  160. '''
  161. self._cb_ctor_done = False
  162. assert isinstance(conf_id, str), 'conf_id is no String. VM attack?!'
  163. self._cb_conf_id = conf_id
  164. with open(CallbackPool.config_path) as json_file:
  165. conf_all = json.load(json_file)
  166. assert isinstance(conf_all, dict), 'The file %s is supposed to define a dict.' % CallbackPool.config_path
  167. try:
  168. self._cb_conf = conf_all[self._cb_conf_id]
  169. except KeyError:
  170. #we cannot throw KeyErrors as we'll otherwise generate incorrect error messages @qubes.app._get_pool()
  171. raise NameError('The specified conf_id %s could not be found inside %s.' % (self._cb_conf_id, CallbackPool.config_path))
  172. try:
  173. bdriver = self._cb_conf['bdriver']
  174. except KeyError:
  175. raise NameError('Missing bdriver for the conf_id %s inside %s.' % (self._cb_conf_id, CallbackPool.config_path))
  176. self._cb_cmd_arg = json.dumps(self._cb_conf, sort_keys=True, indent=2)
  177. try:
  178. cls = qubes.utils.get_entry_point_one(qubes.storage.STORAGE_ENTRY_POINT, bdriver)
  179. except KeyError:
  180. raise NameError('The driver %s was not found on your system.' % bdriver)
  181. assert issubclass(cls, qubes.storage.Pool), 'The class %s must be a subclass of qubes.storage.Pool.' % cls
  182. self._cb_requires_init = self._check_init()
  183. bdriver_args = self._cb_conf.get('bdriver_args', {})
  184. self._cb_impl = cls(name=name, **bdriver_args)
  185. super().__init__(name=name, revisions_to_keep=int(bdriver_args.get('revisions_to_keep', 1)))
  186. self._cb_ctor_done = True
  187. self._callback('on_ctor')
  188. def _check_init(self):
  189. ''' Whether or not this object requires late storage initialization via callback. '''
  190. cmd = self._cb_conf.get('on_sinit')
  191. if not cmd:
  192. cmd = self._cb_conf.get('cmd')
  193. return bool(cmd and cmd != '-')
  194. def _init(self, callback=True):
  195. #late initialization on first use for e.g. decryption on first usage request
  196. #maybe TODO: if this function is meant to be run in parallel (are Pool operations asynchronous?), a function lock is required!
  197. if callback:
  198. self._callback('on_sinit')
  199. self._cb_requires_init = False
  200. def _assertInitialized(self, **kwargs):
  201. if self._cb_requires_init:
  202. self._init(**kwargs)
  203. def _callback(self, cb, cb_args=[], log=logging.getLogger('qubes.storage.callback')):
  204. '''Run a callback.
  205. :param cb: Callback identifier string.
  206. :param cb_args: Optional arguments to pass to the command as first arguments.
  207. Only passed on for the generic command specified as `cmd`, not for `on_xyz` callbacks.
  208. '''
  209. if self._cb_ctor_done:
  210. cmd = self._cb_conf.get(cb)
  211. args = [] #on_xyz callbacks should never receive arguments
  212. if not cmd:
  213. cmd = self._cb_conf.get('cmd')
  214. args = [ self.name, self._cb_conf['bdriver'], cb, self._cb_cmd_arg, *cb_args ]
  215. if cmd and cmd != '-':
  216. args = filter(None, args)
  217. args = ' '.join(quote(str(a)) for a in args)
  218. cmd = ' '.join(filter(None, [cmd, args]))
  219. log.info('callback driver executing (%s, %s %s): %s' % (self._cb_conf_id, cb, cb_args, cmd))
  220. res = subprocess.run(cmd, shell=True, check=True, executable='/bin/bash', stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
  221. #stdout & stderr are reported if the exit code check fails
  222. log.debug('callback driver stdout (%s, %s %s): %s' % (self._cb_conf_id, cb, cb_args, res.stdout))
  223. log.debug('callback driver stderr (%s, %s %s): %s' % (self._cb_conf_id, cb, cb_args, res.stderr))
  224. if self._cb_conf.get('signal_back', False) is True:
  225. self._process_signals(res.stdout, log)
  226. def _process_signals(self, out, log=logging.getLogger('qubes.storage.callback')):
  227. '''Process any signals found inside a string.
  228. :param out: String to check for signals. Each signal must be on a dedicated line.
  229. They are executed in the order they are found. Callbacks are not triggered.
  230. '''
  231. for line in out.splitlines():
  232. if line == 'SIGNAL_setup':
  233. log.info('callback driver processing SIGNAL_setup for %s' % self._cb_conf_id)
  234. self.setup(callback=False)
  235. def __del__(self):
  236. s = super()
  237. if hasattr(s, '__del__'):
  238. s.__del__()
  239. @property
  240. def config(self):
  241. return {
  242. 'name': self.name,
  243. 'driver': CallbackPool.driver,
  244. 'conf_id': self._cb_conf_id,
  245. }
  246. def destroy(self):
  247. self._assertInitialized()
  248. ret = self._cb_impl.destroy()
  249. self._callback('on_destroy')
  250. return ret
  251. def init_volume(self, vm, volume_config):
  252. return CallbackVolume(self, self._cb_impl.init_volume(vm, volume_config))
  253. def setup(self, callback=True):
  254. if callback:
  255. self._callback('on_setup')
  256. self._assertInitialized(callback=False) #setup is assumed to include initialization
  257. return self._cb_impl.setup()
  258. @property
  259. def volumes(self):
  260. for vol in self._cb_impl.volumes:
  261. yield CallbackVolume(self, vol)
  262. def list_volumes(self):
  263. for vol in self._cb_impl.list_volumes():
  264. yield CallbackVolume(self, vol)
  265. def get_volume(self, vid):
  266. return CallbackVolume(self, self._cb_impl.get_volume(vid))
  267. def included_in(self, app):
  268. if self._cb_requires_init:
  269. return None
  270. else:
  271. return self._cb_impl.included_in(app)
  272. @property
  273. def size(self):
  274. if self._cb_requires_init:
  275. return None
  276. else:
  277. return self._cb_impl.size
  278. @property
  279. def usage(self):
  280. if self._cb_requires_init:
  281. return None
  282. else:
  283. return self._cb_impl.usage
  284. #remaining method & attribute delegation ("delegation pattern")
  285. #Convention: The methods of this object have priority over the delegated object's methods. All attributes are
  286. # passed to the delegated object unless their name starts with '_cb_'.
  287. def __getattr__(self, name):
  288. #NOTE: This method is only called when an attribute cannot be resolved locally (not part of the instance,
  289. # not part of the class tree). It is also called for methods that cannot be resolved.
  290. return getattr(self._cb_impl, name)
  291. def __setattr__(self, name, value):
  292. #NOTE: This method is called on every attribute assignment.
  293. if name.startswith('_cb_'):
  294. super().__setattr__(name, value)
  295. else:
  296. setattr(self._cb_impl, name, value)
  297. def __delattr__(self, name):
  298. if name.startswith('_cb_'):
  299. super().__delattr__(name)
  300. else:
  301. delattr(self._cb_impl, name)
  302. class CallbackVolume:
  303. ''' Proxy volume adding callback functionality to other volumes.
  304. Required to support the `on_sinit` callback for late storage initialization.
  305. **Important for Developers**: Even though instances of this class behave exactly as `qubes.storage.Volume` instances,
  306. they are no such instances (e.g. `assert isinstance(obj, qubes.storage.Volume)` will fail).
  307. '''
  308. def __init__(self, pool, impl):
  309. '''Constructor.
  310. :param pool: `CallbackPool` of this volume
  311. :param impl: `qubes.storage.Volume` object to wrap
  312. '''
  313. assert isinstance(impl, qubes.storage.Volume), 'impl must be a qubes.storage.Volume instance. Found a %s instance.' % impl.__class__
  314. assert isinstance(pool, CallbackPool), 'pool must use a qubes.storage.CallbackPool instance. Found a %s instance.' % pool.__class__
  315. self._cb_pool = pool
  316. self._cb_impl = impl
  317. def _assertInitialized(self, **kwargs):
  318. return self._cb_pool._assertInitialized(**kwargs)
  319. def _callback(self, cb, cb_args=[], **kwargs):
  320. vol_args = [ *cb_args, self.name, self.vid ]
  321. return self._cb_pool._callback(cb, cb_args=vol_args, **kwargs)
  322. def create(self):
  323. self._assertInitialized()
  324. self._callback('on_volume_create')
  325. return self._cb_impl.create()
  326. def remove(self):
  327. self._assertInitialized()
  328. ret = self._cb_impl.remove()
  329. self._callback('on_volume_remove')
  330. return ret
  331. def resize(self, size):
  332. self._assertInitialized()
  333. self._callback('on_volume_resize', cb_args=[size])
  334. return self._cb_impl.resize(size)
  335. def start(self):
  336. self._assertInitialized()
  337. self._callback('on_volume_start')
  338. return self._cb_impl.start()
  339. def stop(self):
  340. self._assertInitialized()
  341. ret = self._cb_impl.stop()
  342. self._callback('on_volume_stop')
  343. return ret
  344. def import_data(self):
  345. self._assertInitialized()
  346. self._callback('on_volume_import_data')
  347. return self._cb_impl.import_data()
  348. def import_data_end(self, success):
  349. self._assertInitialized()
  350. ret = self._cb_impl.import_data_end(success)
  351. self._callback('on_volume_import_data_end', cb_args=[success])
  352. return ret
  353. def import_volume(self, src_volume):
  354. self._assertInitialized()
  355. self._callback('on_volume_import', cb_args=[src_volume.vid])
  356. return self._cb_impl.import_volume(src_volume)
  357. def is_dirty(self):
  358. if self._cb_pool._cb_requires_init:
  359. return False
  360. else:
  361. return self._cb_impl.is_dirty()
  362. def is_outdated(self):
  363. if self._cb_pool._cb_requires_init:
  364. return False
  365. else:
  366. return self._cb_impl.is_outdated()
  367. #remaining method & attribute delegation
  368. def __getattr__(self, name):
  369. if name in [ 'block_device', 'verify', 'revert', 'export' ]:
  370. self._assertInitialized()
  371. return getattr(self._cb_impl, name)
  372. def __setattr__(self, name, value):
  373. if name.startswith('_cb_'):
  374. super().__setattr__(name, value)
  375. else:
  376. setattr(self._cb_impl, name, value)
  377. def __delattr__(self, name):
  378. if name.startswith('_cb_'):
  379. super().__delattr__(name)
  380. else:
  381. delattr(self._cb_impl, name)