From b4e4739d52fea65244219f72cabdf1d565439345 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 30 Sep 2026 09:50:47 +0800 Subject: [PATCH] feat(compute): report uBridge tc capabilities and gate filters on them Consume uBridge's ebpf_modes contract (feature/tc-window 40c36ae): a stateful mode is usable iff ebpf=1 and its token is listed; builds predating the field keep their shipped modes (nth/quota/flow), so the pre-correction window semantics can never be installed silently. - gns3server/utils/tc_capabilities.py: shared reply parser, mode policy and filter-type mappings (compute and controller gate on the same rules) - gns3server/compute/ubridge/tc_probe.py: standalone probe spawning a throwaway uBridge as the server's own user, cached by binary identity (path/mtime/size - the version string cannot distinguish rebuilds); failures cache as unknown - GET /v3/compute/capabilities gains ubridge_tc {netem, ebpf, ebpf_modes, cbpf}; the controller forwards it (Compute.capabilities) and available_filters hides kernel-datapath filter types an endpoint compute cannot run (both ends apply their own filters, so one incapable compute hides the type); computes without a report keep the full list and apply-time 409 stays the guard - docker: _ubridge_apply_ebpf_drops gates per mode token; the cleanup path only sends "off" for modes the build declares - split DockerVM's kernel-datapath machinery (veth lifecycle, carrier, marker primitives, tc filter translation, kernel NIO teardown) into compute/docker/docker_kernel_datapath.py (DockerKernelDatapathMixin) --- docs/design/ubridge-kernel-impairment-spec.md | 20 +- docs/features/docker-kernel-datapath.md | 23 +- gns3server/api/routes/compute/capabilities.py | 17 +- .../compute/docker/docker_kernel_datapath.py | 607 ++++++++++++++++++ gns3server/compute/docker/docker_vm.py | 555 +--------------- gns3server/compute/ubridge/tc_probe.py | 114 ++++ gns3server/controller/compute.py | 19 +- gns3server/controller/link.py | 45 +- gns3server/schemas/controller/capabilities.py | 19 +- gns3server/schemas/controller/computes.py | 4 + gns3server/utils/tc_capabilities.py | 85 +++ pyproject.toml | 1 + tests/api/routes/compute/test_capabilities.py | 29 +- .../docker/test_docker_kernel_datapath.py | 77 ++- tests/compute/ubridge/test_tc_probe.py | 115 ++++ tests/controller/test_compute.py | 2 +- tests/controller/test_kernel_datapath_link.py | 42 ++ tests/utils/test_tc_capabilities.py | 77 +++ 18 files changed, 1280 insertions(+), 571 deletions(-) create mode 100644 gns3server/compute/docker/docker_kernel_datapath.py create mode 100644 gns3server/compute/ubridge/tc_probe.py create mode 100644 gns3server/utils/tc_capabilities.py create mode 100644 tests/compute/ubridge/test_tc_probe.py create mode 100644 tests/utils/test_tc_capabilities.py diff --git a/docs/design/ubridge-kernel-impairment-spec.md b/docs/design/ubridge-kernel-impairment-spec.md index 454879a45..2cfa565c4 100644 --- a/docs/design/ubridge-kernel-impairment-spec.md +++ b/docs/design/ubridge-kernel-impairment-spec.md @@ -293,13 +293,23 @@ old uBridge without these commands simply stays on the relay datapath): ``` tc capabilities -100-netem=delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit;ebpf=1;cbpf=1 +100-netem=delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow ``` `ebpf=0` when the load probe fails at startup (kernel < 5.1 or no CAP_BPF); `cbpf=0` when RTM_NEWTFILTER/cls_bpf is unavailable. gns3-server hides the corresponding filter types per capability. +`ebpf_modes` (added on uBridge `feature/tc-window`, commit 40c36ae) is a +build fact, orthogonal to the runtime probes: the same binary flips `ebpf` +between users and kernels while the mode set changes only with the binary. +A mode is usable iff `ebpf=1` **and** its token is listed — gating +`window_drop` on `ebpf=1` alone silently accepts the 4-arg form on a +pre-correction build (`feature/tc-precision`: back-to-back windows, no +gap). Builds that do not emit the field at all predate it and get legacy +treatment; an incompatible mode revision renames its token +(`window` → `window2`) rather than versioning in place. + --- ## F. Test requirements @@ -338,6 +348,14 @@ corresponding filter types per capability. mirrors the grammar's `period ≥ outage` rule and treats `start = 0` as active (an immediate outage). Not yet exposed: `flow_drop` (parameter-shape UX decision). +* **Done** (capability reporting): the `ebpf_modes` contract is consumed + end to end — the compute applies it per mode (window requires its token; + pre-field builds keep their shipped modes), the compute + `GET /capabilities` payload reports `ubridge_tc` through a standalone + spawn-as-own-user probe cached by binary identity, and the controller's + `available_filters` hides kernel-datapath filter types an endpoint + compute reports it cannot run (computes without a report keep the full + list; apply-time 409 stays the guard). * Every veth end owns one qdisc + its filters: per-direction impairment is an architectural freebie to expose later (API `direction` field), aligned with marker `dir` semantics. diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index fe7840197..d560bee31 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -293,6 +293,27 @@ Semantics: frames (on the relay, filter-vs-mark ordering determined visibility instead). Also note `tc qdisc show` cannot print a distribution's name (the kernel stores only the sampled table). +* **eBPF mode tokens.** A stateful mode is usable iff `tc capabilities` + reports `ebpf=1` **and** lists the mode's token in `ebpf_modes` + (uBridge `feature/tc-window`+). Builds predating the field keep the modes + that shipped with it — everything but `window`, whose semantics were + corrected exactly when the field was added: gating on `ebpf=1` alone + would silently install the broken back-to-back-window behaviour on a + pre-correction build. Requesting an unusable mode fails the apply with an + upgrade error; the cleanup ("off") path only touches modes the build + declares. +* **Capability reporting.** The compute exposes the probe through + `GET /v3/compute/capabilities` as `ubridge_tc` (netem keyword list, + `ebpf`/`cbpf` flags, and the usable `ebpf_modes` — legacy fallback + applied), answered by a throwaway uBridge spawned as the server's own + user and cached by binary identity — the runtime `ebpf` flag flips + between users and kernels for the same binary, so a root-run probe would + lie for a non-root server. The controller forwards it through + `GET /v3/computes` and `available_filters` hides kernel-datapath filter + types an endpoint compute reports it cannot run (both ends apply their + own filters, so one incapable compute is enough). Computes that report + nothing — older servers, failed probe — keep the full list; + apply-time validation (409) stays the guard. ## uBridge command surface @@ -313,7 +334,7 @@ tc nth_drop # eBPF exact every-Nth (frequency_drop) tc quota_drop | off # eBPF byte cap (quota) tc window_drop [ []] | off # eBPF time window (window_drop) tc reset # full restore: eBPF filter -> bpf_drops -> clsact -> root qdisc, idempotent -tc capabilities # "netem=;ebpf=0|1;cbpf=0|1", probed once per process +tc capabilities # "netem=;ebpf=0|1;cbpf=0|1[;ebpf_modes=]", probed once per process bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture ``` diff --git a/gns3server/api/routes/compute/capabilities.py b/gns3server/api/routes/compute/capabilities.py index 363b2906b..055d0fb05 100644 --- a/gns3server/api/routes/compute/capabilities.py +++ b/gns3server/api/routes/compute/capabilities.py @@ -25,19 +25,33 @@ from fastapi import APIRouter, Request from gns3server import schemas from gns3server.compute import MODULES +from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities from gns3server.utils.path import get_default_project_directory +from gns3server.utils.tc_capabilities import usable_ebpf_modes from gns3server.version import __version__ router = APIRouter() @router.get("/capabilities", response_model=schemas.Capabilities) -def get_capabilities(request: Request) -> dict: +async def get_capabilities(request: Request) -> dict: node_types = [] for module in MODULES: node_types.extend(module.node_types()) + # Kernel-datapath packet filters: what this host's uBridge can run, + # probed as the server's own user (None = unknown, old/missing uBridge). + ubridge_tc = None + caps = await probe_tc_capabilities() + if caps: + ubridge_tc = { + "netem": [keyword for keyword in caps.get("netem", "").split(",") if keyword], + "ebpf": caps.get("ebpf") == "1", + "ebpf_modes": list(usable_ebpf_modes(caps)), + "cbpf": caps.get("cbpf") == "1", + } + # record the controller hostname or IP address if request.client: request.app.state.controller_host = request.client.host @@ -49,4 +63,5 @@ def get_capabilities(request: Request) -> dict: "memory": psutil.virtual_memory().total, "disk_size": psutil.disk_usage(get_default_project_directory()).total, "node_types": node_types, + "ubridge_tc": ubridge_tc, } diff --git a/gns3server/compute/docker/docker_kernel_datapath.py b/gns3server/compute/docker/docker_kernel_datapath.py new file mode 100644 index 000000000..a47ada1cd --- /dev/null +++ b/gns3server/compute/docker/docker_kernel_datapath.py @@ -0,0 +1,607 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Kernel-datapath machinery for DockerVM, extracted from docker_vm.py: the +veth pair lifecycle (deterministic names, creation, teardown together with +the per-link kernel bridges), the adapter carrier control, the kernel +marker primitives and the tc packet-filter translation (netem, classic-BPF +match-drop, eBPF stateful drops). Every method runs with a DockerVM +instance as ``self`` and reads the same state (_kernel_veths, +_ubridge_tc_caps, the uBridge hypervisor through the BaseNode helpers). +""" + +import os +import contextlib + +from gns3server.utils.tc_capabilities import FILTER_EBPF_MODES, parse_tc_capabilities, usable_ebpf_modes +from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError + +from ..nios.nio_bridge import NIOBridge +from .docker_error import DockerError + +import logging + +log = logging.getLogger(__name__) + + +class DockerKernelDatapathMixin: + """ + The kernel-datapath half of a Docker node — consumed by DockerVM so + docker_vm.py stays focused on the container lifecycle. + """ + + def _veth_names(self, adapter_number, port_number=0): + """ + Deterministic veth pair names for a kernel-datapath adapter port. + The host end stays in the root namespace (enslaved to the per-link + kernel bridge); the guest end is moved into the container namespace + and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3`` + bridge/TAP name space and serve as search keys for stale-interface + cleanup. 8 hex chars of the node id + adapter/port keep the names + unique and within IFNAMSIZ (15). + """ + + suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" + return f"gv{suffix}", f"gc{suffix}" + + async def _create_veth(self, adapter_number, port_number=0): + """ + Create the adapter's unified veth pair: the guest end moves into the + container namespace as eth{N} (the role the TAP used to play on the + relay path), the host end stays in the root namespace with carrier + off until a link attaches. Both datapaths anchor here — kernel links + enslave the host end into a Linux bridge; relay links attach it to + the uBridge relay via AF_PACKET. + """ + + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number + ) + ) + + host_ifc, guest_ifc = self._veth_names(adapter_number, port_number) + # The host end lives in the root namespace and survives container + # death: best-effort removal of a stale pair from a previous run + # (unclean stop or crash) before recreating it. + for stale_ifc in (host_ifc, guest_ifc): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{stale_ifc}"') + + mac_address = self._adapter_mac_address(adapter_number) + try: + await self._ubridge_send(f'docker create_veth "{host_ifc}" "{guest_ifc}"') + # create_veth leaves the host end UP; carrier must be off until + # a link attaches (mirrors the old "bridge add_nio_tap ... off"). + await self._ubridge_send(f'link set "{host_ifc}" down') + try: + await self._ubridge_send(f"docker set_mac_addr {guest_ifc} {mac_address}") + except UbridgeError: + log.warning(f"Could not set MAC address {mac_address} on interface {guest_ifc}") + + ifname = self._get_container_ifname(adapter_number) + log.debug(f"Move container {self.name} adapter {guest_ifc} -> {ifname} in ns {self._namespace}") + try: + await self._ubridge_send(f"docker move_to_ns {guest_ifc} {self._namespace} {ifname}") + except UbridgeError as e: + raise UbridgeNamespaceError(e) + except UbridgeNamespaceError: + raise + except Exception: + # Don't leave a half-created pair behind (the next start would + # fail on create_veth EEXIST until the stale sweep runs). + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{host_ifc}"') + raise + + adapter.host_ifc = host_ifc + self._kernel_veths[(adapter_number, port_number)] = host_ifc + log.debug( + "Created veth adapter {adapter_number} port {port_number} with MAC address {mac_address} in namespace {namespace}".format( + adapter_number=adapter_number, + port_number=port_number, + mac_address=mac_address, + namespace=self._namespace, + ) + ) + + async def _remove_kernel_veths(self): + """ + Delete the root-namespace veth host ends. Unlike relay TAPs (which die + with the container network namespace) the host end outlives the + container and must be removed explicitly. Deleting an enslaved veth + detaches it from its bridge automatically. + + The per-link kernel bridges go too: deleting a node (or closing a + project) never runs the link-teardown path, so a bridge whose ports + just disappeared would stay behind as an empty orphan. Both link + endpoints run this — the peer's port still enslaved makes the delete + fail with EBUSY (suppressed) and the last one wins, the same + contract as _remove_kernel_nio. A node restart re-creates the bridge + from the NIO in _connect_nio, so nothing is lost by deleting it. + """ + + if self.ubridge: + for host_ifc in self._kernel_veths.values(): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{host_ifc}"') + for adapter in self._ethernet_adapters: + for nio in adapter.ports.values(): + if isinstance(nio, NIOBridge): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delete "{nio.bridge}"') + self._kernel_veths.clear() + + async def _set_adapter_carrier(self, adapter_number, connected, port_number=0): + """Replicate a Docker adapter's connection state on its host interface. + + Kernel-datapath adapters toggle the veth host end's admin state (the + container end loses carrier when its peer is down); relay adapters + toggle the TAP carrier from the uBridge fd holder. + """ + + host_ifc = self._kernel_veths.get((adapter_number, port_number)) + if host_ifc is not None: + state = "up" if connected else "down" + await self._ubridge_send(f'link set "{host_ifc}" {state}') + return + + bridge_name = self._bridge_name(adapter_number, port_number) + state = "on" if connected else "off" + await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}") + + def _kernel_marker_anchor(self, anchor): + """ + Whether *anchor* names one of this container's kernel-datapath veth + host ends. Marker anchors are polymorphic: a relay bridge name on the + relay datapath, the veth host interface on the kernel datapath. A veth + name can never collide with a uBridge bridge name (relay bridges are + ``bridge{adapter}``, veths are ``gv…e…p…``). + """ + + return anchor in self._kernel_veths.values() + + async def _ubridge_add_marker_filter( + self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None + ): + """ + Kernel-datapath variant of the `mark` filter attach: ``marker + add_kernel`` binds an AF_PACKET socket to the veth host end. Keyword + pairs and semantics (match → MARK signal + pcap append) are identical + to the relay ``bridge add_packet_filter … mark`` command. + """ + + if self._kernel_marker_anchor(bridge_name): + self._validate_marker_name(name) + cmd = 'marker add_kernel {name} {ifc} "{bpf}"'.format(name=name, ifc=bridge_name, bpf=bpf) + if tag is not None: + cmd += f" tag {tag}" + if link_id: + cmd += f" link {link_id}" + if direction is not None: + cmd += f" dir {direction}" + linktype = self._marker_linktype(data_link_type) + if linktype is not None: + cmd += f" linktype {linktype}" + cmd += ' pcap "{path}"'.format(path=pcap_path) + await self._ubridge_send(cmd) + return + await super()._ubridge_add_marker_filter( + bridge_name, + name, + bpf, + pcap_path, + tag=tag, + link_id=link_id, + direction=direction, + data_link_type=data_link_type, + ) + + async def _ubridge_delete_marker_filter(self, bridge_name, name): + """ + Kernel-datapath variant of the marker removal: ``marker delete_kernel`` + (idempotent in uBridge — a no-op when the marker is already gone). + """ + + if self._kernel_marker_anchor(bridge_name): + if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): + return + try: + await self._ubridge_send(f"marker delete_kernel {bridge_name} {name}") + except UbridgeError as e: + log.warning("Could not remove kernel marker '%s' from %s: %s", name, bridge_name, e) + return + await super()._ubridge_delete_marker_filter(bridge_name, name) + + async def _ubridge_enable_marker_filter(self, anchor, name, state): + """ + Kernel-datapath variant of the marker on/off toggle: ``marker + enable_kernel`` — installed but silent when off, pcap preserved. + """ + + if self._kernel_marker_anchor(anchor): + await self._ubridge_send(f"marker enable_kernel {anchor} {name} {state}") + return + await super()._ubridge_enable_marker_filter(anchor, name, state) + + @staticmethod + def _netem_command_parts(filters): + """ + Build the ``tc netem set`` keyword sequence (in the frozen grammar + order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate, + limit, distribution, seed) plus the set of capability tokens the + sequence needs beyond the original netem surface. Original-surface + keywords (delay, jitter, loss, corrupt, dup) need no probe; the + extension tokens (rate, reorder, gemodel, dist, seed, limit) are + checked against ``tc capabilities`` by the caller. ``correl`` has no + token of its own in the capabilities list, so it gates on "rate" as + the netem-extension build marker. + + :param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...}) + + :returns: (keyword sequence, required extension tokens) + """ + + def values_of(key): + # tolerate the legacy bare-value shape ({"packet_loss": 10}) + values = filters.get(key) + if isinstance(values, (list, tuple)): + return list(values) + return [values] if values else [] + + parts = [] + ext_tokens = set() + + delay = values_of("delay") + if delay: + parts.append(f"delay {int(delay[0])}") + if len(delay) > 1 and int(delay[1]): + parts.append(f"jitter {int(delay[1])}") + + gemodel = values_of("gemodel") + if gemodel: + segment = f"loss gemodel {int(gemodel[0])}" + if len(gemodel) > 1: + segment += f" {int(gemodel[1])}" + if len(gemodel) > 2: + segment += f" {int(gemodel[2])}" + parts.append(segment) + ext_tokens.add("gemodel") + else: + loss = values_of("packet_loss") + if loss and int(loss[0]): + parts.append(f"loss {int(loss[0])}") + if len(loss) > 1 and int(loss[1]): + parts.append(f"correl {int(loss[1])}") + ext_tokens.add("rate") # correl: extension build marker + + duplicate = values_of("duplicate") + if duplicate and int(duplicate[0]): + parts.append(f"dup {int(duplicate[0])}") + if len(duplicate) > 1 and int(duplicate[1]): + parts.append(f"correl {int(duplicate[1])}") + ext_tokens.add("rate") # correl: extension build marker + + corrupt = values_of("corrupt") + if corrupt and int(corrupt[0]): + parts.append(f"corrupt {int(corrupt[0])}") + + reorder = values_of("reorder") + if reorder: + segment = f"reorder {int(reorder[0])}" + if len(reorder) > 1 and int(reorder[1]): + segment += f" correl {int(reorder[1])}" + if len(reorder) > 2 and int(reorder[2]): + segment += f" gap {int(reorder[2])}" + parts.append(segment) + ext_tokens.add("reorder") + + rate = values_of("rate") + if rate and str(rate[0]).strip(): + parts.append(f"rate {str(rate[0]).strip()}") + ext_tokens.add("rate") + + limit = values_of("limit") + if limit and int(limit[0]): + parts.append(f"limit {int(limit[0])}") + ext_tokens.add("limit") + + if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"): + # "uniform" is the kernel default — emitting nothing keeps the + # command compatible with the original netem surface. + parts.append(f"distribution {str(delay[2]).strip().lower()}") + ext_tokens.add("dist") + + seed = values_of("seed") + if seed and int(seed[0]): + # 0 is the "disabled" convention everywhere else — treat it so + # here too (the controller's inactive-filter pass drops it). + parts.append(f"seed {int(seed[0])}") + ext_tokens.add("seed") + + return parts, ext_tokens + + async def _ubridge_apply_netem(self, host_ifc, filters): + """ + Translate the NIO's impairment filters into one tc netem qdisc on the + veth host end. The netem-expressible types (delay, packet_loss, + corrupt and the netem extensions rate/reorder/gemodel/duplicate/ + seed/limit) merge into a single qdisc — its egress covers the traffic + entering this container, and with both link endpoints applying + theirs, every direction of the link is impaired exactly once (the + same net effect as the relay, where both directions cross the single + filtered bridge). + + The kernel's netem replace MERGES optional attributes (rate, + correlation, reorder, corrupt, gemodel, distribution: absent attr = + previous value kept), so re-applying a filter set with a parameter + *removed* would silently keep the old value. A ``tc reset`` before + every ``netem set`` makes the re-apply a true reconcile (reset also + drops clsact and its bpf_drop filters — the caller re-applies them + right after in the same flow). An empty filter set is the reset + alone. + + :param host_ifc: veth host-end interface name + :param filters: NIO filters dictionary ({"delay": [ms, jitter], ...}) + """ + + parts, ext_tokens = self._netem_command_parts(filters) + if parts and ext_tokens: + # Extension keywords need the netem-extension uBridge. Plain + # delay/loss/corrupt/dup never probes — an old uBridge serves + # the original surface untouched (mirrors bpf_drop's guard). + # Checked before the reset so a failed update leaves the + # previously applied qdisc in place. + caps = await self._ubridge_tc_capabilities() + missing = ext_tokens - set(caps.get("netem", "").split(",")) + if missing: + raise DockerError( + "Packet filter(s) using {} need a uBridge with the netem extensions " + "(tc capabilities reports '{}'); upgrade uBridge on this compute".format( + ", ".join(sorted(missing)), caps.get("netem") or "no tc support" + ) + ) + try: + await self._ubridge_send(f'tc reset "{host_ifc}"') + except UbridgeError as e: + # "No such file or directory" = no qdisc was attached: already + # clean. Everything else is a real failure and must surface. + if "No such file" not in str(e): + raise + if not parts: + return + await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts))) + + async def _ubridge_tc_capabilities(self): + """ + uBridge's tc-module feature report (``tc capabilities``), probed once + per uBridge process and cached — the reply shape is + ``netem=;ebpf=0|1;cbpf=0|1[;ebpf_modes=]`` (parsed + by parse_tc_capabilities). uBridge builds without the tc module + answer "Unknown module" and old builds without the command answer + "Unknown command": both mean no kernel filter support, cached as an + empty dict. + """ + + if self._ubridge_tc_caps is None: + caps = {} + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + try: + reply = await self._ubridge_hypervisor.send("tc capabilities") + except UbridgeError: + reply = [] + caps = parse_tc_capabilities(reply[0] if reply else "") + self._ubridge_tc_caps = caps + return self._ubridge_tc_caps + + async def _ubridge_apply_bpf_drops(self, host_ifc, filters): + """ + Translate the NIO's ``bpf`` filter (one expression per line, any line + matching drops the packet) into cls_bpf classifiers on the veth host + end's clsact egress: ``tc bpf_drop`` compiles each line with libpcap + (same compiler as the relay's bpf filter) and drops matches with a + gact TC_ACT_SHOT. Reconcile is always flush + re-add — same-prio + resend replaces the filter node in uBridge, and the flush drops + lines the user removed. + + :param host_ifc: veth host-end interface name + :param filters: NIO filters dictionary + """ + + bpf = filters.get("bpf") + lines = [line.strip() for line in (bpf[0].split("\n") if bpf else []) if line.strip()] + if not lines and self._ubridge_tc_caps is None: + # Nothing to add and nothing was ever installed (no tc module + # probed yet) — skip touching an old uBridge entirely. + return + caps = await self._ubridge_tc_capabilities() + if not lines: + if caps: + await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"') + return + if caps.get("cbpf") != "1": + raise DockerError( + "Packet filter 'bpf' on a kernel-datapath link needs a uBridge with cBPF support " + "(tc capabilities reports no cbpf); upgrade uBridge on this compute or keep the " + "link on the relay datapath" + ) + await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"') + for offset, line in enumerate(lines): + try: + await self._ubridge_send( + 'tc bpf_drop add "{ifc}" {prio} "{expr}"'.format(ifc=host_ifc, prio=10 + offset, expr=line) + ) + except UbridgeError as e: + if "Cannot compile filter" not in str(e): + raise + # Mirror the relay path: a line that no longer compiles on + # this uBridge's libpcap is skipped with a warning instead + # of breaking the link (the controller already validated + # the syntax with tcpdump at create/update time). + message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {line}" + log.warning(message) + self.project.emit("log.warning", {"message": message}) + + async def _ubridge_apply_ebpf_drops(self, host_ifc, filters): + """ + Translate the NIO's stateful filters into uBridge's eBPF impairment + classifier on the veth host end's clsact egress (prio 1, below + bpf_drop's 10-99): ``frequency_drop`` becomes the exact every-Nth + mode (``tc nth_drop``; -1 = drop everything maps to every 1st), the + kernel-only ``quota`` type the byte-cap mode (``tc quota_drop``), + and the kernel-only ``window_drop`` type the time-window mode + (``tc window_drop``: single outage, or recurring flaps with an + optional per-cycle jitter). The program evaluates the modes in the + fixed order nth → quota → window. A mode is only sent when + ``tc capabilities`` declares its token (``ebpf_modes``); builds + predating the field keep their shipped modes (everything but + window). + + Reconcile is a plain re-set on every apply: the preceding netem + apply resets the interface (which detaches the program and drops + uBridge's per-interface registry entry), so a mode set re-loads the + program from scratch, and a mode left absent is turned off + explicitly (idempotent) so a removed filter stops dropping. For + window_drop this also means the schedule restarts on every apply: + the first window opens Start-ms from the moment of this call. + + Semantic difference vs the relay, documented server-side: the relay + counts packets of BOTH directions through its single filtered + bridge, while each veth end's classifier counts only the traffic + entering that container — with both endpoints applying theirs, each + direction drops every Nth independently (their window schedules + start ~simultaneously, so a round trip survives only when both + directions are outside their windows). + + :param host_ifc: veth host-end interface name + :param filters: NIO filters dictionary + """ + + def values_of(key): + values = filters.get(key) + if isinstance(values, (list, tuple)): + return list(values) + return [values] if values else [] + + frequency = values_of("frequency_drop") + quota = values_of("quota") + window = values_of("window_drop") + requested = {filter_type: values_of(filter_type) for filter_type in FILTER_EBPF_MODES} + if not any(requested.values()) and self._ubridge_tc_caps is None: + # Nothing to set and nothing was ever installed (no probe yet) — + # skip touching an old uBridge entirely. + return + caps = await self._ubridge_tc_capabilities() + # A mode is usable iff ebpf=1 and the build declares its token + # (ebpf_modes); builds predating the field keep their shipped modes. + modes = usable_ebpf_modes(caps) + if not any(requested.values()): + # Only modes this build declares can ever have been installed — + # sending "off" for an undeclared mode would hit an unknown + # command; "off" is idempotent in uBridge. + for filter_type, mode in FILTER_EBPF_MODES.items(): + if mode in modes: + await self._ubridge_send(f'tc {mode}_drop "{host_ifc}" off') + return + unsupported = sorted(f for f, values in requested.items() if values and FILTER_EBPF_MODES[f] not in modes) + if unsupported: + if not modes: + raise DockerError( + "Packet filter(s) {} on a kernel-datapath link need a uBridge with eBPF support " + "(tc capabilities reports no ebpf; setcap cap_bpf,cap_net_admin,cap_net_raw=ep on " + "the uBridge binary); upgrade uBridge on this compute or keep the link on the " + "relay datapath".format(", ".join(unsupported)) + ) + raise DockerError( + "Packet filter(s) {} on a kernel-datapath link need a newer uBridge (tc capabilities " + "reports ebpf_modes={}); upgrade uBridge on this compute or keep the link on the " + "relay datapath".format(", ".join(unsupported), ",".join(modes)) + ) + if frequency: + # relay semantics: -1 = drop everything, N = every Nth packet + nth = 1 if int(frequency[0]) == -1 else int(frequency[0]) + await self._ubridge_send(f'tc nth_drop "{host_ifc}" {nth}') + elif "nth" in modes: + await self._ubridge_send(f'tc nth_drop "{host_ifc}" off') + if quota: + await self._ubridge_send( + 'tc quota_drop "{ifc}" {bytes} {pct}'.format(ifc=host_ifc, bytes=int(quota[0]), pct=int(quota[1])) + ) + elif "quota" in modes: + await self._ubridge_send(f'tc quota_drop "{host_ifc}" off') + if window: + # [start, outage, chance, period?, jitter?] — start is relative + # to this apply (the schedule restarts on every reconcile). + params = " ".join(str(int(v)) for v in window) + await self._ubridge_send(f'tc window_drop "{host_ifc}" {params}') + elif "window" in modes: + await self._ubridge_send(f'tc window_drop "{host_ifc}" off') + + async def _remove_kernel_markers(self, host_ifc): + """ + Tear down every kernel marker anchored to *host_ifc*: the veth survives + link deletion (it is the adapter interface, not the link), so the + AF_PACKET sockets must be closed explicitly — otherwise a deleted + link's markers would keep sniffing and signaling. Mirrors the relay + datapath where deleting the uBridge bridge silently drops its filters. + """ + + from gns3server.compute.marker.marker_manager import MarkerManager + + manager = MarkerManager.instance() + markers_dir = self.project.markers_working_directory() + for (name, link_id), anchor in list(self._marker_filter_bridges.items()): + if anchor != host_ifc: + continue + self._marker_filter_bridges.pop((name, link_id)) + self._marker_specs.pop((name, link_id), None) + await self._ubridge_delete_marker_filter(host_ifc, name) + try: + os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")) + except FileNotFoundError: + pass + except OSError as e: + log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e) + manager.unregister(self._id, name) + + async def _remove_kernel_nio(self, nio, adapter_number, port_number=0): + """ + Detach a kernel-datapath adapter port from its per-link kernel bridge. + Both endpoints run this concurrently: deleting a bridge that still has + the peer's port enslaved fails with EBUSY, so the last endpoint to + remove its port wins the deletion and the loser's failure is expected + and suppressed. + """ + + host_ifc = self._kernel_veths.get((adapter_number, port_number)) + if host_ifc is not None: + await self._remove_kernel_markers(host_ifc) + # The veth survives link deletion, so an orphaned netem qdisc + # would keep impairing whatever attaches to the adapter next + # (kernel link or relay). Best-effort: ENOENT just means no + # qdisc was attached. + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tc reset "{host_ifc}"') + if self.status == "started": + await self._set_adapter_carrier(adapter_number, False, port_number) + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"') + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delete "{nio.bridge}"') diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 7e8384b51..98feb66c1 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -48,11 +48,12 @@ from ..error import ImageMissingError from ..nios.nio_bridge import NIOBridge from ..nios.nio_udp import NIOUDP from .docker_error import DockerError, DockerHttp404Error, DockerHttp409Error +from .docker_kernel_datapath import DockerKernelDatapathMixin log = logging.getLogger(__name__) -class DockerVM(BaseNode): +class DockerVM(DockerKernelDatapathMixin, BaseNode): """ Docker container implementation. @@ -1799,134 +1800,11 @@ class DockerVM(BaseNode): mac_address = custom_mac_address return mac_address - def _veth_names(self, adapter_number, port_number=0): - """ - Deterministic veth pair names for a kernel-datapath adapter port. - The host end stays in the root namespace (enslaved to the per-link - kernel bridge); the guest end is moved into the container namespace - and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3`` - bridge/TAP name space and serve as search keys for stale-interface - cleanup. 8 hex chars of the node id + adapter/port keep the names - unique and within IFNAMSIZ (15). - """ - - suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" - return f"gv{suffix}", f"gc{suffix}" - - async def _create_veth(self, adapter_number, port_number=0): - """ - Create the adapter's unified veth pair: the guest end moves into the - container namespace as eth{N} (the role the TAP used to play on the - relay path), the host end stays in the root namespace with carrier - off until a link attaches. Both datapaths anchor here — kernel links - enslave the host end into a Linux bridge; relay links attach it to - the uBridge relay via AF_PACKET. - """ - - try: - adapter = self._ethernet_adapters[adapter_number] - except IndexError: - raise DockerError( - "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( - name=self.name, adapter_number=adapter_number - ) - ) - - host_ifc, guest_ifc = self._veth_names(adapter_number, port_number) - # The host end lives in the root namespace and survives container - # death: best-effort removal of a stale pair from a previous run - # (unclean stop or crash) before recreating it. - for stale_ifc in (host_ifc, guest_ifc): - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'docker delete_veth "{stale_ifc}"') - - mac_address = self._adapter_mac_address(adapter_number) - try: - await self._ubridge_send(f'docker create_veth "{host_ifc}" "{guest_ifc}"') - # create_veth leaves the host end UP; carrier must be off until - # a link attaches (mirrors the old "bridge add_nio_tap ... off"). - await self._ubridge_send(f'link set "{host_ifc}" down') - try: - await self._ubridge_send(f"docker set_mac_addr {guest_ifc} {mac_address}") - except UbridgeError: - log.warning(f"Could not set MAC address {mac_address} on interface {guest_ifc}") - - ifname = self._get_container_ifname(adapter_number) - log.debug(f"Move container {self.name} adapter {guest_ifc} -> {ifname} in ns {self._namespace}") - try: - await self._ubridge_send(f"docker move_to_ns {guest_ifc} {self._namespace} {ifname}") - except UbridgeError as e: - raise UbridgeNamespaceError(e) - except UbridgeNamespaceError: - raise - except Exception: - # Don't leave a half-created pair behind (the next start would - # fail on create_veth EEXIST until the stale sweep runs). - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'docker delete_veth "{host_ifc}"') - raise - - adapter.host_ifc = host_ifc - self._kernel_veths[(adapter_number, port_number)] = host_ifc - log.debug( - "Created veth adapter {adapter_number} port {port_number} with MAC address {mac_address} in namespace {namespace}".format( - adapter_number=adapter_number, - port_number=port_number, - mac_address=mac_address, - namespace=self._namespace, - ) - ) - - async def _remove_kernel_veths(self): - """ - Delete the root-namespace veth host ends. Unlike relay TAPs (which die - with the container network namespace) the host end outlives the - container and must be removed explicitly. Deleting an enslaved veth - detaches it from its bridge automatically. - - The per-link kernel bridges go too: deleting a node (or closing a - project) never runs the link-teardown path, so a bridge whose ports - just disappeared would stay behind as an empty orphan. Both link - endpoints run this — the peer's port still enslaved makes the delete - fail with EBUSY (suppressed) and the last one wins, the same - contract as _remove_kernel_nio. A node restart re-creates the bridge - from the NIO in _connect_nio, so nothing is lost by deleting it. - """ - - if self.ubridge: - for host_ifc in self._kernel_veths.values(): - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'docker delete_veth "{host_ifc}"') - for adapter in self._ethernet_adapters: - for nio in adapter.ports.values(): - if isinstance(nio, NIOBridge): - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'brctl delete "{nio.bridge}"') - self._kernel_veths.clear() - async def _get_namespace(self): result = await self.manager.query("GET", f"containers/{self._cid}/json") return int(result["State"]["Pid"]) - async def _set_adapter_carrier(self, adapter_number, connected, port_number=0): - """Replicate a Docker adapter's connection state on its host interface. - - Kernel-datapath adapters toggle the veth host end's admin state (the - container end loses carrier when its peer is down); relay adapters - toggle the TAP carrier from the uBridge fd holder. - """ - - host_ifc = self._kernel_veths.get((adapter_number, port_number)) - if host_ifc is not None: - state = "up" if connected else "down" - await self._ubridge_send(f'link set "{host_ifc}" {state}') - return - - bridge_name = self._bridge_name(adapter_number, port_number) - state = "on" if connected else "off" - await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}") - async def _connect_nio(self, adapter_number, nio, port_number=0): host_ifc = self._kernel_veths.get((adapter_number, port_number)) @@ -2094,435 +1972,6 @@ class DockerVM(BaseNode): log.debug(f"Docker VM '{self.name}' [{self.id}]: {adapter.host_ifc} removed from adapter {adapter_number}") - def _kernel_marker_anchor(self, anchor): - """ - Whether *anchor* names one of this container's kernel-datapath veth - host ends. Marker anchors are polymorphic: a relay bridge name on the - relay datapath, the veth host interface on the kernel datapath. A veth - name can never collide with a uBridge bridge name (relay bridges are - ``bridge{adapter}``, veths are ``gv…e…p…``). - """ - - return anchor in self._kernel_veths.values() - - async def _ubridge_add_marker_filter( - self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None - ): - """ - Kernel-datapath variant of the `mark` filter attach: ``marker - add_kernel`` binds an AF_PACKET socket to the veth host end. Keyword - pairs and semantics (match → MARK signal + pcap append) are identical - to the relay ``bridge add_packet_filter … mark`` command. - """ - - if self._kernel_marker_anchor(bridge_name): - self._validate_marker_name(name) - cmd = 'marker add_kernel {name} {ifc} "{bpf}"'.format(name=name, ifc=bridge_name, bpf=bpf) - if tag is not None: - cmd += f" tag {tag}" - if link_id: - cmd += f" link {link_id}" - if direction is not None: - cmd += f" dir {direction}" - linktype = self._marker_linktype(data_link_type) - if linktype is not None: - cmd += f" linktype {linktype}" - cmd += ' pcap "{path}"'.format(path=pcap_path) - await self._ubridge_send(cmd) - return - await super()._ubridge_add_marker_filter( - bridge_name, - name, - bpf, - pcap_path, - tag=tag, - link_id=link_id, - direction=direction, - data_link_type=data_link_type, - ) - - async def _ubridge_delete_marker_filter(self, bridge_name, name): - """ - Kernel-datapath variant of the marker removal: ``marker delete_kernel`` - (idempotent in uBridge — a no-op when the marker is already gone). - """ - - if self._kernel_marker_anchor(bridge_name): - if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): - return - try: - await self._ubridge_send(f"marker delete_kernel {bridge_name} {name}") - except UbridgeError as e: - log.warning("Could not remove kernel marker '%s' from %s: %s", name, bridge_name, e) - return - await super()._ubridge_delete_marker_filter(bridge_name, name) - - async def _ubridge_enable_marker_filter(self, anchor, name, state): - """ - Kernel-datapath variant of the marker on/off toggle: ``marker - enable_kernel`` — installed but silent when off, pcap preserved. - """ - - if self._kernel_marker_anchor(anchor): - await self._ubridge_send(f"marker enable_kernel {anchor} {name} {state}") - return - await super()._ubridge_enable_marker_filter(anchor, name, state) - - @staticmethod - def _netem_command_parts(filters): - """ - Build the ``tc netem set`` keyword sequence (in the frozen grammar - order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate, - limit, distribution, seed) plus the set of capability tokens the - sequence needs beyond the original netem surface. Original-surface - keywords (delay, jitter, loss, corrupt, dup) need no probe; the - extension tokens (rate, reorder, gemodel, dist, seed, limit) are - checked against ``tc capabilities`` by the caller. ``correl`` has no - token of its own in the capabilities list, so it gates on "rate" as - the netem-extension build marker. - - :param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...}) - - :returns: (keyword sequence, required extension tokens) - """ - - def values_of(key): - # tolerate the legacy bare-value shape ({"packet_loss": 10}) - values = filters.get(key) - if isinstance(values, (list, tuple)): - return list(values) - return [values] if values else [] - - parts = [] - ext_tokens = set() - - delay = values_of("delay") - if delay: - parts.append(f"delay {int(delay[0])}") - if len(delay) > 1 and int(delay[1]): - parts.append(f"jitter {int(delay[1])}") - - gemodel = values_of("gemodel") - if gemodel: - segment = f"loss gemodel {int(gemodel[0])}" - if len(gemodel) > 1: - segment += f" {int(gemodel[1])}" - if len(gemodel) > 2: - segment += f" {int(gemodel[2])}" - parts.append(segment) - ext_tokens.add("gemodel") - else: - loss = values_of("packet_loss") - if loss and int(loss[0]): - parts.append(f"loss {int(loss[0])}") - if len(loss) > 1 and int(loss[1]): - parts.append(f"correl {int(loss[1])}") - ext_tokens.add("rate") # correl: extension build marker - - duplicate = values_of("duplicate") - if duplicate and int(duplicate[0]): - parts.append(f"dup {int(duplicate[0])}") - if len(duplicate) > 1 and int(duplicate[1]): - parts.append(f"correl {int(duplicate[1])}") - ext_tokens.add("rate") # correl: extension build marker - - corrupt = values_of("corrupt") - if corrupt and int(corrupt[0]): - parts.append(f"corrupt {int(corrupt[0])}") - - reorder = values_of("reorder") - if reorder: - segment = f"reorder {int(reorder[0])}" - if len(reorder) > 1 and int(reorder[1]): - segment += f" correl {int(reorder[1])}" - if len(reorder) > 2 and int(reorder[2]): - segment += f" gap {int(reorder[2])}" - parts.append(segment) - ext_tokens.add("reorder") - - rate = values_of("rate") - if rate and str(rate[0]).strip(): - parts.append(f"rate {str(rate[0]).strip()}") - ext_tokens.add("rate") - - limit = values_of("limit") - if limit and int(limit[0]): - parts.append(f"limit {int(limit[0])}") - ext_tokens.add("limit") - - if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"): - # "uniform" is the kernel default — emitting nothing keeps the - # command compatible with the original netem surface. - parts.append(f"distribution {str(delay[2]).strip().lower()}") - ext_tokens.add("dist") - - seed = values_of("seed") - if seed and int(seed[0]): - # 0 is the "disabled" convention everywhere else — treat it so - # here too (the controller's inactive-filter pass drops it). - parts.append(f"seed {int(seed[0])}") - ext_tokens.add("seed") - - return parts, ext_tokens - - async def _ubridge_apply_netem(self, host_ifc, filters): - """ - Translate the NIO's impairment filters into one tc netem qdisc on the - veth host end. The netem-expressible types (delay, packet_loss, - corrupt and the netem extensions rate/reorder/gemodel/duplicate/ - seed/limit) merge into a single qdisc — its egress covers the traffic - entering this container, and with both link endpoints applying - theirs, every direction of the link is impaired exactly once (the - same net effect as the relay, where both directions cross the single - filtered bridge). - - The kernel's netem replace MERGES optional attributes (rate, - correlation, reorder, corrupt, gemodel, distribution: absent attr = - previous value kept), so re-applying a filter set with a parameter - *removed* would silently keep the old value. A ``tc reset`` before - every ``netem set`` makes the re-apply a true reconcile (reset also - drops clsact and its bpf_drop filters — the caller re-applies them - right after in the same flow). An empty filter set is the reset - alone. - - :param host_ifc: veth host-end interface name - :param filters: NIO filters dictionary ({"delay": [ms, jitter], ...}) - """ - - parts, ext_tokens = self._netem_command_parts(filters) - if parts and ext_tokens: - # Extension keywords need the netem-extension uBridge. Plain - # delay/loss/corrupt/dup never probes — an old uBridge serves - # the original surface untouched (mirrors bpf_drop's guard). - # Checked before the reset so a failed update leaves the - # previously applied qdisc in place. - caps = await self._ubridge_tc_capabilities() - missing = ext_tokens - set(caps.get("netem", "").split(",")) - if missing: - raise DockerError( - "Packet filter(s) using {} need a uBridge with the netem extensions " - "(tc capabilities reports '{}'); upgrade uBridge on this compute".format( - ", ".join(sorted(missing)), caps.get("netem") or "no tc support" - ) - ) - try: - await self._ubridge_send(f'tc reset "{host_ifc}"') - except UbridgeError as e: - # "No such file or directory" = no qdisc was attached: already - # clean. Everything else is a real failure and must surface. - if "No such file" not in str(e): - raise - if not parts: - return - await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts))) - - async def _ubridge_tc_capabilities(self): - """ - uBridge's tc-module feature report (``tc capabilities``), probed once - per uBridge process and cached — the reply shape is - ``netem=;ebpf=0|1;cbpf=0|1``. uBridge builds without the tc - module answer "Unknown module" and old builds without the command - answer "Unknown command": both mean no kernel filter support, cached - as an empty dict. - """ - - if self._ubridge_tc_caps is None: - caps = {} - if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): - try: - reply = await self._ubridge_hypervisor.send("tc capabilities") - except UbridgeError: - reply = [] - for entry in (reply[0] if reply else "").split(";"): - key, _, value = entry.partition("=") - if key: - caps[key.strip()] = value.strip() - self._ubridge_tc_caps = caps - return self._ubridge_tc_caps - - async def _ubridge_apply_bpf_drops(self, host_ifc, filters): - """ - Translate the NIO's ``bpf`` filter (one expression per line, any line - matching drops the packet) into cls_bpf classifiers on the veth host - end's clsact egress: ``tc bpf_drop`` compiles each line with libpcap - (same compiler as the relay's bpf filter) and drops matches with a - gact TC_ACT_SHOT. Reconcile is always flush + re-add — same-prio - resend replaces the filter node in uBridge, and the flush drops - lines the user removed. - - :param host_ifc: veth host-end interface name - :param filters: NIO filters dictionary - """ - - bpf = filters.get("bpf") - lines = [line.strip() for line in (bpf[0].split("\n") if bpf else []) if line.strip()] - if not lines and self._ubridge_tc_caps is None: - # Nothing to add and nothing was ever installed (no tc module - # probed yet) — skip touching an old uBridge entirely. - return - caps = await self._ubridge_tc_capabilities() - if not lines: - if caps: - await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"') - return - if caps.get("cbpf") != "1": - raise DockerError( - "Packet filter 'bpf' on a kernel-datapath link needs a uBridge with cBPF support " - "(tc capabilities reports no cbpf); upgrade uBridge on this compute or keep the " - "link on the relay datapath" - ) - await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"') - for offset, line in enumerate(lines): - try: - await self._ubridge_send( - 'tc bpf_drop add "{ifc}" {prio} "{expr}"'.format(ifc=host_ifc, prio=10 + offset, expr=line) - ) - except UbridgeError as e: - if "Cannot compile filter" not in str(e): - raise - # Mirror the relay path: a line that no longer compiles on - # this uBridge's libpcap is skipped with a warning instead - # of breaking the link (the controller already validated - # the syntax with tcpdump at create/update time). - message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {line}" - log.warning(message) - self.project.emit("log.warning", {"message": message}) - - async def _ubridge_apply_ebpf_drops(self, host_ifc, filters): - """ - Translate the NIO's stateful filters into uBridge's eBPF impairment - classifier on the veth host end's clsact egress (prio 1, below - bpf_drop's 10-99): ``frequency_drop`` becomes the exact every-Nth - mode (``tc nth_drop``; -1 = drop everything maps to every 1st), the - kernel-only ``quota`` type the byte-cap mode (``tc quota_drop``), - and the kernel-only ``window_drop`` type the time-window mode - (``tc window_drop``: single outage, or recurring flaps with an - optional per-cycle jitter). The program evaluates the modes in the - fixed order nth → quota → window. - - Reconcile is a plain re-set on every apply: the preceding netem - apply resets the interface (which detaches the program and drops - uBridge's per-interface registry entry), so a mode set re-loads the - program from scratch, and a mode left absent is turned off - explicitly (idempotent) so a removed filter stops dropping. For - window_drop this also means the schedule restarts on every apply: - the first window opens Start-ms from the moment of this call. - - Semantic difference vs the relay, documented server-side: the relay - counts packets of BOTH directions through its single filtered - bridge, while each veth end's classifier counts only the traffic - entering that container — with both endpoints applying theirs, each - direction drops every Nth independently (their window schedules - start ~simultaneously, so a round trip survives only when both - directions are outside their windows). - - :param host_ifc: veth host-end interface name - :param filters: NIO filters dictionary - """ - - def values_of(key): - values = filters.get(key) - if isinstance(values, (list, tuple)): - return list(values) - return [values] if values else [] - - frequency = values_of("frequency_drop") - quota = values_of("quota") - window = values_of("window_drop") - if not frequency and not quota and not window and self._ubridge_tc_caps is None: - # Nothing to set and nothing was ever installed (no probe yet) — - # skip touching an old uBridge entirely. - return - caps = await self._ubridge_tc_capabilities() - if not frequency and not quota and not window: - if caps.get("ebpf") == "1": - # Only when the eBPF module answers can anything be - # installed; "off" is idempotent in uBridge. - await self._ubridge_send(f'tc nth_drop "{host_ifc}" off') - await self._ubridge_send(f'tc quota_drop "{host_ifc}" off') - await self._ubridge_send(f'tc window_drop "{host_ifc}" off') - return - if caps.get("ebpf") != "1": - raise DockerError( - "Packet filter(s) {} on a kernel-datapath link need a uBridge with eBPF support " - "(tc capabilities reports no ebpf; setcap cap_bpf,cap_net_admin,cap_net_raw=ep on " - "the uBridge binary); upgrade uBridge on this compute or keep the link on the " - "relay datapath".format( - ", ".join(sorted(f for f in ("frequency_drop", "quota", "window_drop") if f in filters)) - ) - ) - if frequency: - # relay semantics: -1 = drop everything, N = every Nth packet - nth = 1 if int(frequency[0]) == -1 else int(frequency[0]) - await self._ubridge_send(f'tc nth_drop "{host_ifc}" {nth}') - else: - await self._ubridge_send(f'tc nth_drop "{host_ifc}" off') - if quota: - await self._ubridge_send( - 'tc quota_drop "{ifc}" {bytes} {pct}'.format(ifc=host_ifc, bytes=int(quota[0]), pct=int(quota[1])) - ) - else: - await self._ubridge_send(f'tc quota_drop "{host_ifc}" off') - if window: - # [start, outage, chance, period?, jitter?] — start is relative - # to this apply (the schedule restarts on every reconcile). - params = " ".join(str(int(v)) for v in window) - await self._ubridge_send(f'tc window_drop "{host_ifc}" {params}') - else: - await self._ubridge_send(f'tc window_drop "{host_ifc}" off') - - async def _remove_kernel_markers(self, host_ifc): - """ - Tear down every kernel marker anchored to *host_ifc*: the veth survives - link deletion (it is the adapter interface, not the link), so the - AF_PACKET sockets must be closed explicitly — otherwise a deleted - link's markers would keep sniffing and signaling. Mirrors the relay - datapath where deleting the uBridge bridge silently drops its filters. - """ - - from gns3server.compute.marker.marker_manager import MarkerManager - - manager = MarkerManager.instance() - markers_dir = self.project.markers_working_directory() - for (name, link_id), anchor in list(self._marker_filter_bridges.items()): - if anchor != host_ifc: - continue - self._marker_filter_bridges.pop((name, link_id)) - self._marker_specs.pop((name, link_id), None) - await self._ubridge_delete_marker_filter(host_ifc, name) - try: - os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")) - except FileNotFoundError: - pass - except OSError as e: - log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e) - manager.unregister(self._id, name) - - async def _remove_kernel_nio(self, nio, adapter_number, port_number=0): - """ - Detach a kernel-datapath adapter port from its per-link kernel bridge. - Both endpoints run this concurrently: deleting a bridge that still has - the peer's port enslaved fails with EBUSY, so the last endpoint to - remove its port wins the deletion and the loser's failure is expected - and suppressed. - """ - - host_ifc = self._kernel_veths.get((adapter_number, port_number)) - if host_ifc is not None: - await self._remove_kernel_markers(host_ifc) - # The veth survives link deletion, so an orphaned netem qdisc - # would keep impairing whatever attaches to the adapter next - # (kernel link or relay). Best-effort: ENOENT just means no - # qdisc was attached. - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'tc reset "{host_ifc}"') - if self.status == "started": - await self._set_adapter_carrier(adapter_number, False, port_number) - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"') - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'brctl delete "{nio.bridge}"') - def get_nio(self, adapter_number, port_number=0): """ Gets an adapter NIO binding. diff --git a/gns3server/compute/ubridge/tc_probe.py b/gns3server/compute/ubridge/tc_probe.py new file mode 100644 index 000000000..33f2962b1 --- /dev/null +++ b/gns3server/compute/ubridge/tc_probe.py @@ -0,0 +1,114 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Standalone ``tc capabilities`` probe. + +Spawns the configured uBridge outside any node lifecycle, asks its tc module +for the feature report and caches the answer by binary identity (path, +mtime, size) — the same binary never answers differently, but replacing it +must re-probe. This feeds the compute ``/capabilities`` payload so the +controller and clients can gate kernel-datapath filter types on what this +host's uBridge can actually run, as the server's own user — which is the +point: the runtime ``ebpf`` flag flips between users and kernels for the +same binary, so a probe run as root would lie for a non-root server. + +A node's own probe (DockerVM._ubridge_tc_capabilities) stays per-process; +this one only answers "what can this binary do here". Never raises: any +failure (missing binary, old build answering "Unknown command", timeout) +means unknown, reported as None and cached as such. +""" + +import os +import uuid +import shutil +import asyncio +import tempfile +import logging +import contextlib + +from gns3server.config import Config +from gns3server.utils.tc_capabilities import parse_tc_capabilities +from .hypervisor import Hypervisor +from .ubridge_error import UbridgeError + +log = logging.getLogger(__name__) + +# binary identity -> parsed report (None = probed and unusable) +_cache = {} +_lock = asyncio.Lock() + + +async def probe_tc_capabilities(timeout: float = 15.0): + """ + Probe the configured uBridge's tc module once per binary identity. + + :param timeout: overall timeout for spawn + ask, in seconds + :returns: parsed ``tc capabilities`` dict, or None when unknown + """ + + 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 _cache: + return _cache[key] + caps = await _probe(path, config, timeout) + _cache[key] = caps + return caps + + +async def _probe(path, config, timeout): + """ + Spawn a throwaway uBridge, ask ``tc capabilities``, tear it down. + """ + + working_dir = tempfile.mkdtemp(prefix="gns3-tc-probe-") + hypervisor = Hypervisor( + None, + path, + working_dir, + config.settings.Server.ubridge_control_transport, + config.settings.Server.host, + str(uuid.uuid4()), + ) + try: + + async def ask(): + await hypervisor.start() + await hypervisor.connect() + reply = await hypervisor.send("tc capabilities") + return parse_tc_capabilities(reply[0] if reply else "") + + try: + caps = await asyncio.wait_for(ask(), timeout=timeout) + except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e: + # Old uBridge builds answer "Unknown command"/"Unknown module"; + # anything else here is a broken install — either way: unknown. + log.debug("uBridge tc capabilities probe failed: %s", e) + return None + return caps if caps else 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 1b3b65e38..ef2593f66 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -92,7 +92,15 @@ class Compute: self._disk_usage_percent = 0 self._last_error = None self._ssl_context = ssl_context - self._capabilities = {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": []} + self._capabilities = { + "version": "", + "platform": "", + "cpus": 0, + "memory": 0, + "disk_size": 0, + "node_types": [], + "ubridge_tc": None, + } self.name = name # Cache of interfaces on remote host self._interfaces_cache = None @@ -288,6 +296,15 @@ class Compute: def disk_usage_percent(self): return self._disk_usage_percent + @property + def capabilities(self) -> dict: + """ + Capabilities reported by this compute (fetched from GET /capabilities + at connect time). + """ + + return self._capabilities + def asdict(self, topology_dump=False): """ :param topology_dump: Filter to keep only properties require for saving on disk diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index a6ff17822..4a324bb65 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -736,11 +736,15 @@ class Link: filter_node = self._get_filter_node() if filter_node: if self.kernel_datapath: - # Kernel-datapath links serve every filter type: netem for - # delay/loss/corrupt and the netem extensions, cls_bpf for - # bpf, the eBPF classifier for frequency_drop, quota and - # window_drop. - return FILTERS + # Kernel-datapath links serve every filter type in principle: + # netem for delay/loss/corrupt and the netem extensions, + # cls_bpf for bpf, the eBPF classifier for frequency_drop, + # quota and window_drop. What is offered is gated on the + # endpoints' computes reporting a uBridge that can run each + # type; computes that report nothing (older servers, failed + # probe) keep the full list — update_filters still rejects + # with 409 on apply. + return [f for f in FILTERS if self._filter_supported_by_computes(f["type"])] # Relay links: hide the kernel-only types the relay cannot run # (they would be rejected with 409 on update). from gns3server.utils.packet_filter_validation import KERNEL_ONLY_FILTERS @@ -748,6 +752,37 @@ class Link: return [f for f in FILTERS if f["type"] not in KERNEL_ONLY_FILTERS] return [] + def _filter_supported_by_computes(self, filter_type): + """ + Whether every endpoint's compute reports a uBridge able to run + *filter_type* on a kernel-datapath link (both ends apply their own + filters, so one incapable compute is enough to hide the type). + Computes without a uBridge tc report impose no constraint. + """ + + from gns3server.utils.tc_capabilities import FILTER_EBPF_MODES, FILTER_NETEM_KEYWORDS + + reported = [] + for side in self._nodes: + capabilities = side["node"].compute.capabilities + if isinstance(capabilities, dict) and capabilities.get("ubridge_tc"): + reported.append(capabilities["ubridge_tc"]) + if not reported: + return True + for caps in reported: + if filter_type in FILTER_EBPF_MODES: + if FILTER_EBPF_MODES[filter_type] not in caps["ebpf_modes"]: + return False + elif filter_type == "bpf": + if not caps["cbpf"]: + return False + elif filter_type in FILTER_NETEM_KEYWORDS: + if FILTER_NETEM_KEYWORDS[filter_type] not in caps["netem"]: + return False + # the remaining types (delay, packet_loss, corrupt) run on any + # build that reports tc capabilities at all + return True + def _get_filter_node(self): """ Return the node where the filter will run diff --git a/gns3server/schemas/controller/capabilities.py b/gns3server/schemas/controller/capabilities.py index 6e881ede7..bd4b8832a 100644 --- a/gns3server/schemas/controller/capabilities.py +++ b/gns3server/schemas/controller/capabilities.py @@ -15,13 +15,27 @@ # along with this program. If not, see . -from typing import List +from typing import List, Optional from pydantic import BaseModel, Field from .nodes import NodeType +class UbridgeTcCapabilities(BaseModel): + """ + uBridge tc-module capabilities (kernel-datapath packet filters), probed + by the compute as its own user. + """ + + netem: List[str] = Field(..., description="netem keywords supported by this uBridge's tc module") + ebpf: bool = Field(..., description="Whether the eBPF stateful classifier loads on this host") + ebpf_modes: List[str] = Field( + ..., description="eBPF classifier modes usable on this host (ebpf is on and the mode token is declared)" + ) + cbpf: bool = Field(..., description="Whether classic-BPF match-drop classifiers are available") + + class Capabilities(BaseModel): """ Capabilities properties. @@ -33,3 +47,6 @@ class Capabilities(BaseModel): cpus: int = Field(..., description="Number of CPUs on this compute") memory: int = Field(..., description="Amount of memory on this compute") disk_size: int = Field(..., description="Disk size on this compute") + ubridge_tc: Optional[UbridgeTcCapabilities] = Field( + None, description="uBridge tc-module capabilities; absent when the probe failed or uBridge has no tc module" + ) diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py index 3db2656cc..836ac9607 100644 --- a/gns3server/schemas/controller/computes.py +++ b/gns3server/schemas/controller/computes.py @@ -21,6 +21,7 @@ from typing import Any, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from .base import DateTimeModelMixin +from .capabilities import UbridgeTcCapabilities from .nodes import NodeType @@ -103,6 +104,9 @@ class Capabilities(BaseModel): cpus: int = Field(..., description="Number of CPUs on this compute") memory: int = Field(..., description="Amount of memory on this compute") disk_size: int = Field(..., description="Disk size on this compute") + ubridge_tc: Optional[UbridgeTcCapabilities] = Field( + None, description="uBridge tc-module capabilities; absent when the probe failed or uBridge has no tc module" + ) class Compute(DateTimeModelMixin, ComputeBase): diff --git a/gns3server/utils/tc_capabilities.py b/gns3server/utils/tc_capabilities.py new file mode 100644 index 000000000..3fed73582 --- /dev/null +++ b/gns3server/utils/tc_capabilities.py @@ -0,0 +1,85 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +uBridge ``tc capabilities`` vocabulary: the reply parser and the server-side +policy built on top of it. Shared by the compute side (the per-node probe in +DockerVM and the standalone /capabilities probe) and the controller side +(filter availability), so both gate on the same rules. + +Reply shape (additive by contract — unknown keys pass through untouched): +``netem=;ebpf=0|1;cbpf=0|1[;ebpf_modes=]`` +""" + +# eBPF classifier modes that shipped before the ``ebpf_modes`` field existed +# (uBridge feature/tc-precision): the field was added together with the +# window-mode correction, so an ebpf=1 build that does not emit it has +# exactly these. window is the only mode whose semantics changed after +# release — it is never offered unless the build declares the token. +LEGACY_EBPF_MODES = ("nth", "quota", "flow") + +# GNS3 filter type -> eBPF classifier mode token +FILTER_EBPF_MODES = { + "frequency_drop": "nth", + "quota": "quota", + "window_drop": "window", +} + +# GNS3 filter type -> netem keyword (netem= token in the capabilities reply) +FILTER_NETEM_KEYWORDS = { + "rate": "rate", + "reorder": "reorder", + "gemodel": "gemodel", + "duplicate": "dup", + "seed": "seed", + "limit": "limit", +} + + +def parse_tc_capabilities(reply): + """ + Parse the payload of a ``tc capabilities`` reply into a dict. + + :param reply: reply payload (first reply line, no status code) + :returns: dict of keys to values, both strings + """ + + caps = {} + for entry in (reply or "").split(";"): + key, _, value = entry.partition("=") + if key: + caps[key.strip()] = value.strip() + return caps + + +def usable_ebpf_modes(caps): + """ + The eBPF classifier modes this uBridge build can actually run: a mode is + usable iff ``ebpf=1`` and its token is listed in ``ebpf_modes``. Builds + that do not emit the field predate it and keep the modes that shipped + with it (LEGACY_EBPF_MODES) — gating on ``ebpf=1`` alone would silently + accept window_drop on the pre-correction build, which drops it into + back-to-back windows. + + :param caps: parsed ``tc capabilities`` dict + :returns: tuple of usable mode tokens, in reply order + """ + + if caps.get("ebpf") != "1": + return () + if "ebpf_modes" not in caps: + return LEGACY_EBPF_MODES + return tuple(mode for mode in caps["ebpf_modes"].split(",") if mode) diff --git a/pyproject.toml b/pyproject.toml index ef7b82573..6450b621e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,7 @@ extend-immutable-calls = [ "gns3server/api/routes/controller/users.py" = ["S105", "S106"] "gns3server/compute/base_node.py" = ["C901", "RUF019"] "gns3server/compute/docker/__init__.py" = ["B006", "C901"] +"gns3server/compute/docker/docker_kernel_datapath.py" = ["C901"] "gns3server/compute/docker/docker_vm.py" = ["B006", "C901", "E731", "RUF003", "RUF006", "S108"] "gns3server/compute/docker/iol_docker_vm.py" = ["RUF002", "S108"] "gns3server/compute/docker/vendor_docker_vm.py" = ["RUF002", "S108"] diff --git a/tests/api/routes/compute/test_capabilities.py b/tests/api/routes/compute/test_capabilities.py index a401e8f24..f3216daea 100644 --- a/tests/api/routes/compute/test_capabilities.py +++ b/tests/api/routes/compute/test_capabilities.py @@ -29,8 +29,12 @@ pytestmark = pytest.mark.asyncio class TestCapabilitiesRoutes: - async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform) -> None: + async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform, monkeypatch) -> None: + async def no_probe(): + return None + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -54,10 +58,15 @@ class TestCapabilitiesRoutes: "cpus": psutil.cpu_count(logical=True), "memory": psutil.virtual_memory().total, "disk_size": psutil.disk_usage(get_default_project_directory()).total, + "ubridge_tc": None, } - async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm) -> None: + async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None: + async def no_probe(): + return None + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -81,4 +90,20 @@ class TestCapabilitiesRoutes: "cpus": psutil.cpu_count(logical=True), "memory": psutil.virtual_memory().total, "disk_size": psutil.disk_usage(get_default_project_directory()).total, + "ubridge_tc": None, + } + + async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: + + async def fake_probe(): + return {"netem": "delay,rate", "ebpf": "1", "cbpf": "0", "ebpf_modes": "nth,quota,window,flow"} + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", fake_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"] == { + "netem": ["delay", "rate"], + "ebpf": True, + "ebpf_modes": ["nth", "quota", "window", "flow"], + "cbpf": False, } diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index abb86049b..058288889 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -569,6 +569,66 @@ async def test_apply_window_drop_requires_ebpf_capability(vm): vm._ubridge_send.assert_not_called() +@pytest.mark.asyncio +async def test_apply_window_drop_requires_mode_token(vm): + """ + ebpf=1 is not enough: a build that does not declare the window mode + token (pre-correction feature/tc-precision semantics) must get a clear + upgrade error instead of silently installing back-to-back windows. + """ + + vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth,quota") + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + with pytest.raises(DockerError, match="ebpf_modes=nth,quota"): + await vm._ubridge_apply_ebpf_drops(host_ifc, {"window_drop": [0, 2000, 100]}) + vm._ubridge_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_apply_ebpf_drops_legacy_build_keeps_shipped_modes(vm): + """ + A build predating the ebpf_modes field (feature/tc-precision) keeps the + modes that shipped with it: nth/quota still apply, window does not. + """ + + vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes=None) + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]}) + vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" 3') + vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" off') + + vm._ubridge_send.reset_mock() + with pytest.raises(DockerError, match="newer uBridge"): + await vm._ubridge_apply_ebpf_drops(host_ifc, {"window_drop": [0, 2000, 100]}) + vm._ubridge_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_mode_off_skips_undeclared_modes(vm): + """ + The cleanup path only sends "off" for modes this build declares — an + undeclared mode's command would be an unknown command on that build. + """ + + vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth") + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + # A first apply with a filter forces the capability probe; the second + # (filters removed) takes the cached caps down the off path. + await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]}) + vm._ubridge_send.reset_mock() + await vm._ubridge_apply_ebpf_drops(host_ifc, {}) + + vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" off') + sent = [call.args[0] for call in vm._ubridge_send.call_args_list] + assert not any("quota_drop" in command or "window_drop" in command for command in sent) + + @pytest.mark.asyncio async def test_apply_ebpf_drops_skips_old_ubridge_without_filters(vm): """ @@ -605,11 +665,18 @@ async def test_apply_ebpf_drops_cleanup_only_probes_with_ebpf(vm): vm._ubridge_send.assert_not_called() -def _caps_hypervisor(cbpf="1", ebpf="0"): - """A hypervisor mock whose direct send() answers `tc capabilities`.""" +def _caps_hypervisor(cbpf="1", ebpf="0", modes="nth,quota,window"): + """ + A hypervisor mock whose direct send() answers `tc capabilities`. + modes=None emulates a build predating the ebpf_modes field. + """ + + reply = f"netem=delay;ebpf={ebpf};cbpf={cbpf}" + if modes is not None: + reply += f";ebpf_modes={modes}" hyp = MagicMock() hyp.is_running.return_value = True - hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf={ebpf};cbpf={cbpf}"]) + hyp.send = AsyncioMagicMock(return_value=[reply]) return hyp @@ -682,7 +749,7 @@ async def test_apply_bpf_drops_compile_failure_warns(vm): raise UbridgeError("209-Cannot compile filter 'icmp': can't parse filter expression: syntax error") vm._ubridge_send = AsyncioMagicMock(side_effect=send) - with patch("gns3server.compute.docker.docker_vm.log.warning") as mock_log: + with patch("gns3server.compute.docker.docker_kernel_datapath.log.warning") as mock_log: await vm._ubridge_apply_bpf_drops(host_ifc, {"bpf": ["icmp"]}) # must not raise assert mock_log.called @@ -708,7 +775,7 @@ async def test_ubridge_tc_capabilities_probed_once(vm): vm._ubridge_hypervisor = _caps_hypervisor() caps = await vm._ubridge_tc_capabilities() - assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1"} + assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1", "ebpf_modes": "nth,quota,window"} await vm._ubridge_tc_capabilities() assert vm._ubridge_hypervisor.send.call_count == 1 diff --git a/tests/compute/ubridge/test_tc_probe.py b/tests/compute/ubridge/test_tc_probe.py new file mode 100644 index 000000000..6bb5178cd --- /dev/null +++ b/tests/compute/ubridge/test_tc_probe.py @@ -0,0 +1,115 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +The standalone `tc capabilities` probe behind the compute /capabilities +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 +from gns3server.compute.ubridge.ubridge_error import UbridgeError + +pytestmark = pytest.mark.asyncio + + +class FakeHypervisor: + """Records spawns; answers `tc capabilities` with a canned reply.""" + + reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] + error = None + spawned = 0 + + def __init__(self, project, path, working_dir, transport, host, node_id): + + self.commands = [] + FakeHypervisor.spawned += 1 + + async def start(self): + pass + + async def connect(self): + pass + + async def send(self, command): + + self.commands.append(command) + if self.error: + raise UbridgeError(self.error) + if command == "tc capabilities": + return FakeHypervisor.reply + return ["OK"] + + async def stop(self): + pass + + +@pytest.fixture(autouse=True) +def probe_env(monkeypatch, tmp_path): + + FakeHypervisor.spawned = 0 + FakeHypervisor.error = None + FakeHypervisor.reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"] + tc_probe._cache.clear() + # a real file on disk: the cache is keyed on its (path, mtime, size) + binary = tmp_path / "ubridge" + binary.write_bytes(b"") + monkeypatch.setattr(tc_probe.shutil, "which", lambda name: str(binary)) + monkeypatch.setattr(tc_probe, "Hypervisor", FakeHypervisor) + + +async def test_probe_returns_parsed_report(): + + caps = await probe_tc_capabilities() + assert caps == { + "netem": "delay,rate", + "ebpf": "1", + "cbpf": "1", + "ebpf_modes": "nth,quota,window,flow", + } + assert FakeHypervisor.spawned == 1 + + +async def test_probe_cached_by_binary_identity(): + + first = await probe_tc_capabilities() + second = await probe_tc_capabilities() + assert first is second + assert FakeHypervisor.spawned == 1 + + +async def test_probe_failure_means_unknown(): + + FakeHypervisor.error = "Unknown command" # old uBridge build + assert await probe_tc_capabilities() is None + # the failure is cached too: no respawn loop on every /capabilities hit + assert await probe_tc_capabilities() is None + assert FakeHypervisor.spawned == 1 + + +async def test_probe_missing_binary_means_unknown(monkeypatch): + + monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None) + assert await probe_tc_capabilities() is None + assert FakeHypervisor.spawned == 0 + + +async def test_probe_empty_reply_means_unknown(): + + FakeHypervisor.reply = [] + assert await probe_tc_capabilities() is None diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 4fb3e9f7e..a6f41e2d3 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": []}, + "capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None}, } assert compute.asdict(topology_dump=True) == { "compute_id": "my_compute_id", diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index f6ee38410..1ce85ac30 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -548,3 +548,45 @@ async def test_prepare_relay_link_strips_kernel_only_filters(project): # carries no filters — the relay filters one bridge) assert by_node[node1.id]["filters"] == {"frequency_drop": [7], "delay": [100, 20], "packet_loss": [5]} assert by_node[node2.id]["filters"] == {} + + +@pytest.mark.asyncio +async def test_available_filters_gated_by_compute_capabilities(project): + """ + Kernel links only offer the filter types the endpoints' computes report + a uBridge able to run (ebpf_modes tokens, cbpf, netem keywords). + """ + + link, node1, _node2 = await _kernel_link(project) + link._link_data = [{"type": "nio_bridge"}] + node1.compute.capabilities = { + "ubridge_tc": { + "netem": ["delay", "jitter", "loss", "rate"], + "ebpf": True, + "ebpf_modes": ["nth", "quota"], + "cbpf": False, + } + } + types = [f["type"] for f in link.available_filters()] + assert "frequency_drop" in types # nth declared + assert "quota" in types # quota declared + assert "window_drop" not in types # token missing + assert "bpf" not in types # cbpf off + assert "rate" in types # netem keyword present + assert "gemodel" not in types # netem keyword missing + assert "delay" in types # core netem runs on any tc-module build + + +@pytest.mark.asyncio +async def test_available_filters_unconstrained_without_report(project): + """ + Computes that report no uBridge tc capabilities (older servers, failed + probe) impose no constraint: the full list stays offered and apply-time + validation is the guard. + """ + + from gns3server.controller.link import FILTERS + + link, _node1, _node2 = await _kernel_link(project) + link._link_data = [{"type": "nio_bridge"}] + assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS] diff --git a/tests/utils/test_tc_capabilities.py b/tests/utils/test_tc_capabilities.py new file mode 100644 index 000000000..75befaea4 --- /dev/null +++ b/tests/utils/test_tc_capabilities.py @@ -0,0 +1,77 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +The `tc capabilities` reply parser and the eBPF mode policy shared by the +compute probe and the controller filter availability. +""" + +from gns3server.utils.tc_capabilities import ( + LEGACY_EBPF_MODES, + parse_tc_capabilities, + usable_ebpf_modes, +) + + +def test_parse_full_reply(): + + caps = parse_tc_capabilities("netem=delay,jitter,loss;ebpf=1;cbpf=0;ebpf_modes=nth,quota,window,flow") + assert caps == { + "netem": "delay,jitter,loss", + "ebpf": "1", + "cbpf": "0", + "ebpf_modes": "nth,quota,window,flow", + } + + +def test_parse_legacy_reply_without_modes(): + """A build predating the field (and unknown future keys) passes through.""" + + caps = parse_tc_capabilities("netem=delay;ebpf=1;cbpf=1") + assert caps == {"netem": "delay", "ebpf": "1", "cbpf": "1"} + + +def test_parse_empty_reply(): + + assert parse_tc_capabilities("") == {} + assert parse_tc_capabilities(None) == {} + + +def test_usable_modes_require_ebpf(): + """The mode list is a build fact; ebpf is the runtime gate for all of them.""" + + assert usable_ebpf_modes({"ebpf": "0", "ebpf_modes": "nth,quota,window,flow"}) == () + assert usable_ebpf_modes({}) == () + + +def test_usable_modes_from_token_list(): + + caps = {"ebpf": "1", "ebpf_modes": "nth,quota,window,flow"} + assert usable_ebpf_modes(caps) == ("nth", "quota", "window", "flow") + + +def test_usable_modes_legacy_build_keeps_shipped_modes(): + """No field: the modes that shipped before it — everything but window.""" + + assert usable_ebpf_modes({"ebpf": "1"}) == LEGACY_EBPF_MODES + assert "window" not in LEGACY_EBPF_MODES + + +def test_window_never_usable_without_token(): + """The pre-correction build reported ebpf=1 with broken window semantics.""" + + assert "window" not in usable_ebpf_modes({"ebpf": "1"}) + assert "window" not in usable_ebpf_modes({"ebpf": "1", "ebpf_modes": "nth,quota"})