Browse Source

Add qubes.VMExec call, for running a single command

With a VMExecGUI variant that waits for a session.

See QubesOS/qubes-issues#4850.
Pawel Marczewski 4 years ago
parent
commit
738548a8e4

+ 4 - 0
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

+ 2 - 0
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

+ 3 - 0
qubes-rpc/qubes.VMExec

@@ -0,0 +1,3 @@
+#!/bin/sh
+
+exec /usr/bin/qubes-vmexec "$@"

+ 1 - 0
qubes-rpc/qubes.VMExecGUI.config

@@ -0,0 +1 @@
+wait-for-session=1

+ 52 - 0
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'])

+ 59 - 0
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()

+ 6 - 0
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

+ 2 - 1
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',
             ],
         }
     )