Browse Source

Migrate qubes.NotifyTools, qubes.FeaturesRequest, qubes.NotifyUpdates

Make them call into qubesd. Create separate socket for "misc" calls - VM
accessible, but not part of Admin API.
Marek Marczykowski-Górecki 7 years ago
parent
commit
28737d16ce

+ 0 - 2
Makefile

@@ -170,8 +170,6 @@ endif
 	cp qubes-rpc/qubes.GetRandomizedTime $(DESTDIR)/etc/qubes-rpc/
 	cp qubes-rpc/qubes.NotifyTools $(DESTDIR)/etc/qubes-rpc/
 	cp qubes-rpc/qubes.NotifyUpdates $(DESTDIR)/etc/qubes-rpc/
-	cp qubes-rpc/qubes-notify-updates $(DESTDIR)/usr/libexec/qubes/
-	cp qubes-rpc/qubes-notify-tools $(DESTDIR)/usr/libexec/qubes/
 	install qubes-rpc/qubesd-query-fast $(DESTDIR)/usr/libexec/qubes/
 	for method in $(ADMIN_API_METHODS_SIMPLE); do \
 		ln -s ../../usr/libexec/qubes/qubesd-query-fast \

+ 0 - 94
qubes-rpc/qubes-notify-tools

@@ -1,94 +0,0 @@
-#!/usr/bin/python2
-
-import os
-import re
-import sys
-import subprocess
-from qubes.qubes import QubesVmCollection,QubesException,QubesHVm
-
-def main():
-
-    source = os.getenv("QREXEC_REMOTE_DOMAIN")
-
-    if source is None:
-        print >> sys.stderr, 'This script must be called as qrexec service!'
-        exit(1)
-
-    prev_qrexec_installed = False
-    source_vm = None
-    qvm_collection = QubesVmCollection()
-    qvm_collection.lock_db_for_writing()
-    try:
-        qvm_collection.load()
-
-        source_vm = qvm_collection.get_vm_by_name(source)
-        if source_vm is None:
-            raise QubesException('Domain ' + source + ' does not exists (?!)')
-
-        if not isinstance(source_vm, QubesHVm):
-            raise QubesException('Service qubes.ToolsNotify is designed only for HVM domains')
-
-        # for now used only to check for the tools presence
-        untrusted_version = source_vm.qdb.read('/qubes-tools/version')
-        # reserved for future use
-        untrusted_os = source_vm.qdb.read('/qubes-tools/os')
-        # qrexec agent presence (0 or 1)
-        untrusted_qrexec = source_vm.qdb.read('/qubes-tools/qrexec')
-        # gui agent presence (0 or 1)
-        untrusted_gui = source_vm.qdb.read('/qubes-tools/gui')
-        # default user for qvm-run etc
-        untrusted_user = source_vm.qdb.read('/qubes-tools/default-user')
-
-        if untrusted_version is None:
-            # tools didn't advertised its features; it's strange that this
-            # service is called, but ignore it
-            return
-
-        # any suspicious string will raise exception here
-        version = int(untrusted_version)
-
-        # untrusted_os - ignore for now
-
-        if untrusted_qrexec is None:
-            qrexec = 0
-        else:
-            qrexec = int(untrusted_qrexec)
-
-        if untrusted_gui is None:
-            gui = 0
-        else:
-            gui = int(untrusted_gui)
-
-        if untrusted_user is not None and re.match(r'^[a-zA-Z0-9-]{1,255}$', untrusted_user):
-            assert '@' not in untrusted_user
-            assert '/' not in untrusted_user
-
-            user = untrusted_user
-        else:
-            user = None
-
-        prev_qrexec_installed = source_vm.qrexec_installed
-        # Let the tools to be able to enable *or disable* each particular component
-        source_vm.qrexec_installed = qrexec > 0
-        source_vm.guiagent_installed = gui > 0
-
-        if user is not None:
-            source_vm.default_user = user
-
-        qvm_collection.save()
-
-    except Exception as e:
-        print >> sys.stderr, e.message
-        exit(1)
-    finally:
-        qvm_collection.unlock_db()
-
-    if not prev_qrexec_installed and source_vm.qrexec_installed:
-        retcode = subprocess.call(['qvm-sync-appmenus', '--force-rpc'])
-        if retcode == 0 and hasattr(source_vm, 'appmenus_recreate'):
-            # TODO: call the same for child VMs? This isn't done for Linux VMs,
-            # so probably should be ignored for Windows also
-            source_vm.appmenus_recreate()
-
-
-main()

+ 0 - 92
qubes-rpc/qubes-notify-updates

@@ -1,92 +0,0 @@
-#!/usr/bin/python
-#
-# The Qubes OS Project, http://www.qubes-os.org
-#
-# Copyright (C) 2012  Marek Marczykowski  <marmarek@invisiblethingslab.com>
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
-#
-#
-import os
-import os.path
-import sys
-import subprocess
-import shutil
-import grp
-from datetime import datetime
-from qubes.qubes import QubesVmCollection
-from qubes.qubes import vm_files
-
-def main():
-
-    qvm_collection = QubesVmCollection()
-    qvm_collection.lock_db_for_reading()
-    qvm_collection.load()
-    qvm_collection.unlock_db()
-
-    source = os.getenv("QREXEC_REMOTE_DOMAIN")
-
-    if source is None:
-        print >> sys.stderr, 'This script must be called as qrexec service!'
-        exit(1)
-
-    source_vm = qvm_collection.get_vm_by_name(source)
-    if source_vm is None:
-        print >> sys.stderr, 'Domain ' + source + ' does not exist (?!)'
-        exit(1)
-
-    os.umask(0002)
-    qubes_gid = grp.getgrnam('qubes').gr_gid
-    
-    untrusted_update_count = sys.stdin.readline(128).strip()
-    if not untrusted_update_count.isdigit():
-        print >> sys.stderr, 'Domain ' + source + ' sent invalid number of updates: %s' % untrusted_update_count
-        exit(1)
-    # now sanitized
-    update_count = untrusted_update_count
-    if source_vm.updateable:
-        # Just trust information from VM itself
-        update_f = open(source_vm.dir_path + '/' + vm_files["updates_stat_file"], "w")
-        update_f.write(update_count)
-        update_f.close()
-        try:
-            os.chown(source_vm.dir_path + '/' + vm_files["updates_stat_file"], -1, qubes_gid)
-        except OSError:
-            pass
-    elif source_vm.template is not None:
-        # Hint about updates availability in template
-        # If template is running - it will notify about updates itself
-        if source_vm.template.is_running():
-            return
-        # Ignore no-updates info
-        if int(update_count) > 0:
-            stat_file = source_vm.template.dir_path + '/' + vm_files["updates_stat_file"]
-            # If VM is started before last updates.stat - it means that updates
-            # already was installed (but VM still hasn't been restarted), or other
-            # VM has already notified about updates availability
-            if os.path.exists(stat_file) and \
-                source_vm.get_start_time() < datetime.fromtimestamp(os.path.getmtime(stat_file)):
-                    return
-            update_f = open(stat_file, "w")
-            update_f.write(update_count)
-            update_f.close()
-            try:
-                os.chown(stat_file, -1, qubes_gid)
-            except OSError:
-                pass
-        else:
-            print >> sys.stderr, 'Ignoring notification of no updates'
-
-main()

+ 3 - 12
qubes-rpc/qubes.FeaturesRequest

@@ -1,13 +1,4 @@
-#!/usr/bin/env python2
+#!/bin/sh
 
-import os
-import qubes
-
-PREFIX = '/features-request/'
-
-app = qubes.Qubes()
-vm = app.domains[os.environ['QREXEC_REMOTE_DOMAIN']]
-vm.fire_event('features-request',
-    untrusted_features={key[len(PREFIX):]: vm.qdb.read(key)
-        for key in vm.qdb.list(PREFIX)})
-app.save()
+exec /usr/bin/qubesd-query -c /var/run/qubesd.misc.sock -e --fail \
+         "$QREXEC_REMOTE_DOMAIN" qubes.FeaturesRequest dom0 "" >/dev/null 2>&1

+ 4 - 1
qubes-rpc/qubes.NotifyTools

@@ -1 +1,4 @@
-/usr/libexec/qubes/qubes-notify-tools
+#!/bin/sh
+
+exec /usr/bin/qubesd-query -c /var/run/qubesd.misc.sock -e --fail \
+         "$QREXEC_REMOTE_DOMAIN" qubes.NotifyTools dom0 "" >/dev/null 2>&1

+ 4 - 1
qubes-rpc/qubes.NotifyUpdates

@@ -1 +1,4 @@
-/usr/libexec/qubes/qubes-notify-updates
+#!/bin/sh
+
+exec /usr/bin/qubesd-query -c /var/run/qubesd.misc.sock --fail \
+         "$QREXEC_REMOTE_DOMAIN" qubes.NotifyUpdates dom0 "" >/dev/null 2>&1

+ 164 - 0
qubes/api/misc.py

@@ -0,0 +1,164 @@
+# -*- encoding: utf8 -*-
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2017 Marek Marczykowski-Górecki
+#                               <marmarek@invisiblethingslab.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, see <http://www.gnu.org/licenses/>.
+
+''' Interface for methods not being part of Admin API, but still handled by
+qubesd. '''
+
+import asyncio
+import string
+
+import qubes.api
+import qubes.api.admin
+import qubes.vm.dispvm
+
+
+class QubesMiscAPI(qubes.api.AbstractQubesAPI):
+    @qubes.api.method('qubes.FeaturesRequest', no_payload=True)
+    @asyncio.coroutine
+    def qubes_features_request(self):
+        ''' qubes.FeaturesRequest handler
+
+        VM (mostly templates) can request some features from dom0 for itself.
+        Then dom0 (qubesd extension) may respect this request or ignore it.
+
+        Technically, VM first write requested features into QubesDB in
+        `/features-request/` subtree, then call this method. The method will
+        dispatch 'features-request' event, which may be handled by
+        appropriate extensions. Requests not explicitly handled by some
+        extension are ignored.
+        '''
+        assert self.dest.name == 'dom0'
+        assert not self.arg
+
+        prefix = '/features-request/'
+
+        untrusted_features = {key[len(prefix):]:
+            self.src.qdb.read(key).decode('ascii', errors='strict')
+                for key in self.src.qdb.list(prefix)}
+
+        safe_set = string.ascii_letters + string.digits
+        for untrusted_key in untrusted_features:
+            untrusted_value = untrusted_features[untrusted_key]
+            assert all((c in safe_set) for c in untrusted_value)
+
+        self.src.fire_event('features-request',
+            untrusted_features=untrusted_features)
+        self.app.save()
+
+    @qubes.api.method('qubes.NotifyTools', no_payload=True)
+    @asyncio.coroutine
+    def qubes_notify_tools(self):
+        '''
+        Legacy version of qubes.FeaturesRequest, used by Qubes Windows Tools
+        '''
+        assert self.dest.name == 'dom0'
+        assert not self.arg
+
+        if getattr(self.src, 'template', None):
+            self.src.log.warning(
+                'Ignoring qubes.NotifyTools for template-based VM')
+            return
+
+        # for now used only to check for the tools presence
+        untrusted_version = self.src.qdb.read('/qubes-tools/version')
+
+        # reserved for future use
+        #untrusted_os = self.src.qdb.read('/qubes-tools/os')
+
+        # qrexec agent presence (0 or 1)
+        untrusted_qrexec = self.src.qdb.read('/qubes-tools/qrexec')
+
+        # gui agent presence (0 or 1)
+        untrusted_gui = self.src.qdb.read('/qubes-tools/gui')
+
+        # default user for qvm-run etc
+        # starting with Qubes 4.x ignored
+        #untrusted_user = self.src.qdb.read('/qubes-tools/default-user')
+
+        if untrusted_version is None:
+            # tools didn't advertised its features; it's strange that this
+            # service is called, but ignore it
+            return
+
+        # any suspicious string will raise exception here
+        int(untrusted_version)
+        del untrusted_version
+
+        # untrusted_os - ignore for now
+
+        if untrusted_qrexec is None:
+            qrexec = False
+        else:
+            qrexec = bool(int(untrusted_qrexec))
+        del untrusted_qrexec
+
+        if untrusted_gui is None:
+            gui = False
+        else:
+            gui = bool(int(untrusted_gui))
+        del untrusted_gui
+
+        # ignore default_user
+
+        prev_qrexec = self.src.features.get('qrexec', False)
+        # Let the tools to be able to enable *or disable*
+        # each particular component
+        self.src.features['qrexec'] = qrexec
+        self.src.features['gui'] = gui
+        self.app.save()
+
+        if not prev_qrexec and qrexec:
+            # if this is the first time qrexec was advertised, now can finish
+            #  template setup
+            self.src.fire_event('template-postinstall')
+
+    @qubes.api.method('qubes.NotifyUpdates')
+    @asyncio.coroutine
+    def qubes_notify_updates(self, untrusted_payload):
+        '''
+        Receive VM notification about updates availability
+
+        Payload contains a single integer - either 0 (no updates) or some
+        positive value (some updates).
+        '''
+
+        untrusted_update_count = untrusted_payload.strip()
+        assert untrusted_update_count.isdigit()
+        # now sanitized
+        update_count = int(untrusted_update_count)
+        del untrusted_update_count
+
+        if self.src.updateable:
+            # Just trust information from VM itself
+            self.src.updates_available = bool(update_count)
+            self.app.save()
+        elif getattr(self.src, 'template', None) is not None:
+            # Hint about updates availability in template
+            # If template is running - it will notify about updates itself
+            if self.src.template.is_running():
+                return
+            # Ignore no-updates info
+            if update_count > 0:
+                # If VM is outdated, updates were probably already installed
+                # in the template - ignore info
+                if self.src.storage.outdated_volumes:
+                    return
+                self.src.template.updates_available = bool(update_count)
+                self.app.save()

+ 1 - 0
qubes/tests/__init__.py

@@ -969,6 +969,7 @@ def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
             'qubes.tests.app',
             'qubes.tests.tarwriter',
             'qubes.tests.api_admin',
+            'qubes.tests.api_misc',
             'qubespolicy.tests',
             'qubes.tests.tools.qubesd',
             ):

+ 410 - 0
qubes/tests/api_misc.py

@@ -0,0 +1,410 @@
+# -*- encoding: utf8 -*-
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2017 Marek Marczykowski-Górecki
+#                               <marmarek@invisiblethingslab.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, see <http://www.gnu.org/licenses/>.
+import asyncio
+from unittest import mock
+import qubes.tests
+import qubes.api.misc
+
+
+class TC_00_API_Misc(qubes.tests.QubesTestCase):
+    def setUp(self):
+        super(TC_00_API_Misc, self).setUp()
+        self.src = mock.NonCallableMagicMock()
+        self.app = mock.NonCallableMock()
+        self.dest = mock.NonCallableMock()
+        self.dest.name = 'dom0'
+        self.app.configure_mock(domains={
+            'dom0': self.dest,
+            'test-vm': self.src,
+        })
+
+    def configure_qdb(self, entries):
+        self.src.configure_mock(**{
+            'qdb.read.side_effect': (lambda path: entries.get(path, None)),
+            'qdb.list.side_effect': (lambda path: sorted(entries.keys())),
+        })
+
+    def call_mgmt_func(self, method, arg=b'', payload=b''):
+        mgmt_obj = qubes.api.misc.QubesMiscAPI(self.app,
+            b'test-vm', method, b'dom0', arg)
+
+        loop = asyncio.get_event_loop()
+        response = loop.run_until_complete(
+            mgmt_obj.execute(untrusted_payload=payload))
+        return response
+
+    def test_000_features_request(self):
+        qdb_entries = {
+            '/features-request/feature1': b'1',
+            '/features-request/feature2': b'',
+            '/features-request/feature3': b'other',
+        }
+        self.configure_qdb(qdb_entries)
+        response = self.call_mgmt_func(b'qubes.FeaturesRequest')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.list('/features-request/'),
+            mock.call.qdb.read('/features-request/feature1'),
+            mock.call.qdb.read('/features-request/feature2'),
+            mock.call.qdb.read('/features-request/feature3'),
+            mock.call.fire_event('features-request', untrusted_features={
+                'feature1': '1', 'feature2': '', 'feature3': 'other'})
+        ])
+
+    def test_001_features_request_empty(self):
+        self.configure_qdb({})
+        response = self.call_mgmt_func(b'qubes.FeaturesRequest')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.list('/features-request/'),
+            mock.call.fire_event('features-request', untrusted_features={})
+        ])
+
+    def test_002_features_request_invalid1(self):
+        qdb_entries = {
+            '/features-request/feature1': b'test spaces',
+        }
+        self.configure_qdb(qdb_entries)
+        with self.assertRaises(AssertionError):
+            self.call_mgmt_func(b'qubes.FeaturesRequest')
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.list('/features-request/'),
+            mock.call.qdb.read('/features-request/feature1'),
+        ])
+
+    def test_003_features_request_invalid2(self):
+        qdb_entries = {
+            '/features-request/feature1': b'\xfe\x01',
+        }
+        self.configure_qdb(qdb_entries)
+        with self.assertRaises(UnicodeDecodeError):
+            self.call_mgmt_func(b'qubes.FeaturesRequest')
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.list('/features-request/'),
+            mock.call.qdb.read('/features-request/feature1'),
+        ])
+
+    def test_010_notify_tools(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'1',
+            '/qubes-tools/gui': b'1',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': False})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+            mock.call.features.get('qrexec', False),
+            mock.call.features.__setitem__('qrexec', True),
+            mock.call.features.__setitem__('gui', True),
+            mock.call.fire_event('template-postinstall')
+        ])
+
+    def test_011_notify_tools_uninstall(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'0',
+            '/qubes-tools/gui': b'0',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+            mock.call.features.get('qrexec', False),
+            mock.call.features.__setitem__('qrexec', False),
+            mock.call.features.__setitem__('gui', False),
+        ])
+
+    def test_012_notify_tools_uninstall2(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+            mock.call.features.get('qrexec', False),
+            mock.call.features.__setitem__('qrexec', False),
+            mock.call.features.__setitem__('gui', False),
+        ])
+
+    def test_013_notify_tools_no_version(self):
+        qdb_entries = {
+            '/qubes-tools/qrexec': b'0',
+            '/qubes-tools/gui': b'0',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+        ])
+
+    def test_014_notify_tools_invalid_version(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'this is invalid',
+            '/qubes-tools/qrexec': b'0',
+            '/qubes-tools/gui': b'0',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        with self.assertRaises(ValueError):
+            self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+        ])
+
+
+    def test_015_notify_tools_invalid_value_qrexec(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'invalid',
+            '/qubes-tools/gui': b'0',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        with self.assertRaises(ValueError):
+            self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+        ])
+
+    def test_016_notify_tools_invalid_value_gui(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'1',
+            '/qubes-tools/gui': b'invalid',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        with self.assertRaises(ValueError):
+            self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+        ])
+
+    def test_017_notify_tools_template_based(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'1',
+            '/qubes-tools/gui': b'invalid',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        self.src.configure_mock(**{'features.get.return_value': True})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.template.__bool__(),
+            mock.call.log.warning(
+                'Ignoring qubes.NotifyTools for template-based VM')
+        ])
+
+    def test_018_notify_tools_already_installed(self):
+        qdb_entries = {
+            '/qubes-tools/version': b'1',
+            '/qubes-tools/qrexec': b'1',
+            '/qubes-tools/gui': b'1',
+            '/qubes-tools/os': b'Linux',
+            '/qubes-tools/default-user': b'user',
+        }
+        self.configure_qdb(qdb_entries)
+        del self.src.template
+        self.src.configure_mock(**{'features.get.return_value': True})
+        response = self.call_mgmt_func(b'qubes.NotifyTools')
+        self.assertIsNone(response)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.qdb.read('/qubes-tools/version'),
+            mock.call.qdb.read('/qubes-tools/qrexec'),
+            mock.call.qdb.read('/qubes-tools/gui'),
+            mock.call.features.get('qrexec', False),
+            mock.call.features.__setitem__('qrexec', True),
+            mock.call.features.__setitem__('gui', True),
+        ])
+
+    def test_020_notify_updates_standalone(self):
+        del self.src.template
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'1\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.updateable.__bool__(),
+        ])
+        self.assertEqual(self.src.updates_available, True)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+
+    def test_021_notify_updates_standalone2(self):
+        del self.src.template
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'0\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.updateable.__bool__(),
+        ])
+        self.assertEqual(self.src.updates_available, False)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+
+    def test_022_notify_updates_invalid(self):
+        del self.src.template
+        with self.assertRaises(AssertionError):
+            self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'')
+        self.assertEqual(self.src.mock_calls, [])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertEqual(self.app.mock_calls, [])
+
+    def test_023_notify_updates_invalid2(self):
+        del self.src.template
+        with self.assertRaises(AssertionError):
+            self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'no updates')
+        self.assertEqual(self.src.mock_calls, [])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertEqual(self.app.mock_calls, [])
+
+    def test_024_notify_updates_template_based_no_updates(self):
+        '''No updates on template-based VM, should not reset state'''
+        self.src.updateable = False
+        self.src.template.is_running.return_value = False
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'0\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.template.is_running(),
+        ])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.template.updates_available, mock.Mock)
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertEqual(self.app.mock_calls, [])
+
+    def test_025_notify_updates_template_based(self):
+        '''Some updates on template-based VM, should save flag'''
+        self.src.updateable = False
+        self.src.template.is_running.return_value = False
+        self.src.storage.outdated_volumes = []
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'1\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.template.is_running(),
+        ])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertEqual(self.src.template.updates_available, True)
+        self.assertEqual(self.app.mock_calls, [
+            mock.call.save()
+        ])
+
+    def test_026_notify_updates_template_based_outdated(self):
+        self.src.updateable = False
+        self.src.template.is_running.return_value = False
+        self.src.storage.outdated_volumes = ['root']
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'1\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.template.is_running(),
+        ])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertIsInstance(self.src.template.updates_available, mock.Mock)
+        self.assertEqual(self.app.mock_calls, [])
+
+    def test_027_notify_updates_template_based_template_running(self):
+        self.src.updateable = False
+        self.src.template.is_running.return_value = True
+        self.src.storage.outdated_volumes = []
+        response = self.call_mgmt_func(b'qubes.NotifyUpdates', payload=b'1\n')
+        self.assertIsNone(response)
+        self.assertEqual(self.src.mock_calls, [
+            mock.call.template.is_running(),
+        ])
+        # not set property returns Mock()
+        self.assertIsInstance(self.src.template.updates_available, mock.Mock)
+        self.assertIsInstance(self.src.updates_available, mock.Mock)
+        self.assertEqual(self.app.mock_calls, [])
+

+ 16 - 4
qubes/tools/qubesd.py

@@ -15,11 +15,13 @@ import qubes
 import qubes.api
 import qubes.api.admin
 import qubes.api.internal
+import qubes.api.misc
 import qubes.utils
 import qubes.vm.qubesvm
 
 QUBESD_SOCK = '/var/run/qubesd.sock'
 QUBESD_INTERNAL_SOCK = '/var/run/qubesd.internal.sock'
+QUBESD_MISC_SOCK = '/var/run/qubesd.misc.sock'
 
 class QubesDaemonProtocol(asyncio.Protocol):
     buffer_size = 65536
@@ -170,10 +172,10 @@ class QubesDaemonProtocol(asyncio.Protocol):
         self.transport.write(str(exc).encode('utf-8') + b'\0')
 
 
-def sighandler(loop, signame, server, server_internal):
+def sighandler(loop, signame, *servers):
     print('caught {}, exiting'.format(signame))
-    server.close()
-    server_internal.close()
+    for server in servers:
+        server.close()
     loop.stop()
 
 parser = qubes.tools.QubesArgumentParser(description='Qubes OS daemon')
@@ -213,12 +215,22 @@ def main(args=None):
             app=args.app, debug=args.debug), QUBESD_INTERNAL_SOCK))
     shutil.chown(QUBESD_INTERNAL_SOCK, group='qubes')
 
+    try:
+        os.unlink(QUBESD_MISC_SOCK)
+    except FileNotFoundError:
+        pass
+    server_misc = loop.run_until_complete(loop.create_unix_server(
+        functools.partial(QubesDaemonProtocol,
+            qubes.api.misc.QubesMiscAPI,
+            app=args.app, debug=args.debug), QUBESD_MISC_SOCK))
+    shutil.chown(QUBESD_MISC_SOCK, group='qubes')
+
     os.umask(old_umask)
     del old_umask
 
     for signame in ('SIGINT', 'SIGTERM'):
         loop.add_signal_handler(getattr(signal, signame),
-            sighandler, loop, signame, server, server_internal)
+            sighandler, loop, signame, server, server_internal, server_misc)
 
     qubes.utils.systemd_notify()
     # make sure children will not inherit this

+ 4 - 0
qubes/vm/qubesvm.py

@@ -509,6 +509,10 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM):
         default=(lambda self: self.app.default_dispvm),
         doc='Default VM to be used as Disposable VM for service calls.')
 
+    updates_available = qubes.property('updates_available',
+        type=bool,
+        default=False,
+        doc='If updates are pending to be installed')
 
     updateable = qubes.property('updateable',
         default=(lambda self: not hasattr(self, 'template')),

+ 3 - 3
rpm_spec/core-dom0.spec

@@ -241,8 +241,9 @@ fi
 %dir %{python3_sitelib}/qubes/api/__pycache__
 %{python3_sitelib}/qubes/api/__pycache__/*
 %{python3_sitelib}/qubes/api/__init__.py
-%{python3_sitelib}/qubes/api/internal.py
 %{python3_sitelib}/qubes/api/admin.py
+%{python3_sitelib}/qubes/api/internal.py
+%{python3_sitelib}/qubes/api/misc.py
 
 %dir %{python3_sitelib}/qubes/vm
 %dir %{python3_sitelib}/qubes/vm/__pycache__
@@ -298,6 +299,7 @@ fi
 %{python3_sitelib}/qubes/tests/extra.py
 
 %{python3_sitelib}/qubes/tests/api_admin.py
+%{python3_sitelib}/qubes/tests/api_misc.py
 %{python3_sitelib}/qubes/tests/app.py
 %{python3_sitelib}/qubes/tests/devices.py
 %{python3_sitelib}/qubes/tests/devices_block.py
@@ -380,8 +382,6 @@ fi
 /usr/lib/qubes/cleanup-dispvms
 /usr/lib/qubes/fix-dir-perms.sh
 /usr/lib/qubes/startup-misc.sh
-/usr/libexec/qubes/qubes-notify-tools
-/usr/libexec/qubes/qubes-notify-updates
 /usr/libexec/qubes/qubesd-query-fast
 %{_unitdir}/qubes-core.service
 %{_unitdir}/qubes-netvm.service