(feat): Implement functional Layer 1 keepalive support for IOU/IOL nodes

This commit is contained in:
Cristi 2026-07-22 10:47:09 +03:00
parent 7bc9ae4aa8
commit dd497e4308
4 changed files with 368 additions and 9 deletions

View File

@ -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

View File

@ -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")

View File

@ -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"

View File

@ -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