qvm_start_daemon.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  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. # cache XID values when the VM was still running -
  279. # for cleanup purpose
  280. self.xid_cache = {}
  281. async def send_monitor_layout(self, vm, layout=None, startup=False):
  282. """Send monitor layout to a given VM
  283. This function is a coroutine.
  284. :param vm: VM to which send monitor layout
  285. :param layout: monitor layout to send; if None, fetch it from
  286. local X server.
  287. :param startup:
  288. :return: None
  289. """
  290. # pylint: disable=no-self-use
  291. if vm.features.check_with_template('no-monitor-layout', False) \
  292. or not vm.is_running():
  293. return
  294. if layout is None:
  295. layout = get_monitor_layout()
  296. if not layout:
  297. return
  298. vm.log.info('Sending monitor layout')
  299. if not startup:
  300. with open(self.guid_pidfile(vm.xid)) as pidfile:
  301. pid = int(pidfile.read())
  302. os.kill(pid, signal.SIGHUP)
  303. try:
  304. with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile:
  305. pid = int(pidfile.read())
  306. os.kill(pid, signal.SIGHUP)
  307. except FileNotFoundError:
  308. pass
  309. try:
  310. await asyncio.get_event_loop(). \
  311. run_in_executor(None,
  312. functools.partial(
  313. vm.run_service_for_stdio,
  314. 'qubes.SetMonitorLayout',
  315. input=''.join(layout).encode(),
  316. autostart=False))
  317. except subprocess.CalledProcessError as e:
  318. vm.log.warning('Failed to send monitor layout: %s', e.stderr)
  319. def send_monitor_layout_all(self):
  320. """Send monitor layout to all (running) VMs"""
  321. monitor_layout = get_monitor_layout()
  322. for vm in self.app.domains:
  323. if getattr(vm, 'guivm', None) != vm.app.local_name:
  324. continue
  325. if vm.klass == 'AdminVM':
  326. continue
  327. if vm.is_running():
  328. if not vm.features.check_with_template('gui', True):
  329. continue
  330. asyncio.ensure_future(self.send_monitor_layout(vm,
  331. monitor_layout))
  332. @staticmethod
  333. def kde_guid_args(vm):
  334. """Return KDE-specific arguments for gui-daemon, if applicable"""
  335. guid_cmd = []
  336. # native decoration plugins is used, so adjust window properties
  337. # accordingly
  338. guid_cmd += ['-T'] # prefix window titles with VM name
  339. # get owner of X11 session
  340. session_owner = None
  341. for line in subprocess.check_output(['xhost']).splitlines():
  342. if line == b'SI:localuser:root':
  343. pass
  344. elif line.startswith(b'SI:localuser:'):
  345. session_owner = line.split(b':')[2].decode()
  346. if session_owner is not None:
  347. data_dir = os.path.expanduser(
  348. '~{}/.local/share'.format(session_owner))
  349. else:
  350. # fallback to current user
  351. data_dir = os.path.expanduser('~/.local/share')
  352. guid_cmd += ['-p',
  353. '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format(
  354. os.path.join(data_dir,
  355. 'qubes-kde',
  356. vm.label.name + '.colors'))]
  357. return guid_cmd
  358. def common_guid_args(self, vm):
  359. """Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
  360. guid_cmd = [GUI_DAEMON_PATH,
  361. '-N', vm.name,
  362. '-c', vm.label.color,
  363. '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png',
  364. '-l', str(vm.label.index)]
  365. if vm.debug:
  366. guid_cmd += ['-v', '-v']
  367. # elif not verbose:
  368. else:
  369. guid_cmd += ['-q']
  370. if vm.features.check_with_template('rpc-clipboard', False):
  371. guid_cmd.extend(['-Q'])
  372. guivm = self.app.domains[vm.guivm]
  373. options = retrieve_gui_daemon_options(vm, guivm)
  374. config = serialize_gui_daemon_options(options)
  375. config_path = self.guid_config_file(vm.xid)
  376. self.write_guid_config(config_path, config)
  377. guid_cmd.extend(['-C', config_path])
  378. return guid_cmd
  379. @staticmethod
  380. def write_guid_config(config_path, config):
  381. """Write guid configuration to a file"""
  382. with open(config_path, 'w') as config_file:
  383. config_file.write(config)
  384. @staticmethod
  385. def guid_pidfile(xid):
  386. """Helper function to construct a GUI pidfile path"""
  387. return '/var/run/qubes/guid-running.{}'.format(xid)
  388. @staticmethod
  389. def guid_config_file(xid):
  390. """Helper function to construct a GUI configuration file path"""
  391. return '/var/run/qubes/guid-conf.{}'.format(xid)
  392. @staticmethod
  393. def pacat_pidfile(xid):
  394. """Helper function to construct an AUDIO pidfile path"""
  395. return '/var/run/qubes/pacat.{}'.format(xid)
  396. @staticmethod
  397. def pacat_domid(vm):
  398. """Determine target domid for an AUDIO daemon"""
  399. xid = vm.stubdom_xid \
  400. if vm.features.check_with_template('audio-model', False) \
  401. and vm.virt_mode == 'hvm' \
  402. else vm.xid
  403. return xid
  404. async def start_gui_for_vm(self, vm, monitor_layout=None):
  405. """Start GUI daemon (qubes-guid) connected directly to a VM
  406. This function is a coroutine.
  407. :param vm: VM for which start GUI daemon
  408. :param monitor_layout: monitor layout to send; if None, fetch it from
  409. local X server.
  410. """
  411. guid_cmd = self.common_guid_args(vm)
  412. if self.kde:
  413. guid_cmd.extend(self.kde_guid_args(vm))
  414. guid_cmd.extend(['-d', str(vm.xid)])
  415. if vm.virt_mode == 'hvm':
  416. guid_cmd.extend(['-n'])
  417. stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid)
  418. if not vm.debug and os.path.exists(stubdom_guid_pidfile):
  419. # Terminate stubdom guid once "real" gui agent connects
  420. with open(stubdom_guid_pidfile, 'r') as pidfile:
  421. stubdom_guid_pid = pidfile.read().strip()
  422. guid_cmd += ['-K', stubdom_guid_pid]
  423. vm.log.info('Starting GUI')
  424. await asyncio.create_subprocess_exec(*guid_cmd)
  425. await self.send_monitor_layout(vm, layout=monitor_layout,
  426. startup=True)
  427. async def start_gui_for_stubdomain(self, vm, force=False):
  428. """Start GUI daemon (qubes-guid) connected to a stubdomain
  429. This function is a coroutine.
  430. """
  431. want_stubdom = force
  432. if not want_stubdom and \
  433. vm.features.check_with_template('gui-emulated', False):
  434. want_stubdom = True
  435. # if no 'gui' or 'gui-emulated' feature set at all, use emulated GUI
  436. if not want_stubdom and \
  437. vm.features.check_with_template('gui', None) is None and \
  438. vm.features.check_with_template('gui-emulated', None) is None:
  439. want_stubdom = True
  440. if not want_stubdom and vm.debug:
  441. want_stubdom = True
  442. if not want_stubdom:
  443. return
  444. if os.path.exists(self.guid_pidfile(vm.stubdom_xid)):
  445. return
  446. vm.log.info('Starting GUI (stubdomain)')
  447. guid_cmd = self.common_guid_args(vm)
  448. guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)])
  449. await asyncio.create_subprocess_exec(*guid_cmd)
  450. async def start_audio_for_vm(self, vm):
  451. """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM
  452. This function is a coroutine.
  453. :param vm: VM for which start AUDIO daemon
  454. """
  455. # pylint: disable=no-self-use
  456. pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name]
  457. vm.log.info('Starting AUDIO')
  458. await asyncio.create_subprocess_exec(*pacat_cmd)
  459. async def start_gui(self, vm, force_stubdom=False, monitor_layout=None):
  460. """Start GUI daemon regardless of start event.
  461. This function is a coroutine.
  462. :param vm: VM for which GUI daemon should be started
  463. :param force_stubdom: Force GUI daemon for stubdomain, even if the
  464. one for target AppVM is running.
  465. :param monitor_layout: monitor layout configuration
  466. """
  467. guivm = getattr(vm, 'guivm', None)
  468. if guivm != vm.app.local_name:
  469. vm.log.info('GUI connected to {}. Skipping.'.format(guivm))
  470. return
  471. if vm.virt_mode == 'hvm':
  472. await self.start_gui_for_stubdomain(vm, force=force_stubdom)
  473. if not vm.features.check_with_template('gui', True):
  474. return
  475. if not os.path.exists(self.guid_pidfile(vm.xid)):
  476. await self.start_gui_for_vm(vm, monitor_layout=monitor_layout)
  477. async def start_audio(self, vm):
  478. """Start AUDIO daemon regardless of start event.
  479. This function is a coroutine.
  480. :param vm: VM for which AUDIO daemon should be started
  481. """
  482. audiovm = getattr(vm, 'audiovm', None)
  483. if audiovm != vm.app.local_name:
  484. vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm))
  485. return
  486. if not vm.features.check_with_template('audio', True):
  487. return
  488. xid = self.pacat_domid(vm)
  489. if not os.path.exists(self.pacat_pidfile(xid)):
  490. await self.start_audio_for_vm(vm)
  491. def on_domain_spawn(self, vm, _event, **kwargs):
  492. """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain"""
  493. if not self.is_watched(vm):
  494. return
  495. try:
  496. if getattr(vm, 'guivm', None) != vm.app.local_name:
  497. return
  498. if not vm.features.check_with_template('gui', True):
  499. return
  500. if vm.virt_mode == 'hvm' and \
  501. kwargs.get('start_guid', 'True') == 'True':
  502. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  503. except qubesadmin.exc.QubesException as e:
  504. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  505. def on_domain_start(self, vm, _event, **kwargs):
  506. """Handler of 'domain-start' event, starts GUI/AUDIO daemon for
  507. actual VM """
  508. if not self.is_watched(vm):
  509. return
  510. self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid
  511. try:
  512. if getattr(vm, 'guivm', None) == vm.app.local_name and \
  513. vm.features.check_with_template('gui', True) and \
  514. kwargs.get('start_guid', 'True') == 'True':
  515. asyncio.ensure_future(self.start_gui_for_vm(vm))
  516. except qubesadmin.exc.QubesException as e:
  517. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  518. try:
  519. if getattr(vm, 'audiovm', None) == vm.app.local_name and \
  520. vm.features.check_with_template('audio', True) and \
  521. kwargs.get('start_audio', 'True') == 'True':
  522. asyncio.ensure_future(self.start_audio_for_vm(vm))
  523. except qubesadmin.exc.QubesException as e:
  524. vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
  525. def on_connection_established(self, _subject, _event, **_kwargs):
  526. """Handler of 'connection-established' event, used to launch GUI/AUDIO
  527. daemon for domains started before this tool. """
  528. monitor_layout = get_monitor_layout()
  529. self.app.domains.clear_cache()
  530. for vm in self.app.domains:
  531. if vm.klass == 'AdminVM':
  532. continue
  533. if not self.is_watched(vm):
  534. continue
  535. power_state = vm.get_power_state()
  536. if power_state == 'Running':
  537. asyncio.ensure_future(
  538. self.start_gui(vm, monitor_layout=monitor_layout))
  539. asyncio.ensure_future(self.start_audio(vm))
  540. self.xid_cache[vm.name] = vm.xid, vm.stubdom_xid
  541. elif power_state == 'Transient':
  542. # it is still starting, we'll get 'domain-start'
  543. # event when fully started
  544. if vm.virt_mode == 'hvm':
  545. asyncio.ensure_future(
  546. self.start_gui_for_stubdomain(vm))
  547. def on_domain_stopped(self, vm, _event, **_kwargs):
  548. """Handler of 'domain-stopped' event, cleans up"""
  549. if not self.is_watched(vm):
  550. return
  551. # read XID from cache, as stopped domain reports it already as -1
  552. try:
  553. xid, stubdom_xid = self.xid_cache[vm.name]
  554. del self.xid_cache[vm.name]
  555. except KeyError:
  556. return
  557. if xid != -1:
  558. self.cleanup_guid(xid)
  559. if stubdom_xid != -1:
  560. self.cleanup_guid(stubdom_xid)
  561. def cleanup_guid(self, xid):
  562. """
  563. Clean up after qubes-guid. Removes the auto-generated configuration
  564. file, if any.
  565. """
  566. config_path = self.guid_config_file(xid)
  567. if os.path.exists(config_path):
  568. os.unlink(config_path)
  569. def register_events(self, events):
  570. """Register domain startup events in app.events dispatcher"""
  571. events.add_handler('domain-spawn', self.on_domain_spawn)
  572. events.add_handler('domain-start', self.on_domain_start)
  573. events.add_handler('connection-established',
  574. self.on_connection_established)
  575. events.add_handler('domain-stopped', self.on_domain_stopped)
  576. def is_watched(self, vm):
  577. """
  578. Should we watch this VM for changes
  579. """
  580. if self.vm_names is None:
  581. return True
  582. return vm.name in self.vm_names
  583. if 'XDG_RUNTIME_DIR' in os.environ:
  584. pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
  585. 'qvm-start-daemon.pid')
  586. else:
  587. pidfile_path = os.path.join(os.environ.get('HOME', '/'),
  588. '.qvm-start-daemon.pid')
  589. parser = qubesadmin.tools.QubesArgumentParser(
  590. description='start GUI for qube(s)', vmname_nargs='*')
  591. parser.add_argument('--watch', action='store_true',
  592. help='Keep watching for further domain startups')
  593. parser.add_argument('--force-stubdomain', action='store_true',
  594. help='Start GUI to stubdomain-emulated VGA,'
  595. ' even if gui-agent is running in the VM')
  596. parser.add_argument('--pidfile', action='store', default=pidfile_path,
  597. help='Pidfile path to create in --watch mode')
  598. parser.add_argument('--notify-monitor-layout', action='store_true',
  599. help='Notify running instance in --watch mode'
  600. ' about changed monitor layout')
  601. parser.add_argument('--kde', action='store_true',
  602. help='Set KDE specific arguments to gui-daemon.')
  603. # Add it for the help only
  604. parser.add_argument('--force', action='store_true', default=False,
  605. help='Force running daemon without enabled services'
  606. ' \'guivm\' or \'audiovm\'')
  607. def main(args=None):
  608. """ Main function of qvm-start-daemon tool"""
  609. only_if_service_enabled = ['guivm', 'audiovm']
  610. enabled_services = [service for service in only_if_service_enabled if
  611. os.path.exists('/var/run/qubes-service/%s' % service)]
  612. if not enabled_services and '--force' not in sys.argv and \
  613. not os.path.exists('/etc/qubes-release'):
  614. print(parser.format_help())
  615. return
  616. args = parser.parse_args(args)
  617. if args.watch and args.notify_monitor_layout:
  618. parser.error('--watch cannot be used with --notify-monitor-layout')
  619. if args.all_domains:
  620. vm_names = None
  621. else:
  622. vm_names = [vm.name for vm in args.domains]
  623. launcher = DAEMONLauncher(
  624. args.app,
  625. vm_names=vm_names,
  626. kde=args.kde)
  627. if args.watch:
  628. with daemon.pidfile.TimeoutPIDLockFile(args.pidfile):
  629. loop = asyncio.get_event_loop()
  630. # pylint: disable=no-member
  631. events = qubesadmin.events.EventsDispatcher(args.app)
  632. # pylint: enable=no-member
  633. launcher.register_events(events)
  634. events_listener = asyncio.ensure_future(events.listen_for_events())
  635. for signame in ('SIGINT', 'SIGTERM'):
  636. loop.add_signal_handler(getattr(signal, signame),
  637. events_listener.cancel) # pylint: disable=no-member
  638. loop.add_signal_handler(signal.SIGHUP,
  639. launcher.send_monitor_layout_all)
  640. conn = xcffib.connect()
  641. x_watcher = XWatcher(conn, args.app)
  642. x_fd = conn.get_file_descriptor()
  643. loop.add_reader(x_fd, x_watcher.event_reader,
  644. events_listener.cancel)
  645. try:
  646. loop.run_until_complete(events_listener)
  647. except asyncio.CancelledError:
  648. pass
  649. loop.remove_reader(x_fd)
  650. loop.stop()
  651. loop.run_forever()
  652. loop.close()
  653. elif args.notify_monitor_layout:
  654. try:
  655. with open(pidfile_path, 'r') as pidfile:
  656. pid = int(pidfile.read().strip())
  657. os.kill(pid, signal.SIGHUP)
  658. except (FileNotFoundError, ValueError) as e:
  659. parser.error('Cannot open pidfile {}: {}'.format(pidfile_path,
  660. str(e)))
  661. else:
  662. loop = asyncio.get_event_loop()
  663. tasks = []
  664. for vm in args.domains:
  665. if vm.is_running():
  666. tasks.append(asyncio.ensure_future(launcher.start_gui(
  667. vm, force_stubdom=args.force_stubdomain)))
  668. tasks.append(asyncio.ensure_future(launcher.start_audio(
  669. vm)))
  670. if tasks:
  671. loop.run_until_complete(asyncio.wait(tasks))
  672. loop.stop()
  673. loop.run_forever()
  674. loop.close()
  675. if __name__ == '__main__':
  676. main()