qvm_start_daemon.py 20 KB

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