qvm_start_gui.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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 daemon launcher tool'''
  21. import os
  22. import signal
  23. import subprocess
  24. import asyncio
  25. import re
  26. import daemon.pidfile
  27. import qubesadmin
  28. import qubesadmin.tools
  29. import qubesadmin.vm
  30. have_events = False
  31. try:
  32. # pylint: disable=wrong-import-position
  33. import qubesadmin.events
  34. have_events = True
  35. except ImportError:
  36. pass
  37. GUI_DAEMON_PATH = '/usr/bin/qubes-guid'
  38. QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices'
  39. # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
  40. REGEX_OUTPUT = re.compile(r'''
  41. (?x) # ignore whitespace
  42. ^ # start of string
  43. (?P<output>[A-Za-z0-9\-]*)[ ] # LVDS VGA etc
  44. (?P<connect>(dis)?connected) # dis/connected
  45. ([ ]
  46. (?P<primary>(primary)?)[ ]?
  47. (( # a group
  48. (?P<width>\d+)x # either 1024x768+0+0
  49. (?P<height>\d+)[+]
  50. (?P<x>\d+)[+]
  51. (?P<y>\d+)
  52. )|[\D]) # or not a digit
  53. ([ ]\(.*\))?[ ]? # ignore options
  54. ( # 304mm x 228mm
  55. (?P<width_mm>\d+)mm[ ]x[ ]
  56. (?P<height_mm>\d+)mm
  57. )?
  58. .* # ignore rest of line
  59. )? # everything after (dis)connect is optional
  60. ''')
  61. def get_monitor_layout():
  62. '''Get list of monitors and their size/position'''
  63. outputs = []
  64. for line in subprocess.Popen(
  65. ['xrandr', '-q'], stdout=subprocess.PIPE).stdout:
  66. line = line.decode()
  67. if not line.startswith("Screen") and not line.startswith(" "):
  68. output_params = REGEX_OUTPUT.match(line).groupdict()
  69. if output_params['width']:
  70. phys_size = ""
  71. if output_params['width_mm'] and int(output_params['width_mm']):
  72. # don't provide real values for privacy reasons - see
  73. # #1951 for details
  74. dpi = (int(output_params['width']) * 254 //
  75. int(output_params['width_mm']) // 10)
  76. if dpi > 300:
  77. dpi = 300
  78. elif dpi > 200:
  79. dpi = 200
  80. elif dpi > 150:
  81. dpi = 150
  82. else:
  83. # if lower, don't provide this info to the VM at all
  84. dpi = 0
  85. if dpi:
  86. # now calculate dimensions based on approximate DPI
  87. phys_size = " {} {}".format(
  88. int(output_params['width']) * 254 // dpi // 10,
  89. int(output_params['height']) * 254 // dpi // 10,
  90. )
  91. outputs.append("%s %s %s %s%s\n" % (
  92. output_params['width'],
  93. output_params['height'],
  94. output_params['x'],
  95. output_params['y'],
  96. phys_size,
  97. ))
  98. return outputs
  99. class GUILauncher(object):
  100. '''Launch GUI daemon for VMs'''
  101. def __init__(self, app: qubesadmin.app.QubesBase):
  102. ''' Initialize GUILauncher.
  103. :param app: :py:class:`qubesadmin.Qubes` instance
  104. '''
  105. self.app = app
  106. self.started_processes = {}
  107. @staticmethod
  108. def kde_guid_args(vm):
  109. '''Return KDE-specific arguments for gui-daemon, if applicable'''
  110. guid_cmd = []
  111. # Avoid using environment variables for checking the current session,
  112. # because this script may be called with cleared env (like with sudo).
  113. if subprocess.check_output(
  114. ['xprop', '-root', '-notype', 'KWIN_RUNNING']) == \
  115. b'KWIN_RUNNING = 0x1\n':
  116. # native decoration plugins is used, so adjust window properties
  117. # accordingly
  118. guid_cmd += ['-T'] # prefix window titles with VM name
  119. # get owner of X11 session
  120. session_owner = None
  121. for line in subprocess.check_output(['xhost']).splitlines():
  122. if line == b'SI:localuser:root':
  123. pass
  124. elif line.startswith(b'SI:localuser:'):
  125. session_owner = line.split(b':')[2].decode()
  126. if session_owner is not None:
  127. data_dir = os.path.expanduser(
  128. '~{}/.local/share'.format(session_owner))
  129. else:
  130. # fallback to current user
  131. data_dir = os.path.expanduser('~/.local/share')
  132. guid_cmd += ['-p',
  133. '_KDE_NET_WM_COLOR_SCHEME=s:{}'.format(
  134. os.path.join(data_dir,
  135. 'qubes-kde', vm.label.name + '.colors'))]
  136. return guid_cmd
  137. def common_guid_args(self, vm):
  138. '''Common qubes-guid arguments for PV(H), HVM and Stubdomain'''
  139. guid_cmd = [GUI_DAEMON_PATH,
  140. '-N', vm.name,
  141. '-c', vm.label.color,
  142. '-i', os.path.join(QUBES_ICON_DIR, vm.label.icon) + '.png',
  143. '-l', str(vm.label.index)]
  144. if vm.debug:
  145. guid_cmd += ['-v', '-v']
  146. # elif not verbose:
  147. else:
  148. guid_cmd += ['-q']
  149. guid_cmd += self.kde_guid_args(vm)
  150. return guid_cmd
  151. @staticmethod
  152. def guid_pidfile(xid):
  153. '''Helper function to construct a pidfile path'''
  154. return '/var/run/qubes/guid-running.{}'.format(xid)
  155. @asyncio.coroutine
  156. def start_gui_for_vm(self, vm, monitor_layout=None):
  157. '''Start GUI daemon (qubes-guid) connected directly to a VM
  158. This function is a coroutine.
  159. :param vm: VM for which start GUI daemon
  160. :param monitor_layout: monitor layout to send; if None, fetch it from
  161. local X server.
  162. '''
  163. guid_cmd = self.common_guid_args(vm)
  164. guid_cmd.extend(['-d', str(vm.xid)])
  165. if vm.virt_mode == 'hvm':
  166. guid_cmd.extend(['-n'])
  167. if vm.features.check_with_template('rpc-clipboard', False):
  168. guid_cmd.extend(['-Q'])
  169. stubdom_guid_pidfile = self.guid_pidfile(vm.stubdom_xid)
  170. if not vm.debug and os.path.exists(stubdom_guid_pidfile):
  171. # Terminate stubdom guid once "real" gui agent connects
  172. with open(stubdom_guid_pidfile, 'r') as pidfile:
  173. stubdom_guid_pid = pidfile.read().strip()
  174. guid_cmd += ['-K', stubdom_guid_pid]
  175. vm.log.info('Starting GUI')
  176. yield from asyncio.create_subprocess_exec(*guid_cmd)
  177. yield from self.send_monitor_layout(vm, layout=monitor_layout,
  178. startup=True)
  179. @asyncio.coroutine
  180. def start_gui_for_stubdomain(self, vm, force=False):
  181. '''Start GUI daemon (qubes-guid) connected to a stubdomain
  182. This function is a coroutine.
  183. '''
  184. want_stubdom = force
  185. # if no 'gui' feature set at all, assume no gui agent installed
  186. if not want_stubdom and \
  187. vm.features.check_with_template('gui', None) is None:
  188. want_stubdom = True
  189. if not want_stubdom and vm.debug:
  190. want_stubdom = True
  191. if not want_stubdom:
  192. return
  193. if os.path.exists(self.guid_pidfile(vm.stubdom_xid)):
  194. return
  195. vm.log.info('Starting GUI (stubdomain)')
  196. guid_cmd = self.common_guid_args(vm)
  197. guid_cmd.extend(['-d', str(vm.stubdom_xid), '-t', str(vm.xid)])
  198. yield from asyncio.create_subprocess_exec(*guid_cmd)
  199. @asyncio.coroutine
  200. def start_gui(self, vm, force_stubdom=False, monitor_layout=None):
  201. '''Start GUI daemon regardless of start event.
  202. This function is a coroutine.
  203. :param vm: VM for which GUI daemon should be started
  204. :param force_stubdom: Force GUI daemon for stubdomain, even if the
  205. one for target AppVM is running.
  206. '''
  207. if not vm.features.check_with_template('gui', True):
  208. return
  209. if vm.virt_mode == 'hvm':
  210. yield from self.start_gui_for_stubdomain(vm,
  211. force=force_stubdom)
  212. if not os.path.exists(self.guid_pidfile(vm.xid)):
  213. yield from self.start_gui_for_vm(vm, monitor_layout=monitor_layout)
  214. @asyncio.coroutine
  215. def send_monitor_layout(self, vm, layout=None, startup=False):
  216. '''Send monitor layout to a given VM
  217. This function is a coroutine.
  218. :param vm: VM to which send monitor layout
  219. :param layout: monitor layout to send; if None, fetch it from
  220. local X server.
  221. :param startup:
  222. :return: None
  223. '''
  224. # pylint: disable=no-self-use
  225. if vm.features.check_with_template('no-monitor-layout', False) \
  226. or not vm.is_running():
  227. return
  228. if layout is None:
  229. layout = get_monitor_layout()
  230. if not layout:
  231. return
  232. vm.log.info('Sending monitor layout')
  233. if not startup:
  234. with open(self.guid_pidfile(vm.xid)) as pidfile:
  235. pid = int(pidfile.read())
  236. os.kill(pid, signal.SIGHUP)
  237. try:
  238. with open(self.guid_pidfile(vm.stubdom_xid)) as pidfile:
  239. pid = int(pidfile.read())
  240. os.kill(pid, signal.SIGHUP)
  241. except FileNotFoundError:
  242. pass
  243. try:
  244. yield from asyncio.get_event_loop().run_in_executor(None,
  245. vm.run_service_for_stdio, 'qubes.SetMonitorLayout',
  246. ''.join(layout).encode())
  247. except subprocess.CalledProcessError as e:
  248. vm.log.warning('Failed to send monitor layout: %s', e.stderr)
  249. def send_monitor_layout_all(self):
  250. '''Send monitor layout to all (running) VMs'''
  251. monitor_layout = get_monitor_layout()
  252. for vm in self.app.domains:
  253. if vm.klass == 'AdminVM':
  254. continue
  255. if vm.is_running():
  256. if not vm.features.check_with_template('gui', True):
  257. continue
  258. asyncio.ensure_future(self.send_monitor_layout(vm,
  259. monitor_layout))
  260. def on_domain_spawn(self, vm, _event, **kwargs):
  261. '''Handler of 'domain-spawn' event, starts GUI daemon for stubdomain'''
  262. if not vm.features.check_with_template('gui', True):
  263. return
  264. if vm.virt_mode == 'hvm' and kwargs.get('start_guid', 'True') == 'True':
  265. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  266. def on_domain_start(self, vm, _event, **kwargs):
  267. '''Handler of 'domain-start' event, starts GUI daemon for actual VM'''
  268. if not vm.features.check_with_template('gui', True):
  269. return
  270. if kwargs.get('start_guid', 'True') == 'True':
  271. asyncio.ensure_future(self.start_gui_for_vm(vm))
  272. def on_connection_established(self, _subject, _event, **_kwargs):
  273. '''Handler of 'connection-established' event, used to launch GUI
  274. daemon for domains started before this tool. '''
  275. monitor_layout = get_monitor_layout()
  276. self.app.domains.clear_cache()
  277. for vm in self.app.domains:
  278. if vm.klass == 'AdminVM':
  279. continue
  280. if not vm.features.check_with_template('gui', True):
  281. continue
  282. power_state = vm.get_power_state()
  283. if power_state == 'Running':
  284. asyncio.ensure_future(self.start_gui(vm,
  285. monitor_layout=monitor_layout))
  286. elif power_state == 'Transient':
  287. # it is still starting, we'll get 'domain-start' event when
  288. # fully started
  289. if vm.virt_mode == 'hvm':
  290. asyncio.ensure_future(self.start_gui_for_stubdomain(vm))
  291. def register_events(self, events):
  292. '''Register domain startup events in app.events dispatcher'''
  293. events.add_handler('domain-spawn', self.on_domain_spawn)
  294. events.add_handler('domain-start', self.on_domain_start)
  295. events.add_handler('connection-established',
  296. self.on_connection_established)
  297. if 'XDG_RUNTIME_DIR' in os.environ:
  298. pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
  299. 'qvm-start-gui.pid')
  300. else:
  301. pidfile_path = os.path.join(os.environ.get('HOME', '/'),
  302. '.qvm-start-gui.pid')
  303. parser = qubesadmin.tools.QubesArgumentParser(
  304. description='start GUI for qube(s)', vmname_nargs='*')
  305. parser.add_argument('--watch', action='store_true',
  306. help='Keep watching for further domains startups, must be used with --all')
  307. parser.add_argument('--force-stubdomain', action='store_true',
  308. help='Start GUI to stubdomain-emulated VGA, even if gui-agent is running '
  309. 'in the VM')
  310. parser.add_argument('--pidfile', action='store', default=pidfile_path,
  311. help='Pidfile path to create in --watch mode')
  312. parser.add_argument('--notify-monitor-layout', action='store_true',
  313. help='Notify running instance in --watch mode about changed monitor layout')
  314. def main(args=None):
  315. ''' Main function of qvm-start-gui tool'''
  316. args = parser.parse_args(args)
  317. if args.watch and not args.all_domains:
  318. parser.error('--watch option must be used with --all')
  319. if args.watch and args.notify_monitor_layout:
  320. parser.error('--watch cannot be used with --notify-monitor-layout')
  321. launcher = GUILauncher(args.app)
  322. if args.watch:
  323. if not have_events:
  324. parser.error('--watch option require Python >= 3.5')
  325. with daemon.pidfile.TimeoutPIDLockFile(args.pidfile):
  326. loop = asyncio.get_event_loop()
  327. # pylint: disable=no-member
  328. events = qubesadmin.events.EventsDispatcher(args.app)
  329. # pylint: enable=no-member
  330. launcher.register_events(events)
  331. events_listener = asyncio.ensure_future(events.listen_for_events())
  332. for signame in ('SIGINT', 'SIGTERM'):
  333. loop.add_signal_handler(getattr(signal, signame),
  334. events_listener.cancel) # pylint: disable=no-member
  335. loop.add_signal_handler(signal.SIGHUP,
  336. launcher.send_monitor_layout_all)
  337. try:
  338. loop.run_until_complete(events_listener)
  339. except asyncio.CancelledError:
  340. pass
  341. loop.stop()
  342. loop.run_forever()
  343. loop.close()
  344. elif args.notify_monitor_layout:
  345. try:
  346. with open(pidfile_path, 'r') as pidfile:
  347. pid = int(pidfile.read().strip())
  348. os.kill(pid, signal.SIGHUP)
  349. except (FileNotFoundError, ValueError) as e:
  350. parser.error('Cannot open pidfile {}: {}'.format(pidfile_path,
  351. str(e)))
  352. else:
  353. loop = asyncio.get_event_loop()
  354. tasks = []
  355. for vm in args.domains:
  356. if vm.is_running():
  357. tasks.append(asyncio.ensure_future(launcher.start_gui(
  358. vm, force_stubdom=args.force_stubdomain)))
  359. if tasks:
  360. loop.run_until_complete(asyncio.wait(tasks))
  361. loop.stop()
  362. loop.run_forever()
  363. loop.close()
  364. if __name__ == '__main__':
  365. main()