Browse Source

qubes/features: check_with_(template_and_)adminvm

- Two new methods: .features.check_with_adminvm() and
  .check_with_template_and_adminvm(). Common code refactored.

- Two new AdminAPI calls to take advantage of the methods:
  - admin.vm.feature.CheckWithAdminVM
  - admin.vm.feature.CheckWithTemplateAndAdminVM

- Features manager moved to separate module in anticipation of features
  on app object in R5.0. The attribute Features.vm renamed to
  Features.subject.

- Documentation, tests.
Wojtek Porczyk 5 years ago
parent
commit
ff612a870b
8 changed files with 259 additions and 142 deletions
  1. 2 0
      Makefile
  2. 25 24
      doc/qubes-features.rst
  3. 26 0
      qubes/api/admin.py
  4. 180 0
      qubes/features.py
  5. 22 0
      qubes/tests/api_admin.py
  6. 1 1
      qubes/tests/vm/init.py
  7. 2 117
      qubes/vm/__init__.py
  8. 1 0
      rpm_spec/core-dom0.spec.in

+ 2 - 0
Makefile

@@ -72,6 +72,8 @@ ADMIN_API_METHODS_SIMPLE = \
 	admin.vm.device.mic.Set.persistent \
 	admin.vm.feature.CheckWithNetvm \
 	admin.vm.feature.CheckWithTemplate \
+	admin.vm.feature.CheckWithAdminVM \
+	admin.vm.feature.CheckWithTemplateAndAdminVM \
 	admin.vm.feature.Get \
 	admin.vm.feature.List \
 	admin.vm.feature.Remove \

+ 25 - 24
doc/qubes-features.rst

@@ -1,5 +1,5 @@
-:py:class:`qubes.vm.Features` - Qubes VM features, services
-============================================================
+:py:mod:`qubes.features` - Qubes VM features, services
+======================================================
 
 Features are generic mechanism for storing key-value pairs attached to a
 VM. The primary use case for them is data storage for extensions (you can think
@@ -18,12 +18,12 @@ however if you assign instances of :py:class:`bool`, they are converted as
 described above. Be aware that assigning the number `0` (which is considered
 false in Python) will result in string `'0'`, which is considered true.
 
-:py:class:`qubes.vm.Features` inherits from :py:class:`dict`, so provide all the
-standard functions to get, list and set values.  Additionally provide helper
-functions to check if given feature is set on the VM and default to the value
-on the VM's template or netvm. This is useful for features which nature is
-inherited from other VMs, like "is package X is installed" or "is VM behind a
-VPN".
+:py:class:`qubes.features.Features` inherits from :py:class:`dict`, so provide
+all the standard functions to get, list and set values.  Additionally provide
+helper functions to check if given feature is set on the VM and default to the
+value on the VM's template or netvm. This is useful for features which nature is
+inherited from other VMs, like "is package X is installed" or "is VM behind
+a VPN".
 
 Example usage of features in extension:
 
@@ -31,12 +31,12 @@ Example usage of features in extension:
 
    import qubes.exc
    import qubes.ext
-   
+
    class ExampleExtension(qubes.ext.Extension):
       @qubes.ext.handler('domain-pre-start')
       def on_domain_start(self, vm, event, **kwargs):
          if vm.features.get('do-not-start', False):
-            raise qubes.exc.QubesVMError(vm, 
+            raise qubes.exc.QubesVMError(vm,
                'Start prohibited because of do-not-start feature')
 
          if vm.features.check_with_template('something-installed', False):
@@ -45,7 +45,8 @@ Example usage of features in extension:
 The above extension does two things:
 
  - prevent starting a qube with ``do-not-start`` feature set
- - do something when ``something-installed`` feature is set on the qube, or its template
+ - do something when ``something-installed`` feature is set on the qube, or its
+   template
 
 
 qvm-features-request, qubes.PostInstall service
@@ -53,9 +54,9 @@ qvm-features-request, qubes.PostInstall service
 
 When some package in the VM want to request feature to be set (aka advertise
 support for it), it should place a shell script in ``/etc/qubes/post-install.d``.
-This script should call :program:`qvm-features-request` with ``FEATURE=VALUE`` pair(s) as
-arguments to request those features. It is recommended to use very simple
-values here (for example ``1``). The script should be named in form
+This script should call :program:`qvm-features-request` with ``FEATURE=VALUE``
+pair(s) as arguments to request those features. It is recommended to use very
+simple values here (for example ``1``). The script should be named in form
 ``XX-package-name.sh`` where ``XX`` is two-digits number below 90 and
 ``package-name`` is unique name specific to this package (preferably actual
 package name). The script needs executable bit set.
@@ -65,17 +66,17 @@ installation and also after initial template installation.
 This way package have a chance to report to dom0 if any feature is
 added/removed.
 
-The features flow to dom0 according to the diagram below. Important part is
-that qubes core :py:class:`qubes.ext.Extension` is responsible for handling such request in
-``features-request`` event handler. If no extension handles given feature request,
-it will be ignored. The extension should carefuly validate requested
-features (ignoring those not recognized - may be for another extension) and
-only then set appropriate value on VM object
+The features flow to dom0 according to the diagram below. Important part is that
+qubes core :py:class:`qubes.ext.Extension` is responsible for handling such
+request in ``features-request`` event handler. If no extension handles given
+feature request, it will be ignored. The extension should carefuly validate
+requested features (ignoring those not recognized - may be for another
+extension) and only then set appropriate value on VM object
 (:py:attr:`qubes.vm.BaseVM.features`). It is recommended to make the
 verification code as bulletproof  as possible (for example allow only specific
-simple values, instead of complex structures), because feature requests
-come from untrusted sources. The features actually set on the VM in some cases
-may not be necessary those requested. Similar for values.
+simple values, instead of complex structures), because feature requests come
+from untrusted sources. The features actually set on the VM in some cases may
+not be necessary those requested. Similar for values.
 
 .. graphviz::
 
@@ -185,7 +186,7 @@ Services and features can be then inspected from dom0 using
 Module contents
 ---------------
 
-.. autoclass:: qubes.vm.Features
+.. automodule:: qubes.features
    :members:
    :show-inheritance:
 

+ 26 - 0
qubes/api/admin.py

@@ -938,6 +938,32 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
         return value
 
+    @qubes.api.method('admin.vm.feature.CheckWithAdminVM', no_payload=True,
+        scope='local', read=True)
+    @asyncio.coroutine
+    def vm_feature_checkwithadminvm(self):
+        # validation of self.arg done by qrexec-policy is enough
+
+        self.fire_event_for_permission()
+        try:
+            value = self.dest.features.check_with_adminvm(self.arg)
+        except KeyError:
+            raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
+        return value
+
+    @qubes.api.method('admin.vm.feature.CheckWithTemplateAndAdminVM',
+        no_payload=True, scope='local', read=True)
+    @asyncio.coroutine
+    def vm_feature_checkwithtpladminvm(self):
+        # validation of self.arg done by qrexec-policy is enough
+
+        self.fire_event_for_permission()
+        try:
+            value = self.dest.features.check_with_template_and_adminvm(self.arg)
+        except KeyError:
+            raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
+        return value
+
     @qubes.api.method('admin.vm.feature.Remove', no_payload=True,
         scope='local', write=True)
     @asyncio.coroutine

+ 180 - 0
qubes/features.py

@@ -0,0 +1,180 @@
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2010-2015  Joanna Rutkowska <joanna@invisiblethingslab.com>
+# Copyright (C) 2011-2015  Marek Marczykowski-Górecki
+#                              <marmarek@invisiblethingslab.com>
+# Copyright (C) 2014-2018  Wojtek Porczyk <woju@invisiblethingslab.com>
+#
+# 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 <https://www.gnu.org/licenses/>.
+#
+
+from . import vm as _vm
+
+_NO_DEFAULT = object()
+
+class Features(dict):
+    '''Manager of the features.
+
+    Features can have three distinct values: no value (not present in mapping,
+    which is closest thing to :py:obj:`None`), empty string (which is
+    interpreted as :py:obj:`False`) and non-empty string, which is
+    :py:obj:`True`. Anything assigned to the mapping is coerced to strings,
+    however if you assign instances of :py:class:`bool`, they are converted as
+    described above. Be aware that assigning the number `0` (which is considered
+    false in Python) will result in string `'0'`, which is considered true.
+
+    This class inherits from dict, but has most of the methods that manipulate
+    the item disarmed (they raise NotImplementedError). The ones that are left
+    fire appropriate events on the qube that owns an instance of this class.
+    '''
+
+    #
+    # Those are the methods that affect contents. Either disarm them or make
+    # them report appropriate events. Good approach is to rewrite them carefully
+    # using official documentation, but use only our (overloaded) methods.
+    #
+    def __init__(self, subject, other=None, **kwargs):
+        super().__init__()
+        self.subject = subject
+        self.update(other, **kwargs)
+
+    def __delitem__(self, key):
+        super().__delitem__(key)
+        self.subject.fire_event('domain-feature-delete:' + key, feature=key)
+
+    def __setitem__(self, key, value):
+        if value is None or isinstance(value, bool):
+            value = '1' if value else ''
+        else:
+            value = str(value)
+        try:
+            oldvalue = self[key]
+            has_oldvalue = True
+        except KeyError:
+            has_oldvalue = False
+        super().__setitem__(key, value)
+        if has_oldvalue:
+            self.subject.fire_event('domain-feature-set:' + key, feature=key,
+                value=value, oldvalue=oldvalue)
+        else:
+            self.subject.fire_event('domain-feature-set:' + key, feature=key,
+                value=value)
+
+    def clear(self):
+        for key in tuple(self):
+            del self[key]
+
+    def pop(self, _key, _default=None):
+        '''Not implemented
+        :raises: NotImplementedError
+        '''
+        raise NotImplementedError()
+
+    def popitem(self):
+        '''Not implemented
+        :raises: NotImplementedError
+        '''
+        raise NotImplementedError()
+
+    def setdefault(self, _key, _default=None):
+        '''Not implemented
+        :raises: NotImplementedError
+        '''
+        raise NotImplementedError()
+
+    def update(self, other=None, **kwargs):
+        if other is not None:
+            if hasattr(other, 'keys'):
+                for key in other:
+                    self[key] = other[key]
+            else:
+                for key, value in other:
+                    self[key] = value
+
+        for key in kwargs:
+            self[key] = kwargs[key]
+
+    #
+    # end of overriding
+    #
+
+    def _recursive_check(self, attr=None, *, feature, default,
+            check_adminvm=False, check_app=False):
+        '''Recursive search for a feature.
+
+        Traverse domains along one attribute, like
+        :py:attr:`qubes.vm.qubesvm.QubesVM.netvm` or
+        :py:attr:`qubes.vm.appvm.AppVM.template`, starting with current domain
+        (:py:attr:`subject`). Search stops when first `feature` is found. If
+        a qube has no attribute, or if the attribute is :py:obj:`None`, the
+        *default* is returned, or if not specified, :py:class:`KeyError` is
+        raised.
+
+        If `check_adminvm` is true, before returning default, also AdminVM is
+        consulted (the recursion does not restart).
+
+        If `check_app` is true, also the app feature is checked. This is not
+        implemented, as app does not have features yet.
+        '''
+        if check_app:
+            raise NotImplementedError('app does not have features yet')
+
+        assert isinstance(self.subject, _vm.BaseVM), (
+            'recursive checks do not work for {}'.format(
+                type(self.subject).__name__))
+
+        subject = self.subject
+        while subject is not None:
+            try:
+                return subject.features[feature]
+            except KeyError:
+                if attr is None:
+                    break
+                subject = getattr(subject, attr, None)
+
+        if check_adminvm:
+            adminvm = self.subject.app.domains['dom0']
+            if adminvm not in (None, self.subject):
+                try:
+                    return adminvm.features[feature]
+                except KeyError:
+                    pass
+
+        # TODO check_app
+
+        if default is not _NO_DEFAULT:
+            return default
+
+        raise KeyError(feature)
+
+    def check_with_template(self, feature, default=_NO_DEFAULT):
+        '''Check if the subject's template has the specified feature.'''
+        return self._recursive_check('template',
+            feature=feature, default=default)
+
+    def check_with_netvm(self, feature, default=_NO_DEFAULT):
+        '''Check if the subject's netvm has the specified feature.'''
+        return self._recursive_check('netvm',
+            feature=feature, default=default)
+
+    def check_with_adminvm(self, feature, default=_NO_DEFAULT):
+        '''Check if the AdminVM has the specified feature.'''
+        return self._recursive_check(check_adminvm=True,
+            feature=feature, default=default)
+
+    def check_with_template_and_adminvm(self, feature, default=_NO_DEFAULT):
+        '''Check if the template and AdminVM has the specified feature.'''
+        return self._recursive_check('template', check_adminvm=True,
+            feature=feature, default=default)

+ 22 - 0
qubes/tests/api_admin.py

@@ -1123,6 +1123,28 @@ class TC_00_VMs(AdminAPITestCase):
                 b'test-vm1', b'test-feature')
         self.assertFalse(self.app.save.called)
 
+    def test_318_feature_checkwithadminvm(self):
+        self.app.domains['dom0'].features['test-feature'] = 'some-value'
+        value = self.call_mgmt_func(b'admin.vm.feature.CheckWithAdminVM',
+            b'test-vm1', b'test-feature')
+        self.assertEqual(value, 'some-value')
+        self.assertFalse(self.app.save.called)
+
+    def test_319_feature_checkwithtpladminvm(self):
+        self.app.domains['dom0'].features['test-feature'] = 'some-value'
+        value = self.call_mgmt_func(
+            b'admin.vm.feature.CheckWithTemplateAndAdminVM',
+            b'test-vm1', b'test-feature')
+        self.assertEqual(value, 'some-value')
+
+        self.template.features['test-feature'] = 'some-value2'
+        value = self.call_mgmt_func(
+            b'admin.vm.feature.CheckWithTemplateAndAdminVM',
+            b'test-vm1', b'test-feature')
+        self.assertEqual(value, 'some-value2')
+
+        self.assertFalse(self.app.save.called)
+
     def test_320_feature_set(self):
         value = self.call_mgmt_func(b'admin.vm.feature.Set',
             b'test-vm1', b'test-feature', b'some-value')

+ 1 - 1
qubes/tests/vm/init.py

@@ -213,7 +213,7 @@ class TC_21_Features(qubes.tests.QubesTestCase):
     def setUp(self):
         super(TC_21_Features, self).setUp()
         self.vm = qubes.tests.TestEmitter()
-        self.features = qubes.vm.Features(self.vm)
+        self.features = qubes.features.Features(self.vm)
 
     def test_000_set(self):
         self.features['testfeature'] = 'value'

+ 2 - 117
qubes/vm/__init__.py

@@ -33,6 +33,7 @@ import lxml.etree
 import qubes
 import qubes.devices
 import qubes.events
+import qubes.features
 import qubes.log
 
 VM_ENTRY_POINT = 'qubes.vm'
@@ -85,122 +86,6 @@ def _setter_qid(self, prop, value):
                 prop.__name__))
     return value
 
-class Features(dict):
-    '''Manager of the features.
-
-    Features can have three distinct values: no value (not present in mapping,
-    which is closest thing to :py:obj:`None`), empty string (which is
-    interpreted as :py:obj:`False`) and non-empty string, which is
-    :py:obj:`True`. Anything assigned to the mapping is coerced to strings,
-    however if you assign instances of :py:class:`bool`, they are converted as
-    described above. Be aware that assigning the number `0` (which is considered
-    false in Python) will result in string `'0'`, which is considered true.
-
-    This class inherits from dict, but has most of the methods that manipulate
-    the item disarmed (they raise NotImplementedError). The ones that are left
-    fire appropriate events on the qube that owns an instance of this class.
-    '''
-
-    #
-    # Those are the methods that affect contents. Either disarm them or make
-    # them report appropriate events. Good approach is to rewrite them carefully
-    # using official documentation, but use only our (overloaded) methods.
-    #
-    def __init__(self, vm, other=None, **kwargs):
-        super(Features, self).__init__()
-        self.vm = vm
-        self.update(other, **kwargs)
-
-    def __delitem__(self, key):
-        super(Features, self).__delitem__(key)
-        self.vm.fire_event('domain-feature-delete:' + key, feature=key)
-
-    def __setitem__(self, key, value):
-        if value is None or isinstance(value, bool):
-            value = '1' if value else ''
-        else:
-            value = str(value)
-        try:
-            oldvalue = self[key]
-            has_oldvalue = True
-        except KeyError:
-            has_oldvalue = False
-        super(Features, self).__setitem__(key, value)
-        if has_oldvalue:
-            self.vm.fire_event('domain-feature-set:' + key, feature=key,
-                value=value, oldvalue=oldvalue)
-        else:
-            self.vm.fire_event('domain-feature-set:' + key, feature=key,
-                value=value)
-
-    def clear(self):
-        for key in tuple(self):
-            del self[key]
-
-    def pop(self, _key, _default=None):
-        '''Not implemented
-        :raises: NotImplementedError
-        '''
-        raise NotImplementedError()
-
-    def popitem(self):
-        '''Not implemented
-        :raises: NotImplementedError
-        '''
-        raise NotImplementedError()
-
-    def setdefault(self, _key, _default=None):
-        '''Not implemented
-        :raises: NotImplementedError
-        '''
-        raise NotImplementedError()
-
-    def update(self, other=None, **kwargs):
-        if other is not None:
-            if hasattr(other, 'keys'):
-                for key in other:
-                    self[key] = other[key]
-            else:
-                for key, value in other:
-                    self[key] = value
-
-        for key in kwargs:
-            self[key] = kwargs[key]
-
-    #
-    # end of overriding
-    #
-
-    _NO_DEFAULT = object()
-
-    def check_with_template(self, feature, default=_NO_DEFAULT):
-        ''' Check if the vm's template has the specified feature. '''
-        if feature in self:
-            return self[feature]
-
-        if hasattr(self.vm, 'template') and self.vm.template is not None:
-            return self.vm.template.features.check_with_template(feature,
-                default)
-
-        if default is self._NO_DEFAULT:
-            raise KeyError(feature)
-
-        return default
-
-    def check_with_netvm(self, feature, default=_NO_DEFAULT):
-        ''' Check if the vm's netvm has the specified feature. '''
-        if feature in self:
-            return self[feature]
-
-        if hasattr(self.vm, 'netvm') and self.vm.netvm is not None:
-            return self.vm.netvm.features.check_with_netvm(feature,
-                default)
-
-        if default is self._NO_DEFAULT:
-            raise KeyError(feature)
-
-        return default
-
 
 class Tags(set):
     '''Manager of the tags.
@@ -332,7 +217,7 @@ class BaseVM(qubes.PropertyHolder):
         super(BaseVM, self).__init__(xml, **kwargs)
 
         #: dictionary of features of this qube
-        self.features = Features(self, features)
+        self.features = qubes.features.Features(self, features)
 
         #: :py:class:`DeviceManager` object keeping devices that are attached to
         #: this domain

+ 1 - 0
rpm_spec/core-dom0.spec.in

@@ -223,6 +223,7 @@ fi
 %{python3_sitelib}/qubes/dochelpers.py
 %{python3_sitelib}/qubes/events.py
 %{python3_sitelib}/qubes/exc.py
+%{python3_sitelib}/qubes/features.py
 %{python3_sitelib}/qubes/firewall.py
 %{python3_sitelib}/qubes/log.py
 %{python3_sitelib}/qubes/rngdoc.py