diff --git a/qubesmgmt/app.py b/qubesmgmt/app.py index adfc7ad..1b58e85 100644 --- a/qubesmgmt/app.py +++ b/qubesmgmt/app.py @@ -274,7 +274,8 @@ class QubesRemote(QubesBase): service_name = method if arg is not None: service_name += '+' + arg - p = subprocess.Popen(['qrexec-client-vm', dest, service_name], + p = subprocess.Popen([qubesmgmt.config.QREXEC_CLIENT_VM, + dest, service_name], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (stdout, stderr) = p.communicate(payload) diff --git a/qubesmgmt/tests/tools/qvm_run.py b/qubesmgmt/tests/tools/qvm_run.py new file mode 100644 index 0000000..699cc88 --- /dev/null +++ b/qubesmgmt/tests/tools/qvm_run.py @@ -0,0 +1,232 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2017 Marek Marczykowski-Górecki +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +import io +import unittest.mock + +import subprocess + +import qubesmgmt.tests +import qubesmgmt.tools.qvm_run + + +class TC_00_qvm_run(qubesmgmt.tests.QubesTestCase): + def test_000_run_single(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + ret = qubesmgmt.tools.qvm_run.main(['test-vm', 'command'], app=self.app) + self.assertEqual(ret, 0) + # make sure we have the same instance below + null = self.app.service_calls[0][2]['stdout'] + self.assertIsInstance(null, io.BufferedWriter) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': null, + 'stderr': null, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\n') + ]) + self.assertAllCalled() + + def test_001_run_multiple(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' \ + b'test-vm2 class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + ret = qubesmgmt.tools.qvm_run.main(['test-vm', 'test-vm2', 'command'], + app=self.app) + self.assertEqual(ret, 0) + for i in range(0, len(self.app.service_calls), 2): + self.assertIsInstance(self.app.service_calls[i][2]['stdout'], + io.BufferedWriter) + self.assertIsInstance(self.app.service_calls[i][2]['stderr'], + io.BufferedWriter) + # make sure we have the same instance below + null = self.app.service_calls[0][2]['stdout'] + null2 = self.app.service_calls[2][2]['stdout'] + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': null, + 'stderr': null, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\n'), + ('test-vm2', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': null2, + 'stderr': null2, + 'user': None, + }), + ('test-vm2', 'qubes.VMShell', b'command\n') + ]) + self.assertAllCalled() + + def test_002_passio(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + echo = subprocess.Popen(['echo', 'some-data'], stdout=subprocess.PIPE) + with unittest.mock.patch('sys.stdin', echo.stdout): + ret = qubesmgmt.tools.qvm_run.main( + ['--pass-io', 'test-vm', 'command'], + app=self.app) + + self.assertEqual(ret, 0) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': None, + 'stderr': None, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\nsome-data\n') + ]) + self.assertAllCalled() + + def test_002_color_output(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + stdout = io.StringIO() + echo = subprocess.Popen(['echo', 'some-data'], stdout=subprocess.PIPE) + with unittest.mock.patch('sys.stdin', echo.stdout): + with unittest.mock.patch('sys.stdout', stdout): + ret = qubesmgmt.tools.qvm_run.main( + ['--pass-io', 'test-vm', 'command'], + app=self.app) + + self.assertEqual(ret, 0) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': None, + 'stderr': None, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\nsome-data\n') + ]) + self.assertEqual(stdout.getvalue(), '\033[0;31m\033[0m') + stdout.close() + self.assertAllCalled() + + def test_003_no_color_output(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + stdout = io.StringIO() + echo = subprocess.Popen(['echo', 'some-data'], stdout=subprocess.PIPE) + with unittest.mock.patch('sys.stdin', echo.stdout): + with unittest.mock.patch('sys.stdout', stdout): + ret = qubesmgmt.tools.qvm_run.main( + ['--pass-io', '--no-color-output', 'test-vm', 'command'], + app=self.app) + + self.assertEqual(ret, 0) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': None, + 'stdout': None, + 'stderr': None, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\nsome-data\n') + ]) + self.assertEqual(stdout.getvalue(), '') + stdout.close() + self.assertAllCalled() + + def test_004_no_filter_esc(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + stdout = io.StringIO() + echo = subprocess.Popen(['echo', 'some-data'], stdout=subprocess.PIPE) + with unittest.mock.patch('sys.stdin', echo.stdout): + with unittest.mock.patch('sys.stdout', stdout): + ret = qubesmgmt.tools.qvm_run.main( + ['--pass-io', '--no-filter-esc', 'test-vm', 'command'], + app=self.app) + + self.assertEqual(ret, 0) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': False, + 'localcmd': None, + 'stdout': None, + 'stderr': None, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\nsome-data\n') + ]) + self.assertEqual(stdout.getvalue(), '') + stdout.close() + self.assertAllCalled() + + def test_005_localcmd(self): + self.app.expected_calls[ + ('dom0', 'mgmt.vm.List', None, None)] = \ + b'0\x00test-vm class=AppVM state=Running\n' + # self.app.expected_calls[ + # ('test-vm', 'mgmt.vm.List', None, None)] = \ + # b'0\x00test-vm class=AppVM state=Running\n' + ret = qubesmgmt.tools.qvm_run.main( + ['--pass-io', '--localcmd', 'local-command', + 'test-vm', 'command'], + app=self.app) + self.assertEqual(ret, 0) + self.assertEqual(self.app.service_calls, [ + ('test-vm', 'qubes.VMShell', { + 'filter_esc': True, + 'localcmd': 'local-command', + 'stdout': None, + 'stderr': None, + 'user': None, + }), + ('test-vm', 'qubes.VMShell', b'command\n') + ]) + self.assertAllCalled() diff --git a/qubesmgmt/tools/qvm_run.py b/qubesmgmt/tools/qvm_run.py new file mode 100644 index 0000000..429f888 --- /dev/null +++ b/qubesmgmt/tools/qvm_run.py @@ -0,0 +1,194 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2017 Marek Marczykowski-Górecki +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +''' qvm-run tool''' + +import os +import signal +import sys + +import asyncio + +import functools + +import logging + +import qubesmgmt.tools +import qubesmgmt.exc + +parser = qubesmgmt.tools.QubesArgumentParser(vmname_nargs='+') + +parser.add_argument('--user', '-u', metavar='USER', + help='run command in a qube as USER (available only from dom0)') + +parser.add_argument('--autostart', '--auto', '-a', + action='store_true', default=True, + help='option ignored, this is default') + +parser.add_argument('--no-autostart', '--no-auto', '-n', + action='store_false', + help='do not autostart qube') + +parser.add_argument('--pass-io', '-p', + action='store_true', dest='passio', default=False, + help='pass stdio from remote program') + +parser.add_argument('--localcmd', metavar='COMMAND', + help='with --pass-io, pass stdio to the given program') + +parser.add_argument('--gui', + action='store_true', default=True, + help='run the command with GUI (default on)') + +parser.add_argument('--no-gui', '--nogui', + action='store_false', dest='gui', + help='run the command without GUI') + +parser.add_argument('--colour-output', '--color-output', metavar='COLOUR', + action='store', dest='color_output', default=None, + help='mark the qube output with given ANSI colour (ie. "31" for red)') + +parser.add_argument('--colour-stderr', '--color-stderr', metavar='COLOUR', + action='store', dest='color_stderr', default=None, + help='mark the qube stderr with given ANSI colour (ie. "31" for red)') + +parser.add_argument('--no-colour-output', '--no-color-output', + action='store_false', dest='color_output', + help='disable colouring the stdio') + +parser.add_argument('--no-colour-stderr', '--no-color-stderr', + action='store_false', dest='color_stderr', + help='disable colouring the stderr') + +parser.add_argument('--filter-escape-chars', + action='store_true', dest='filter_esc', + default=os.isatty(sys.stdout.fileno()), + help='filter terminal escape sequences (default if output is terminal)') + +parser.add_argument('--no-filter-escape-chars', + action='store_false', dest='filter_esc', + help='do not filter terminal escape sequences; DANGEROUS when output is a' + ' terminal emulator') + +parser.add_argument('cmd', metavar='COMMAND', + help='command to run') + + +class DataCopyProtocol(asyncio.Protocol): + '''Simple protocol to copy received data into another stream''' + + def __init__(self, target_stream, eof_callback=None): + self.target_stream = target_stream + self.eof_callback = eof_callback + + def data_received(self, data): + '''Handle received data''' + self.target_stream.write(data) + self.target_stream.flush() + + def eof_received(self): + '''Handle received EOF''' + if self.eof_callback: + self.eof_callback() + + +def main(args=None, app=None): + '''Main function of qvm-run tool''' + args = parser.parse_args(args, app=app) + if args.color_output is None and args.filter_esc: + args.color_output = '31' + + if args.color_output is None and os.isatty(sys.stderr.fileno()): + args.color_stderr = 31 + + if len(args.domains) > 1 and args.passio and not args.localcmd: + parser.error('--passio cannot be used when more than 1 qube is chosen ' + 'and no --localcmd is used') + if args.localcmd and not args.passio: + parser.error('--localcmd have no effect without --pass-io') + if args.color_output and not args.filter_esc: + parser.error('--color-output must be used with --filter-escape-chars') + + retcode = 0 + run_kwargs = {} + if not args.passio: + run_kwargs['stdout'] = open(os.devnull, 'wb') + run_kwargs['stderr'] = run_kwargs['stdout'] + else: + # connect process output to stdout/err directly if --pass-io is given + run_kwargs['stdout'] = None + run_kwargs['stderr'] = None + + log = logging.getLogger('qvm_run') + if args.color_output: + sys.stdout.write('\033[0;{}m'.format(args.color_output)) + sys.stdout.flush() + if args.color_stderr: + sys.stderr.write('\033[0;{}m'.format(args.color_stderr)) + sys.stderr.flush() + try: + procs = [] + for vm in args.domains: + if not args.autostart and not vm.is_running(): + continue + try: + log.info('Running {!r} on {!s}'.format(args.cmd, vm.name)) + if args.passio and not args.localcmd: + loop = asyncio.new_event_loop() + loop.add_signal_handler(signal.SIGCHLD, loop.stop) + proc = vm.run_service('qubes.VMShell', + user=args.user, + localcmd=args.localcmd, + filter_esc=args.filter_esc, + **run_kwargs) + proc.stdin.write(vm.prepare_input_for_vmshell(args.cmd)) + proc.stdin.flush() + if args.passio and not args.localcmd: + asyncio.ensure_future(loop.connect_read_pipe( + functools.partial(DataCopyProtocol, proc.stdin, + loop.stop), + sys.stdin), loop=loop) + loop.run_forever() + loop.close() + proc.stdin.close() + procs.append(proc) + except qubesmgmt.exc.QubesException as e: + if args.color_output: + sys.stdout.write('\033[0m') + sys.stdout.flush() + vm.log.error(str(e)) + return -1 + for proc in procs: + retcode = max(retcode, proc.wait()) + finally: + if args.color_output: + sys.stdout.write('\033[0m') + sys.stdout.flush() + if args.color_stderr: + sys.stderr.write('\033[0m') + sys.stderr.flush() + if run_kwargs['stdout'] is not None: + run_kwargs['stdout'].close() + + return retcode + + +if __name__ == '__main__': + sys.exit(main())