From 3540f04a420d0bd693b8136a36e1c76e61e2ca0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Marczewski?= Date: Thu, 25 Jun 2020 16:30:27 +0200 Subject: [PATCH] Generate qubes-guid options based on features Allow configuring options per VM or globally per GuiVM. The qvm-start-daemon program reads the options from VM features, and generates a configuration file for qubes-guid. Requires QubesOS/qubes-gui-daemon#47 (customizing the configuration file). --- doc/manpages/qvm-features.rst | 13 ++ qubesadmin/tests/tools/qvm_start_daemon.py | 168 +++++++++++++-------- qubesadmin/tools/qvm_start_daemon.py | 123 ++++++++++++++- 3 files changed, 241 insertions(+), 63 deletions(-) diff --git a/doc/manpages/qvm-features.rst b/doc/manpages/qvm-features.rst index dcac541..736c8dc 100644 --- a/doc/manpages/qvm-features.rst +++ b/doc/manpages/qvm-features.rst @@ -82,6 +82,19 @@ See also `gui` feature. If neither `gui` nor `gui-emulated` is set, emulated VGA is used (if applicable for given VM virtualization mode). +gui-\*, gui-default-\* +^^^^^^^^^^^^^^^^^^^^^^ + +GUI daemon configuration. See `/etc/qubes/guid.conf` for a list of supported +options. + +To change a given GUI option for a specific qube, set the `gui-{option}` +feature (with underscores replaced with dashes). For example, to enable +`allow_utf8_titles` for a qube, set `gui-allow-utf8-titles` to `True`. + +To change a given GUI option globally, set the `gui-default-{option}` feature +on the GuiVM for that qube. + qrexec ^^^^^^ diff --git a/qubesadmin/tests/tools/qvm_start_daemon.py b/qubesadmin/tests/tools/qvm_start_daemon.py index 7f655a9..dc93685 100644 --- a/qubesadmin/tests/tools/qvm_start_daemon.py +++ b/qubesadmin/tests/tools/qvm_start_daemon.py @@ -22,11 +22,13 @@ import os import signal import tempfile import unittest.mock +import re import asyncio import qubesadmin.tests import qubesadmin.tools.qvm_start_daemon +from qubesadmin.tools.qvm_start_daemon import GUI_DAEMON_OPTIONS import qubesadmin.vm @@ -73,16 +75,20 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase): self.assertAllCalled() - def test_010_common_args(self): + def setup_common_args(self): self.app.expected_calls[ ('dom0', 'admin.vm.List', None, None)] = \ - b'0\x00test-vm class=AppVM state=Running\n' + b'0\x00test-vm class=AppVM state=Running\n' \ + b'gui-vm class=AppVM state=Running' self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'label', None)] = \ b'0\x00default=False type=label red' self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ b'0\x00default=False type=bool False' + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'guivm', None)] = \ + b'0\x00default=False type=vm gui-vm' self.app.expected_calls[ ('dom0', 'admin.label.Get', 'red', None)] = \ b'0\x000xff0000' @@ -94,86 +100,124 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase): 'rpc-clipboard', None)] = \ b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' - with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \ - kde_mock: + self.app.expected_calls[ + ('test-vm', 'admin.vm.property.Get', 'xid', None)] = \ + b'0\x00default=99 type=int 99' + + for name, _kind in GUI_DAEMON_OPTIONS: + self.app.expected_calls[ + ('test-vm', 'admin.vm.feature.Get', + 'gui-' + name.replace('_', '-'), None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + + self.app.expected_calls[ + ('gui-vm', 'admin.vm.feature.Get', + 'gui-default-' + name.replace('_', '-'), None)] = \ + b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' + + def run_common_args(self): + with unittest.mock.patch.object( + self.launcher, 'kde_guid_args') as kde_mock, \ + unittest.mock.patch.object( + self.launcher, 'write_guid_config') as write_config_mock: kde_mock.return_value = [] args = self.launcher.common_guid_args(self.app.domains['test-vm']) - self.assertEqual(args, [ - '/usr/bin/qubes-guid', '-N', 'test-vm', - '-c', '0xff0000', - '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', - '-l', '1', '-q']) + + self.assertEqual(len(write_config_mock.mock_calls), 1) + + config_args = write_config_mock.mock_calls[0][1] + self.assertEqual(config_args[0], '/var/run/qubes/guid-conf.99') + config = config_args[1] + + # Strip comments and empty lines + config = re.sub(r'^#.*\n', '', config) + config = re.sub(r'^\n', '', config) self.assertAllCalled() + return args, config + + def test_010_common_args(self): + self.setup_common_args() + + args, config = self.run_common_args() + self.assertEqual(args, [ + '/usr/bin/qubes-guid', '-N', 'test-vm', + '-c', '0xff0000', + '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', + '-l', '1', '-q', + '-C', '/var/run/qubes/guid-conf.99', + ]) + + self.assertEqual(config, '''\ +global: { +} +''') def test_011_common_args_debug(self): - self.app.expected_calls[ - ('dom0', 'admin.vm.List', None, None)] = \ - b'0\x00test-vm class=AppVM state=Running\n' - self.app.expected_calls[ - ('test-vm', 'admin.vm.property.Get', 'label', None)] = \ - b'0\x00default=False type=label red' + self.setup_common_args() self.app.expected_calls[ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ b'0\x00default=False type=bool True' - self.app.expected_calls[ - ('dom0', 'admin.label.Get', 'red', None)] = \ - b'0\x000xff0000' - self.app.expected_calls[ - ('dom0', 'admin.label.Index', 'red', None)] = \ - b'0\x001' - self.app.expected_calls[ - ('test-vm', 'admin.vm.feature.CheckWithTemplate', - 'rpc-clipboard', None)] = \ - b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' - with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \ - kde_mock: - kde_mock.return_value = [] - - args = self.launcher.common_guid_args(self.app.domains['test-vm']) - self.assertEqual(args, [ - '/usr/bin/qubes-guid', '-N', 'test-vm', - '-c', '0xff0000', - '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', - '-l', '1', '-v', '-v']) - - self.assertAllCalled() + args, config = self.run_common_args() + self.assertEqual(args, [ + '/usr/bin/qubes-guid', '-N', 'test-vm', + '-c', '0xff0000', + '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', + '-l', '1', '-v', '-v', + '-C', '/var/run/qubes/guid-conf.99', + ]) + self.assertEqual(config, '''\ +global: { +} +''') def test_012_common_args_rpc_clipboard(self): - self.app.expected_calls[ - ('dom0', 'admin.vm.List', None, None)] = \ - b'0\x00test-vm class=AppVM state=Running\n' - self.app.expected_calls[ - ('test-vm', 'admin.vm.property.Get', 'label', None)] = \ - b'0\x00default=False type=label red' - self.app.expected_calls[ - ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ - b'0\x00default=False type=bool False' - self.app.expected_calls[ - ('dom0', 'admin.label.Get', 'red', None)] = \ - b'0\x000xff0000' - self.app.expected_calls[ - ('dom0', 'admin.label.Index', 'red', None)] = \ - b'0\x001' + self.setup_common_args() self.app.expected_calls[ ('test-vm', 'admin.vm.feature.CheckWithTemplate', 'rpc-clipboard', None)] = \ b'0\x001' - with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \ - kde_mock: - kde_mock.return_value = [] + args, config = self.run_common_args() - args = self.launcher.common_guid_args(self.app.domains['test-vm']) - self.assertEqual(args, [ - '/usr/bin/qubes-guid', '-N', 'test-vm', - '-c', '0xff0000', - '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', - '-l', '1', '-q', '-Q']) + self.assertEqual(args, [ + '/usr/bin/qubes-guid', '-N', 'test-vm', + '-c', '0xff0000', + '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', + '-l', '1', '-q', '-Q', + '-C', '/var/run/qubes/guid-conf.99', + ]) + self.assertEqual(config, '''\ +global: { +} +''') - self.assertAllCalled() + def test_013_common_args_guid_config(self): + self.setup_common_args() + + self.app.expected_calls[ + ('test-vm', 'admin.vm.feature.Get', + 'gui-allow-fullscreen', None)] = \ + b'0\x001' + # The template will not be asked for this feature + del self.app.expected_calls[ + ('gui-vm', 'admin.vm.feature.Get', + 'gui-default-allow-fullscreen', None)] + + self.app.expected_calls[ + ('gui-vm', 'admin.vm.feature.Get', + 'gui-default-secure-copy-sequence', None)] = \ + b'0\x00Ctrl-Alt-Shift-c' + + _args, config = self.run_common_args() + self.assertEqual(config, '''\ +global: { + allow_fullscreen = true; + secure_copy_sequence = "Ctrl-Alt-Shift-c"; +} +''') @unittest.mock.patch('asyncio.create_subprocess_exec') def test_020_start_gui_for_vm(self, proc_mock): diff --git a/qubesadmin/tools/qvm_start_daemon.py b/qubesadmin/tools/qvm_start_daemon.py index 7732bd8..0a1f6a7 100644 --- a/qubesadmin/tools/qvm_start_daemon.py +++ b/qubesadmin/tools/qvm_start_daemon.py @@ -1,4 +1,4 @@ -# -*- encoding: utf8 -*- +# -*- encoding: utf-8 -*- # # The Qubes OS Project, http://www.qubes-os.org # @@ -49,6 +49,109 @@ GUI_DAEMON_PATH = '/usr/bin/qubes-guid' PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan' QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices' +GUI_DAEMON_OPTIONS = [ + ('allow_fullscreen', 'bool'), + ('override_redirect_protection', 'bool'), + ('allow_utf8_titles', 'bool'), + ('secure_copy_sequence', 'str'), + ('secure_paste_sequence', 'str'), + ('windows_count_limit', 'int'), + ('trayicon_mode', 'str'), + ('startup_timeout', 'int'), +] + + +def retrieve_gui_daemon_options(vm, guivm): + ''' + Construct a list of GUI daemon options based on VM features. + + This checks 'gui-*' features on the VM, and if they're absent, + 'gui-default-*' features on the GuiVM. + ''' + + options = {} + + for name, kind in GUI_DAEMON_OPTIONS: + feature_value = vm.features.get( + 'gui-' + name.replace('_', '-'), None) + if feature_value is None: + feature_value = guivm.features.get( + 'gui-default-' + name.replace('_', '-'), None) + if feature_value is None: + continue + + if kind == 'bool': + value = bool(feature_value) + elif kind == 'int': + value = int(feature_value) + elif kind == 'str': + value = feature_value + else: + assert False, kind + + options[name] = value + return options + + +def serialize_gui_daemon_options(options): + ''' + Prepare configuration file content for GUI daemon. Currently uses libconfig + format. + ''' + + lines = [ + '# Auto-generated file, do not edit!', + '', + 'global: {', + ] + for name, kind in GUI_DAEMON_OPTIONS: + if name in options: + value = options[name] + if kind == 'bool': + serialized = 'true' if value else 'false' + elif kind == 'int': + serialized = str(value) + elif kind == 'str': + serialized = escape_config_string(value) + else: + assert False, kind + + lines.append(' {} = {};'.format(name, serialized)) + lines.append('}') + lines.append('') + return '\n'.join(lines) + + +NON_ASCII_RE = re.compile(r'[^\x00-\x7F]') +UNPRINTABLE_CHARACTER_RE = re.compile(r'[\x00-\x1F\x7F]') + +def escape_config_string(value): + ''' + Convert a string to libconfig format. + + Format specification: + http://www.hyperrealm.com/libconfig/libconfig_manual.html#String-Values + + See dump_string() for python-libconf: + https://github.com/Grk0/python-libconf/blob/master/libconf.py + ''' + + assert not NON_ASCII_RE.match(value), 'expected an ASCII string: {!r}'.format(value) + + value = ( + value.replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\f', r'\f') + .replace('\n', r'\n') + .replace('\r', r'\r') + .replace('\t', r'\t') + ) + value = UNPRINTABLE_CHARACTER_RE.sub( + lambda m: r'\x{:02x}'.format(ord(m.group(0))), + value) + return '"' + value + '"' + + # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm" REGEX_OUTPUT = re.compile(r""" (?x) # ignore whitespace @@ -262,14 +365,32 @@ class DAEMONLauncher: if vm.features.check_with_template('rpc-clipboard', False): guid_cmd.extend(['-Q']) + guivm = self.app.domains[vm.guivm] + options = retrieve_gui_daemon_options(vm, guivm) + config = serialize_gui_daemon_options(options) + config_path = self.guid_config_file(vm.xid) + self.write_guid_config(config_path, config) + guid_cmd.extend(['-C', config_path]) + guid_cmd += self.kde_guid_args(vm) return guid_cmd + @staticmethod + def write_guid_config(config_path, config): + """Write guid configuration to a file""" + with open(config_path, 'w') as config_file: + config_file.write(config) + @staticmethod def guid_pidfile(xid): """Helper function to construct a GUI pidfile path""" return '/var/run/qubes/guid-running.{}'.format(xid) + @staticmethod + def guid_config_file(xid): + """Helper function to construct a GUI configuration file path""" + return '/var/run/qubes/guid-conf.{}'.format(xid) + @staticmethod def pacat_pidfile(xid): """Helper function to construct an AUDIO pidfile path"""