qvm_start_daemon.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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. self.kde = False
  136. @asyncio.coroutine
  137. def send_monitor_layout(self, vm, layout=None, startup=False):
  138. """Send monitor layout to a given VM
  139. This function is a coroutine.
  140. :param vm: VM to which send monitor layout
  141. :param layout: monitor layout to send; if None, fetch it from
  142. local X server.
  143. :param startup:
  144. :return: None
  145. """
  146. # pylint: disable=no-self-use
  147. if vm.features.check_with_template('no-monitor-layout', False) \
  148. or not vm.is_running():
  149. return
  150. if layout is None:
  151. layout = get_monitor_layout()
  152. if not layout:
  153. return
  154. vm.log.info('Sending monitor layout')
  155. if not startup:
  156. with open(self.guid_pidfile(vm.xid)) as pidfile:
  157. pid = int(pidfile.read())
  158. os.kill(pid, signal.SIGHUP)
  159. try:
  160. with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile:
  161. pid = int(pidfile.read())
  162. os.kill(pid, signal.SIGHUP)
  163. except FileNotFoundError:
  164. pass
  165. try:
  166. yield from asyncio.get_event_loop(). \
  167. run_in_executor(None,
  168. functools.partial(
  169. vm.run_service_for_stdio,
  170. 'qubes.SetMonitorLayout',
  171. input=''.join(layout).encode(),
  172. autostart=False))
  173. except subprocess.CalledProcessError as e:
  174. vm.log.warning('Failed to send monitor layout: %s', e.stderr)
  175. def send_monitor_layout_all(self):
  176. """Send monitor layout to all (running) VMs"""
  177. monitor_layout = get_monitor_layout()
  178. for vm in self.app.domains:
  179. if getattr(vm, 'guivm', None) != vm.app.local_name:
  180. continue
  181. if vm.klass == 'AdminVM':
  182. continue
  183. if vm.is_running():
  184. if not vm.features.check_with_template('gui', True):
  185. continue
  186. asyncio.ensure_future(self.send_monitor_layout(vm,
  187. monitor_layout))
  188. @staticmethod
  189. def kde_guid_args(vm):
  190. """Return KDE-specific arguments for gui-daemon, if applicable"""
  191. guid_cmd = []
  192. # native decoration plugins is used, so adjust window properties
  193. # accordingly
  194. guid_cmd += ['-T'] # prefix window titles with VM name
  195. # get owner of X11 session
  196. session_owner = None
  197. for line in subprocess.check_output(['xhost']).splitlines():
  198. if line == b'SI:localuser:root':
  199. pass
  200. elif line.startswith(b'SI:localuser:'):
  201. session_owner = line.split(b':')[2].decode()
  202. if session_owner is not None:
  203. data_dir = os.path.expanduser(
  204. '~{}/.local/share'.format(session_owner))
  205. else:
  206. # fallback to current user
  207. data_dir = os.path.expanduser('~/.local/share')
  208. guid_cmd += ['-p',
  209. '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format(
  210. os.path.join(data_dir,
  211. 'qubes-kde',
  212. vm.label.name + '.colors'))]
  213. return guid_cmd
  214. def common_guid_args(self, vm):
  215. """Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
  216. guid_cmd = [GUI_DAEMON_PATH,
  217. '-N', vm.name,
  218. '-c', vm.label.color,
  219. '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png',
  220. '-l', str(vm.label.index)]
  221. if vm.debug:
  222. guid_cmd += ['-v', '-v']
  223. # elif not verbose:
  224. else:
  225. guid_cmd += ['-q']
  226. if vm.features.check_with_template('rpc-clipboard', False):
  227. guid_cmd.extend(['-Q'])
  228. return guid_cmd
  229. @staticmethod
  230. def guid_pidfile(xid):
  231. """Helper function to construct a GUI pidfile path"""
  232. return '/var/run/qubes/guid-running.{}'.format(xid)
  233. @staticmethod
  234. def pacat_pidfile(xid):
  235. """Helper function to construct an AUDIO pidfile path"""
  236. return '/var/run/qubes/pacat.{}'.format(xid)
  237. @staticmethod
  238. def pacat_domid(vm):
  239. """Determine target domid for an AUDIO daemon"""
  240. xid = vm.stubdom_xid \
  241. if vm.features.check_with_template('audio-model', False) \
  242. and vm.virt_mode == 'hvm' \
  243. else vm.xid
  244. return xid
  245. @asyncio.coroutine
  246. def start_gui_for_vm(self, vm, monitor_layout=None):
  247. """Start GUI daemon (qubes-guid) connected directly to a VM
  248. This function is a coroutine.
  249. :param vm: VM for which start GUI daemon
  250. :param monitor_layout: monitor layout to send; if None, fetch it from
  251. local X server.
  252. """
  253. guid_cmd = self.common_guid_args(vm)
  254. if self.kde:
  255. guid_cmd.extend(self.kde_guid_args(vm))
  256. guid_cmd.extend(['-d', str(vm.xid)])
  257. if vm.virt_mode == 'hvm':
  258. guid_cmd.extend(['-n'])
  259. stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid)
  260. if not vm.debug and os.path.exists(stubdom_guid_pidfile):
  261. # Terminate stubdom guid once "real" gui agent connects
  262. with open(stubdom_guid_pidfile, 'r') as pidfile:
  263. stubdom_guid_pid = pidfile.read().strip()
  264. guid_cmd += ['-K', stubdom_guid_pid]
  265. vm.log.info('Starting GUI')
  266. yield from asyncio.create_subprocess_exec(*guid_cmd)
  267. yield from self.send_monitor_layout(vm, layout=monitor_layout,
  268. startup=True)
  269. @asyncio.coroutine
  270. def start_gui_for_stubdomain(self, vm, force=False):
  271. """Start GUI daemon (qubes-guid) connected to a stubdomain
  272. This function is a coroutine.
  273. """
  274. want_stubdom = force
  275. if not want_stubdom and \
  276. vm.features.check_with_template('gui-emulated', False):
  277. want_stubdom = True
  278. # if no 'gui' or 'gui-emulated' feature set at all, use emulated GUI
  279. if not want_stubdom and \
  280. vm.features.check_with_template('gui', None) is None and \
  281. vm.features.check_with_template('gui-emulated', None) is None:
  282. want_stubdom = True
  283. if not want_stubdom and vm.debug:
  284. want_stubdom = True
  285. if not want_stubdom:
  286. return
  287. if os.path.exists(self.guid_pidfile(vm.stubdom_xid)):
  288. return
  289. vm.log.info('Starting GUI (stubdomain)')
  290. guid_cmd = self.common_guid_args(vm)
  291. guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)])
  292. yield from asyncio.create_subprocess_exec(*guid_cmd)
  293. @asyncio.coroutine
  294. def start_audio_for_vm(self, vm):
  295. """Start AUDIO daemon (pacat-simple-vchan) connected directly to a VM
  296. This function is a coroutine.
  297. :param vm: VM for which start AUDIO daemon
  298. """
  299. # pylint: disable=no-self-use
  300. pacat_cmd = [PACAT_DAEMON_PATH, '-l', self.pacat_domid(vm), vm.name]
  301. vm.log.info('Starting AUDIO')
  302. yield from asyncio.create_subprocess_exec(*pacat_cmd)
  303. @asyncio.coroutine
  304. def start_gui(self, vm, force_stubdom=False, monitor_layout=None):
  305. """Start GUI daemon regardless of start event.
  306. This function is a coroutine.
  307. :param vm: VM for which GUI daemon should be started
  308. :param force_stubdom: Force GUI daemon for stubdomain, even if the
  309. one for target AppVM is running.
  310. :param monitor_layout: monitor layout configuration
  311. """
  312. guivm = getattr(vm, 'guivm', None)
  313. if guivm != vm.app.local_name:
  314. vm.log.info('GUI connected to {}. Skipping.'.format(guivm))
  315. return
  316. if vm.virt_mode == 'hvm':
  317. yield from self.start_gui_for_stubdomain(vm, force=force_stubdom)
  318. if not vm.features.check_with_template('gui', True):
  319. return
  320. if not os.path.exists(self.guid_pidfile(vm.xid)):
  321. yield from self.start_gui_for_vm(vm, monitor_layout=monitor_layout)
  322. @asyncio.coroutine
  323. def start_audio(self, vm):
  324. """Start AUDIO daemon regardless of start event.
  325. This function is a coroutine.
  326. :param vm: VM for which AUDIO daemon should be started
  327. """
  328. audiovm = getattr(vm, 'audiovm', None)
  329. if audiovm != vm.app.local_name:
  330. vm.log.info('AUDIO connected to {}. Skipping.'.format(audiovm))
  331. return
  332. if not vm.features.check_with_template('audio', True):
  333. return
  334. xid = self.pacat_domid(vm)
  335. if not os.path.exists(self.pacat_pidfile(xid)):
  336. yield from self.start_audio_for_vm(vm)
  337. def on_domain_spawn(self, vm, _event, **kwargs):
  338. """Handler of 'domain-spawn' event, starts GUI daemon for stubdomain"""
  339. try:
  340. if getattr(vm, 'guivm', None) != vm.app.local_name:
  341. return
  342. if not vm.features.check_with_template('gui', True):
  343. return
  344. if vm.virt_mode == 'hvm' and \
  345. kwargs.get('start_guid', 'True') == 'True':
  346. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  347. except qubesadmin.exc.QubesException as e:
  348. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  349. def on_domain_start(self, vm, _event, **kwargs):
  350. """Handler of 'domain-start' event, starts GUI/AUDIO daemon for
  351. actual VM """
  352. try:
  353. if getattr(vm, 'guivm', None) == vm.app.local_name and \
  354. vm.features.check_with_template('gui', True) and \
  355. kwargs.get('start_guid', 'True') == 'True':
  356. asyncio.ensure_future(self.start_gui_for_vm(vm))
  357. except qubesadmin.exc.QubesException as e:
  358. vm.log.warning('Failed to start GUI for %s: %s', vm.name, str(e))
  359. try:
  360. if getattr(vm, 'audiovm', None) == vm.app.local_name and \
  361. vm.features.check_with_template('audio', True) and \
  362. kwargs.get('start_audio', 'True') == 'True':
  363. asyncio.ensure_future(self.start_audio_for_vm(vm))
  364. except qubesadmin.exc.QubesException as e:
  365. vm.log.warning('Failed to start AUDIO for %s: %s', vm.name, str(e))
  366. def on_connection_established(self, _subject, _event, **_kwargs):
  367. """Handler of 'connection-established' event, used to launch GUI/AUDIO
  368. daemon for domains started before this tool. """
  369. monitor_layout = get_monitor_layout()
  370. self.app.domains.clear_cache()
  371. for vm in self.app.domains:
  372. if vm.klass == 'AdminVM':
  373. continue
  374. power_state = vm.get_power_state()
  375. if power_state == 'Running':
  376. asyncio.ensure_future(
  377. self.start_gui(vm, monitor_layout=monitor_layout))
  378. asyncio.ensure_future(self.start_audio(vm))
  379. elif power_state == 'Transient':
  380. # it is still starting, we'll get 'domain-start'
  381. # event when fully started
  382. if vm.virt_mode == 'hvm':
  383. asyncio.ensure_future(
  384. self.start_gui_for_stubdomain(vm))
  385. def register_events(self, events):
  386. """Register domain startup events in app.events dispatcher"""
  387. events.add_handler('domain-spawn', self.on_domain_spawn)
  388. events.add_handler('domain-start', self.on_domain_start)
  389. events.add_handler('connection-established',
  390. self.on_connection_established)
  391. def x_reader(conn, callback):
  392. """Try reading something from X connection to check if it's still alive.
  393. In case it isn't, call *callback*.
  394. """
  395. try:
  396. conn.poll_for_event()
  397. except xcffib.ConnectionException:
  398. callback()
  399. if 'XDG_RUNTIME_DIR' in os.environ:
  400. pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
  401. 'qvm-start-daemon.pid')
  402. else:
  403. pidfile_path = os.path.join(os.environ.get('HOME', '/'),
  404. '.qvm-start-daemon.pid')
  405. parser = qubesadmin.tools.QubesArgumentParser(
  406. description='start GUI for qube(s)', vmname_nargs='*')
  407. parser.add_argument('--watch', action='store_true',
  408. help='Keep watching for further domains'
  409. ' startups, must be used with --all')
  410. parser.add_argument('--force-stubdomain', action='store_true',
  411. help='Start GUI to stubdomain-emulated VGA,'
  412. ' even if gui-agent is running in the VM')
  413. parser.add_argument('--pidfile', action='store', default=pidfile_path,
  414. help='Pidfile path to create in --watch mode')
  415. parser.add_argument('--notify-monitor-layout', action='store_true',
  416. help='Notify running instance in --watch mode'
  417. ' about changed monitor layout')
  418. parser.add_argument('--set-keyboard-layout', action='store_true',
  419. help='Set keyboard layout values into GuiVM features.'
  420. 'This option is implied by --watch')
  421. parser.add_argument('--kde', action='store_true',
  422. help='Set KDE specific arguments to gui-daemon.')
  423. # Add it for the help only
  424. parser.add_argument('--force', action='store_true', default=False,
  425. help='Force running daemon without enabled services'
  426. ' \'guivm-gui-agent\' or \'audiovm-audio-agent\'')
  427. def main(args=None):
  428. """ Main function of qvm-start-daemon tool"""
  429. only_if_service_enabled = ['guivm-gui-agent', 'audiovm-audio-agent']
  430. enabled_services = [service for service in only_if_service_enabled if
  431. os.path.exists('/var/run/qubes-service/%s' % service)]
  432. if not enabled_services and '--force' not in sys.argv and \
  433. not os.path.exists('/etc/qubes-release'):
  434. print(parser.format_help())
  435. return
  436. args = parser.parse_args(args)
  437. if args.watch and not args.all_domains:
  438. parser.error('--watch option must be used with --all')
  439. if args.watch and args.notify_monitor_layout:
  440. parser.error('--watch cannot be used with --notify-monitor-layout')
  441. if args.watch and 'guivm-gui-agent' in enabled_services:
  442. args.set_keyboard_layout = True
  443. if args.set_keyboard_layout or os.path.exists('/etc/qubes-release'):
  444. guivm = args.app.domains.get_blind(args.app.local_name)
  445. set_keyboard_layout(guivm)
  446. launcher = DAEMONLauncher(args.app)
  447. if args.kde:
  448. launcher.kde = True
  449. if args.watch:
  450. if not have_events:
  451. parser.error('--watch option require Python >= 3.5')
  452. with daemon.pidfile.TimeoutPIDLockFile(args.pidfile):
  453. loop = asyncio.get_event_loop()
  454. # pylint: disable=no-member
  455. events = qubesadmin.events.EventsDispatcher(args.app)
  456. # pylint: enable=no-member
  457. launcher.register_events(events)
  458. events_listener = asyncio.ensure_future(events.listen_for_events())
  459. for signame in ('SIGINT', 'SIGTERM'):
  460. loop.add_signal_handler(getattr(signal, signame),
  461. events_listener.cancel) # pylint: disable=no-member
  462. loop.add_signal_handler(signal.SIGHUP,
  463. launcher.send_monitor_layout_all)
  464. conn = xcffib.connect()
  465. x_fd = conn.get_file_descriptor()
  466. loop.add_reader(x_fd, x_reader, conn, events_listener.cancel)
  467. try:
  468. loop.run_until_complete(events_listener)
  469. except asyncio.CancelledError:
  470. pass
  471. loop.remove_reader(x_fd)
  472. loop.stop()
  473. loop.run_forever()
  474. loop.close()
  475. elif args.notify_monitor_layout:
  476. try:
  477. with open(pidfile_path, 'r') as pidfile:
  478. pid = int(pidfile.read().strip())
  479. os.kill(pid, signal.SIGHUP)
  480. except (FileNotFoundError, ValueError) as e:
  481. parser.error('Cannot open pidfile {}: {}'.format(pidfile_path,
  482. str(e)))
  483. else:
  484. loop = asyncio.get_event_loop()
  485. tasks = []
  486. for vm in args.domains:
  487. if vm.is_running():
  488. tasks.append(asyncio.ensure_future(launcher.start_gui(
  489. vm, force_stubdom=args.force_stubdomain)))
  490. tasks.append(asyncio.ensure_future(launcher.start_audio(
  491. vm)))
  492. if tasks:
  493. loop.run_until_complete(asyncio.wait(tasks))
  494. loop.stop()
  495. loop.run_forever()
  496. loop.close()
  497. if __name__ == '__main__':
  498. main()