diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 11bffb69a..3697fc327 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -54,6 +54,85 @@ import sys log = logging.getLogger(__name__) +class IOUL1KeepaliveProtocol(asyncio.DatagramProtocol): + """Handle IOU/IOL Layer 1 keepalives for connected interfaces.""" + + _header = struct.Struct("!HHBBBB") + _message_type = 3 + + def __init__(self, vm): + self._vm = vm + self.transport = None + + def connection_made(self, transport): + self.transport = transport + + @staticmethod + def encode_interface(adapter_number, port_number): + """Encode an IOU bay/unit for the L1 keepalive protocol.""" + + # IOU stores the zero-based unit in the high nibble and the + # zero-based bay in the low nibble. + return (port_number << 4) | adapter_number + + @staticmethod + def decode_interface(interface): + """Decode an L1 keepalive interface into an IOU bay/unit.""" + + return interface & 0x0F, interface >> 4 + + def datagram_received(self, data, address): + if len(data) != self._header.size: + log.debug('IOU "%s": ignored malformed L1 keepalive of %d bytes', self._vm.name, len(data)) + return + + destination, source, destination_interface, source_interface, message_type, channel = self._header.unpack(data) + if ( + destination != self._vm.l1_bridge_id + or source != self._vm.application_id + or message_type != self._message_type + or not self._vm.has_nio_for_iou_interface(source_interface) + ): + return + + response = self._header.pack( + source, + destination, + source_interface, + destination_interface, + message_type, + channel, + ) + try: + self.transport.sendto(response, self._vm.l1_iou_socket_path) + except OSError as e: + # IOU creates its endpoint during startup and removes it on stop. + # Dropping a keepalive during either transition is harmless. + log.debug('IOU "%s": could not send an L1 keepalive response: %s', self._vm.name, e) + + def send_keepalives(self): + """Tell IOU that every interface with an attached NIO has Layer 1 connectivity.""" + + for adapter_number, adapter in enumerate(self._vm.adapters): + for port_number, nio in adapter.ports.items(): + if nio is None: + continue + interface = self.encode_interface(adapter_number, port_number) + keepalive = self._header.pack( + self._vm.application_id, + self._vm.l1_bridge_id, + interface, + interface, + self._message_type, + 0, + ) + try: + self.transport.sendto(keepalive, self._vm.l1_iou_socket_path) + except OSError as e: + # The IOU endpoint does not exist until the image has started. + log.debug('IOU "%s": could not send an L1 keepalive: %s', self._vm.name, e) + + class IOUVM(BaseNode): module_name = "iou" @@ -98,6 +177,8 @@ class IOUVM(BaseNode): self._lib_base = self.manager.get_images_directory() self._loader = None self._license_check = True + self._l1_keepalive_transport = None + self._l1_keepalive_task = None # IOU settings self._ethernet_adapters = [] @@ -110,7 +191,7 @@ class IOUVM(BaseNode): self._private_config = "" self._ram = 1024 # Megabytes self._application_id = application_id - self._l1_keepalives = False # used to overcome the always-up Ethernet interfaces (not supported by all IOSes). + self._l1_keepalives = False def _nvram_changed(self, path): """ @@ -637,6 +718,10 @@ class IOUVM(BaseNode): raise IOUError(f"Could not create symbolic link: {e}") command = await self._build_command() + # Only start the responder when the capability probe actually + # enabled IOU's L1 protocol on the command line. + if "-l" in command: + await self._start_l1_keepalive_responder() try: if self._loader: log.info(f"Starting IOU: {command} with loader {self._loader}") @@ -657,8 +742,10 @@ class IOUVM(BaseNode): callback = functools.partial(self._termination_callback, "IOU") gns3server.utils.asyncio.monitor_process(self._iou_process, callback) except FileNotFoundError as e: + self._stop_l1_keepalive_responder() raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead") except (OSError, subprocess.SubprocessError) as e: + self._stop_l1_keepalive_responder() iou_stdout = self.read_iou_stdout() log.error(f"Could not start IOU {self._path}: {e}\n{iou_stdout}") raise IOUError(f"Could not start IOU {self._path}: {e}\n{iou_stdout}") @@ -760,6 +847,7 @@ class IOUVM(BaseNode): """ self._terminate_process_iou() + self._stop_l1_keepalive_responder() if returncode != 0: if returncode == -11: message = 'IOU VM "{}" process has stopped with return code: {} (segfault). This could be an issue with the IOU image, using a different image may fix this.\n{}'.format( @@ -792,6 +880,7 @@ class IOUVM(BaseNode): Stops the IOU process. """ + self._stop_l1_keepalive_responder() await self._stop_ubridge() if self._nvram_watcher: self._nvram_watcher.close() @@ -894,6 +983,83 @@ class IOUVM(BaseNode): except OSError as e: raise IOUError(f"Could not create {netmap_path}: {e}") + @property + def l1_bridge_id(self): + return self.application_id + 512 + + @property + def l1_socket_directory(self): + # IOU hard-codes this directory independently from TMPDIR. + return os.path.join("/tmp", f"netl1{os.geteuid()}") + + @property + def l1_bridge_socket_path(self): + return os.path.join(self.l1_socket_directory, f"L1{self.l1_bridge_id}") + + @property + def l1_iou_socket_path(self): + return os.path.join(self.l1_socket_directory, f"L1{self.application_id}") + + def has_nio_for_iou_interface(self, interface): + """Return whether the IOU bay/unit encoded in one byte is connected.""" + + adapter_number, port_number = IOUL1KeepaliveProtocol.decode_interface(interface) + if adapter_number >= len(self._adapters): + return False + adapter = self._adapters[adapter_number] + return adapter.port_exists(port_number) and adapter.get_nio(port_number) is not None + + async def _start_l1_keepalive_responder(self): + """Create the bridge-side UNIX datagram endpoint used by IOU's ``-l`` option.""" + + if self._l1_keepalive_transport is not None: + return + + socket_directory = self.l1_socket_directory + try: + os.makedirs(socket_directory, mode=0o755, exist_ok=True) + if os.path.islink(socket_directory) or os.stat(socket_directory).st_uid != os.geteuid(): + raise IOUError(f"Unsafe IOU L1 keepalive directory '{socket_directory}'") + if os.path.lexists(self.l1_bridge_socket_path): + os.unlink(self.l1_bridge_socket_path) + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + lambda: IOUL1KeepaliveProtocol(self), + local_addr=self.l1_bridge_socket_path, + family=socket.AF_UNIX, + ) + self._l1_keepalive_transport = transport + self._l1_keepalive_task = asyncio.create_task(self._send_l1_keepalives(protocol)) + log.info( + 'IOU "%s" [%s]: L1 keepalive responder listening on %s', + self._name, + self._id, + self.l1_bridge_socket_path, + ) + except (OSError, RuntimeError) as e: + self._stop_l1_keepalive_responder() + raise IOUError(f"Could not start IOU L1 keepalive responder: {e}") + + async def _send_l1_keepalives(self, protocol): + while self._l1_keepalive_transport is not None: + protocol.send_keepalives() + await asyncio.sleep(1) + + def _stop_l1_keepalive_responder(self): + """Stop the L1 endpoint and remove its bridge-side socket.""" + + if self._l1_keepalive_task is not None: + self._l1_keepalive_task.cancel() + self._l1_keepalive_task = None + if self._l1_keepalive_transport is not None: + self._l1_keepalive_transport.close() + self._l1_keepalive_transport = None + try: + if os.path.lexists(self.l1_bridge_socket_path): + os.unlink(self.l1_bridge_socket_path) + except OSError as e: + log.warning('Could not remove IOU L1 keepalive socket "%s": %s', self.l1_bridge_socket_path, e) + async def _build_command(self): """ Command to start the IOU process. @@ -1268,8 +1434,9 @@ class IOUVM(BaseNode): """ env = os.environ.copy() - if "IOURC" not in os.environ: - env["IOURC"] = self.iourc_path + iourc_path = self.iourc_path + if "IOURC" not in os.environ and iourc_path: + env["IOURC"] = iourc_path try: output = await gns3server.utils.asyncio.subprocess_check_output( *self._loader, self._path, "-h", cwd=self.working_dir, env=env, stderr=True diff --git a/gns3server/schemas/compute/iou_nodes.py b/gns3server/schemas/compute/iou_nodes.py index fe3fe8570..c04b6f0ef 100644 --- a/gns3server/schemas/compute/iou_nodes.py +++ b/gns3server/schemas/compute/iou_nodes.py @@ -38,7 +38,10 @@ class IOUBase(BaseModel): ethernet_adapters: Optional[int] = Field(None, description="How many Ethernet adapters are connected to IOU") ram: Optional[int] = Field(None, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(None, gt=0, description="Amount of NVRAM in KB") - l1_keepalives: Optional[bool] = Field(None, description="Use default IOU values") + l1_keepalives: Optional[bool] = Field( + None, + description="Enable Layer 1 keepalives so IOU interfaces report accurate link state", + ) use_default_iou_values: Optional[bool] = Field(None, description="Use default IOU values") startup_config_content: Optional[str] = Field(None, description="Content of IOU startup configuration file") private_config_content: Optional[str] = Field(None, description="Content of IOU private configuration file") diff --git a/gns3server/schemas/controller/templates/iou_templates.py b/gns3server/schemas/controller/templates/iou_templates.py index 6dd83d14b..c0a8d398b 100644 --- a/gns3server/schemas/controller/templates/iou_templates.py +++ b/gns3server/schemas/controller/templates/iou_templates.py @@ -35,7 +35,10 @@ class IOUTemplate(TemplateBase): use_default_iou_values: Optional[bool] = Field(False, description="Use default IOU values") startup_config: Optional[str] = Field("iou_l3_base_startup-config.txt", description="Startup-config of IOU") private_config: Optional[str] = Field("", description="Private-config of IOU") - l1_keepalives: Optional[bool] = Field(False, description="Always keep up Ethernet interface (does not always work)") + l1_keepalives: Optional[bool] = Field( + False, + description="Enable Layer 1 keepalives so IOU interfaces report accurate link state", + ) console_type: Optional[ConsoleType] = Field(ConsoleType.telnet, description="Console type") console_auto_start: Optional[bool] = Field( False, description="Automatically start the console when the node has started" diff --git a/tests/compute/iou/test_iou_vm.py b/tests/compute/iou/test_iou_vm.py index 446a6544b..a3ec3318a 100644 --- a/tests/compute/iou/test_iou_vm.py +++ b/tests/compute/iou/test_iou_vm.py @@ -21,13 +21,14 @@ import asyncio import os import stat import socket +import struct import uuid import shutil from tests.utils import asyncio_patch, AsyncioMagicMock -from unittest.mock import MagicMock -from gns3server.compute.iou.iou_vm import IOUVM +from unittest.mock import MagicMock, call +from gns3server.compute.iou.iou_vm import IOUL1KeepaliveProtocol, IOUVM from gns3server.compute.iou.iou_error import IOUError from gns3server.compute.iou import IOU @@ -76,6 +77,7 @@ def test_vm(compute_project, manager): vm = IOUVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) assert vm.name == "test" assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0f" + assert vm.l1_keepalives is False def test_vm_startup_config_content(compute_project, manager): @@ -111,6 +113,45 @@ async def test_start(vm): vm._ubridge_send.assert_any_call("iol_bridge start IOL-BRIDGE-513") +@pytest.mark.asyncio +async def test_start_does_not_start_l1_responder_without_l_option(vm): + + process = MagicMock(returncode=None) + process.communicate = AsyncioMagicMock(return_value=(None, None)) + vm.l1_keepalives = True + vm._check_requirements = AsyncioMagicMock(return_value=True) + vm._check_iou_license = AsyncioMagicMock(return_value=True) + vm._start_ubridge = AsyncioMagicMock(return_value=True) + vm._ubridge_send = AsyncioMagicMock() + vm._build_command = AsyncioMagicMock(return_value=[vm.path, str(vm.application_id)]) + vm._start_l1_keepalive_responder = AsyncioMagicMock() + + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): + await vm.start() + + vm._start_l1_keepalive_responder.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_failure_stops_l1_responder(vm): + + vm.l1_keepalives = True + vm._check_requirements = AsyncioMagicMock(return_value=True) + vm._check_iou_license = AsyncioMagicMock(return_value=True) + vm._start_ubridge = AsyncioMagicMock(return_value=True) + vm._ubridge_send = AsyncioMagicMock() + vm._build_command = AsyncioMagicMock(return_value=[vm.path, "-l", str(vm.application_id)]) + vm._start_l1_keepalive_responder = AsyncioMagicMock() + vm._stop_l1_keepalive_responder = MagicMock() + + with asyncio_patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("missing image")): + with pytest.raises(IOUError): + await vm.start() + + vm._start_l1_keepalive_responder.assert_called_once_with() + vm._stop_l1_keepalive_responder.assert_called_once_with() + + @pytest.mark.asyncio async def test_start_with_iourc(vm, tmpdir, config): @@ -261,10 +302,155 @@ def test_create_netmap_config(vm): assert "513:15/3 1:15/3" in content +@pytest.mark.parametrize( + "adapter_number,port_number,interface", + [ + (0, 0, 0x00), + (0, 1, 0x10), + (0, 2, 0x20), + (0, 3, 0x30), + (1, 0, 0x01), + (1, 3, 0x31), + (15, 3, 0x3F), + ], +) +def test_l1_keepalive_interface_encoding(adapter_number, port_number, interface): + + assert IOUL1KeepaliveProtocol.encode_interface(adapter_number, port_number) == interface + assert IOUL1KeepaliveProtocol.decode_interface(interface) == (adapter_number, port_number) + + +def test_l1_keepalive_response_for_connected_interface(vm): + + vm._adapters[0].add_nio(1, MagicMock()) + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + keepalive = struct.pack("!HHBBBB", 513, 1, 0x10, 0x10, 3, 0) + protocol.datagram_received(keepalive, None) + + transport.sendto.assert_called_once_with( + struct.pack("!HHBBBB", 1, 513, 0x10, 0x10, 3, 0), + vm.l1_iou_socket_path, + ) + + +def test_l1_keepalive_sent_for_connected_interface(vm): + + vm._adapters[0].add_nio(2, MagicMock()) + vm._adapters[0].add_nio(3, MagicMock()) + vm._adapters[1].add_nio(2, MagicMock()) + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + protocol.send_keepalives() + + assert transport.sendto.call_args_list == [ + call(struct.pack("!HHBBBB", 1, 513, 0x20, 0x20, 3, 0), vm.l1_iou_socket_path), + call(struct.pack("!HHBBBB", 1, 513, 0x30, 0x30, 3, 0), vm.l1_iou_socket_path), + call(struct.pack("!HHBBBB", 1, 513, 0x21, 0x21, 3, 0), vm.l1_iou_socket_path), + ] + + +def test_l1_keepalives_preserve_mixed_iou_interface_numbers(vm): + + vm.ethernet_adapters = 4 + vm.serial_adapters = 2 + vm._adapters[0].add_nio(0, MagicMock()) # Ethernet0/0 + vm._adapters[1].add_nio(1, MagicMock()) # Ethernet1/1 + vm._adapters[2].add_nio(2, MagicMock()) # Ethernet2/2 + vm._adapters[4].add_nio(0, MagicMock()) # Serial4/0 + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + protocol.send_keepalives() + + assert transport.sendto.call_args_list == [ + call(struct.pack("!HHBBBB", 1, 513, 0x00, 0x00, 3, 0), vm.l1_iou_socket_path), + call(struct.pack("!HHBBBB", 1, 513, 0x11, 0x11, 3, 0), vm.l1_iou_socket_path), + call(struct.pack("!HHBBBB", 1, 513, 0x22, 0x22, 3, 0), vm.l1_iou_socket_path), + call(struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0), vm.l1_iou_socket_path), + ] + + +def test_l1_keepalive_response_for_serial4_0(vm): + + vm.ethernet_adapters = 4 + vm.serial_adapters = 2 + vm._adapters[4].add_nio(0, MagicMock()) + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0), None) + + transport.sendto.assert_called_once_with( + struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0), + vm.l1_iou_socket_path, + ) + + +def test_stop_l1_keepalive_responder_cleans_up(vm): + + task = MagicMock() + transport = MagicMock() + vm._l1_keepalive_task = task + vm._l1_keepalive_transport = transport + + with asyncio_patch("os.path.lexists", return_value=False): + vm._stop_l1_keepalive_responder() + vm._stop_l1_keepalive_responder() + + task.cancel.assert_called_once_with() + transport.close.assert_called_once_with() + assert vm._l1_keepalive_task is None + assert vm._l1_keepalive_transport is None + + +def test_l1_keepalive_ignored_for_disconnected_interface(vm): + + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 3, 0), None) + + transport.sendto.assert_not_called() + + +@pytest.mark.parametrize( + "keepalive", + [ + b"invalid", + struct.pack("!HHBBBB", 514, 1, 0x00, 0x00, 3, 0), + struct.pack("!HHBBBB", 513, 2, 0x00, 0x00, 3, 0), + struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 2, 0), + struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0), + struct.pack("!HHBBBB", 513, 1, 0x40, 0x40, 3, 0), + ], +) +def test_invalid_l1_keepalive_is_ignored(vm, keepalive): + + vm._adapters[0].add_nio(0, MagicMock()) + transport = MagicMock() + protocol = IOUL1KeepaliveProtocol(vm) + protocol.connection_made(transport) + + protocol.datagram_received(keepalive, None) + + transport.sendto.assert_not_called() + + @pytest.mark.asyncio async def test_build_command(vm): - assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", str(vm.application_id)] + vm.l1_keepalives = True + help_output = "-l\t\tEnable Layer 1 keepalive messages\n" + with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value=help_output): + assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", "-l", str(vm.application_id)] def test_get_startup_config(vm): @@ -350,7 +536,7 @@ async def test_enable_l1_keepalives(vm): command = ["test"] with pytest.raises(IOUError): await vm._enable_l1_keepalives(command) - assert command == ["test"] + assert command == ["test"] @pytest.mark.asyncio