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).
This commit is contained in:
Paweł Marczewski 2020-06-25 16:30:27 +02:00
parent ae39c75867
commit 3540f04a42
No known key found for this signature in database
GPG Key ID: DE42EE9B14F96465
3 changed files with 241 additions and 63 deletions

View File

@ -82,6 +82,19 @@ See also `gui` feature.
If neither `gui` nor `gui-emulated` is set, emulated VGA is used (if If neither `gui` nor `gui-emulated` is set, emulated VGA is used (if
applicable for given VM virtualization mode). 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 qrexec
^^^^^^ ^^^^^^

View File

@ -22,11 +22,13 @@ import os
import signal import signal
import tempfile import tempfile
import unittest.mock import unittest.mock
import re
import asyncio import asyncio
import qubesadmin.tests import qubesadmin.tests
import qubesadmin.tools.qvm_start_daemon import qubesadmin.tools.qvm_start_daemon
from qubesadmin.tools.qvm_start_daemon import GUI_DAEMON_OPTIONS
import qubesadmin.vm import qubesadmin.vm
@ -73,16 +75,20 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase):
self.assertAllCalled() self.assertAllCalled()
def test_010_common_args(self): def setup_common_args(self):
self.app.expected_calls[ self.app.expected_calls[
('dom0', 'admin.vm.List', None, None)] = \ ('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[ self.app.expected_calls[
('test-vm', 'admin.vm.property.Get', 'label', None)] = \ ('test-vm', 'admin.vm.property.Get', 'label', None)] = \
b'0\x00default=False type=label red' b'0\x00default=False type=label red'
self.app.expected_calls[ self.app.expected_calls[
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
b'0\x00default=False type=bool False' 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[ self.app.expected_calls[
('dom0', 'admin.label.Get', 'red', None)] = \ ('dom0', 'admin.label.Get', 'red', None)] = \
b'0\x000xff0000' b'0\x000xff0000'
@ -94,86 +100,124 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase):
'rpc-clipboard', None)] = \ 'rpc-clipboard', None)] = \
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00' b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \ self.app.expected_calls[
kde_mock: ('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 = [] kde_mock.return_value = []
args = self.launcher.common_guid_args(self.app.domains['test-vm']) args = self.launcher.common_guid_args(self.app.domains['test-vm'])
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, [ self.assertEqual(args, [
'/usr/bin/qubes-guid', '-N', 'test-vm', '/usr/bin/qubes-guid', '-N', 'test-vm',
'-c', '0xff0000', '-c', '0xff0000',
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
'-l', '1', '-q']) '-l', '1', '-q',
'-C', '/var/run/qubes/guid-conf.99',
])
self.assertAllCalled() self.assertEqual(config, '''\
global: {
}
''')
def test_011_common_args_debug(self): def test_011_common_args_debug(self):
self.app.expected_calls[ self.setup_common_args()
('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[ self.app.expected_calls[
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \ ('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
b'0\x00default=False type=bool True' 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 \ args, config = self.run_common_args()
kde_mock:
kde_mock.return_value = []
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
self.assertEqual(args, [ self.assertEqual(args, [
'/usr/bin/qubes-guid', '-N', 'test-vm', '/usr/bin/qubes-guid', '-N', 'test-vm',
'-c', '0xff0000', '-c', '0xff0000',
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
'-l', '1', '-v', '-v']) '-l', '1', '-v', '-v',
'-C', '/var/run/qubes/guid-conf.99',
self.assertAllCalled() ])
self.assertEqual(config, '''\
global: {
}
''')
def test_012_common_args_rpc_clipboard(self): def test_012_common_args_rpc_clipboard(self):
self.app.expected_calls[ self.setup_common_args()
('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.app.expected_calls[ self.app.expected_calls[
('test-vm', 'admin.vm.feature.CheckWithTemplate', ('test-vm', 'admin.vm.feature.CheckWithTemplate',
'rpc-clipboard', None)] = \ 'rpc-clipboard', None)] = \
b'0\x001' b'0\x001'
with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \ args, config = self.run_common_args()
kde_mock:
kde_mock.return_value = []
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
self.assertEqual(args, [ self.assertEqual(args, [
'/usr/bin/qubes-guid', '-N', 'test-vm', '/usr/bin/qubes-guid', '-N', 'test-vm',
'-c', '0xff0000', '-c', '0xff0000',
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png', '-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
'-l', '1', '-q', '-Q']) '-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') @unittest.mock.patch('asyncio.create_subprocess_exec')
def test_020_start_gui_for_vm(self, proc_mock): def test_020_start_gui_for_vm(self, proc_mock):

View File

@ -1,4 +1,4 @@
# -*- encoding: utf8 -*- # -*- encoding: utf-8 -*-
# #
# The Qubes OS Project, http://www.qubes-os.org # 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' PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan'
QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices' 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" # "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
REGEX_OUTPUT = re.compile(r""" REGEX_OUTPUT = re.compile(r"""
(?x) # ignore whitespace (?x) # ignore whitespace
@ -262,14 +365,32 @@ class DAEMONLauncher:
if vm.features.check_with_template('rpc-clipboard', False): if vm.features.check_with_template('rpc-clipboard', False):
guid_cmd.extend(['-Q']) 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) guid_cmd += self.kde_guid_args(vm)
return guid_cmd 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 @staticmethod
def guid_pidfile(xid): def guid_pidfile(xid):
"""Helper function to construct a GUI pidfile path""" """Helper function to construct a GUI pidfile path"""
return '/var/run/qubes/guid-running.{}'.format(xid) 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 @staticmethod
def pacat_pidfile(xid): def pacat_pidfile(xid):
"""Helper function to construct an AUDIO pidfile path""" """Helper function to construct an AUDIO pidfile path"""