# -*- encoding: utf8 -*- # # The Qubes OS Project, http://www.qubes-os.org # # Copyright (C) 2017 Marek Marczykowski-Górecki # # # This program 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 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 Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License along # with this program; if not, see . """ GUI/AUDIO daemon launcher tool""" import os import signal import subprocess import asyncio import re import functools import xcffib import xcffib.xproto # pylint: disable=unused-import import daemon.pidfile import qubesadmin import qubesadmin.exc import qubesadmin.tools import qubesadmin.vm 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' # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm" REGEX_OUTPUT = re.compile(r""" (?x) # ignore whitespace ^ # start of string (?P[A-Za-z0-9\-]*)[ ] # LVDS VGA etc (?P(dis)?connected) # dis/connected ([ ] (?P(primary)?)[ ]? (( # a group (?P\d+)x # either 1024x768+0+0 (?P\d+)[+] (?P\d+)[+] (?P\d+) )|[\D]) # or not a digit ([ ]\(.*\))?[ ]? # ignore options ( # 304mm x 228mm (?P\d+)mm[ ]x[ ] (?P\d+)mm )? .* # ignore rest of line )? # everything after (dis)connect is optional """) def get_monitor_layout(): """Get list of monitors and their size/position""" outputs = [] for line in subprocess.Popen( ['xrandr', '-q'], stdout=subprocess.PIPE).stdout: line = line.decode() if not line.startswith("Screen") and not line.startswith(" "): output_params = REGEX_OUTPUT.match(line).groupdict() if output_params['width']: phys_size = "" if output_params['width_mm'] and int(output_params['width_mm']): # don't provide real values for privacy reasons - see # #1951 for details dpi = (int(output_params['width']) * 254 // int(output_params['width_mm']) // 10) if dpi > 300: dpi = 300 elif dpi > 200: dpi = 200 elif dpi > 150: dpi = 150 else: # if lower, don't provide this info to the VM at all dpi = 0 if dpi: # now calculate dimensions based on approximate DPI phys_size = " {} {}".format( int(output_params['width']) * 254 // dpi // 10, int(output_params['height']) * 254 // dpi // 10, ) outputs.append("%s %s %s %s%s\n" % ( output_params['width'], output_params['height'], output_params['x'], output_params['y'], phys_size, )) return outputs class DAEMONLauncher: """Launch GUI/AUDIO daemon for VMs""" def __init__(self, app: qubesadmin.app.QubesBase): """ Initialize DAEMONLauncher. :param app: :py:class:`qubesadmin.Qubes` instance """ self.app = app self.started_processes = {} @asyncio.coroutine def send_monitor_layout(self, vm, layout=None, startup=False): """Send monitor layout to a given VM This function is a coroutine. :param vm: VM to which send monitor layout :param layout: monitor layout to send; if None, fetch it from local X server. :param startup: :return: None """ # pylint: disable=no-self-use if vm.features.check_with_template('no-monitor-layout', False) \ or not vm.is_running(): return if layout is None: layout = get_monitor_layout() if not layout: return vm.log.info('Sending monitor layout') if not startup: with open(self.guid_pidfile(vm.xid)) as pidfile: pid = int(pidfile.read()) os.kill(pid, signal.SIGHUP) try: with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile: pid = int(pidfile.read()) os.kill(pid, signal.SIGHUP) except FileNotFoundError: pass try: yield from asyncio.get_event_loop(). \ run_in_executor(None, functools.partial( vm.run_service_for_stdio, 'qubes.SetMonitorLayout', input=''.join(layout).encode(), autostart=False)) except subprocess.CalledProcessError as e: vm.log.warning('Failed to send monitor layout: %s', e.stderr) def send_monitor_layout_all(self): """Send monitor layout to all (running) VMs""" monitor_layout = get_monitor_layout() for vm in self.app.domains: if getattr(vm, 'guivm', None) != vm.app.local_name: continue if vm.klass == 'AdminVM': continue if vm.is_running(): if not vm.features.check_with_template('gui', True): continue asyncio.ensure_future(self.send_monitor_layout(vm, monitor_layout)) @staticmethod def kde_guid_args(vm): """Return KDE-specific arguments for gui-daemon, if applicable""" guid_cmd = [] # Avoid using environment variables for checking the current session, # because this script may be called with cleared env (like with sudo). if subprocess.check_output( ['xprop', '-root', '-notype', 'KWIN_RUNNING']) == \ b'KWIN_RUNNING = 0x1\n': # native decoration plugins is used, so adjust window properties # accordingly guid_cmd += ['-T'] # prefix window titles with VM name # get owner of X11 session session_owner = None for line in subprocess.check_output(['xhost']).splitlines(): if line == b'SI:localuser:root': pass elif line.startswith(b'SI:localuser:'): session_owner = line.split(b':')[2].decode() if session_owner is not None: data_dir = os.path.expanduser( '~{}/.local/share'.format(session_owner)) else: # fallback to current user data_dir = os.path.expanduser('~/.local/share') guid_cmd += ['-p', '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format( os.path.join(data_dir, 'qubes-kde', vm.label.name + '.colors'))] return guid_cmd def common_guid_args(self, vm): """Common qubes-guid arguments for PV(H), HVM and Stubdomain""" guid_cmd = [GUI_DAEMON_PATH, '-N', vm.name, '-c', vm.label.color, '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png', '-l', str(vm.label.index)] if vm.debug: guid_cmd += ['-v', '-v'] # elif not verbose: else: guid_cmd += ['-q'] if vm.features.check_with_template('rpc-clipboard', False): guid_cmd.extend(['-Q']) guid_cmd += self.kde_guid_args(vm) return guid_cmd @staticmethod def guid_pidfile(xid): """Helper function to construct a GUI pidfile path""" return '/var/run/qubes/guid-running.{}'.format(xid) @staticmethod def pacat_pidfile(xid): """Helper function to construct an AUDIO pidfile path""" return '/var/run/qubes/pacat-running.{}'.format(xid) @asyncio.coroutine 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. :param vm: VM for which start GUI daemon :param monitor_layout: monitor layout to send; if None, fetch it from local X server. """ guid_cmd = self.common_guid_args(vm) guid_cmd.extend(['-d', str(vm.xid)]) if vm.virt_mode == 'hvm': guid_cmd.extend(['-n']) stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid) if not vm.debug and os.path.exists(stubdom_guid_pidfile): # Terminate stubdom guid once "real" gui agent connects with open(stubdom_guid_pidfile, 'r') as pidfile: stubdom_guid_pid = pidfile.read().strip() guid_cmd += ['-K', stubdom_guid_pid] vm.log.info('Starting GUI') yield from asyncio.create_subprocess_exec(*guid_cmd) yield from self.send_monitor_layout(vm, layout=monitor_layout, startup=True) @asyncio.coroutine def start_gui_for_stubdomain(self, vm, force=False): """Start GUI daemon (qubes-guid) connected to a stubdomain This function is a coroutine. """ want_stubdom = force if not want_stubdom and \ vm.features.check_with_template('gui-emulated', False): want_stubdom = True # if no 'gui' or 'gui-emulated' feature set at all, use emulated GUI if not want_stubdom and \ vm.features.check_with_template('gui', None) is None and \ vm.features.check_with_template('gui-emulated', None) is None: want_stubdom = True if not want_stubdom and vm.debug: want_stubdom = True if not want_stubdom: return if os.path.exists(self.guid_pidfile(vm.stubdom_xid)): return vm.log.info('Starting GUI (stubdomain)') 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) @asyncio.coroutine def start_audio_for_vm(self, vm): """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM This function is a coroutine. :param vm: VM for which start AUDIO daemon """ # pylint: disable=no-self-use pacat_cmd = [PACAT_DAEMON_PATH, vm.xid, vm.name] vm.log.info('Starting AUDIO') yield from asyncio.create_subprocess_exec(*pacat_cmd) @asyncio.coroutine def start_gui(self, vm, force_stubdom=False, monitor_layout=None): """Start GUI daemon regardless of start event. This function is a coroutine. :param vm: VM for which GUI daemon should be started :param force_stubdom: Force GUI daemon for stubdomain, even if the one for target AppVM is running. :param monitor_layout: monitor layout configuration """ guivm = getattr(vm, 'guivm', None) if guivm != vm.app.local_name: vm.log.info('GUI connected to {}. Skipping.'.format(guivm)) return if vm.virt_mode == 'hvm': yield from 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) @asyncio.coroutine def start_audio(self, vm): """Start AUDIO daemon regardless of start event. This function is a coroutine. :param vm: VM for which AUDIO daemon should be started """ audiovm = getattr(vm, 'audiovm', None) if audiovm != vm.app.local_name: vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm)) return if not vm.features.check_with_template('audio', True): return if not os.path.exists(self.pacat_pidfile(vm.xid)): yield from self.start_audio_for_vm(vm) def on_domain_spawn(self, vm, _event, **kwargs): """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain""" try: if getattr(vm, 'guivm', None) != vm.app.local_name: return if not vm.features.check_with_template('gui', True): return if vm.virt_mode == 'hvm' and \ kwargs.get('start_guid', 'True') == 'True': asyncio.ensure_future(self.start_gui_for_stubdomain(vm)) except qubesadmin.exc.QubesException as e: vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e)) def on_domain_start(self, vm, _event, **kwargs): """Handler of 'domain-start' event, starts GUI/AUDIO daemon for actual VM """ try: if getattr(vm, 'guivm', None) != vm.app.local_name: return if not vm.features.check_with_template('gui', True): return if kwargs.get('start_guid', 'True') == 'True': asyncio.ensure_future(self.start_gui_for_vm(vm)) except qubesadmin.exc.QubesException as e: vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e)) try: if getattr(vm, 'audiovm', None) != vm.app.local_name: return if not vm.features.check_with_template('audio', True): return if kwargs.get('start_audio', 'True') == 'True': asyncio.ensure_future(self.start_audio_for_vm(vm)) except qubesadmin.exc.QubesException as e: vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e)) def on_connection_established(self, _subject, _event, **_kwargs): """Handler of 'connection-established' event, used to launch GUI/AUDIO daemon for domains started before this tool. """ monitor_layout = get_monitor_layout() self.app.domains.clear_cache() for vm in self.app.domains: if vm.klass == 'AdminVM': continue if getattr(vm, 'guivm', None) == vm.app.local_name: if vm.features.check_with_template('gui', True): power_state = vm.get_power_state() if power_state == 'Running': asyncio.ensure_future( self.start_gui(vm, monitor_layout=monitor_layout)) elif power_state == 'Transient': # it is still starting, we'll get 'domain-start' # event when fully started if vm.virt_mode == 'hvm': asyncio.ensure_future( self.start_gui_for_stubdomain(vm)) if getattr(vm, 'audiovm', None) == vm.app.local_name: if vm.features.check_with_template('audio', True): power_state = vm.get_power_state() if power_state == 'Running': asyncio.ensure_future(self.start_audio(vm)) def register_events(self, events): """Register domain startup events in app.events dispatcher""" events.add_handler('domain-spawn', self.on_domain_spawn) events.add_handler('domain-start', self.on_domain_start) events.add_handler('connection-established', self.on_connection_established) def x_reader(conn, callback): """Try reading something from X connection to check if it's still alive. In case it isn't, call *callback*. """ try: conn.poll_for_event() except xcffib.ConnectionException: callback() if 'XDG_RUNTIME_DIR' in os.environ: pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'qvm-start-daemon.pid') else: pidfile_path = os.path.join(os.environ.get('HOME', '/'), '.qvm-start-daemon.pid') 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') parser.add_argument('--force-stubdomain', action='store_true', help='Start GUI to stubdomain-emulated VGA,' ' even if gui-agent is running in the VM') parser.add_argument('--pidfile', action='store', default=pidfile_path, help='Pidfile path to create in --watch mode') parser.add_argument('--notify-monitor-layout', action='store_true', help='Notify running instance in --watch mode' ' about changed monitor layout') def main(args=None): """ Main function of qvm-start-daemon tool""" 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.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 events = qubesadmin.events.EventsDispatcher(args.app) # pylint: enable=no-member launcher.register_events(events) events_listener = asyncio.ensure_future(events.listen_for_events()) for signame in ('SIGINT', 'SIGTERM'): loop.add_signal_handler(getattr(signal, signame), events_listener.cancel) # pylint: disable=no-member loop.add_signal_handler(signal.SIGHUP, launcher.send_monitor_layout_all) conn = xcffib.connect() x_fd = conn.get_file_descriptor() loop.add_reader(x_fd, x_reader, conn, events_listener.cancel) try: loop.run_until_complete(events_listener) except asyncio.CancelledError: pass loop.remove_reader(x_fd) loop.stop() loop.run_forever() loop.close() elif args.notify_monitor_layout: try: with open(pidfile_path, 'r') as pidfile: pid = int(pidfile.read().strip()) os.kill(pid, signal.SIGHUP) except (FileNotFoundError, ValueError) as e: parser.error('Cannot open pidfile {}: {}'.format(pidfile_path, str(e))) else: loop = asyncio.get_event_loop() tasks = [] for vm in args.domains: if vm.is_running(): tasks.append(asyncio.ensure_future(launcher.start_gui( vm, force_stubdom=args.force_stubdomain))) tasks.append(asyncio.ensure_future(launcher.start_audio( vm))) if tasks: loop.run_until_complete(asyncio.wait(tasks)) loop.stop() loop.run_forever() loop.close() if __name__ == '__main__': main()