qvm_start_daemon.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. # -*- encoding: utf8 -*-
  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. have_events = False
  36. try:
  37. # pylint: disable=wrong-import-position
  38. import qubesadmin.events
  39. have_events = True
  40. except ImportError:
  41. pass
  42. GUI_DAEMON_PATH = '/usr/bin/qubes-guid'
  43. PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan'
  44. QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices'
  45. # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
  46. REGEX_OUTPUT = re.compile(r"""
  47. (?x) # ignore whitespace
  48. ^ # start of string
  49. (?P<output>[A-Za-z0-9\-]*)[ ] # LVDS VGA etc
  50. (?P<connect>(dis)?connected) # dis/connected
  51. ([ ]
  52. (?P<primary>(primary)?)[ ]?
  53. (( # a group
  54. (?P<width>\d+)x # either 1024x768+0+0
  55. (?P<height>\d+)[+]
  56. (?P<x>\d+)[+]
  57. (?P<y>\d+)
  58. )|[\D]) # or not a digit
  59. ([ ]\(.*\))?[ ]? # ignore options
  60. ( # 304mm x 228mm
  61. (?P<width_mm>\d+)mm[ ]x[ ]
  62. (?P<height_mm>\d+)mm
  63. )?
  64. .* # ignore rest of line
  65. )? # everything after (dis)connect is optional
  66. """)
  67. def get_monitor_layout():
  68. """Get list of monitors and their size/position"""
  69. outputs = []
  70. for line in subprocess.Popen(
  71. ['xrandr', '-q'], stdout=subprocess.PIPE).stdout:
  72. line = line.decode()
  73. if not line.startswith("Screen") and not line.startswith(" "):
  74. output_params = REGEX_OUTPUT.match(line).groupdict()
  75. if output_params['width']:
  76. phys_size = ""
  77. if output_params['width_mm'] and int(output_params['width_mm']):
  78. # don't provide real values for privacy reasons - see
  79. # #1951 for details
  80. dpi = (int(output_params['width']) * 254 //
  81. int(output_params['width_mm']) // 10)
  82. if dpi > 300:
  83. dpi = 300
  84. elif dpi > 200:
  85. dpi = 200
  86. elif dpi > 150:
  87. dpi = 150
  88. else:
  89. # if lower, don't provide this info to the VM at all
  90. dpi = 0
  91. if dpi:
  92. # now calculate dimensions based on approximate DPI
  93. phys_size = " {} {}".format(
  94. int(output_params['width']) * 254 // dpi // 10,
  95. int(output_params['height']) * 254 // dpi // 10,
  96. )
  97. outputs.append("%s %s %s %s%s\n" % (
  98. output_params['width'],
  99. output_params['height'],
  100. output_params['x'],
  101. output_params['y'],
  102. phys_size,
  103. ))
  104. return outputs
  105. def set_keyboard_layout(vm):
  106. """Set layout configuration into features for Gui admin extension"""
  107. try:
  108. # Examples of 'xprop -root _XKB_RULES_NAMES' output values:
  109. # "evdev", "pc105", "fr", "oss", ""
  110. # "evdev", "pc105", "pl,us", ",", "grp:win_switch,compose:caps"
  111. # We use the first layout provided
  112. xkb_re = r'_XKB_RULES_NAMES\(STRING\) = ' \
  113. r'\"(.*)\", \"(.*)\", \"(.*)\", \"(.*)\", \"(.*)\"\n'
  114. xkb_rules_names = subprocess.check_output(
  115. ['xprop', '-root', '_XKB_RULES_NAMES']).decode()
  116. xkb_parsed = re.match(xkb_re, xkb_rules_names)
  117. if xkb_parsed:
  118. xkb_layout = [x.split(',')[0] for x in xkb_parsed.groups()[2:4]]
  119. # We keep all options
  120. xkb_layout.append(xkb_parsed.group(5))
  121. keyboard_layout = '+'.join(xkb_layout)
  122. vm.features['keyboard-layout'] = keyboard_layout
  123. else:
  124. vm.log.warning('Failed to parse layout for %s', vm)
  125. except subprocess.CalledProcessError as e:
  126. vm.log.warning('Failed to set layout for %s: %s', vm, str(e))
  127. class DAEMONLauncher:
  128. """Launch GUI/AUDIO daemon for VMs"""
  129. def __init__(self, app: qubesadmin.app.QubesBase):
  130. """ Initialize DAEMONLauncher.
  131. :param app: :py:class:`qubesadmin.Qubes` instance
  132. """
  133. self.app = app
  134. self.started_processes = {}
  135. @asyncio.coroutine
  136. def send_monitor_layout(self, vm, layout=None, startup=False):
  137. """Send monitor layout to a given VM
  138. This function is a coroutine.
  139. :param vm: VM to which send monitor layout
  140. :param layout: monitor layout to send; if None, fetch it from
  141. local X server.
  142. :param startup:
  143. :return: None
  144. """
  145. # pylint: disable=no-self-use
  146. if vm.features.check_with_template('no-monitor-layout', False) \
  147. or not vm.is_running():
  148. return
  149. if layout is None:
  150. layout = get_monitor_layout()
  151. if not layout:
  152. return
  153. vm.log.info('Sending monitor layout')
  154. if not startup:
  155. with open(self.guid_pidfile(vm.xid)) as pidfile:
  156. pid = int(pidfile.read())
  157. os.kill(pid, signal.SIGHUP)
  158. try:
  159. with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile:
  160. pid = int(pidfile.read())
  161. os.kill(pid, signal.SIGHUP)
  162. except FileNotFoundError:
  163. pass
  164. try:
  165. yield from asyncio.get_event_loop(). \
  166. run_in_executor(None,
  167. functools.partial(
  168. vm.run_service_for_stdio,
  169. 'qubes.SetMonitorLayout',
  170. input=''.join(layout).encode(),
  171. autostart=False))
  172. except subprocess.CalledProcessError as e:
  173. vm.log.warning('Failed to send monitor layout: %s', e.stderr)
  174. def send_monitor_layout_all(self):
  175. """Send monitor layout to all (running) VMs"""
  176. monitor_layout = get_monitor_layout()
  177. for vm in self.app.domains:
  178. if getattr(vm, 'guivm', None) != vm.app.local_name:
  179. continue
  180. if vm.klass == 'AdminVM':
  181. continue
  182. if vm.is_running():
  183. if not vm.features.check_with_template('gui', True):
  184. continue
  185. asyncio.ensure_future(self.send_monitor_layout(vm,
  186. monitor_layout))
  187. @staticmethod
  188. def kde_guid_args(vm):
  189. """Return KDE-specific arguments for gui-daemon, if applicable"""
  190. guid_cmd = []
  191. # Avoid using environment variables for checking the current session,
  192. # because this script may be called with cleared env (like with sudo).
  193. if subprocess.check_output(
  194. ['xprop', '-root', '-notype', 'KWIN_RUNNING']) == \
  195. b'KWIN_RUNNING = 0x1\n':
  196. # native decoration plugins is used, so adjust window properties
  197. # accordingly
  198. guid_cmd += ['-T'] # prefix window titles with VM name
  199. # get owner of X11 session
  200. session_owner = None
  201. for line in subprocess.check_output(['xhost']).splitlines():
  202. if line == b'SI:localuser:root':
  203. pass
  204. elif line.startswith(b'SI:localuser:'):
  205. session_owner = line.split(b':')[2].decode()
  206. if session_owner is not None:
  207. data_dir = os.path.expanduser(
  208. '~{}/.local/share'.format(session_owner))
  209. else:
  210. # fallback to current user
  211. data_dir = os.path.expanduser('~/.local/share')
  212. guid_cmd += ['-p',
  213. '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format(
  214. os.path.join(data_dir,
  215. 'qubes-kde',
  216. vm.label.name + '.colors'))]
  217. return guid_cmd
  218. def common_guid_args(self, vm):
  219. """Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
  220. guid_cmd = [GUI_DAEMON_PATH,
  221. '-N', vm.name,
  222. '-c', vm.label.color,
  223. '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png',
  224. '-l', str(vm.label.index)]
  225. if vm.debug:
  226. guid_cmd += ['-v', '-v']
  227. # elif not verbose:
  228. else:
  229. guid_cmd += ['-q']
  230. if vm.features.check_with_template('rpc-clipboard', False):
  231. guid_cmd.extend(['-Q'])
  232. guid_cmd += self.kde_guid_args(vm)
  233. return guid_cmd
  234. @staticmethod
  235. def guid_pidfile(xid):
  236. """Helper function to construct a GUI pidfile path"""
  237. return '/var/run/qubes/guid-running.{}'.format(xid)
  238. @staticmethod
  239. def pacat_pidfile(xid):
  240. """Helper function to construct an AUDIO pidfile path"""
  241. return '/var/run/qubes/pacat.{}'.format(xid)
  242. @staticmethod
  243. def pacat_domid(vm):
  244. """Determine target domid for an AUDIO daemon"""
  245. xid = vm.stubdom_xid \
  246. if vm.features.check_with_template('audio-model', False) \
  247. and vm.virt_mode == 'hvm' \
  248. else vm.xid
  249. return xid
  250. @asyncio.coroutine
  251. def start_gui_for_vm(self, vm, monitor_layout=None):
  252. """Start GUI daemon (qubes-guid) connected directly to a VM
  253. This function is a coroutine.
  254. :param vm: VM for which start GUI daemon
  255. :param monitor_layout: monitor layout to send; if None, fetch it from
  256. local X server.
  257. """
  258. guid_cmd = self.common_guid_args(vm)
  259. guid_cmd.extend(['-d', str(vm.xid)])
  260. if vm.virt_mode == 'hvm':
  261. guid_cmd.extend(['-n'])
  262. stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid)
  263. if not vm.debug and os.path.exists(stubdom_guid_pidfile):
  264. # Terminate stubdom guid once "real" gui agent connects
  265. with open(stubdom_guid_pidfile, 'r') as pidfile:
  266. stubdom_guid_pid = pidfile.read().strip()
  267. guid_cmd += ['-K', stubdom_guid_pid]
  268. vm.log.info('Starting GUI')
  269. yield from asyncio.create_subprocess_exec(*guid_cmd)
  270. yield from self.send_monitor_layout(vm, layout=monitor_layout,
  271. startup=True)
  272. @asyncio.coroutine
  273. def start_gui_for_stubdomain(self, vm, force=False):
  274. """Start GUI daemon (qubes-guid) connected to a stubdomain
  275. This function is a coroutine.
  276. """
  277. want_stubdom = force
  278. if not want_stubdom and \
  279. vm.features.check_with_template('gui-emulated', False):
  280. want_stubdom = True
  281. # if no 'gui' or 'gui-emulated' feature set at all, use emulated GUI
  282. if not want_stubdom and \
  283. vm.features.check_with_template('gui', None) is None and \
  284. vm.features.check_with_template('gui-emulated', None) is None:
  285. want_stubdom = True
  286. if not want_stubdom and vm.debug:
  287. want_stubdom = True
  288. if not want_stubdom:
  289. return
  290. if os.path.exists(self.guid_pidfile(vm.stubdom_xid)):
  291. return
  292. vm.log.info('Starting GUI (stubdomain)')
  293. guid_cmd = self.common_guid_args(vm)
  294. guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)])
  295. yield from asyncio.create_subprocess_exec(*guid_cmd)
  296. @asyncio.coroutine
  297. def start_audio_for_vm(self, vm):
  298. """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM
  299. This function is a coroutine.
  300. :param vm: VM for which start AUDIO daemon
  301. """
  302. # pylint: disable=no-self-use
  303. pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name]
  304. vm.log.info('Starting AUDIO')
  305. yield from asyncio.create_subprocess_exec(*pacat_cmd)
  306. @asyncio.coroutine
  307. def start_gui(self, vm, force_stubdom=False, monitor_layout=None):
  308. """Start GUI daemon regardless of start event.
  309. This function is a coroutine.
  310. :param vm: VM for which GUI daemon should be started
  311. :param force_stubdom: Force GUI daemon for stubdomain, even if the
  312. one for target AppVM is running.
  313. :param monitor_layout: monitor layout configuration
  314. """
  315. guivm = getattr(vm, 'guivm', None)
  316. if guivm != vm.app.local_name:
  317. vm.log.info('GUI connected to {}. Skipping.'.format(guivm))
  318. return
  319. if vm.virt_mode == 'hvm':
  320. yield from self.start_gui_for_stubdomain(vm, force=force_stubdom)
  321. if not vm.features.check_with_template('gui', True):
  322. return
  323. if not os.path.exists(self.guid_pidfile(vm.xid)):
  324. yield from self.start_gui_for_vm(vm, monitor_layout=monitor_layout)
  325. @asyncio.coroutine
  326. def start_audio(self, vm):
  327. """Start AUDIO daemon regardless of start event.
  328. This function is a coroutine.
  329. :param vm: VM for which AUDIO daemon should be started
  330. """
  331. audiovm = getattr(vm, 'audiovm', None)
  332. if audiovm != vm.app.local_name:
  333. vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm))
  334. return
  335. if not vm.features.check_with_template('audio', True):
  336. return
  337. xid = self.pacat_domid(vm)
  338. if not os.path.exists(self.pacat_pidfile(xid)):
  339. yield from self.start_audio_for_vm(vm)
  340. def on_domain_spawn(self, vm, _event, **kwargs):
  341. """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain"""
  342. try:
  343. if getattr(vm, 'guivm', None) != vm.app.local_name:
  344. return
  345. if not vm.features.check_with_template('gui', True):
  346. return
  347. if vm.virt_mode == 'hvm' and \
  348. kwargs.get('start_guid', 'True') == 'True':
  349. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  350. except qubesadmin.exc.QubesException as e:
  351. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  352. def on_domain_start(self, vm, _event, **kwargs):
  353. """Handler of 'domain-start' event, starts GUI/AUDIO daemon for
  354. actual VM """
  355. try:
  356. if getattr(vm, 'guivm', None) == vm.app.local_name and \
  357. vm.features.check_with_template('gui', True) and \
  358. kwargs.get('start_guid', 'True') == 'True':
  359. asyncio.ensure_future(self.start_gui_for_vm(vm))
  360. except qubesadmin.exc.QubesException as e:
  361. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  362. try:
  363. if getattr(vm, 'audiovm', None) == vm.app.local_name and \
  364. vm.features.check_with_template('audio', True) and \
  365. kwargs.get('start_audio', 'True') == 'True':
  366. asyncio.ensure_future(self.start_audio_for_vm(vm))
  367. except qubesadmin.exc.QubesException as e:
  368. vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
  369. def on_connection_established(self, _subject, _event, **_kwargs):
  370. """Handler of 'connection-established' event, used to launch GUI/AUDIO
  371. daemon for domains started before this tool. """
  372. monitor_layout = get_monitor_layout()
  373. self.app.domains.clear_cache()
  374. for vm in self.app.domains:
  375. if vm.klass == 'AdminVM':
  376. continue
  377. power_state = vm.get_power_state()
  378. if power_state == 'Running':
  379. asyncio.ensure_future(
  380. self.start_gui(vm, monitor_layout=monitor_layout))
  381. asyncio.ensure_future(self.start_audio(vm))
  382. elif power_state == 'Transient':
  383. # it is still starting, we'll get 'domain-start'
  384. # event when fully started
  385. if vm.virt_mode == 'hvm':
  386. asyncio.ensure_future(
  387. self.start_gui_for_stubdomain(vm))
  388. def register_events(self, events):
  389. """Register domain startup events in app.events dispatcher"""
  390. events.add_handler('domain-spawn', self.on_domain_spawn)
  391. events.add_handler('domain-start', self.on_domain_start)
  392. events.add_handler('connection-established',
  393. self.on_connection_established)
  394. def x_reader(conn, callback):
  395. """Try reading something from X connection to check if it's still alive.
  396. In case it isn't, call *callback*.
  397. """
  398. try:
  399. conn.poll_for_event()
  400. except xcffib.ConnectionException:
  401. callback()
  402. if 'XDG_RUNTIME_DIR' in os.environ:
  403. pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
  404. 'qvm-start-daemon.pid')
  405. else:
  406. pidfile_path = os.path.join(os.environ.get('HOME', '/'),
  407. '.qvm-start-daemon.pid')
  408. parser = qubesadmin.tools.QubesArgumentParser(
  409. description='start GUI for qube(s)', vmname_nargs='*')
  410. parser.add_argument('--watch', action='store_true',
  411. help='Keep watching for further domains'
  412. ' startups, must be used with --all')
  413. parser.add_argument('--force-stubdomain', action='store_true',
  414. help='Start GUI to stubdomain-emulated VGA,'
  415. ' even if gui-agent is running in the VM')
  416. parser.add_argument('--pidfile', action='store', default=pidfile_path,
  417. help='Pidfile path to create in --watch mode')
  418. parser.add_argument('--notify-monitor-layout', action='store_true',
  419. help='Notify running instance in --watch mode'
  420. ' about changed monitor layout')
  421. parser.add_argument('--set-keyboard-layout', action='store_true',
  422. help='Set keyboard layout values into GuiVM features.'
  423. 'This option is implied by --watch')
  424. # Add it for the help only
  425. parser.add_argument('--force', action='store_true', default=False,
  426. help='Force running daemon without enabled services'
  427. ' \'guivm-gui-agent\' or \'audiovm-audio-agent\'')
  428. def main(args=None):
  429. """ Main function of qvm-start-daemon tool"""
  430. only_if_service_enabled = ['guivm-gui-agent', 'audiovm-audio-agent']
  431. enabled_services = [service for service in only_if_service_enabled if
  432. os.path.exists('/var/run/qubes-service/%s' % service)]
  433. if not enabled_services and '--force' not in sys.argv and \
  434. not os.path.exists('/etc/qubes-release'):
  435. print(parser.format_help())
  436. return
  437. args = parser.parse_args(args)
  438. if args.watch and not args.all_domains:
  439. parser.error('--watch option must be used with --all')
  440. if args.watch and args.notify_monitor_layout:
  441. parser.error('--watch cannot be used with --notify-monitor-layout')
  442. if args.watch and 'guivm-gui-agent' in enabled_services:
  443. args.set_keyboard_layout = True
  444. if args.set_keyboard_layout or os.path.exists('/etc/qubes-release'):
  445. guivm = args.app.domains.get_blind(args.app.local_name)
  446. set_keyboard_layout(guivm)
  447. launcher = DAEMONLauncher(args.app)
  448. if args.watch:
  449. if not have_events:
  450. parser.error('--watch option require Python >= 3.5')
  451. with daemon.pidfile.TimeoutPIDLockFile(args.pidfile):
  452. loop = asyncio.get_event_loop()
  453. # pylint: disable=no-member
  454. events = qubesadmin.events.EventsDispatcher(args.app)
  455. # pylint: enable=no-member
  456. launcher.register_events(events)
  457. events_listener = asyncio.ensure_future(events.listen_for_events())
  458. for signame in ('SIGINT', 'SIGTERM'):
  459. loop.add_signal_handler(getattr(signal, signame),
  460. events_listener.cancel) # pylint: disable=no-member
  461. loop.add_signal_handler(signal.SIGHUP,
  462. launcher.send_monitor_layout_all)
  463. conn = xcffib.connect()
  464. x_fd = conn.get_file_descriptor()
  465. loop.add_reader(x_fd, x_reader, conn, events_listener.cancel)
  466. try:
  467. loop.run_until_complete(events_listener)
  468. except asyncio.CancelledError:
  469. pass
  470. loop.remove_reader(x_fd)
  471. loop.stop()
  472. loop.run_forever()
  473. loop.close()
  474. elif args.notify_monitor_layout:
  475. try:
  476. with open(pidfile_path, 'r') as pidfile:
  477. pid = int(pidfile.read().strip())
  478. os.kill(pid, signal.SIGHUP)
  479. except (FileNotFoundError, ValueError) as e:
  480. parser.error('Cannot open pidfile {}: {}'.format(pidfile_path,
  481. str(e)))
  482. else:
  483. loop = asyncio.get_event_loop()
  484. tasks = []
  485. for vm in args.domains:
  486. if vm.is_running():
  487. tasks.append(asyncio.ensure_future(launcher.start_gui(
  488. vm, force_stubdom=args.force_stubdomain)))
  489. tasks.append(asyncio.ensure_future(launcher.start_audio(
  490. vm)))
  491. if tasks:
  492. loop.run_until_complete(asyncio.wait(tasks))
  493. loop.stop()
  494. loop.run_forever()
  495. loop.close()
  496. if __name__ == '__main__':
  497. main()