# # The Qubes OS Project, https://www.qubes-os.org/ # # Copyright (C) 2020 David Hobach # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, see . # import logging import subprocess import json from shlex import quote import qubes.storage class CallbackPool(qubes.storage.Pool): ''' Proxy storage pool driver adding callback functionality to other pool drivers. This way, users can extend storage pool drivers with custom functionality using the programming language of their choice. All configuration for this pool driver must be done in `/etc/qubes_callback.json`. Each configuration ID `conf_id` can be used to create a callback pool with e.g. `qvm-pool -o conf_id=your_conf_id -a pool_name callback`. Check `/usr/share/doc/qubes/qubes_callback.json.example` for an overview of the available options. Example applications of this driver: - custom pool mounts - encryption - debugging **Integration tests**: (all of these tests assume the `qubes_callback.json.example` configuration) Tests that should **fail**: ``` qvm-pool -a test callback qvm-pool -o conf_id=non-existing -a test callback qvm-pool -o conf_id=conf_id -a test callback qvm-pool -o conf_id=testing-fail-missing-all -a test callback qvm-pool -o conf_id=testing-fail-missing-bdriver-args -a test callback ``` Tests that should **work**: ``` qvm-pool -o conf_id=testing-succ-file-01 -a test callback qvm-pool ls /mnt/test01 qvm-pool -r test && sudo rm -rf /mnt/test01 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 rm -f /tmp/callback.log qvm-pool -o conf_id=testing-succ-file-02 -a test callback qvm-pool ls /mnt/test02 less /tmp/callback.log (on_ctor & on_setup should be there and in that order) qvm-create -l red -P test test-vm cat /tmp/callback.log (2x on_volume_create should be added) qvm-start test-vm qvm-volume | grep test-vm grep test-vm /var/lib/qubes/qubes.xml ls /mnt/test02/appvms/ cat /tmp/callback.log (2x on_volume_start should be added) qvm-shutdown test-vm cat /tmp/callback.log (2x on_volume_stop should be added) #reboot cat /tmp/callback.log (only (!) on_ctor should be there) qvm-start test-vm cat /tmp/callback.log (on_sinit & 2x on_volume_start should be added) qvm-shutdown --wait test-vm && qvm-remove test-vm qvm-pool -r test && sudo rm -rf /mnt/test02 less /tmp/callback.log (2x on_volume_stop, 2x on_volume_remove, on_destroy should be added) qvm-pool -o conf_id=testing-succ-file-03 -a test callback qvm-pool ls /mnt/test03 less /tmp/callback.log (on_ctor & on_setup should be there, no more arguments) qvm-pool -r test && sudo rm -rf /mnt/test03 less /tmp/callback.log (nothing should have been added) #luks pool test: #(make sure /mnt/test.key & /mnt/test.luks don't exist) qvm-pool -o conf_id=testing-succ-file-luks -a tluks callback ls /mnt/ qvm-pool sudo cryptsetup status test-luks sudo mount | grep test_luks ls /mnt/test_luks/ qvm-create -l red -P tluks test-luks (journalctl -b0 should show two on_volume_create callbacks) ls /mnt/test_luks/appvms/test-luks/ qvm-volume | grep test-luks qvm-start test-luks #reboot grep luks /var/lib/qubes/qubes.xml sudo cryptsetup status test-luks (should be inactive due to late on_sinit!) qvm-start test-luks sudo mount | grep test_luks qvm-shutdown --wait test-luks qvm-remove test-luks qvm-pool -r tluks sudo cryptsetup status test-luks ls -l /mnt/ #ephemeral luks pool test (key in RAM / lost on reboot): qvm-pool -o conf_id=testing-succ-file-luks-eph -a teph callback (executes setup() twice due to signal_back) ls /mnt/ ls /mnt/ram md5sum /mnt/ram/teph.key (1) sudo mount|grep -E 'ram|test' sudo cryptsetup status test-eph qvm-create -l red -P teph test-eph (should execute two on_volume_create callbacks) qvm-volume | grep test-eph ls /mnt/test_eph/appvms qvm-start test-eph #reboot ls /mnt/ram (should be empty) ls /mnt/ sudo mount|grep -E 'ram|test' (should be empty) qvm-ls | grep eph (should still have test-eph) grep eph /var/lib/qubes/qubes.xml (should still have test-eph) qvm-remove test-eph (should create a new encrypted pool backend) sudo cryptsetup status test-eph grep eph /var/lib/qubes/qubes.xml (only the pool should be left) ls /mnt/test_eph/ (should have the appvms directory etc.) qvm-create -l red -P teph test-eph2 ls /mnt/test_eph/appvms/ ls /mnt/ram qvm-start test-eph2 md5sum /mnt/ram/teph.key ((2), different than in (1)) qvm-shutdown --wait test-eph2 systemctl restart qubesd qvm-start test-eph2 (trigger storage re-init) md5sum /mnt/ram/teph.key (same as in (2)) qvm-shutdown test-eph2 sudo umount /mnt/test_eph qvm-create -l red -P teph test-eph-fail (must fail with error in journalctl) ls /mnt/test_eph/ (should be empty) systemctl restart qubesd qvm-remove test-eph2 qvm-create -l red -P teph test-eph3 md5sum /mnt/ram/teph.key (same as in (2)) sudo mount|grep -E 'ram|test' ls /mnt/test_eph/appvms/test_eph3 qvm-remove test-eph3 qvm-ls | grep test-eph qvm-pool -r teph grep eph /var/lib/qubes/qubes.xml (nothing should be left) qvm-pool ls /mnt/ ls /mnt/ram/ (should be empty) ``` ''' driver = 'callback' config_path = '/etc/qubes_callback.json' def __init__(self, *, name, conf_id): '''Constructor. :param conf_id: Identifier as found inside the user-controlled configuration at `/etc/qubes_callback.json`. Non-ASCII, non-alphanumeric characters may be disallowed. **Security Note**: Depending on your RPC policy (admin.pool.Add) this constructor and its parameters may be called from an untrusted VM (not by default though). In those cases it may be security-relevant not to pick easily guessable `conf_id` values for your configuration as untrusted VMs may otherwise execute callbacks meant for other pools. ''' #NOTE: attribute names **must** start with `_cb_` unless they are meant to be stored as self._cb_impl attributes self._cb_ctor_done = False #: Boolean to indicate whether or not `__init__` successfully ran through. self._cb_log = logging.getLogger('qubes.storage.callback') #: Logger instance. if not isinstance(conf_id, str): raise qubes.storage.StoragePoolException('conf_id is no String. VM attack?!') self._cb_conf_id = conf_id #: Configuration ID as passed to `__init__`. with open(CallbackPool.config_path) as json_file: conf_all = json.load(json_file) if not isinstance(conf_all, dict): raise qubes.storage.StoragePoolException('The file %s is supposed to define a dict.' % CallbackPool.config_path) try: self._cb_conf = conf_all[self._cb_conf_id] #: Dictionary holding all configuration for the given _cb_conf_id. except KeyError: #we cannot throw KeyErrors as we'll otherwise generate incorrect error messages @qubes.app._get_pool() raise qubes.storage.StoragePoolException('The specified conf_id %s could not be found inside %s.' % (self._cb_conf_id, CallbackPool.config_path)) try: bdriver = self._cb_conf['bdriver'] except KeyError: raise qubes.storage.StoragePoolException('Missing bdriver for the conf_id %s inside %s.' % (self._cb_conf_id, CallbackPool.config_path)) self._cb_cmd_arg = json.dumps(self._cb_conf, sort_keys=True, indent=2) #: Full configuration as string in the format required by _callback(). try: cls = qubes.utils.get_entry_point_one(qubes.storage.STORAGE_ENTRY_POINT, bdriver) except KeyError: raise qubes.storage.StoragePoolException('The driver %s was not found on your system.' % bdriver) if not issubclass(cls, qubes.storage.Pool): raise qubes.storage.StoragePoolException('The class %s must be a subclass of qubes.storage.Pool.' % cls) self._cb_requires_init = self._check_init() #: Boolean indicating whether late storage initialization yet has to be done or not. bdriver_args = self._cb_conf.get('bdriver_args', {}) self._cb_impl = cls(name=name, **bdriver_args) #: Instance of the backend pool driver. super().__init__(name=name, revisions_to_keep=int(bdriver_args.get('revisions_to_keep', 1))) self._cb_ctor_done = True self._callback('on_ctor') def _check_init(self): ''' Whether or not this object requires late storage initialization via callback. ''' cmd = self._cb_conf.get('on_sinit') if not cmd: cmd = self._cb_conf.get('cmd') return bool(cmd and cmd != '-') def _init(self, callback=True): ''' Late storage initialization on first use for e.g. decryption on first usage request. :param callback: Whether to trigger the `on_sinit` callback or not. ''' #maybe TODO: if this function is meant to be run in parallel (are Pool operations asynchronous?), a function lock is required! if callback: self._callback('on_sinit') self._cb_requires_init = False def _assert_initialized(self, **kwargs): if self._cb_requires_init: self._init(**kwargs) def _callback(self, cb, cb_args=None): '''Run a callback. :param cb: Callback identifier string. :param cb_args: Optional list of arguments to pass to the command as last arguments. Only passed on for the generic command specified as `cmd`, not for `on_xyz` callbacks. ''' if self._cb_ctor_done: cmd = self._cb_conf.get(cb) args = [] #on_xyz callbacks should never receive arguments if not cmd: if cb_args is None: cb_args = [] cmd = self._cb_conf.get('cmd') args = [self.name, self._cb_conf['bdriver'], cb, self._cb_cmd_arg, *cb_args] if cmd and cmd != '-': args = filter(None, args) args = ' '.join(quote(str(a)) for a in args) cmd = ' '.join(filter(None, [cmd, args])) self._cb_log.info('callback driver executing (%s, %s %s): %s', self._cb_conf_id, cb, cb_args, cmd) res = subprocess.run(['/bin/bash', '-c', cmd], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) #stdout & stderr are reported if the exit code check fails self._cb_log.debug('callback driver stdout (%s, %s %s): %s', self._cb_conf_id, cb, cb_args, res.stdout) self._cb_log.debug('callback driver stderr (%s, %s %s): %s', self._cb_conf_id, cb, cb_args, res.stderr) if self._cb_conf.get('signal_back', False) is True: self._process_signals(res.stdout) def _process_signals(self, out): '''Process any signals found inside a string. :param out: String to check for signals. Each signal must be on a dedicated line. They are executed in the order they are found. Callbacks are not triggered. ''' for line in out.splitlines(): if line == 'SIGNAL_setup': self._cb_log.info('callback driver processing SIGNAL_setup for %s', self._cb_conf_id) self._setup_cb(callback=False) @property def config(self): return { 'name': self.name, 'driver': CallbackPool.driver, 'conf_id': self._cb_conf_id, } def destroy(self): self._assert_initialized() ret = self._cb_impl.destroy() self._callback('on_destroy') return ret def init_volume(self, vm, volume_config): return CallbackVolume(self, self._cb_impl.init_volume(vm, volume_config)) def _setup_cb(self, callback=True): if callback: self._callback('on_setup') self._assert_initialized(callback=False) #setup is assumed to include storage initialization return self._cb_impl.setup() def setup(self): return self._setup_cb() @property def volumes(self): for vol in self._cb_impl.volumes: yield CallbackVolume(self, vol) def list_volumes(self): for vol in self._cb_impl.list_volumes(): yield CallbackVolume(self, vol) def get_volume(self, vid): return CallbackVolume(self, self._cb_impl.get_volume(vid)) def included_in(self, app): if self._cb_requires_init: return None return self._cb_impl.included_in(app) @property def size(self): if self._cb_requires_init: return None return self._cb_impl.size @property def usage(self): if self._cb_requires_init: return None return self._cb_impl.usage #remaining method & attribute delegation ("delegation pattern") #Convention: The methods of this object have priority over the delegated object's methods. All attributes are # passed to the delegated object unless their name starts with '_cb_'. def __getattr__(self, name): #NOTE: This method is only called when an attribute cannot be resolved locally (not part of the instance, # not part of the class tree). It is also called for methods that cannot be resolved. return getattr(self._cb_impl, name) def __setattr__(self, name, value): #NOTE: This method is called on every attribute assignment. if name.startswith('_cb_'): super().__setattr__(name, value) else: setattr(self._cb_impl, name, value) def __delattr__(self, name): if name.startswith('_cb_'): super().__delattr__(name) else: delattr(self._cb_impl, name) class CallbackVolume: ''' Proxy volume adding callback functionality to other volumes. Required to support the `on_sinit` callback for late storage initialization. **Important for Developers**: Even though instances of this class behave exactly as `qubes.storage.Volume` instances, they are no such instances (e.g. `assert isinstance(obj, qubes.storage.Volume)` will fail). ''' def __init__(self, pool, impl): '''Constructor. :param pool: `CallbackPool` of this volume :param impl: `qubes.storage.Volume` object to wrap ''' assert isinstance(impl, qubes.storage.Volume), 'impl must be a qubes.storage.Volume instance. Found a %s instance.' % impl.__class__ assert isinstance(pool, CallbackPool), 'pool must use a qubes.storage.CallbackPool instance. Found a %s instance.' % pool.__class__ self._cb_pool = pool #: CallbackPool instance the Volume belongs to. self._cb_impl = impl #: Backend volume implementation instance. def _assert_initialized(self, **kwargs): return self._cb_pool._assert_initialized(**kwargs) # pylint: disable=protected-access def _callback(self, cb, cb_args=None, **kwargs): if cb_args is None: cb_args = [] vol_args = [self.name, self.vid, *cb_args] return self._cb_pool._callback(cb, cb_args=vol_args, **kwargs) # pylint: disable=protected-access def create(self): self._assert_initialized() self._callback('on_volume_create') return self._cb_impl.create() def remove(self): self._assert_initialized() ret = self._cb_impl.remove() self._callback('on_volume_remove') return ret def resize(self, size): self._assert_initialized() self._callback('on_volume_resize', cb_args=[size]) return self._cb_impl.resize(size) def start(self): self._assert_initialized() self._callback('on_volume_start') return self._cb_impl.start() def stop(self): self._assert_initialized() ret = self._cb_impl.stop() self._callback('on_volume_stop') return ret def import_data(self): self._assert_initialized() self._callback('on_volume_import_data') return self._cb_impl.import_data() def import_data_end(self, success): self._assert_initialized() ret = self._cb_impl.import_data_end(success) self._callback('on_volume_import_data_end', cb_args=[success]) return ret def import_volume(self, src_volume): self._assert_initialized() self._callback('on_volume_import', cb_args=[src_volume.vid]) return self._cb_impl.import_volume(src_volume) def is_dirty(self): if self._cb_pool._cb_requires_init: # pylint: disable=protected-access return False return self._cb_impl.is_dirty() def is_outdated(self): if self._cb_pool._cb_requires_init: # pylint: disable=protected-access return False return self._cb_impl.is_outdated() #remaining method & attribute delegation def __getattr__(self, name): if name in ['block_device', 'verify', 'revert', 'export']: self._assert_initialized() return getattr(self._cb_impl, name) def __setattr__(self, name, value): if name.startswith('_cb_'): super().__setattr__(name, value) else: setattr(self._cb_impl, name, value) def __delattr__(self, name): if name.startswith('_cb_'): super().__delattr__(name) else: delattr(self._cb_impl, name)