From 738548a8e439bdb705cd08d30b4b44f48390b2bc Mon Sep 17 00:00:00 2001 From: Pawel Marczewski Date: Thu, 23 Jan 2020 13:31:19 +0100 Subject: [PATCH] Add qubes.VMExec call, for running a single command With a VMExecGUI variant that waits for a session. See QubesOS/qubes-issues#4850. --- debian/qubes-core-agent.install | 4 +++ qubes-rpc/Makefile | 2 ++ qubes-rpc/qubes.VMExec | 3 ++ qubes-rpc/qubes.VMExecGUI.config | 1 + qubesagent/test_vmexec.py | 52 ++++++++++++++++++++++++++++ qubesagent/vmexec.py | 59 ++++++++++++++++++++++++++++++++ rpm_spec/core-agent.spec.in | 6 ++++ setup.py | 3 +- 8 files changed, 129 insertions(+), 1 deletion(-) create mode 100755 qubes-rpc/qubes.VMExec create mode 100644 qubes-rpc/qubes.VMExecGUI.config create mode 100644 qubesagent/test_vmexec.py create mode 100644 qubesagent/vmexec.py diff --git a/debian/qubes-core-agent.install b/debian/qubes-core-agent.install index 781d93b..c952819 100644 --- a/debian/qubes-core-agent.install +++ b/debian/qubes-core-agent.install @@ -30,6 +30,8 @@ etc/qubes-rpc/qubes.SuspendPreAll etc/qubes-rpc/qubes.ConnectTCP etc/qubes-rpc/qubes.VMShell etc/qubes-rpc/qubes.VMRootShell +etc/qubes-rpc/qubes.VMExec +etc/qubes-rpc/qubes.VMExecGUI etc/qubes-rpc/qubes.WaitForSession etc/qubes-rpc/qubes.GetDate etc/qubes-suspend-module-blacklist @@ -43,6 +45,7 @@ etc/qubes/rpc-config/qubes.SelectDirectory etc/qubes/rpc-config/qubes.StartApp etc/qubes/rpc-config/qubes.InstallUpdatesGUI etc/qubes/rpc-config/qubes.VMShell+WaitForSession +etc/qubes/rpc-config/qubes.VMExecGUI etc/qubes/suspend-post.d/README etc/qubes/suspend-post.d/*.sh etc/qubes/suspend-pre.d/README @@ -94,6 +97,7 @@ usr/bin/qubes-desktop-run usr/bin/qubes-open usr/bin/qubes-session-autostart usr/bin/qubes-run-terminal +usr/bin/qubes-vmexec usr/bin/qvm-copy usr/bin/qvm-copy-to-vm usr/bin/qvm-features-request diff --git a/qubes-rpc/Makefile b/qubes-rpc/Makefile index fe090b6..90ce274 100644 --- a/qubes-rpc/Makefile +++ b/qubes-rpc/Makefile @@ -52,6 +52,7 @@ install: install -t $(DESTDIR)$(QUBESRPCCMDDIR) \ qubes.Filecopy qubes.OpenInVM qubes.VMShell \ qubes.VMRootShell \ + qubes.VMExec \ qubes.OpenURL \ qubes.SuspendPre qubes.SuspendPost qubes.GetAppmenus \ qubes.SuspendPreAll \ @@ -69,6 +70,7 @@ install: qubes.GetDate \ qubes.ShowInTerminal \ qubes.ConnectTCP + ln -s qubes.VMExec $(DESTDIR)$(QUBESRPCCMDDIR)/qubes.VMExecGUI for config in *.config; do \ install -D -m 0644 "$$config" "$(DESTDIR)$(QUBESRPCCONFDIR)/$${config%.config}"; \ done diff --git a/qubes-rpc/qubes.VMExec b/qubes-rpc/qubes.VMExec new file mode 100755 index 0000000..eab3221 --- /dev/null +++ b/qubes-rpc/qubes.VMExec @@ -0,0 +1,3 @@ +#!/bin/sh + +exec /usr/bin/qubes-vmexec "$@" diff --git a/qubes-rpc/qubes.VMExecGUI.config b/qubes-rpc/qubes.VMExecGUI.config new file mode 100644 index 0000000..094e56c --- /dev/null +++ b/qubes-rpc/qubes.VMExecGUI.config @@ -0,0 +1 @@ +wait-for-session=1 diff --git a/qubesagent/test_vmexec.py b/qubesagent/test_vmexec.py new file mode 100644 index 0000000..4049be3 --- /dev/null +++ b/qubesagent/test_vmexec.py @@ -0,0 +1,52 @@ +from unittest import TestCase +from unittest.mock import patch, call + +from qubesagent.vmexec import main, decode, DecodeError + + +class TestVmExec(TestCase): + def test_00_decode_simple(self): + self.assertEqual(decode('echo+Hello'), [b'echo', b'Hello']) + + def test_01_decode_empty(self): + self.assertEqual(decode('echo+'), [b'echo', b'']) + + def test_02_decode_escaping(self): + self.assertEqual(decode('echo+Hello-20world'), + [b'echo', b'Hello world']) + self.assertEqual(decode('-0A-0D'), + [b'\n\r']) + self.assertEqual(decode('-2Fbin-2Fls'), + [b'/bin/ls']) + self.assertEqual(decode('ls+--la'), + [b'ls', b'-la']) + self.assertEqual(decode('ls+---61'), + [b'ls', b'-a']) + self.assertEqual(decode('ls+----help'), + [b'ls', b'--help']) + + def test_03_decode_errors(self): + with self.assertRaises(DecodeError): + decode('illegal/slash') + with self.assertRaises(DecodeError): + decode('illegal-singledash') + with self.assertRaises(DecodeError): + decode('smalletters-0a-0d') + with self.assertRaises(DecodeError): + decode('incompletebyte-A') + with self.assertRaises(DecodeError): + decode('incomplete-Abyte') + with self.assertRaises(DecodeError): + decode('ls+---threeslashes') + + def test_10_main_exec(self): + with patch('os.execvp') as mock_execvp: + main(['vmexec', 'ls+--la']) + self.assertEqual(mock_execvp.call_args_list, [ + call(b'ls', [b'ls', b'-la'])]) + + def test_11_main_fail(self): + with self.assertRaises(SystemExit): + main(['vmexec']) + with self.assertRaises(SystemExit): + main(['vmexec', 'illegal/slash']) diff --git a/qubesagent/vmexec.py b/qubesagent/vmexec.py new file mode 100644 index 0000000..79d2abe --- /dev/null +++ b/qubesagent/vmexec.py @@ -0,0 +1,59 @@ +import sys +import os +import re + + +class DecodeError(ValueError): + pass + + +ESCAPE_RE = re.compile(br'--|-([A-F0-9]{2})') + + +def decode_part(part): + if not re.match(r'^[a-zA-Z0-9._-]*$', part): + raise DecodeError('illegal characters found') + + part = part.encode('ascii') + + # Check if no '-' remains outside of legal escape sequences. + if b'-' in ESCAPE_RE.sub(b'', part): + raise DecodeError("'-' can be used only in '-HH' or '--'") + + def convert(m): + if m.group(0) == b'--': + return b'-' + num = int(m.group(1), 16) + return bytes([num]) + + return ESCAPE_RE.sub(convert, part) + + +def decode(arg): + ''' + Decode the argument for executing. The format is as follows: + - individual parts are split by '+' + - bytes are escaped as '-HH' (where HH is hex code, capital letters only) + - literal '-' is encoded as '--' + - otherwise, only [a-zA-Z0-9._] are allowed + + :param arg: argument, as a string + :returns: list of exec arguments (each as bytes) + ''' + return [decode_part(part) for part in arg.split('+')] + + +def main(argv=sys.argv): + if len(argv) != 2: + print('This service requires exactly one argument', file=sys.stderr) + exit(1) + try: + command = decode(argv[1]) + except DecodeError as e: + print('Decode error: {}'.format(e), file=sys.stderr) + exit(1) + os.execvp(command[0], command) + + +if __name__ == '__main__': + main() diff --git a/rpm_spec/core-agent.spec.in b/rpm_spec/core-agent.spec.in index efe490d..b33f3a2 100644 --- a/rpm_spec/core-agent.spec.in +++ b/rpm_spec/core-agent.spec.in @@ -549,6 +549,8 @@ rm -f %{name}-%{version} %config(noreplace) /etc/qubes-rpc/qubes.GetAppmenus %config(noreplace) /etc/qubes-rpc/qubes.ConnectTCP %config(noreplace) /etc/qubes-rpc/qubes.VMShell +%config(noreplace) /etc/qubes-rpc/qubes.VMExec +%config(noreplace) /etc/qubes-rpc/qubes.VMExecGUI %config(noreplace) /etc/qubes-rpc/qubes.VMRootShell %config(noreplace) /etc/qubes-rpc/qubes.SuspendPre %config(noreplace) /etc/qubes-rpc/qubes.SuspendPreAll @@ -574,6 +576,7 @@ rm -f %{name}-%{version} %config(noreplace) /etc/qubes/rpc-config/qubes.StartApp %config(noreplace) /etc/qubes/rpc-config/qubes.InstallUpdatesGUI %config(noreplace) /etc/qubes/rpc-config/qubes.VMShell+WaitForSession +%config(noreplace) /etc/qubes/rpc-config/qubes.VMExecGUI %dir /etc/qubes/autostart %config(noreplace) /etc/default/grub.qubes /etc/qubes/autostart/README.txt @@ -616,6 +619,7 @@ rm -f %{name}-%{version} /usr/bin/qubes-session-autostart /usr/bin/qvm-console /usr/bin/qvm-connect-tcp +/usr/bin/qubes-vmexec %dir /usr/lib/qubes /usr/lib/qubes/prepare-suspend /usr/lib/qubes/qfile-agent @@ -667,6 +671,8 @@ rm -f %{name}-%{version} %{python3_sitelib}/qubesagent/__init__.py* %{python3_sitelib}/qubesagent/firewall.py* %{python3_sitelib}/qubesagent/test_firewall.py* +%{python3_sitelib}/qubesagent/vmexec.py* +%{python3_sitelib}/qubesagent/test_vmexec.py* %{python3_sitelib}/qubesagent/xdg.py* /usr/share/qubes/mime-override/globs diff --git a/setup.py b/setup.py index 2c0f163..323262c 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,8 @@ if __name__ == '__main__': entry_points={ 'console_scripts': [ - 'qubes-firewall = qubesagent.firewall:main' + 'qubes-firewall = qubesagent.firewall:main', + 'qubes-vmexec = qubesagent.vmexec:main', ], } )