diff --git a/Makefile b/Makefile index 5fe4645f..d072e92f 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ install: install -m 0644 etc/qvm-start-daemon-kde.desktop $(DESTDIR)/etc/xdg/autostart/ install -d $(DESTDIR)/usr/bin ln -sf qvm-start-daemon $(DESTDIR)/usr/bin/qvm-start-gui + install -m 0755 scripts/qubes-guivm-session $(DESTDIR)/usr/bin/ clean: rm -rf test-packages/__pycache__ qubesadmin/__pycache__ diff --git a/ci/requirements.txt b/ci/requirements.txt index 99d1a075..d89e9448 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -9,3 +9,4 @@ mock lxml PyYAML xcffib +asynctest diff --git a/doc/manpages/qvm-start-daemon.rst b/doc/manpages/qvm-start-daemon.rst index 630bdf94..f8413b9d 100644 --- a/doc/manpages/qvm-start-daemon.rst +++ b/doc/manpages/qvm-start-daemon.rst @@ -47,7 +47,7 @@ Options .. option:: --watch - Keep watching for further domains startups, must be used with --all + Keep watching for further domain startups .. option:: --force-stubdomain diff --git a/qubesadmin/tests/tools/qvm_start_daemon.py b/qubesadmin/tests/tools/qvm_start_daemon.py index bf61f55d..43079c83 100644 --- a/qubesadmin/tests/tools/qvm_start_daemon.py +++ b/qubesadmin/tests/tools/qvm_start_daemon.py @@ -23,9 +23,10 @@ import tempfile import unittest.mock import re - import asyncio +import asynctest + import qubesadmin.tests import qubesadmin.tools.qvm_start_daemon from qubesadmin.tools.qvm_start_daemon import GUI_DAEMON_OPTIONS @@ -207,7 +208,7 @@ def test_013_common_args_guid_config(self): } ''') - @unittest.mock.patch('asyncio.create_subprocess_exec') + @asynctest.patch('asyncio.create_subprocess_exec') def test_020_start_gui_for_vm(self, proc_mock): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -238,7 +239,7 @@ def test_020_start_gui_for_vm(self, proc_mock): self.assertAllCalled() - @unittest.mock.patch('asyncio.create_subprocess_exec') + @asynctest.patch('asyncio.create_subprocess_exec') def test_021_start_gui_for_vm_hvm(self, proc_mock): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -307,7 +308,7 @@ def test_022_start_gui_for_vm_hvm_stubdom(self): pidfile.flush() self.addCleanup(pidfile.close) - patch_proc = unittest.mock.patch('asyncio.create_subprocess_exec') + patch_proc = asynctest.patch('asyncio.create_subprocess_exec') patch_args = unittest.mock.patch.object(self.launcher, 'common_guid_args', lambda vm: []) @@ -350,7 +351,7 @@ def test_030_start_gui_for_stubdomain(self): None)] = \ b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' proc_mock = unittest.mock.Mock() - with unittest.mock.patch('asyncio.create_subprocess_exec', + with asynctest.patch('asyncio.create_subprocess_exec', lambda *args: self.mock_coroutine(proc_mock, *args)): with unittest.mock.patch.object(self.launcher, @@ -384,7 +385,7 @@ def test_031_start_gui_for_stubdomain_forced(self): None)] = \ b'0\x001' proc_mock = unittest.mock.Mock() - with unittest.mock.patch('asyncio.create_subprocess_exec', + with asynctest.patch('asyncio.create_subprocess_exec', lambda *args: self.mock_coroutine(proc_mock, *args)): with unittest.mock.patch.object(self.launcher, diff --git a/qubesadmin/tools/qvm_start_daemon.py b/qubesadmin/tools/qvm_start_daemon.py index 291de09e..62f6ae1d 100644 --- a/qubesadmin/tools/qvm_start_daemon.py +++ b/qubesadmin/tools/qvm_start_daemon.py @@ -32,20 +32,12 @@ import daemon.pidfile import qubesadmin +import qubesadmin.events import qubesadmin.exc import qubesadmin.tools import qubesadmin.vm from . import xcffibhelpers -have_events = False -try: - # pylint: disable=wrong-import-position - import qubesadmin.events - - have_events = True -except ImportError: - pass - GUI_DAEMON_PATH = '/usr/bin/qubes-guid' PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan' QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices' @@ -322,17 +314,19 @@ def get_monitor_layout(): class DAEMONLauncher: """Launch GUI/AUDIO daemon for VMs""" - def __init__(self, app: qubesadmin.app.QubesBase): + def __init__(self, app: qubesadmin.app.QubesBase, vm_names=None, kde=False): """ Initialize DAEMONLauncher. :param app: :py:class:`qubesadmin.Qubes` instance + :param vm_names: VM names to watch for, or None if watching for all + :param kde: add KDE-specific arguments for guid """ self.app = app self.started_processes = {} - self.kde = False + self.vm_names = vm_names + self.kde = kde - @asyncio.coroutine - def send_monitor_layout(self, vm, layout=None, startup=False): + async def send_monitor_layout(self, vm, layout=None, startup=False): """Send monitor layout to a given VM This function is a coroutine. @@ -367,7 +361,7 @@ def send_monitor_layout(self, vm, layout=None, startup=False): pass try: - yield from asyncio.get_event_loop(). \ + await asyncio.get_event_loop(). \ run_in_executor(None, functools.partial( vm.run_service_for_stdio, @@ -476,8 +470,7 @@ def pacat_domid(vm): else vm.xid return xid - @asyncio.coroutine - def start_gui_for_vm(self, vm, monitor_layout=None): + async def start_gui_for_vm(self, vm, monitor_layout=None): """Start GUI daemon (qubes-guid) connected directly to a VM This function is a coroutine. @@ -503,13 +496,12 @@ def start_gui_for_vm(self, vm, monitor_layout=None): vm.log.info('Starting GUI') - yield from asyncio.create_subprocess_exec(*guid_cmd) + await asyncio.create_subprocess_exec(*guid_cmd) - yield from self.send_monitor_layout(vm, layout=monitor_layout, + await self.send_monitor_layout(vm, layout=monitor_layout, startup=True) - @asyncio.coroutine - def start_gui_for_stubdomain(self, vm, force=False): + async def start_gui_for_stubdomain(self, vm, force=False): """Start GUI daemon (qubes-guid) connected to a stubdomain This function is a coroutine. @@ -533,10 +525,9 @@ def start_gui_for_stubdomain(self, vm, force=False): guid_cmd = self.common_guid_args(vm) guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)]) - yield from asyncio.create_subprocess_exec(*guid_cmd) + await asyncio.create_subprocess_exec(*guid_cmd) - @asyncio.coroutine - def start_audio_for_vm(self, vm): + async def start_audio_for_vm(self, vm): """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM This function is a coroutine. @@ -547,10 +538,9 @@ def start_audio_for_vm(self, vm): pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name] vm.log.info('Starting AUDIO') - yield from asyncio.create_subprocess_exec(*pacat_cmd) + await asyncio.create_subprocess_exec(*pacat_cmd) - @asyncio.coroutine - def start_gui(self, vm, force_stubdom=False, monitor_layout=None): + async def start_gui(self, vm, force_stubdom=False, monitor_layout=None): """Start GUI daemon regardless of start event. This function is a coroutine. @@ -566,16 +556,15 @@ def start_gui(self, vm, force_stubdom=False, monitor_layout=None): return if vm.virt_mode == 'hvm': - yield from self.start_gui_for_stubdomain(vm, force=force_stubdom) + await self.start_gui_for_stubdomain(vm, force=force_stubdom) if not vm.features.check_with_template('gui', True): return if not os.path.exists(self.guid_pidfile(vm.xid)): - yield from self.start_gui_for_vm(vm, monitor_layout=monitor_layout) + await self.start_gui_for_vm(vm, monitor_layout=monitor_layout) - @asyncio.coroutine - def start_audio(self, vm): + async def start_audio(self, vm): """Start AUDIO daemon regardless of start event. This function is a coroutine. @@ -592,10 +581,14 @@ def start_audio(self, vm): xid = self.pacat_domid(vm) if not os.path.exists(self.pacat_pidfile(xid)): - yield from self.start_audio_for_vm(vm) + await self.start_audio_for_vm(vm) def on_domain_spawn(self, vm, _event, **kwargs): """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain""" + + if not self.is_watched(vm): + return + try: if getattr(vm, 'guivm', None) != vm.app.local_name: return @@ -610,6 +603,10 @@ def on_domain_spawn(self, vm, _event, **kwargs): def on_domain_start(self, vm, _event, **kwargs): """Handler of 'domain-start' event, starts GUI/AUDIO daemon for actual VM """ + + if not self.is_watched(vm): + return + try: if getattr(vm, 'guivm', None) == vm.app.local_name and \ vm.features.check_with_template('gui', True) and \ @@ -636,6 +633,9 @@ def on_connection_established(self, _subject, _event, **_kwargs): if vm.klass == 'AdminVM': continue + if not self.is_watched(vm): + continue + power_state = vm.get_power_state() if power_state == 'Running': asyncio.ensure_future( @@ -650,6 +650,10 @@ def on_connection_established(self, _subject, _event, **_kwargs): def on_domain_stopped(self, vm, _event, **_kwargs): """Handler of 'domain-stopped' event, cleans up""" + + if not self.is_watched(vm): + return + self.cleanup_guid(vm.xid) if vm.virt_mode == 'hvm': self.cleanup_guid(vm.stubdom_xid) @@ -672,6 +676,16 @@ def register_events(self, events): self.on_connection_established) events.add_handler('domain-stopped', self.on_domain_stopped) + def is_watched(self, vm): + """ + Should we watch this VM for changes + """ + + if self.vm_names is None: + return True + return vm.name in self.vm_names + + if 'XDG_RUNTIME_DIR' in os.environ: pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'qvm-start-daemon.pid') @@ -682,8 +696,7 @@ def register_events(self, events): parser = qubesadmin.tools.QubesArgumentParser( description='start GUI for qube(s)', vmname_nargs='*') parser.add_argument('--watch', action='store_true', - help='Keep watching for further domains' - ' startups, must be used with --all') + help='Keep watching for further domain startups') parser.add_argument('--force-stubdomain', action='store_true', help='Start GUI to stubdomain-emulated VGA,' ' even if gui-agent is running in the VM') @@ -710,16 +723,19 @@ def main(args=None): print(parser.format_help()) return args = parser.parse_args(args) - if args.watch and not args.all_domains: - parser.error('--watch option must be used with --all') if args.watch and args.notify_monitor_layout: parser.error('--watch cannot be used with --notify-monitor-layout') - launcher = DAEMONLauncher(args.app) - if args.kde: - launcher.kde = True + + if args.all_domains: + vm_names = None + else: + vm_names = [vm.name for vm in args.domains] + launcher = DAEMONLauncher( + args.app, + vm_names=vm_names, + kde=args.kde) + if args.watch: - if not have_events: - parser.error('--watch option require Python >= 3.5') with daemon.pidfile.TimeoutPIDLockFile(args.pidfile): loop = asyncio.get_event_loop() # pylint: disable=no-member diff --git a/scripts/qubes-guivm-session b/scripts/qubes-guivm-session new file mode 100755 index 00000000..a5073386 --- /dev/null +++ b/scripts/qubes-guivm-session @@ -0,0 +1,21 @@ +#!/bin/bash -e + +print_usage() { +cat >&2 <