diff --git a/gns3server/api/routes/compute/capabilities.py b/gns3server/api/routes/compute/capabilities.py index f2a51d702..df5e9d0e6 100644 --- a/gns3server/api/routes/compute/capabilities.py +++ b/gns3server/api/routes/compute/capabilities.py @@ -25,7 +25,7 @@ from fastapi import APIRouter, Request from gns3server import schemas from gns3server.compute import MODULES -from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities, probe_tap_support +from gns3server.compute.ubridge.tc_probe import probe_iol_tap_support, probe_tc_capabilities, probe_tap_support from gns3server.utils.path import get_default_project_directory from gns3server.utils.tc_capabilities import usable_ebpf_modes from gns3server.version import __version__ @@ -56,6 +56,10 @@ async def get_capabilities(request: Request) -> dict: # datapath. None = unknown (probe failed or uBridge too old). ubridge_tap = await probe_tap_support() + # IOL-port TAP termination (iol_bridge add_nio_tap): the anchor IOU's + # Ethernet ports need for the kernel datapath. None = unknown. + ubridge_iol_tap = await probe_iol_tap_support() + # record the controller hostname or IP address if request.client: request.app.state.controller_host = request.client.host @@ -69,4 +73,5 @@ async def get_capabilities(request: Request) -> dict: "node_types": node_types, "ubridge_tc": ubridge_tc, "ubridge_tap": ubridge_tap, + "ubridge_iol_tap": ubridge_iol_tap, } diff --git a/gns3server/compute/ubridge/tc_probe.py b/gns3server/compute/ubridge/tc_probe.py index f6ba7a3ec..23ed916fb 100644 --- a/gns3server/compute/ubridge/tc_probe.py +++ b/gns3server/compute/ubridge/tc_probe.py @@ -51,6 +51,8 @@ log = logging.getLogger(__name__) _cache = {} # binary identity -> whether the tap module works (None = unknown) _tap_cache = {} +# binary identity -> whether iol_bridge can bind a port to a TAP (None = unknown) +_iol_tap_cache = {} _lock = asyncio.Lock() @@ -183,3 +185,92 @@ async def _probe_tap(path, config, timeout): with contextlib.suppress(Exception): await hypervisor.stop() shutil.rmtree(working_dir, ignore_errors=True) + + +# Above the per-node IOL bridge ids: a real node's application id is 1..512, +# so its bridge locks /tmp/netio/513..1024. 1050 cannot collide with a +# node — only with another probe process, whose failure then answers "unknown" +# (the safe answer: IOU stays on the relay datapath). +_IOL_PROBE_BRIDGE_ID = 1050 + + +async def probe_iol_tap_support(timeout: float = 15.0): + """ + Whether this host's uBridge can terminate an IOL port on a persistent + TAP (``iol_bridge add_nio_tap``) — the anchor IOU's Ethernet ports need + for the kernel datapath. Probed on a scratch IOL bridge with a scratch + TAP (both cleaned up), cached by binary identity like the tap probe; + any failure (missing binary, old build answering "Unknown command", + lock contention with another server process) means unknown, reported as + None and cached as such. + """ + + config = Config.instance() + path = shutil.which(config.settings.Server.ubridge_path) + if not path: + return None + try: + stat = os.stat(path) + key = (path, stat.st_mtime_ns, stat.st_size) + except OSError: + return None + + async with _lock: + if key in _iol_tap_cache: + return _iol_tap_cache[key] + supported = await _probe_iol_tap(path, config, timeout) + _iol_tap_cache[key] = supported + return supported + + +async def _probe_iol_tap(path, config, timeout): + """ + Spawn a throwaway uBridge and run one add_nio_tap/delete_nio_tap cycle on + a scratch bridge and scratch TAP — attaching works on a stopped bridge, + so no iol_bridge start is needed. The scratch resources are cleaned up + best-effort even when the probe itself fails. + """ + + working_dir = tempfile.mkdtemp(prefix="gns3-iol-probe-") + hypervisor = Hypervisor( + None, + path, + working_dir, + config.settings.Server.ubridge_control_transport, + config.settings.Server.host, + str(uuid.uuid4()), + ) + bridge = f"gns3iolprobe{uuid.uuid4().hex[:4]}" + tap = f"gns3ita{uuid.uuid4().hex[:6]}" + try: + + async def ask(): + await hypervisor.start() + await hypervisor.connect() + await hypervisor.send(f"iol_bridge create {bridge} {_IOL_PROBE_BRIDGE_ID}") + try: + await hypervisor.send(f"tap create {tap}") + await hypervisor.send( + f"iol_bridge add_nio_tap {bridge} {_IOL_PROBE_BRIDGE_ID - 1} 0 0 {tap}" + ) + await hypervisor.send(f"iol_bridge delete_nio_tap {bridge} 0 0") + finally: + # iol_bridge delete releases every port NIO (the TAP fd), + # so the subsequent tap delete cannot hit EBADFD. + with contextlib.suppress(UbridgeError): + await hypervisor.send(f"iol_bridge delete {bridge}") + with contextlib.suppress(UbridgeError): + await hypervisor.send(f"tap delete {tap}") + return True + + try: + return await asyncio.wait_for(ask(), timeout=timeout) + except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e: + # Old build ("Unknown command"), missing module, or no + # CAP_NET_ADMIN: unknown. + log.debug("uBridge iol-tap probe failed: %s", e) + return None + finally: + with contextlib.suppress(Exception): + await hypervisor.stop() + shutil.rmtree(working_dir, ignore_errors=True) diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 3c470b1bb..8162a5e9c 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -101,6 +101,7 @@ class Compute: "node_types": [], "ubridge_tc": None, "ubridge_tap": None, + "ubridge_iol_tap": None, } self.name = name # Cache of interfaces on remote host diff --git a/gns3server/schemas/controller/capabilities.py b/gns3server/schemas/controller/capabilities.py index 72786f755..106df4b0f 100644 --- a/gns3server/schemas/controller/capabilities.py +++ b/gns3server/schemas/controller/capabilities.py @@ -55,3 +55,9 @@ class Capabilities(BaseModel): description="Whether uBridge can create the persistent TAPs a QEMU adapter anchors on " "(needed to wire QEMU links on the kernel datapath); absent when the probe failed", ) + ubridge_iol_tap: Optional[bool] = Field( + None, + description="Whether uBridge can bind an IOL port to a persistent TAP (iol_bridge add_nio_tap, " + "the anchor IOU Ethernet ports need to wire IOU links on the kernel datapath); absent when the " + "probe failed", + ) diff --git a/tests/api/routes/compute/test_capabilities.py b/tests/api/routes/compute/test_capabilities.py index 4934ae6f2..b196de34c 100644 --- a/tests/api/routes/compute/test_capabilities.py +++ b/tests/api/routes/compute/test_capabilities.py @@ -29,7 +29,7 @@ pytestmark = pytest.mark.asyncio async def _no_tap_probe(): - """The tap probe spawns uBridge — stub it out for payload assertions.""" + """The probes spawn uBridge — stub them out for payload assertions.""" return None @@ -42,6 +42,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -67,6 +68,7 @@ class TestCapabilitiesRoutes: "disk_size": psutil.disk_usage(get_default_project_directory()).total, "ubridge_tc": None, "ubridge_tap": None, + "ubridge_iol_tap": None, } async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None: @@ -76,6 +78,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -101,6 +104,7 @@ class TestCapabilitiesRoutes: "disk_size": psutil.disk_usage(get_default_project_directory()).total, "ubridge_tc": None, "ubridge_tap": None, + "ubridge_iol_tap": None, } async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: @@ -110,6 +114,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", fake_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json()["ubridge_tc"] == { @@ -129,6 +134,22 @@ class TestCapabilitiesRoutes: return True monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json()["ubridge_tap"] is True + + async def test_get_reports_ubridge_iol_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: + """ + The IOL-port TAP capability is what makes an IOU node kernel-eligible, + so it travels with the other compute capabilities. + """ + + async def fake_iol_tap_probe(): + return True + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", fake_iol_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) + response = await compute_client.get(app.url_path_for("compute:get_capabilities")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["ubridge_iol_tap"] is True diff --git a/tests/compute/ubridge/test_tc_probe.py b/tests/compute/ubridge/test_tc_probe.py index 3a7d2a640..96735ab8e 100644 --- a/tests/compute/ubridge/test_tc_probe.py +++ b/tests/compute/ubridge/test_tc_probe.py @@ -22,7 +22,7 @@ payload: spawn a throwaway uBridge, ask once, cache by binary identity. import pytest from gns3server.compute.ubridge import tc_probe -from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities, probe_tap_support +from gns3server.compute.ubridge.tc_probe import probe_iol_tap_support, probe_tc_capabilities, probe_tap_support from gns3server.compute.ubridge.ubridge_error import UbridgeError pytestmark = pytest.mark.asyncio @@ -35,6 +35,7 @@ class FakeHypervisor: reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] error = None + error_prefix = None # fail only commands starting with this (a build missing one command) spawned = 0 def __init__(self, project, path, working_dir, transport, host, node_id): @@ -54,6 +55,8 @@ class FakeHypervisor: self.commands.append(command) if self.error: raise UbridgeError(self.error) + if self.error_prefix and command.startswith(self.error_prefix): + raise UbridgeError(f"202-Unknown command") if command == "tc capabilities": return FakeHypervisor.reply return ["OK"] @@ -67,9 +70,11 @@ def probe_env(monkeypatch, tmp_path): FakeHypervisor.spawned = 0 FakeHypervisor.error = None + FakeHypervisor.error_prefix = None FakeHypervisor.reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] tc_probe._cache.clear() tc_probe._tap_cache.clear() + tc_probe._iol_tap_cache.clear() FakeHypervisor.last = None # a real file on disk: the cache is keyed on its (path, mtime, size) binary = tmp_path / "ubridge" @@ -143,3 +148,48 @@ async def test_probe_tap_support_missing_binary(monkeypatch): monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None) assert await probe_tap_support() is None assert FakeHypervisor.spawned == 0 + + +async def test_probe_iol_tap_support_runs_one_cycle_on_a_scratch_bridge(): + """ + The probe walks the exact add_nio_tap/delete_nio_tap cycle a kernel link + uses, on a scratch bridge whose id sits above the per-node id space, and + cleans up both scratch resources even when the cycle itself fails. + """ + + assert await probe_iol_tap_support() is True + assert FakeHypervisor.spawned == 1 + commands = FakeHypervisor.last.commands + assert commands[0].startswith("iol_bridge create gns3iolprobe") + assert commands[0].endswith(" 1050") + assert commands[1].startswith("tap create gns3ita") + assert commands[2].startswith("iol_bridge add_nio_tap ") + assert commands[2].split()[-1].startswith("gns3ita") + assert commands[3].startswith("iol_bridge delete_nio_tap ") + # cleanup: bridge first (it releases the TAP fd), then the device + assert commands[4].startswith("iol_bridge delete ") + assert commands[5].startswith("tap delete ") + + +async def test_probe_iol_tap_support_unknown_on_an_old_build(): + """ + A uBridge without iol_bridge add_nio_tap answers "Unknown command": the + probe reports unknown (IOU stays on the relay datapath), cleans up its + scratch bridge and tap, and caches the failure. + """ + + FakeHypervisor.error_prefix = "iol_bridge add_nio_tap" + assert await probe_iol_tap_support() is None + commands = FakeHypervisor.last.commands + assert any(c.startswith("iol_bridge delete ") for c in commands) + assert any(c.startswith("tap delete ") for c in commands) + # cached like the other probes: no respawn on every /capabilities hit + assert await probe_iol_tap_support() is None + assert FakeHypervisor.spawned == 1 + + +async def test_probe_iol_tap_support_missing_binary(monkeypatch): + + monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None) + assert await probe_iol_tap_support() is None + assert FakeHypervisor.spawned == 0 diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 1fce70eb1..ee48a143e 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -381,7 +381,7 @@ async def test_json(compute): "disk_usage_percent": 0, "connected": True, "last_error": None, - "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None}, + "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None, "ubridge_tap": None, "ubridge_iol_tap": None}, } assert compute.asdict(topology_dump=True) == { "compute_id": "my_compute_id",