diff --git a/qubes/app.py b/qubes/app.py index c14d1f60..eb5dc0c5 100644 --- a/qubes/app.py +++ b/qubes/app.py @@ -743,6 +743,12 @@ class Qubes(qubes.PropertyHolder): default=lambda app: app.domains['dom0'], allow_none=True, doc='Default GuiVM for VMs.') + default_audiovm = qubes.VMProperty( + 'default_audiovm', + load_stage=3, + default=lambda app: app.domains['dom0'], allow_none=True, + doc='Default AudioVM for VMs.') + default_netvm = qubes.VMProperty( 'default_netvm', load_stage=3, diff --git a/qubes/ext/audio.py b/qubes/ext/audio.py new file mode 100644 index 00000000..f4b13308 --- /dev/null +++ b/qubes/ext/audio.py @@ -0,0 +1,93 @@ +# +# The Qubes OS Project, https://www.qubes-os.org/ +# +# Copyright (C) 2019 Frédéric Pierret +# +# 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 asyncio + +import qubes.config +import qubes.ext + + +class AUDIO(qubes.ext.Extension): + # pylint: disable=unused-argument,no-self-use + @staticmethod + def attached_vms(vm): + for domain in vm.app.domains: + if getattr(domain, 'audiovm', None) and domain.audiovm == vm: + yield domain + + @qubes.ext.handler('domain-pre-shutdown') + @asyncio.coroutine + def on_domain_pre_shutdown(self, vm, event, **kwargs): + attached_vms = [domain for domain in self.attached_vms(vm) if + domain.is_running()] + if attached_vms and not kwargs.get('force', False): + raise qubes.exc.QubesVMError( + self, 'There are running VMs using this VM as AudioVM: ' + '{}'.format(', '.join(vm.name for vm in attached_vms))) + + @qubes.ext.handler('domain-init', 'domain-load') + def on_domain_init_load(self, vm, event): + if getattr(vm, 'audiovm', None): + if 'audiovm-' + vm.audiovm.name not in vm.tags: + self.on_property_set(vm, event, name='audiovm', + newvalue=vm.audiovm) + + # property-del <=> property-reset-to-default + @qubes.ext.handler('property-del:audiovm') + def on_property_del(self, subject, event, name, oldvalue=None): + newvalue = getattr(subject, 'audiovm', None) + self.on_property_set(subject, event, name, newvalue, oldvalue) + + @qubes.ext.handler('property-set:audiovm') + def on_property_set(self, subject, event, name, newvalue, oldvalue=None): + # Clean other 'audiovm-XXX' tags. + # pulseaudio agent (module-vchan-sink) can connect to only one domain + tags_list = list(subject.tags) + for tag in tags_list: + if tag.startswith('audiovm-'): + subject.tags.remove(tag) + + if newvalue: + audiovm = 'audiovm-' + newvalue.name + subject.tags.add(audiovm) + + @qubes.ext.handler('domain-qdb-create') + def on_domain_qdb_create(self, vm, event): + # Add AudioVM Xen ID for gui-agent + if getattr(vm, 'audiovm', None): + if vm != vm.audiovm and vm.audiovm.is_running(): + vm.untrusted_qdb.write('/qubes-audio-domain-xid', + str(vm.audiovm.xid)) + + @qubes.ext.handler('property-set:default_audiovm', system=True) + def on_property_set_default_audiovm(self, app, event, name, newvalue, + oldvalue=None): + for vm in app.domains: + if hasattr(vm, 'audiovm') and vm.property_is_default('audiovm'): + vm.fire_event('property-set:audiovm', + name='audiovm', newvalue=newvalue, + oldvalue=oldvalue) + + @qubes.ext.handler('domain-start') + def on_domain_start(self, vm, event, **kwargs): + attached_vms = [domain for domain in self.attached_vms(vm) if + domain.is_running()] + for attached_vm in attached_vms: + attached_vm.untrusted_qdb.write('/qubes-audio-domain-xid', + str(vm.xid)) diff --git a/qubes/ext/gui.py b/qubes/ext/gui.py index 69e97df6..e542a360 100644 --- a/qubes/ext/gui.py +++ b/qubes/ext/gui.py @@ -26,8 +26,22 @@ import qubes.ext class GUI(qubes.ext.Extension): - # pylint: disable=too-few-public-methods - # TODO put this somewhere... + # pylint: disable=too-few-public-methods,unused-argument,no-self-use + @staticmethod + def attached_vms(vm): + for domain in vm.app.domains: + if getattr(domain, 'guivm', None) and domain.guivm == vm: + yield domain + + @qubes.ext.handler('domain-pre-shutdown') + def on_domain_pre_shutdown(self, vm, event, **kwargs): + attached_vms = [domain for domain in self.attached_vms(vm) if + domain.is_running()] + if attached_vms and not kwargs.get('force', False): + raise qubes.exc.QubesVMError( + self, 'There are running VMs using this VM as GuiVM: ' + '{}'.format(', '.join(vm.name for vm in attached_vms))) + @staticmethod def send_gui_mode(vm): vm.run_service('qubes.SetGuiMode', @@ -35,6 +49,12 @@ class GUI(qubes.ext.Extension): if vm.features.get('gui-seamless', False) else 'FULLSCREEN')) + @qubes.ext.handler('domain-init', 'domain-load') + def on_domain_init_load(self, vm, event): + if getattr(vm, 'guivm', None): + if 'guivm-' + vm.guivm.name not in vm.tags: + self.on_property_set(vm, event, name='guivm', newvalue=vm.guivm) + # property-del <=> property-reset-to-default @qubes.ext.handler('property-del:guivm') def on_property_del(self, subject, event, name, oldvalue=None): @@ -43,13 +63,11 @@ class GUI(qubes.ext.Extension): @qubes.ext.handler('property-set:guivm') def on_property_set(self, subject, event, name, newvalue, oldvalue=None): - # pylint: disable=unused-argument,no-self-use - # Clean other 'guivm-XXX' tags. # gui-daemon can connect to only one domain tags_list = list(subject.tags) for tag in tags_list: - if 'guivm-' in tag: + if tag.startswith('guivm-'): subject.tags.remove(tag) if newvalue: @@ -58,7 +76,6 @@ class GUI(qubes.ext.Extension): @qubes.ext.handler('domain-qdb-create') def on_domain_qdb_create(self, vm, event): - # pylint: disable=unused-argument,no-self-use for feature in ('gui-videoram-overhead', 'gui-videoram-min'): try: vm.untrusted_qdb.write( @@ -70,7 +87,7 @@ class GUI(qubes.ext.Extension): # Add GuiVM Xen ID for gui-daemon if getattr(vm, 'guivm', None): - if vm != vm.guivm: + if vm != vm.guivm and vm.guivm.is_running(): vm.untrusted_qdb.write('/qubes-gui-domain-xid', str(vm.guivm.xid)) @@ -98,9 +115,16 @@ class GUI(qubes.ext.Extension): @qubes.ext.handler('property-set:default_guivm', system=True) def on_property_set_default_guivm(self, app, event, name, newvalue, oldvalue=None): - # pylint: disable=unused-argument,no-self-use for vm in app.domains: if hasattr(vm, 'guivm') and vm.property_is_default('guivm'): vm.fire_event('property-set:guivm', name='guivm', newvalue=newvalue, oldvalue=oldvalue) + + @qubes.ext.handler('domain-start') + def on_domain_start(self, vm, event, **kwargs): + attached_vms = [domain for domain in self.attached_vms(vm) if + domain.is_running()] + for attached_vm in attached_vms: + attached_vm.untrusted_qdb.write('/qubes-gui-domain-xid', + str(vm.xid)) diff --git a/qubes/tests/api_admin.py b/qubes/tests/api_admin.py index 7364df1f..ab8b7373 100644 --- a/qubes/tests/api_admin.py +++ b/qubes/tests/api_admin.py @@ -1951,7 +1951,7 @@ netvm default=True type=vm \n''' self.vm.tags.add('tag1') self.vm.tags.add('tag2') value = self.call_mgmt_func(b'admin.vm.tag.List', b'test-vm1') - self.assertEqual(value, 'tag1\ntag2\n') + self.assertEqual(value, 'audiovm-dom0\nguivm-dom0\ntag1\ntag2\n') self.assertFalse(self.app.save.called) def test_540_tag_get(self): diff --git a/qubes/tests/app.py b/qubes/tests/app.py index eceb62ee..f4c202ea 100644 --- a/qubes/tests/app.py +++ b/qubes/tests/app.py @@ -596,18 +596,88 @@ class TC_90_Qubes(qubes.tests.QubesTestCase): holder = MyTestHolder(None) guivm = self.app.add_new_vm('AppVM', name='sys-gui', guivm='dom0', template=self.template, label='red') + vncvm = self.app.add_new_vm('AppVM', name='sys-vnc', guivm='dom0', + template=self.template, label='red') appvm = self.app.add_new_vm('AppVM', name='test-vm', guivm='dom0', template=self.template, label='red') holder.guivm = 'sys-gui' self.assertEqual(holder.guivm, 'sys-gui') - self.assertFalse(appvm.property_is_default('guivm')) - appvm.guivm = guivm self.assertEventFired(holder, 'property-set:guivm', kwargs={'name': 'guivm', 'newvalue': 'sys-gui'}) + # Set GuiVM + self.assertFalse(appvm.property_is_default('guivm')) + appvm.guivm = guivm self.assertIn('guivm-sys-gui', appvm.tags) + # Change GuiVM + appvm.guivm = vncvm + self.assertIn('guivm-sys-vnc', appvm.tags) + self.assertNotIn('guivm-sys-gui', appvm.tags) + + # Empty GuiVM + del appvm.guivm + self.assertNotIn('guivm-sys-vnc', appvm.tags) + self.assertNotIn('guivm-sys-gui', appvm.tags) + self.assertNotIn('guivm-', appvm.tags) + + def test_114_default_audiovm(self): + class MyTestHolder(qubes.tests.TestEmitter, qubes.PropertyHolder): + default_audiovm = qubes.property('default_audiovm', + default=(lambda self: 'dom0')) + + holder = MyTestHolder(None) + audiovm = self.app.add_new_vm('AppVM', name='sys-audio', audiovm='dom0', + template=self.template, label='red') + appvm = self.app.add_new_vm('AppVM', name='test-vm', + template=self.template, label='red') + holder.default_audiovm = 'sys-audio' + self.assertEqual(holder.default_audiovm, 'sys-audio') + self.assertIsNotNone(self.app.default_audiovm) + self.assertTrue(appvm.property_is_default('audiovm')) + self.app.default_audiovm = audiovm + self.assertEventFired(holder, 'property-set:default_audiovm', + kwargs={'name': 'default_audiovm', + 'newvalue': 'sys-audio'}) + + self.assertIn('audiovm-sys-audio', appvm.tags) + + def test_115_audiovm(self): + class MyTestHolder(qubes.tests.TestEmitter, qubes.PropertyHolder): + audiovm = qubes.property('audiovm', + default=(lambda self: 'dom0')) + + holder = MyTestHolder(None) + audiovm = self.app.add_new_vm('AppVM', name='sys-audio', audiovm='dom0', + template=self.template, label='red') + guivm = self.app.add_new_vm('AppVM', name='sys-gui', audiovm='dom0', + template=self.template, label='red') + appvm = self.app.add_new_vm('AppVM', name='test-vm', audiovm='dom0', + template=self.template, label='red') + holder.audiovm = 'sys-audio' + self.assertEqual(holder.audiovm, 'sys-audio') + + self.assertEventFired(holder, 'property-set:audiovm', + kwargs={'name': 'audiovm', + 'newvalue': 'sys-audio'}) + + # Set AudioVM + self.assertFalse(appvm.property_is_default('audiovm')) + appvm.audiovm = audiovm + self.assertIn('audiovm-sys-audio', appvm.tags) + + # Change AudioVM + appvm.audiovm = guivm + self.assertIn('audiovm-sys-gui', appvm.tags) + self.assertNotIn('audiovm-sys-audio', appvm.tags) + + # Empty AudioVM + del appvm.audiovm + self.assertNotIn('audiovm-sys-gui', appvm.tags) + self.assertNotIn('audiovm-sys-audio', appvm.tags) + self.assertNotIn('audiovm-', appvm.tags) + def test_200_remove_template(self): appvm = self.app.add_new_vm('AppVM', name='test-vm', template=self.template, diff --git a/qubes/tests/vm/qubesvm.py b/qubes/tests/vm/qubesvm.py index 9963d84d..45b1401f 100644 --- a/qubes/tests/vm/qubesvm.py +++ b/qubes/tests/vm/qubesvm.py @@ -1819,7 +1819,7 @@ class TC_90_QubesVM(QubesVMTestsMixin, qubes.tests.QubesTestCase): @unittest.mock.patch('qubes.utils.get_timezone') @unittest.mock.patch('qubes.utils.urandom') @unittest.mock.patch('qubes.vm.qubesvm.QubesVM.untrusted_qdb') - def test_622_qdb_keyboard_layout(self, mock_qubesdb, mock_urandom, + def test_622_qdb_guivm_keyboard_layout(self, mock_qubesdb, mock_urandom, mock_timezone): mock_urandom.return_value = b'A' * 64 mock_timezone.return_value = 'UTC' @@ -1840,6 +1840,7 @@ class TC_90_QubesVM(QubesVMTestsMixin, qubes.tests.QubesTestCase): '"complete"\x09};\x0a\x09xkb_symbols { include ' \ '"pc+fr+inet(evdev)"\x09};\x0a\x09xkb_geometry ' \ '{ include "pc(pc105)"\x09};\x0a};' + guivm.is_running = lambda: True vm.events_enabled = True test_qubesdb = TestQubesDB() mock_qubesdb.write.side_effect = test_qubesdb.write @@ -1871,6 +1872,51 @@ class TC_90_QubesVM(QubesVMTestsMixin, qubes.tests.QubesTestCase): '/connected-ips6': '', }) + @unittest.mock.patch('qubes.utils.get_timezone') + @unittest.mock.patch('qubes.utils.urandom') + @unittest.mock.patch('qubes.vm.qubesvm.QubesVM.untrusted_qdb') + def test_623_qdb_audiovm(self, mock_qubesdb, mock_urandom, + mock_timezone): + mock_urandom.return_value = b'A' * 64 + mock_timezone.return_value = 'UTC' + template = self.get_vm( + cls=qubes.vm.templatevm.TemplateVM, name='template') + template.netvm = None + audiovm = self.get_vm(cls=qubes.vm.appvm.AppVM, template=template, + name='sys-audio', qid=2, provides_network=False) + vm = self.get_vm(cls=qubes.vm.appvm.AppVM, template=template, + name='appvm', qid=3) + vm.netvm = None + vm.audiovm = audiovm + audiovm.is_running = lambda: True + vm.events_enabled = True + test_qubesdb = TestQubesDB() + mock_qubesdb.write.side_effect = test_qubesdb.write + mock_qubesdb.rm.side_effect = test_qubesdb.rm + vm.create_qdb_entries() + self.maxDiff = None + self.assertEqual(test_qubesdb.data, { + '/name': 'test-inst-appvm', + '/type': 'AppVM', + '/default-user': 'user', + '/qubes-vm-type': 'AppVM', + '/qubes-audio-domain-xid': '{}'.format(audiovm.xid), + '/qubes-debug-mode': '0', + '/qubes-base-template': 'test-inst-template', + '/qubes-timezone': 'UTC', + '/qubes-random-seed': base64.b64encode(b'A' * 64), + '/qubes-vm-persistence': 'rw-only', + '/qubes-vm-updateable': 'False', + '/qubes-block-devices': '', + '/qubes-usb-devices': '', + '/qubes-iptables': 'reload', + '/qubes-iptables-error': '', + '/qubes-iptables-header': unittest.mock.ANY, + '/qubes-service/qubes-update-check': '0', + '/qubes-service/meminfo-writer': '1', + '/connected-ips': '', + '/connected-ips6': '', + }) @asyncio.coroutine def coroutine_mock(self, mock, *args, **kwargs): diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index 4f92e3e0..fbe8d6c5 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -519,6 +519,10 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): default=(lambda self: self.app.default_guivm), doc='VM used for Gui') + audiovm = qubes.VMProperty('audiovm', load_stage=4, allow_none=True, + default=(lambda self: self.app.default_audiovm), + doc='VM used for Audio') + virt_mode = qubes.property( 'virt_mode', type=str, setter=_setter_virt_mode, @@ -686,7 +690,10 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): if self.libvirt_domain is None: return -1 try: - return self.libvirt_domain.ID() + if self.is_running(): + return self.libvirt_domain.ID() + + return -1 except libvirt.libvirtError as e: if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: return -1 diff --git a/rpm_spec/core-dom0.spec.in b/rpm_spec/core-dom0.spec.in index 98fa6b68..5b962337 100644 --- a/rpm_spec/core-dom0.spec.in +++ b/rpm_spec/core-dom0.spec.in @@ -276,6 +276,7 @@ fi %{python3_sitelib}/qubes/ext/block.py %{python3_sitelib}/qubes/ext/core_features.py %{python3_sitelib}/qubes/ext/gui.py +%{python3_sitelib}/qubes/ext/audio.py %{python3_sitelib}/qubes/ext/pci.py %{python3_sitelib}/qubes/ext/qubesmanager.py %{python3_sitelib}/qubes/ext/r3compatibility.py diff --git a/setup.py b/setup.py index cc3c5c7b..ddbc132e 100644 --- a/setup.py +++ b/setup.py @@ -64,6 +64,7 @@ if __name__ == '__main__': 'qubes.ext.core_features = qubes.ext.core_features:CoreFeatures', 'qubes.ext.qubesmanager = qubes.ext.qubesmanager:QubesManager', 'qubes.ext.gui = qubes.ext.gui:GUI', + 'qubes.ext.audio = qubes.ext.audio:AUDIO', 'qubes.ext.r3compatibility = qubes.ext.r3compatibility:R3Compatibility', 'qubes.ext.pci = qubes.ext.pci:PCIDeviceExtension', 'qubes.ext.block = qubes.ext.block:BlockDeviceExtension',