qvm_start_daemon.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794
  1. # -*- encoding: utf-8 -*-
  2. #
  3. # The Qubes OS Project, http://www.qubes-os.org
  4. #
  5. # Copyright (C) 2017 Marek Marczykowski-Górecki
  6. # <marmarek@invisiblethingslab.com>
  7. #
  8. # This program is free software; you can redistribute it and/or modify
  9. # it under the terms of the GNU Lesser General Public License as published by
  10. # the Free Software Foundation; either version 2.1 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU Lesser General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public License along
  19. # with this program; if not, see <http://www.gnu.org/licenses/>.
  20. """ GUI/AUDIO daemon launcher tool"""
  21. import os
  22. import signal
  23. import subprocess
  24. import asyncio
  25. import re
  26. import functools
  27. import sys
  28. import xcffib
  29. import xcffib.xproto # pylint: disable=unused-import
  30. import daemon.pidfile
  31. import qubesadmin
  32. import qubesadmin.events
  33. import qubesadmin.exc
  34. import qubesadmin.tools
  35. import qubesadmin.vm
  36. from . import xcffibhelpers
  37. GUI_DAEMON_PATH = '/usr/bin/qubes-guid'
  38. PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan'
  39. QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices'
  40. GUI_DAEMON_OPTIONS = [
  41. ('allow_fullscreen', 'bool'),
  42. ('override_redirect_protection', 'bool'),
  43. ('allow_utf8_titles', 'bool'),
  44. ('secure_copy_sequence', 'str'),
  45. ('secure_paste_sequence', 'str'),
  46. ('windows_count_limit', 'int'),
  47. ('trayicon_mode', 'str'),
  48. ('startup_timeout', 'int'),
  49. ]
  50. def retrieve_gui_daemon_options(vm, guivm):
  51. '''
  52. Construct a list of GUI daemon options based on VM features.
  53. This checks 'gui-*' features on the VM, and if they're absent,
  54. 'gui-default-*' features on the GuiVM.
  55. '''
  56. options = {}
  57. for name, kind in GUI_DAEMON_OPTIONS:
  58. feature_value = vm.features.get(
  59. 'gui-' + name.replace('_', '-'), None)
  60. if feature_value is None:
  61. feature_value = guivm.features.get(
  62. 'gui-default-' + name.replace('_', '-'), None)
  63. if feature_value is None:
  64. continue
  65. if kind == 'bool':
  66. value = bool(feature_value)
  67. elif kind == 'int':
  68. value = int(feature_value)
  69. elif kind == 'str':
  70. value = feature_value
  71. else:
  72. assert False, kind
  73. options[name] = value
  74. return options
  75. def serialize_gui_daemon_options(options):
  76. '''
  77. Prepare configuration file content for GUI daemon. Currently uses libconfig
  78. format.
  79. '''
  80. lines = [
  81. '# Auto-generated file, do not edit!',
  82. '',
  83. 'global: {',
  84. ]
  85. for name, kind in GUI_DAEMON_OPTIONS:
  86. if name in options:
  87. value = options[name]
  88. if kind == 'bool':
  89. serialized = 'true' if value else 'false'
  90. elif kind == 'int':
  91. serialized = str(value)
  92. elif kind == 'str':
  93. serialized = escape_config_string(value)
  94. else:
  95. assert False, kind
  96. lines.append(' {} = {};'.format(name, serialized))
  97. lines.append('}')
  98. lines.append('')
  99. return '\n'.join(lines)
  100. NON_ASCII_RE = re.compile(r'[^\x00-\x7F]')
  101. UNPRINTABLE_CHARACTER_RE = re.compile(r'[\x00-\x1F\x7F]')
  102. def escape_config_string(value):
  103. '''
  104. Convert a string to libconfig format.
  105. Format specification:
  106. http://www.hyperrealm.com/libconfig/libconfig_manual.html#String-Values
  107. See dump_string() for python-libconf:
  108. https://github.com/Grk0/python-libconf/blob/master/libconf.py
  109. '''
  110. assert not NON_ASCII_RE.match(value),\
  111. 'expected an ASCII string: {!r}'.format(value)
  112. value = (
  113. value.replace('\\', '\\\\')
  114. .replace('"', '\\"')
  115. .replace('\f', r'\f')
  116. .replace('\n', r'\n')
  117. .replace('\r', r'\r')
  118. .replace('\t', r'\t')
  119. )
  120. value = UNPRINTABLE_CHARACTER_RE.sub(
  121. lambda m: r'\x{:02x}'.format(ord(m.group(0))),
  122. value)
  123. return '"' + value + '"'
  124. # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
  125. REGEX_OUTPUT = re.compile(r"""
  126. (?x) # ignore whitespace
  127. ^ # start of string
  128. (?P<output>[A-Za-z0-9\-]*)[ ] # LVDS VGA etc
  129. (?P<connect>(dis)?connected) # dis/connected
  130. ([ ]
  131. (?P<primary>(primary)?)[ ]?
  132. (( # a group
  133. (?P<width>\d+)x # either 1024x768+0+0
  134. (?P<height>\d+)[+]
  135. (?P<x>\d+)[+]
  136. (?P<y>\d+)
  137. )|[\D]) # or not a digit
  138. ([ ]\(.*\))?[ ]? # ignore options
  139. ( # 304mm x 228mm
  140. (?P<width_mm>\d+)mm[ ]x[ ]
  141. (?P<height_mm>\d+)mm
  142. )?
  143. .* # ignore rest of line
  144. )? # everything after (dis)connect is optional
  145. """)
  146. class KeyboardLayout:
  147. """Class to store and parse X Keyboard layout data"""
  148. # pylint: disable=too-few-public-methods
  149. def __init__(self, binary_string):
  150. split_string = binary_string.split(b'\0')
  151. self.languages = split_string[2].decode().split(',')
  152. self.variants = split_string[3].decode().split(',')
  153. self.options = split_string[4].decode()
  154. def get_property(self, layout_num):
  155. """Return the selected keyboard layout as formatted for keyboard_layout
  156. property."""
  157. return '+'.join([self.languages[layout_num],
  158. self.variants[layout_num],
  159. self.options])
  160. class XWatcher:
  161. """Watch and react for X events related to the keyboard layout changes."""
  162. def __init__(self, conn, app):
  163. self.app = app
  164. self.current_vm = self.app.domains[self.app.local_name]
  165. self.conn = conn
  166. self.ext = self.initialize_extension()
  167. # get root window
  168. self.setup = self.conn.get_setup()
  169. self.root = self.setup.roots[0].root
  170. # atoms (strings) of events we need to watch
  171. # keyboard layout was switched
  172. self.atom_xklavier = self.conn.core.InternAtom(
  173. False, len("XKLAVIER_ALLOW_SECONDARY"),
  174. "XKLAVIER_ALLOW_SECONDARY").reply().atom
  175. # keyboard layout was changed
  176. self.atom_xkb_rules = self.conn.core.InternAtom(
  177. False, len("_XKB_RULES_NAMES"),
  178. "_XKB_RULES_NAMES").reply().atom
  179. self.conn.core.ChangeWindowAttributesChecked(
  180. self.root, xcffib.xproto.CW.EventMask,
  181. [xcffib.xproto.EventMask.PropertyChange])
  182. self.conn.flush()
  183. # initialize state
  184. self.keyboard_layout = KeyboardLayout(self.get_keyboard_layout())
  185. self.selected_layout = self.get_selected_layout()
  186. def initialize_extension(self):
  187. """Initialize XKB extension (not supported by xcffib by default"""
  188. ext = self.conn(xcffibhelpers.key)
  189. ext.UseExtension()
  190. return ext
  191. def get_keyboard_layout(self):
  192. """Check what is current keyboard layout definition"""
  193. property_cookie = self.conn.core.GetProperty(
  194. False, # delete
  195. self.root, # window
  196. self.atom_xkb_rules,
  197. xcffib.xproto.Atom.STRING,
  198. 0, 1000
  199. )
  200. prop_reply = property_cookie.reply()
  201. return prop_reply.value.buf()
  202. def get_selected_layout(self):
  203. """Check which keyboard layout is currently selected"""
  204. state_reply = self.ext.GetState().reply()
  205. return state_reply.lockedGroup[0]
  206. def update_keyboard_layout(self):
  207. """Update current vm's keyboard_layout property"""
  208. new_property = self.keyboard_layout.get_property(
  209. self.selected_layout)
  210. current_property = self.current_vm.keyboard_layout
  211. if new_property != current_property:
  212. self.current_vm.keyboard_layout = new_property
  213. def event_reader(self, callback):
  214. """Poll for X events related to keyboard layout"""
  215. try:
  216. for event in iter(self.conn.poll_for_event, None):
  217. if isinstance(event, xcffib.xproto.PropertyNotifyEvent):
  218. if event.atom == self.atom_xklavier:
  219. self.selected_layout = self.get_selected_layout()
  220. elif event.atom == self.atom_xkb_rules:
  221. self.keyboard_layout = KeyboardLayout(
  222. self.get_keyboard_layout())
  223. else:
  224. continue
  225. self.update_keyboard_layout()
  226. except xcffib.ConnectionException:
  227. callback()
  228. def get_monitor_layout():
  229. """Get list of monitors and their size/position"""
  230. outputs = []
  231. for line in subprocess.Popen(
  232. ['xrandr', '-q'], stdout=subprocess.PIPE).stdout:
  233. line = line.decode()
  234. if not line.startswith("Screen") and not line.startswith(" "):
  235. output_params = REGEX_OUTPUT.match(line).groupdict()
  236. if output_params['width']:
  237. phys_size = ""
  238. if output_params['width_mm'] and int(output_params['width_mm']):
  239. # don't provide real values for privacy reasons - see
  240. # #1951 for details
  241. dpi = (int(output_params['width']) * 254 //
  242. int(output_params['width_mm']) // 10)
  243. if dpi > 300:
  244. dpi = 300
  245. elif dpi > 200:
  246. dpi = 200
  247. elif dpi > 150:
  248. dpi = 150
  249. else:
  250. # if lower, don't provide this info to the VM at all
  251. dpi = 0
  252. if dpi:
  253. # now calculate dimensions based on approximate DPI
  254. phys_size = " {} {}".format(
  255. int(output_params['width']) * 254 // dpi // 10,
  256. int(output_params['height']) * 254 // dpi // 10,
  257. )
  258. outputs.append("%s %s %s %s%s\n" % (
  259. output_params['width'],
  260. output_params['height'],
  261. output_params['x'],
  262. output_params['y'],
  263. phys_size,
  264. ))
  265. return outputs
  266. class DAEMONLauncher:
  267. """Launch GUI/AUDIO daemon for VMs"""
  268. def __init__(self, app: qubesadmin.app.QubesBase, vm_names=None, kde=False):
  269. """ Initialize DAEMONLauncher.
  270. :param app: :py:class:`qubesadmin.Qubes` instance
  271. :param vm_names: VM names to watch for, or None if watching for all
  272. :param kde: add KDE-specific arguments for guid
  273. """
  274. self.app = app
  275. self.started_processes = {}
  276. self.vm_names = vm_names
  277. self.kde = kde
  278. async def send_monitor_layout(self, vm, layout=None, startup=False):
  279. """Send monitor layout to a given VM
  280. This function is a coroutine.
  281. :param vm: VM to which send monitor layout
  282. :param layout: monitor layout to send; if None, fetch it from
  283. local X server.
  284. :param startup:
  285. :return: None
  286. """
  287. # pylint: disable=no-self-use
  288. if vm.features.check_with_template('no-monitor-layout', False) \
  289. or not vm.is_running():
  290. return
  291. if layout is None:
  292. layout = get_monitor_layout()
  293. if not layout:
  294. return
  295. vm.log.info('Sending monitor layout')
  296. if not startup:
  297. with open(self.guid_pidfile(vm.xid)) as pidfile:
  298. pid = int(pidfile.read())
  299. os.kill(pid, signal.SIGHUP)
  300. try:
  301. with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile:
  302. pid = int(pidfile.read())
  303. os.kill(pid, signal.SIGHUP)
  304. except FileNotFoundError:
  305. pass
  306. try:
  307. await asyncio.get_event_loop(). \
  308. run_in_executor(None,
  309. functools.partial(
  310. vm.run_service_for_stdio,
  311. 'qubes.SetMonitorLayout',
  312. input=''.join(layout).encode(),
  313. autostart=False))
  314. except subprocess.CalledProcessError as e:
  315. vm.log.warning('Failed to send monitor layout: %s', e.stderr)
  316. def send_monitor_layout_all(self):
  317. """Send monitor layout to all (running) VMs"""
  318. monitor_layout = get_monitor_layout()
  319. for vm in self.app.domains:
  320. if getattr(vm, 'guivm', None) != vm.app.local_name:
  321. continue
  322. if vm.klass == 'AdminVM':
  323. continue
  324. if vm.is_running():
  325. if not vm.features.check_with_template('gui', True):
  326. continue
  327. asyncio.ensure_future(self.send_monitor_layout(vm,
  328. monitor_layout))
  329. @staticmethod
  330. def kde_guid_args(vm):
  331. """Return KDE-specific arguments for gui-daemon, if applicable"""
  332. guid_cmd = []
  333. # native decoration plugins is used, so adjust window properties
  334. # accordingly
  335. guid_cmd += ['-T'] # prefix window titles with VM name
  336. # get owner of X11 session
  337. session_owner = None
  338. for line in subprocess.check_output(['xhost']).splitlines():
  339. if line == b'SI:localuser:root':
  340. pass
  341. elif line.startswith(b'SI:localuser:'):
  342. session_owner = line.split(b':')[2].decode()
  343. if session_owner is not None:
  344. data_dir = os.path.expanduser(
  345. '~{}/.local/share'.format(session_owner))
  346. else:
  347. # fallback to current user
  348. data_dir = os.path.expanduser('~/.local/share')
  349. guid_cmd += ['-p',
  350. '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format(
  351. os.path.join(data_dir,
  352. 'qubes-kde',
  353. vm.label.name + '.colors'))]
  354. return guid_cmd
  355. def common_guid_args(self, vm):
  356. """Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
  357. guid_cmd = [GUI_DAEMON_PATH,
  358. '-N', vm.name,
  359. '-c', vm.label.color,
  360. '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png',
  361. '-l', str(vm.label.index)]
  362. if vm.debug:
  363. guid_cmd += ['-v', '-v']
  364. # elif not verbose:
  365. else:
  366. guid_cmd += ['-q']
  367. if vm.features.check_with_template('rpc-clipboard', False):
  368. guid_cmd.extend(['-Q'])
  369. guivm = self.app.domains[vm.guivm]
  370. options = retrieve_gui_daemon_options(vm, guivm)
  371. config = serialize_gui_daemon_options(options)
  372. config_path = self.guid_config_file(vm.xid)
  373. self.write_guid_config(config_path, config)
  374. guid_cmd.extend(['-C', config_path])
  375. return guid_cmd
  376. @staticmethod
  377. def write_guid_config(config_path, config):
  378. """Write guid configuration to a file"""
  379. with open(config_path, 'w') as config_file:
  380. config_file.write(config)
  381. @staticmethod
  382. def guid_pidfile(xid):
  383. """Helper function to construct a GUI pidfile path"""
  384. return '/var/run/qubes/guid-running.{}'.format(xid)
  385. @staticmethod
  386. def guid_config_file(xid):
  387. """Helper function to construct a GUI configuration file path"""
  388. return '/var/run/qubes/guid-conf.{}'.format(xid)
  389. @staticmethod
  390. def pacat_pidfile(xid):
  391. """Helper function to construct an AUDIO pidfile path"""
  392. return '/var/run/qubes/pacat.{}'.format(xid)
  393. @staticmethod
  394. def pacat_domid(vm):
  395. """Determine target domid for an AUDIO daemon"""
  396. xid = vm.stubdom_xid \
  397. if vm.features.check_with_template('audio-model', False) \
  398. and vm.virt_mode == 'hvm' \
  399. else vm.xid
  400. return xid
  401. async def start_gui_for_vm(self, vm, monitor_layout=None):
  402. """Start GUI daemon (qubes-guid) connected directly to a VM
  403. This function is a coroutine.
  404. :param vm: VM for which start GUI daemon
  405. :param monitor_layout: monitor layout to send; if None, fetch it from
  406. local X server.
  407. """
  408. guid_cmd = self.common_guid_args(vm)
  409. if self.kde:
  410. guid_cmd.extend(self.kde_guid_args(vm))
  411. guid_cmd.extend(['-d', str(vm.xid)])
  412. if vm.virt_mode == 'hvm':
  413. guid_cmd.extend(['-n'])
  414. stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid)
  415. if not vm.debug and os.path.exists(stubdom_guid_pidfile):
  416. # Terminate stubdom guid once "real" gui agent connects
  417. with open(stubdom_guid_pidfile, 'r') as pidfile:
  418. stubdom_guid_pid = pidfile.read().strip()
  419. guid_cmd += ['-K', stubdom_guid_pid]
  420. vm.log.info('Starting GUI')
  421. await asyncio.create_subprocess_exec(*guid_cmd)
  422. await self.send_monitor_layout(vm, layout=monitor_layout,
  423. startup=True)
  424. async def start_gui_for_stubdomain(self, vm, force=False):
  425. """Start GUI daemon (qubes-guid) connected to a stubdomain
  426. This function is a coroutine.
  427. """
  428. want_stubdom = force
  429. if not want_stubdom and \
  430. vm.features.check_with_template('gui-emulated', False):
  431. want_stubdom = True
  432. # if no 'gui' or 'gui-emulated' feature set at all, use emulated GUI
  433. if not want_stubdom and \
  434. vm.features.check_with_template('gui', None) is None and \
  435. vm.features.check_with_template('gui-emulated', None) is None:
  436. want_stubdom = True
  437. if not want_stubdom and vm.debug:
  438. want_stubdom = True
  439. if not want_stubdom:
  440. return
  441. if os.path.exists(self.guid_pidfile(vm.stubdom_xid)):
  442. return
  443. vm.log.info('Starting GUI (stubdomain)')
  444. guid_cmd = self.common_guid_args(vm)
  445. guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)])
  446. await asyncio.create_subprocess_exec(*guid_cmd)
  447. async def start_audio_for_vm(self, vm):
  448. """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM
  449. This function is a coroutine.
  450. :param vm: VM for which start AUDIO daemon
  451. """
  452. # pylint: disable=no-self-use
  453. pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name]
  454. vm.log.info('Starting AUDIO')
  455. await asyncio.create_subprocess_exec(*pacat_cmd)
  456. async def start_gui(self, vm, force_stubdom=False, monitor_layout=None):
  457. """Start GUI daemon regardless of start event.
  458. This function is a coroutine.
  459. :param vm: VM for which GUI daemon should be started
  460. :param force_stubdom: Force GUI daemon for stubdomain, even if the
  461. one for target AppVM is running.
  462. :param monitor_layout: monitor layout configuration
  463. """
  464. guivm = getattr(vm, 'guivm', None)
  465. if guivm != vm.app.local_name:
  466. vm.log.info('GUI connected to {}. Skipping.'.format(guivm))
  467. return
  468. if vm.virt_mode == 'hvm':
  469. await self.start_gui_for_stubdomain(vm, force=force_stubdom)
  470. if not vm.features.check_with_template('gui', True):
  471. return
  472. if not os.path.exists(self.guid_pidfile(vm.xid)):
  473. await self.start_gui_for_vm(vm, monitor_layout=monitor_layout)
  474. async def start_audio(self, vm):
  475. """Start AUDIO daemon regardless of start event.
  476. This function is a coroutine.
  477. :param vm: VM for which AUDIO daemon should be started
  478. """
  479. audiovm = getattr(vm, 'audiovm', None)
  480. if audiovm != vm.app.local_name:
  481. vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm))
  482. return
  483. if not vm.features.check_with_template('audio', True):
  484. return
  485. xid = self.pacat_domid(vm)
  486. if not os.path.exists(self.pacat_pidfile(xid)):
  487. await self.start_audio_for_vm(vm)
  488. def on_domain_spawn(self, vm, _event, **kwargs):
  489. """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain"""
  490. if not self.is_watched(vm):
  491. return
  492. try:
  493. if getattr(vm, 'guivm', None) != vm.app.local_name:
  494. return
  495. if not vm.features.check_with_template('gui', True):
  496. return
  497. if vm.virt_mode == 'hvm' and \
  498. kwargs.get('start_guid', 'True') == 'True':
  499. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  500. except qubesadmin.exc.QubesException as e:
  501. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  502. def on_domain_start(self, vm, _event, **kwargs):
  503. """Handler of 'domain-start' event, starts GUI/AUDIO daemon for
  504. actual VM """
  505. if not self.is_watched(vm):
  506. return
  507. try:
  508. if getattr(vm, 'guivm', None) == vm.app.local_name and \
  509. vm.features.check_with_template('gui', True) and \
  510. kwargs.get('start_guid', 'True') == 'True':
  511. asyncio.ensure_future(self.start_gui_for_vm(vm))
  512. except qubesadmin.exc.QubesException as e:
  513. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  514. try:
  515. if getattr(vm, 'audiovm', None) == vm.app.local_name and \
  516. vm.features.check_with_template('audio', True) and \
  517. kwargs.get('start_audio', 'True') == 'True':
  518. asyncio.ensure_future(self.start_audio_for_vm(vm))
  519. except qubesadmin.exc.QubesException as e:
  520. vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
  521. def on_connection_established(self, _subject, _event, **_kwargs):
  522. """Handler of 'connection-established' event, used to launch GUI/AUDIO
  523. daemon for domains started before this tool. """
  524. monitor_layout = get_monitor_layout()
  525. self.app.domains.clear_cache()
  526. for vm in self.app.domains:
  527. if vm.klass == 'AdminVM':
  528. continue
  529. if not self.is_watched(vm):
  530. continue
  531. power_state = vm.get_power_state()
  532. if power_state == 'Running':
  533. asyncio.ensure_future(
  534. self.start_gui(vm, monitor_layout=monitor_layout))
  535. asyncio.ensure_future(self.start_audio(vm))
  536. elif power_state == 'Transient':
  537. # it is still starting, we'll get 'domain-start'
  538. # event when fully started
  539. if vm.virt_mode == 'hvm':
  540. asyncio.ensure_future(
  541. self.start_gui_for_stubdomain(vm))
  542. def on_domain_stopped(self, vm, _event, **_kwargs):
  543. """Handler of 'domain-stopped' event, cleans up"""
  544. if not self.is_watched(vm):
  545. return
  546. self.cleanup_guid(vm.xid)
  547. if vm.virt_mode == 'hvm':
  548. self.cleanup_guid(vm.stubdom_xid)
  549. def cleanup_guid(self, xid):
  550. """
  551. Clean up after qubes-guid. Removes the auto-generated configuration
  552. file, if any.
  553. """
  554. config_path = self.guid_config_file(xid)
  555. if os.path.exists(config_path):
  556. os.unlink(config_path)
  557. def register_events(self, events):
  558. """Register domain startup events in app.events dispatcher"""
  559. events.add_handler('domain-spawn', self.on_domain_spawn)
  560. events.add_handler('domain-start', self.on_domain_start)
  561. events.add_handler('connection-established',
  562. self.on_connection_established)
  563. events.add_handler('domain-stopped', self.on_domain_stopped)
  564. def is_watched(self, vm):
  565. """
  566. Should we watch this VM for changes
  567. """
  568. if self.vm_names is None:
  569. return True
  570. return vm.name in self.vm_names
  571. if 'XDG_RUNTIME_DIR' in os.environ:
  572. pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
  573. 'qvm-start-daemon.pid')
  574. else:
  575. pidfile_path = os.path.join(os.environ.get('HOME', '/'),
  576. '.qvm-start-daemon.pid')
  577. parser = qubesadmin.tools.QubesArgumentParser(
  578. description='start GUI for qube(s)', vmname_nargs='*')
  579. parser.add_argument('--watch', action='store_true',
  580. help='Keep watching for further domain startups')
  581. parser.add_argument('--force-stubdomain', action='store_true',
  582. help='Start GUI to stubdomain-emulated VGA,'
  583. ' even if gui-agent is running in the VM')
  584. parser.add_argument('--pidfile', action='store', default=pidfile_path,
  585. help='Pidfile path to create in --watch mode')
  586. parser.add_argument('--notify-monitor-layout', action='store_true',
  587. help='Notify running instance in --watch mode'
  588. ' about changed monitor layout')
  589. parser.add_argument('--kde', action='store_true',
  590. help='Set KDE specific arguments to gui-daemon.')
  591. # Add it for the help only
  592. parser.add_argument('--force', action='store_true', default=False,
  593. help='Force running daemon without enabled services'
  594. ' \'guivm-gui-agent\' or \'audiovm-audio-agent\'')
  595. def main(args=None):
  596. """ Main function of qvm-start-daemon tool"""
  597. only_if_service_enabled = ['guivm-gui-agent', 'audiovm-audio-agent']
  598. enabled_services = [service for service in only_if_service_enabled if
  599. os.path.exists('/var/run/qubes-service/%s' % service)]
  600. if not enabled_services and '--force' not in sys.argv and \
  601. not os.path.exists('/etc/qubes-release'):
  602. print(parser.format_help())
  603. return
  604. args = parser.parse_args(args)
  605. if args.watch and args.notify_monitor_layout:
  606. parser.error('--watch cannot be used with --notify-monitor-layout')
  607. if args.all_domains:
  608. vm_names = None
  609. else:
  610. vm_names = [vm.name for vm in args.domains]
  611. launcher = DAEMONLauncher(
  612. args.app,
  613. vm_names=vm_names,
  614. kde=args.kde)
  615. if args.watch:
  616. with daemon.pidfile.TimeoutPIDLockFile(args.pidfile):
  617. loop = asyncio.get_event_loop()
  618. # pylint: disable=no-member
  619. events = qubesadmin.events.EventsDispatcher(args.app)
  620. # pylint: enable=no-member
  621. launcher.register_events(events)
  622. events_listener = asyncio.ensure_future(events.listen_for_events())
  623. for signame in ('SIGINT', 'SIGTERM'):
  624. loop.add_signal_handler(getattr(signal, signame),
  625. events_listener.cancel) # pylint: disable=no-member
  626. loop.add_signal_handler(signal.SIGHUP,
  627. launcher.send_monitor_layout_all)
  628. conn = xcffib.connect()
  629. x_watcher = XWatcher(conn, args.app)
  630. x_fd = conn.get_file_descriptor()
  631. loop.add_reader(x_fd, x_watcher.event_reader,
  632. events_listener.cancel)
  633. try:
  634. loop.run_until_complete(events_listener)
  635. except asyncio.CancelledError:
  636. pass
  637. loop.remove_reader(x_fd)
  638. loop.stop()
  639. loop.run_forever()
  640. loop.close()
  641. elif args.notify_monitor_layout:
  642. try:
  643. with open(pidfile_path, 'r') as pidfile:
  644. pid = int(pidfile.read().strip())
  645. os.kill(pid, signal.SIGHUP)
  646. except (FileNotFoundError, ValueError) as e:
  647. parser.error('Cannot open pidfile {}: {}'.format(pidfile_path,
  648. str(e)))
  649. else:
  650. loop = asyncio.get_event_loop()
  651. tasks = []
  652. for vm in args.domains:
  653. if vm.is_running():
  654. tasks.append(asyncio.ensure_future(launcher.start_gui(
  655. vm, force_stubdom=args.force_stubdomain)))
  656. tasks.append(asyncio.ensure_future(launcher.start_audio(
  657. vm)))
  658. if tasks:
  659. loop.run_until_complete(asyncio.wait(tasks))
  660. loop.stop()
  661. loop.run_forever()
  662. loop.close()
  663. if __name__ == '__main__':
  664. main()