qvm_start_daemon.py 28 KB

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