@@ -7,11 +7,244 @@
import os
import re
import time
+import fcntl
+import glob
+import json
+import runpy
+import shutil
+import socket
+import stat
+import subprocess
+import sys
+import tempfile
+from concurrent.futures import ThreadPoolExecutor
+from types import SimpleNamespace
+from unittest.mock import patch
import oe.types
from oeqa.core.decorator import OETestTag
from oeqa.core.decorator.data import skipIfNotArch, skipIfNotMachine
from oeqa.selftest.case import OESelftestTestCase
from oeqa.utils.commands import bitbake, runqemu, get_bb_var, get_bb_vars
+from oeqa.utils.qemurunner import QemuRunner
+
+
+@OETestTag("runqemu")
+class RunqemuSlirpTests(OESelftestTestCase):
+ """Exercise slirp forwarding without booting a guest."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ bitbake('qemu-helper-native -c addto_recipe_sysroot')
+ bindir = get_bb_var('STAGING_BINDIR_NATIVE', 'qemu-helper-native')
+ cls.qemu_binary = sorted(glob.glob(os.path.join(bindir, 'qemu-system-*')))[0]
+ sys.path.append(os.path.join(os.path.dirname(os.path.dirname(bindir)), 'usr/lib/qemu-python'))
+ from qmp.legacy import QEMUMonitorProtocol
+ cls.monitor_protocol = QEMUMonitorProtocol
+ runqemu_module = runpy.run_path(shutil.which('runqemu'))
+ cls.base_config = runqemu_module['BaseConfig']
+ cls.runqemu_error = runqemu_module['RunQemuError']
+
+ def setUpLocal(self):
+ directory = tempfile.TemporaryDirectory(prefix='slirp-selftest-')
+ self.addCleanup(directory.cleanup)
+ self.config = self.base_config()
+ self.config.internal_qmp_path = os.path.join(directory.name, 'internal.sock')
+ self.config.slirp_hostfwd_allowlist = []
+ self.addCleanup(self.config.release_lock)
+ self.runner = QemuRunner('', '', '', directory.name, directory.name,
+ None, 30, directory.name, False, self.logger,
+ use_slirp=True, workdir=directory.name)
+ self.addCleanup(self.runner.stop)
+ monitor_path = os.path.join(directory.name, 'monitor.sock')
+ self.process = subprocess.Popen([
+ self.qemu_binary, '-machine', 'none', '-nodefaults', '-display', 'none',
+ '-S', '-netdev', 'user,id=net0',
+ '-qmp', 'unix:%s,server=on,wait=off' % self.config.internal_qmp_path,
+ '-qmp', 'unix:%s,server=on,wait=off' % monitor_path,
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ self.addCleanup(self._stop_qemu)
+ self.config.add_slirp_hostfwds()
+ self.runner.qmp = self.monitor_protocol(monitor_path)
+ self.runner.qmp.settimeout(5)
+ self.assertIn('QMP', self.runner.qmp.connect())
+
+ def _stop_qemu(self):
+ if self.process.poll() is None:
+ self.process.terminate()
+ self.process.communicate(timeout=10)
+
+ def _monitor(self, command, arguments=None, timeout=60):
+ return self.runner.qmp.cmd_raw(command, arguments)
+
+ def _socket(self, protocol='tcp'):
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM if protocol == 'tcp' else socket.SOCK_DGRAM)
+ self.addCleanup(probe.close)
+ probe.bind(('127.0.0.1', 0))
+ if protocol == 'tcp':
+ probe.listen()
+ return probe
+
+ def _free_port(self):
+ probe = self._socket()
+ port = probe.getsockname()[1]
+ probe.close()
+ return port
+
+ def _startup_forward(self, port, protocol='tcp', guest_port=1234, host='127.0.0.1'):
+ self.config.slirp_hostfwd_allowlist = [(protocol, host, port, '', guest_port)]
+ self.config.add_slirp_hostfwds()
+ return self.runner.get_host_port(guest_port, protocol=protocol)
+
+ def test_startup_rejected_candidate(self):
+ """Move a startup forward away from a port QEMU cannot bind."""
+ busy = self._socket()
+ port = busy.getsockname()[1]
+ actual = self._startup_forward(port)
+ self.assertNotEqual(actual, port)
+
+ def test_protocols_share_owned_reservations(self):
+ """Allow TCP and UDP forwards to share a host port."""
+ for guest_port, owner in ((1234, 'runqemu'), (1235, 'qemurunner'), (1236, 'runqemu-dynamic')):
+ with self.subTest(owner=owner):
+ port = self._free_port()
+ if owner == 'runqemu':
+ self.config.slirp_hostfwd_allowlist = [
+ (protocol, '127.0.0.1', port, '', guest_port)
+ for protocol in ('tcp', 'udp')]
+ self.config.add_slirp_hostfwds()
+ elif owner == 'runqemu-dynamic':
+ self.assertEqual(self._startup_forward(port, guest_port=guest_port), port)
+ else:
+ self.assertEqual(self.runner.add_hostfwd(guest_port, port), port)
+ self.assertEqual(self.runner.get_host_port(guest_port), port)
+ if owner != 'runqemu':
+ self.assertEqual(self.runner.add_hostfwd(guest_port, port, protocol='udp'), port)
+ self.assertEqual(self.runner.get_host_port(guest_port, protocol='UDP'), port)
+
+ def test_dynamic_rejected_candidates(self):
+ """Reject dynamic forwards when binding or monitor commands fail."""
+ for protocol in ('tcp', 'udp'):
+ with self.subTest(protocol=protocol):
+ busy = self._socket(protocol)
+ port = busy.getsockname()[1]
+ self.assertIsNone(self.runner.add_hostfwd(1234, port, protocol=protocol))
+ port = self._free_port()
+ for failure in (None, {}, {'error': {}}, {'return': '', 'error': {}},
+ {'return': 'Could not set up host forwarding rule'}, {'return': 0}):
+ with self.subTest(response=failure):
+ def reply(command, arguments=None):
+ if arguments and arguments['command-line'].startswith('hostfwd_add'):
+ return failure
+ return self._monitor(command, arguments)
+ with patch.object(self.runner, 'run_monitor', side_effect=reply):
+ self.assertIsNone(self.runner.add_hostfwd(1234, port))
+
+ def test_internal_monitor_timeouts(self):
+ self.config.SLIRP_QMP_TIMEOUT = 0.1
+ for phase in ('greeting', 'capabilities', 'hostfwd'):
+ with self.subTest(phase=phase), tempfile.TemporaryDirectory() as directory:
+ port = self._free_port()
+ self.config.internal_qmp_path = os.path.join(directory, 'silent.sock')
+ self.config.slirp_hostfwd_allowlist = [('tcp', '127.0.0.1', port, '', 1234)]
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener:
+ listener.settimeout(5)
+ listener.bind(self.config.internal_qmp_path)
+ listener.listen()
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ future = executor.submit(self.config.add_slirp_hostfwds)
+ with listener.accept()[0] as peer:
+ peer.settimeout(2)
+ with peer.makefile('rw') as stream:
+ if phase != 'greeting':
+ stream.write(json.dumps({'QMP': {}}) + '\n')
+ stream.flush()
+ self.assertEqual(json.loads(stream.readline())['execute'], 'qmp_capabilities')
+ if phase == 'hostfwd':
+ stream.write(json.dumps({'return': {}}) + '\n')
+ stream.flush()
+ self.assertEqual(json.loads(stream.readline())['execute'], 'human-monitor-command')
+ with self.assertRaisesRegex(self.runqemu_error, 'Timed out communicating'):
+ future.result(timeout=2)
+
+ def test_private_internal_monitor(self):
+ """Add a private forwarding monitor without replacing the requested monitor.
+
+ The internal socket's directory must be accessible only to its owner
+ and registered for cleanup. Both QMP sockets must appear in QEMU's
+ command line.
+ """
+ self.config.slirp_hostfwd_allowlist = [('tcp', '127.0.0.1', 2222, '', 22)]
+ self.config.qmp = 'unix:requested-monitor.sock'
+ self.config.setup_qmp()
+ directory = os.path.dirname(self.config.internal_qmp_path)
+ self.addCleanup(shutil.rmtree, directory)
+ self.assertEqual(stat.S_IMODE(os.stat(directory).st_mode), 0o700)
+ self.assertIn(directory, self.config.cleanup_files)
+ self.assertIn('-qmp unix:requested-monitor.sock,server,nowait', self.config.qemu_opt)
+ self.assertIn('-qmp unix:%s,server,nowait' % self.config.internal_qmp_path, self.config.qemu_opt)
+
+ def test_mac_slot_reuse(self):
+ """Allocate distinct MAC slots and reuse a slot after its lock is released.
+
+ A third configuration must take the first configuration's freed slot
+ while the second configuration continues to hold its own slot.
+ """
+ with tempfile.TemporaryDirectory() as directory:
+ configs = [self.base_config() for index in range(3)]
+ try:
+ self.assertEqual(configs[0].alloc_mac_slot(directory), 1)
+ self.assertEqual(configs[1].alloc_mac_slot(directory), 2)
+ configs[0].release_lock()
+ self.assertEqual(configs[2].alloc_mac_slot(directory), 1)
+ finally:
+ for config in configs:
+ config.release_lock()
+
+ def test_mac_slot_exhaustion(self):
+ """Report exhaustion only after trying all 254 usable MAC slot locks."""
+ with tempfile.TemporaryDirectory() as directory:
+ with patch.object(self.config, 'acquire_lock', return_value=False) as acquire:
+ with self.assertRaisesRegex(self.runqemu_error, 'Too many concurrent'):
+ self.config.alloc_mac_slot(directory)
+ self.assertEqual(acquire.call_count, 254)
+ self.assertEqual([os.path.basename(call.args[0]) for call in acquire.call_args_list],
+ ['%d.lock' % slot for slot in range(1, 255)])
+
+ def test_bind_addresses(self):
+ """Preserve explicit bind addresses and resolve wildcard binds to localhost."""
+ for guest_port, host, expected in ((1234, '127.0.0.2', '127.0.0.2'), (1235, '0.0.0.0', '127.0.0.1')):
+ with self.subTest(host=host):
+ actual = self._startup_forward(self._free_port(), guest_port=guest_port, host=host)
+ endpoint = self.runner._get_slirp_host_endpoint(guest_port)
+ self.assertEqual(endpoint, (expected, actual))
+ with socket.create_connection(endpoint, timeout=5):
+ pass
+
+ def test_unsupported_protocol(self):
+ """Reject unsupported forwarding protocols."""
+ with self.assertRaises(ValueError):
+ self.runner.add_hostfwd(1234, protocol='sctp')
+
+ def test_explicit_forward_with_non_slirp_primary(self):
+ """Manage slirp forwards independently of the primary networking mode."""
+ self.runner.use_slirp = False
+ for protocol in ('tcp', 'udp'):
+ with self.subTest(protocol=protocol):
+ port = self._free_port()
+ self.assertEqual(self.runner.add_hostfwd(1234, port, protocol=protocol), port)
+ self.assertEqual(self.runner.slirp_port_mappings[(protocol, 1234)], port)
+ self.assertEqual(self.runner.get_host_port(1234, protocol=protocol), port)
+ port = self.runner.get_host_port(1236)
+ self.assertEqual(self.runner.slirp_port_mappings[('tcp', 1236)], port)
+ with socket.create_connection(('127.0.0.1', port), timeout=5):
+ pass
+ response = self.runner.run_monitor('netdev_del', {'id': 'net0'})
+ self.assertEqual(response, {'return': {}})
+ port = self._free_port()
+ self.assertIsNone(self.runner.add_hostfwd(1235, port))
+ with self.assertRaisesRegex(RuntimeError, 'Failed to add a slirp tcp hostfwd'):
+ self.runner.get_host_port(1235)
@OETestTag("runqemu")