From 0a8d430d503b9f0771fd3f0616a7e47744765ba5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 28 Sep 2026 20:07:21 +0800 Subject: [PATCH] feat(docker): packet filters on kernel-datapath links via tc netem Impairment filters with a netem equivalent (delay, packet_loss, corrupt) now run on the kernel datapath as one tc netem qdisc per veth host end, pushed through uBridge's netlink tc module (tc netem set / tc reset). This removes the relay delay filter's nanosleep bottleneck (upstream Both link endpoints carry the filters: each end's qdisc egress covers the traffic entering that container, so every direction is impaired exactly once -- the same net effect as the relay, where both directions cross the single filtered bridge. - controller: eligibility keys on relay-only types (frequency_drop, bpf) instead of any filter; kernel links push the real filters (the synthetic suspend frequency_drop no longer flips a suspended link to relay on reopen/reset); update_filters 409s only relay-only types; available_filters hides them on kernel links - compute: create_nio whitelists netem-capable filter types; the NIO update path keeps its own guard (PUT mutates the NIO in place); _ubridge_apply_netem translates the filter dict into one atomic netem-set (full re-apply IS the reconcile); node restart restores the qdisc, link deletion detaches it (the veth survives the link) - e2e (27/27): delay 100 -> 200.2 ms RTT, atomic replace 50 -> 100.2 ms, clear -> baseline, packet_loss 30 -> 48 % round-trip (expected 51 %), suspend/resume and node restart keep the qdisc, link delete leaves no residual impairment --- docs/features/docker-kernel-datapath.md | 69 +++++- gns3server/compute/base_manager.py | 14 +- gns3server/compute/docker/docker_vm.py | 75 +++++- gns3server/compute/nios/nio_bridge.py | 5 +- gns3server/controller/link.py | 29 ++- gns3server/controller/udp_link.py | 66 ++++-- gns3server/schemas/compute/nios.py | 11 +- gns3server/utils/packet_filter_validation.py | 10 +- .../docker/test_docker_kernel_datapath.py | 213 ++++++++++++++---- tests/controller/test_kernel_datapath_link.py | 60 ++++- tests/controller/test_udp_link.py | 2 +- 11 files changed, 459 insertions(+), 95 deletions(-) diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index f3aa181e0..21c95f352 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -24,12 +24,12 @@ Frames never leave the kernel. | Link | uBridge bridge, TAP fd ↔ UDP, userspace copy | per-link kernel bridge `gns3{link_id[:11]}`, zero-copy | | Relay latency | ~0.3 ms RTT | ~0.05 ms RTT (measured) | | Link attach at runtime | yes (relay attaches the pre-existing TAP) | yes (brctl addif / add_nio_ethernet — interface untouched) | -| Packet filters | uBridge userspace filters | not supported yet (tc netem roadmap) → relay fallback | +| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt); frequency_drop & bpf stay relay-only | | Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end | -The work landed in four stages on branch stack `feat/docker-kernel-datapath` → +The work landed in five stages on branch stack `feat/docker-kernel-datapath` → `feat/docker-kernel-capture` → `feat/docker-kernel-markers` → -`feat/docker-veth-everywhere`: +`feat/docker-veth-everywhere` → `feat/docker-kernel-filters`: 1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based suspend, crash-safe reconciliation. @@ -39,6 +39,10 @@ The work landed in four stages on branch stack `feat/docker-kernel-datapath` → 4. **veth-everywhere** — the TAP path deleted; every adapter is a veth and the datapath is a *runtime* decision (kernel or relay) that never touches a running container's interfaces. +5. **Filters** — impairment filters (delay, packet_loss, corrupt) become one + tc netem qdisc per veth host end, pushed through uBridge's netlink `tc` + module. This also removes the relay delay filter's nanosleep bottleneck + (upstream #2827) from the kernel path. ## Adapter interface types @@ -121,10 +125,10 @@ endpoints became eligible are upgraded to the kernel datapath automatically. | Operation | Kernel-datapath action | |---|---| -| Create / attach | `brctl create` (EEXIST tolerated via `brctl show` verify) + `link set up` + `brctl addif` both ends (concurrent, race-tolerant) + carrier up | -| Delete / detach | marker teardown → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The veth survives — unlike a relay bridge, whose death dropped its filters, an orphaned AF_PACKET marker would keep sniffing, hence the explicit teardown | +| Create / attach | `brctl create` (EEXIST tolerated via `brctl show` verify) + `link set up` + `brctl addif` both ends (concurrent, race-tolerant) + carrier up + markers + `tc netem set` (filters) | +| Delete / detach | marker teardown → `tc reset` (netem teardown — the veth survives the link) → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The veth survives — unlike a relay bridge, whose death dropped its filters, an orphaned AF_PACKET marker would keep sniffing, hence the explicit teardown | | Reset (`POST /links/{id}/reset`) | delete + create (re-evaluates eligibility) | -| Suspend (`PUT /links/{id}` `{"suspend": true}`) | veth host end admin-state down both ends — 100 % loss, no synthetic filter needed; resume restores | +| Suspend (`PUT /links/{id}` `{"suspend": true}`) | veth host end admin-state down both ends — 100 % loss, no synthetic filter needed; resume restores (netem qdisc survives the carrier flap) | ## Capture @@ -156,11 +160,38 @@ marker enable_kernel # off = installed but silent ## Packet filters -Not supported on the kernel datapath yet: a link with active filters is wired -on the relay (where uBridge's userspace filters apply), and adding filters to -an existing kernel link returns 409. Kernelization via `tc netem` on the veth -is the planned next stage — it also removes the relay's `delay` filter -nanosleep bottleneck under high packet rates. +Impairment filters run **in the kernel** as tc netem qdiscs on the veth host +ends (uBridge `tc netem set`, raw netlink — no `tc` binary needed): + +| GNS3 filter | netem mapping | Notes | +|---|---|---| +| `delay [ms, jitter]` | `delay X jitter Y` | jitter 0 omitted | +| `packet_loss [%]` | `loss P` | per direction (see below) | +| `corrupt [%]` | `corrupt P` | | +| `frequency_drop` | — no netem equivalent | 409 on kernel links; relay fallback | +| `bpf` | — needs a tc classifier | 409 on kernel links; relay fallback | + +Semantics: + +* **Both endpoints** receive the filter dict and attach one qdisc to *their* + veth host end. The qdisc's egress covers traffic entering that container, + so every direction of the link is impaired exactly once — the same net + effect as the relay, where both directions cross the single filtered + bridge. A `delay 100` link measures ≈200 ms RTT; `packet_loss 30` + measures ≈51 % round-trip (1 − 0.7²). +* **Reconcile = full re-apply.** `netem set` is an atomic replace (NLM_F_REPLACE), + so every NIO update just rebuilds the qdisc from the current filters; no + per-parameter diffing. An empty filter set detaches it (`tc reset`, + ENOENT-tolerated). +* **Node restart** restores the qdisc from the NIO (like capture and markers); + **link deletion** detaches it explicitly — the veth survives the link and an + orphaned qdisc would keep impairing the next one. +* **Suspend** keeps the qdisc (carrier-driven loss); the first packet after + resume takes one extra delay interval (known netem idle-baseline behaviour) + and steady state is exact. +* A link carrying `frequency_drop` or `bpf` is wired on the **relay** (they + only exist in uBridge's userspace filters); `available_filters` hides them + on kernel links and setting them returns 409 with a clear message. ## uBridge command surface @@ -173,9 +204,14 @@ brctl create / delete / addif / delif / show link set up|down capture start_kernel / stop_kernel marker add_kernel / delete_kernel / enable_kernel +tc netem set [delay ] [jitter ] [loss <%>] [dup <%>] [corrupt <%>] / tc reset bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture ``` +Note: `tc reset` currently replies 207-ENOENT when no qdisc is attached — the +server tolerates it; making the command idempotent in uBridge is a desirable +polish. + ## Configuration ```ini @@ -193,6 +229,15 @@ End-to-end on a five-container FRR topology (mixed runtime-drawn and reloaded links): 8/8 links on the kernel datapath, ping RTT ≈ 0.05 ms, FDB learning, suspend = 100 % loss / resume restores, project reopen upgrades relay links, runtime link creation on running containers, marker pcaps exact (tx/rx -pairs), capture freeze on stop, server restart reconciliation. Unit tests: +pairs), capture freeze on stop, server restart reconciliation. + +Filters (two-container kernel link, 27/27 checks): baseline 0.06 ms → +`delay 100` = 200.2 ms RTT (2× per direction) → `delay 50` = 100.2 ms +(atomic replace) → clear = baseline (qdisc detached); `packet_loss 30` = +48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop`/`bpf` = +409; suspend with delay active = 100 % loss, resume keeps the qdisc +(200.1 ms); node restart restores it (200.1 ms); link delete detaches it +(`/usr/sbin/tc qdisc show` — no netem left); re-created link has no residual +impairment. Unit tests: `tests/compute/docker/test_docker_kernel_datapath.py`, `tests/controller/test_kernel_datapath_link.py`. diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 7c4887727..30a6fc1c3 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -361,8 +361,18 @@ class BaseManager: nio.suspend = nio_settings.get("suspend", False) elif nio_settings["type"] == "nio_bridge": nio = NIOBridge(nio_settings["bridge"]) - if nio_settings.get("filters"): - raise ComputeError("Packet filters are not supported on kernel-datapath links") + # Impairment filters with a tc netem equivalent run on the veth + # host end; the relay-only types are rejected here (the controller + # already keeps them off kernel links — this is the second guard). + from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS + + unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio_settings.get("filters") or {}) + if unsupported: + raise ComputeError( + "Packet filter(s) {} cannot run on a kernel-datapath link " + "(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported))) + ) + nio.filters = nio_settings.get("filters", {}) # Markers ride the NIO and attach to the veth host interface via # uBridge's AF_PACKET marker module (marker add_kernel). nio.markers = nio_settings.get("markers", {}) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index b4c88f62d..921bcfc0e 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -40,6 +40,7 @@ from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.hostname import is_rfc1123_hostname_valid +from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS from ..adapters.ethernet_adapter import EthernetAdapter from ..base_node import BaseNode @@ -1856,8 +1857,10 @@ class DockerVM(BaseNode): 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, + adapter_number=adapter_number, + port_number=port_number, + mac_address=mac_address, + namespace=self._namespace, ) ) @@ -1931,6 +1934,9 @@ class DockerVM(BaseNode): # Markers carried by the NIO attach to the veth host end (AF_PACKET # taps) — the anchor is the interface, not a relay bridge. await self._ubridge_apply_markers(host_ifc, nio) + # Impairment filters become one tc netem qdisc on the veth host + # end (restored here on node restart, like the capture above). + await self._ubridge_apply_netem(host_ifc, nio.filters) return # Relay NIO. On a veth-backed adapter (the unified Docker interface) @@ -1992,10 +1998,14 @@ class DockerVM(BaseNode): if self.ubridge: if isinstance(nio, NIOBridge): - if nio.filters: + # The controller keeps relay-only filters off kernel links and + # create_nio rejects them on the POST path; this PUT path + # mutates the NIO in place, so guard here too. + unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {}) + if unsupported: raise DockerError( - "Packet filters are not supported on kernel-datapath links; " - "delete and recreate the link to use them" + "Packet filter(s) {} cannot run on a kernel-datapath link " + "(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported))) ) if self.status == "started": host_ifc = self._kernel_veths.get((adapter_number, port_number)) @@ -2003,6 +2013,9 @@ class DockerVM(BaseNode): # Incremental marker reconcile on the veth anchor — # the relay-datapath equivalent of the branch below. await self._ubridge_apply_markers(host_ifc, nio) + # netem set is an atomic replace, so a full re-apply + # IS the incremental reconcile for impairment filters. + await self._ubridge_apply_netem(host_ifc, nio.filters) await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) return @@ -2085,7 +2098,14 @@ class DockerVM(BaseNode): 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 + 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): @@ -2115,6 +2135,43 @@ class DockerVM(BaseNode): return await super()._ubridge_enable_marker_filter(anchor, name, state) + 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 supported types (delay, packet_loss, corrupt) 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). + ``netem set`` atomically replaces whatever qdisc was attached, so a + full re-apply on every NIO update *is* the incremental reconcile. An + empty filter set detaches the qdisc (``tc reset``). + + :param host_ifc: veth host-end interface name + :param filters: NIO filters dictionary ({"delay": [ms, jitter], ...}) + """ + + parts = [] + delay = filters.get("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])}") + for filter_type, netem_kw in (("packet_loss", "loss"), ("corrupt", "corrupt")): + values = filters.get(filter_type) + if values and int(values[0]): + parts.append(f"{netem_kw} {int(values[0])}") + if parts: + await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts))) + return + 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 + async def _remove_kernel_markers(self, host_ifc): """ Tear down every kernel marker anchored to *host_ifc*: the veth survives @@ -2154,6 +2211,12 @@ class DockerVM(BaseNode): 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): diff --git a/gns3server/compute/nios/nio_bridge.py b/gns3server/compute/nios/nio_bridge.py index 785ac61e3..d65e38471 100644 --- a/gns3server/compute/nios/nio_bridge.py +++ b/gns3server/compute/nios/nio_bridge.py @@ -20,8 +20,9 @@ Interface for kernel-datapath bridge NIOs. A NIO of this type instructs the node to enslave its adapter's veth host end into the named Linux kernel bridge instead of wiring a uBridge UDP relay. -Frames then flow entirely in the kernel (veth -> bridge -> veth). Packet -filters are not available on this datapath (they live in the uBridge relay); +Frames then flow entirely in the kernel (veth -> bridge -> veth). Impairment +filters ride the NIO and run as one tc netem qdisc per veth host end (the +types without a netem equivalent — frequency_drop, bpf — stay relay-only); markers and packet capture are served by uBridge's AF_PACKET modules on the veth host end (marker add_kernel / capture start_kernel). """ diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index a919c8037..fdfaa1162 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -116,8 +116,9 @@ class Link: """ Whether this link is wired on the kernel datapath (veth pairs enslaved into a per-link Linux bridge — no uBridge relay in the - forwarding path, so filters and markers are unavailable; capture is - served by uBridge's AF_PACKET module). + forwarding path). Impairment filters run as tc netem on the veth + host end; markers and capture are served by uBridge's AF_PACKET + module. """ return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or [])) @@ -234,11 +235,18 @@ class Link: except FilterValidationError as e: raise ControllerError(f"Invalid packet filter parameters: {e!s}") - if new_filters and self.kernel_datapath: - raise ControllerError( - "Packet filters are not supported on kernel-datapath links (no uBridge relay " - "in the forwarding path); delete and recreate the link to use filters" - ) + if self.kernel_datapath: + from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS + + unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(new_filters or {}) + if unsupported: + raise ControllerError( + "Packet filter(s) {} cannot run on a kernel-datapath link (no uBridge relay " + "in the forwarding path; delay, packet loss and corrupt are served by tc " + "netem on the veth); delete and recreate the link to use them".format( + ", ".join(sorted(unsupported)) + ) + ) if new_filters != self.filters: self._filters = new_filters @@ -637,6 +645,13 @@ class Link: """ filter_node = self._get_filter_node() if filter_node: + if self.kernel_datapath: + # Kernel-datapath links serve filters via tc netem on the + # veth host end; types without a netem equivalent (which + # only run in the uBridge relay) are hidden from the picker. + from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS + + return [f for f in FILTERS if f["type"] not in KERNEL_UNSUPPORTED_FILTERS] return FILTERS return [] diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 4d02cf9fd..494bfc976 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -20,7 +20,10 @@ import asyncio import logging from gns3server.config import Config -from gns3server.utils.packet_filter_validation import validate_bpf_syntax +from gns3server.utils.packet_filter_validation import ( + KERNEL_UNSUPPORTED_FILTERS, + validate_bpf_syntax, +) from .controller_error import ControllerError, ControllerNotFoundError from .link import _UNSET, Link @@ -82,19 +85,32 @@ class UDPLink(Link): """Whether NIO creation is waiting for missing node images.""" return self._deferred - def _get_node_filters(self, node1, node2): + def _get_node_filters(self, node1, node2, kernel=None): """ Determine which node gets the active filters applied. + :param kernel: explicit datapath decision for the caller's context. + ``_prepare`` must pass it: it clears ``_link_data`` (the source + of the ``kernel_datapath`` property) before rebuilding the NIO + specs, so deriving the datapath there would read the previous + run's. Callers operating on established link data (``update``) + derive it from the property. + :returns: Tuple of (node1_filters, node2_filters) """ # Kernel-datapath links implement suspend natively (interface carrier # driven by the NIO suspend flag). The synthetic frequency_drop filter # that get_active_filters() injects for suspended links is a - # relay-datapath emulation mechanism — sending it would trip the - # compute-side kernel guard, so kernel links always push empty filters. - if self.kernel_datapath: - return {}, {} + # relay-datapath emulation mechanism, so kernel links push the *real* + # filters straight from storage. Both endpoints receive them: each + # end's compute attaches one tc netem qdisc to its veth host end + # (egress = traffic entering that container), so every direction of + # the link is impaired exactly once — the same net effect as the + # relay, where both directions cross the single filtered bridge. + if kernel is None: + kernel = self.kernel_datapath + if kernel: + return self._filters, self._filters filter_node = self._get_filter_node() return ( self.get_active_filters() if filter_node == node1 else {}, @@ -135,9 +151,11 @@ class UDPLink(Link): """ Whether this link can be wired on the kernel datapath (veth pairs enslaved into a per-link Linux bridge) instead of the uBridge UDP - relay. The kernel path has no userspace relay, so filters — which - live in the relay — disqualify it. Markers and capture are served - by uBridge's AF_PACKET modules on the veth host end. + relay. Impairment filters with a tc netem equivalent (delay, + packet_loss, corrupt) are served on the veth host end; the others + (frequency_drop, bpf) only exist in the uBridge relay and disqualify + the kernel path. Capture and markers are served by uBridge's + AF_PACKET modules on the veth host end. Docker adapters are born as veth pairs (unified interface), so the datapath is a runtime decision — links attach to running containers @@ -152,7 +170,11 @@ class UDPLink(Link): return False if node1.compute.id != node2.compute.id: return False - if self.get_active_filters(): + # Look at the *stored* filters, not get_active_filters(): a suspended + # link reports the synthetic frequency_drop emulation, which is + # relay-only mechanics and must not flip a kernel link to the relay + # on reopen/reset. + if KERNEL_UNSUPPORTED_FILTERS.intersection(self._filters or {}): return False if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2): return False @@ -192,11 +214,27 @@ class UDPLink(Link): # Markers ride the NIO like on the relay datapath, routed by # capture node; they attach to the veth host end via uBridge's # AF_PACKET marker module instead of a relay `mark` filter. + # Filters (delay/packet_loss/corrupt — eligibility guarantees no + # other type is present) become one tc netem qdisc per veth host + # end, pushed to both endpoints. bridge_name = "gns3" + self._id.replace("-", "")[:11] + node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True) node1_markers, node2_markers = self._get_node_markers(node1, node2) self._link_data = [ - {"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node1_markers, "suspend": self._suspended}, - {"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node2_markers, "suspend": self._suspended}, + { + "type": "nio_bridge", + "bridge": bridge_name, + "filters": node1_filters, + "markers": node1_markers, + "suspend": self._suspended, + }, + { + "type": "nio_bridge", + "bridge": bridge_name, + "filters": node2_filters, + "markers": node2_markers, + "suspend": self._suspended, + }, ] return [ (node1, adapter_number1, port_number1, self._link_data[0]), @@ -223,7 +261,7 @@ class UDPLink(Link): _allocate_port(node1.compute), _allocate_port(node2.compute) ) - node1_filters, node2_filters = self._get_node_filters(node1, node2) + node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=False) node1_markers, node2_markers = self._get_node_markers(node1, node2) # Build the tunnel specs for both sides. Index 0 is always node1 so @@ -331,7 +369,7 @@ class UDPLink(Link): self._link_data[1]["suspend"] = self._suspended if node2.node_type != "ethernet_hub": await node2.put( - f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221 + f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120 ) async def delete(self): diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 6ee90c2a3..53d9dc585 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -74,15 +74,18 @@ class BridgeNIO(BaseModel): """ Kernel-datapath bridge Network Input/Output properties. The NIO tells the node to enslave its veth host end into the named kernel bridge instead of - wiring a uBridge UDP relay. ``filters`` must stay empty on this datapath - (rejected server-side); markers and packet capture are served by uBridge's - AF_PACKET modules on the veth host end. + wiring a uBridge UDP relay. Impairment filters with a tc netem equivalent + (delay, packet_loss, corrupt) run on the veth host end; the relay-only + kinds (frequency_drop, bpf) are rejected. Markers and packet capture are + served by uBridge's AF_PACKET modules on the veth host end. """ type: BridgeNIOType bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5") suspend: Optional[bool] = Field(None, description="Suspend the NIO") - filters: Optional[dict] = Field(None, description="Packet filters (unsupported, must be empty)") + filters: Optional[dict] = Field( + None, description="Packet filters served by tc netem on the veth host end (delay/packet_loss/corrupt)" + ) markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)") diff --git a/gns3server/utils/packet_filter_validation.py b/gns3server/utils/packet_filter_validation.py index 39f994016..57bcdba60 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -4,7 +4,7 @@ Packet filter parameter validation utilities. import logging import subprocess -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional log = logging.getLogger(__name__) @@ -15,7 +15,13 @@ class FilterValidationError(Exception): pass -def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Any]: +# Packet filters with no kernel-datapath equivalent (no tc netem mapping): +# they only run in the uBridge userspace relay, so a link carrying one is +# wired on the relay, and adding them to a kernel-datapath link is rejected. +KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop", "bpf"}) + + +def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]: """ Validate BPF filter expression syntax using tcpdump. diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index f09ba6591..53f57d9a4 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -55,7 +55,7 @@ async def vm(compute_project, manager): vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none") vm._cid = "e90e34656842" - vm.mac_address = '02:42:3d:b7:93:00' + vm.mac_address = "02:42:3d:b7:93:00" vm._start_interface_monitor = AsyncioMagicMock() vm._stop_interface_monitor = AsyncioMagicMock() return vm @@ -65,6 +65,7 @@ async def vm(compute_project, manager): # NIO factory # --------------------------------------------------------------------------- + def test_create_nio_bridge(vm): nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) @@ -73,10 +74,25 @@ def test_create_nio_bridge(vm): assert nio.suspend is False -def test_create_nio_bridge_rejects_filters(vm): +def test_create_nio_bridge_accepts_netem_filters(vm): + """ + Impairment filters with a tc netem equivalent ride the kernel NIO (they + become one netem qdisc on the veth host end); relay-only types are + rejected. + """ - with pytest.raises(ComputeError): - vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"loss": [10]}}) + nio = vm.manager.create_nio( + {"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [50, 5], "packet_loss": [10]}} + ) + assert isinstance(nio, NIOBridge) + assert nio.filters == {"delay": [50, 5], "packet_loss": [10]} + + +@pytest.mark.parametrize("filters", [{"frequency_drop": [10]}, {"bpf": ["icmp"]}]) +def test_create_nio_bridge_rejects_relay_only_filters(vm, filters): + + with pytest.raises(ComputeError, match="kernel-datapath"): + vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": filters}) def test_create_nio_bridge_accepts_markers(vm): @@ -94,6 +110,7 @@ def test_create_nio_bridge_accepts_markers(vm): # Adapter wiring (start path) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_add_ubridge_kernel_connection(vm): @@ -110,8 +127,8 @@ async def test_add_ubridge_kernel_connection(vm): call.send(f'docker delete_veth "{guest_ifc}"'), call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'), call.send(f'link set "{host_ifc}" down'), - call.send(f'docker set_mac_addr {guest_ifc} 02:42:3d:b7:93:00'), - call.send(f'docker move_to_ns {guest_ifc} 42 eth0'), + call.send(f"docker set_mac_addr {guest_ifc} 02:42:3d:b7:93:00"), + call.send(f"docker move_to_ns {guest_ifc} 42 eth0"), # link attach call.send(f'brctl create "{BRIDGE}"'), call.send(f'link set "{BRIDGE}" up'), @@ -124,6 +141,26 @@ async def test_add_ubridge_kernel_connection(vm): assert "bridge0" not in vm._bridges +@pytest.mark.asyncio +async def test_add_ubridge_kernel_connection_applies_netem(vm): + """ + Filters carried by the NIO become a tc netem qdisc at start (node + restart restore), exactly like capture and markers. + """ + + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + nio = vm.manager.create_nio( + {"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [50, 5], "packet_loss": [10]}} + ) + + await vm._add_ubridge_connection(nio, 0) + + host_ifc, _ = vm._veth_names(0, 0) + netem_calls = [c for c in vm._ubridge_hypervisor.method_calls if "netem set" in str(c)] + assert call.send(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss 10') in netem_calls + + @pytest.mark.asyncio async def test_add_ubridge_connection_none_nio_creates_veth(vm): """ @@ -138,11 +175,14 @@ async def test_add_ubridge_connection_none_nio_creates_veth(vm): await vm._add_ubridge_connection(None, 0) host_ifc, guest_ifc = vm._veth_names(0, 0) - vm._ubridge_hypervisor.assert_has_calls([ - call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'), - call.send(f'link set "{host_ifc}" down'), - call.send(f'docker move_to_ns {guest_ifc} 42 eth0'), - ], any_order=True) + vm._ubridge_hypervisor.assert_has_calls( + [ + call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'), + call.send(f'link set "{host_ifc}" down'), + call.send(f"docker move_to_ns {guest_ifc} 42 eth0"), + ], + any_order=True, + ) assert vm._kernel_veths[(0, 0)] == host_ifc # no relay bridge and no TAP for an unconnected adapter assert "bridge0" not in vm._bridges @@ -165,13 +205,16 @@ async def test_add_ubridge_connection_udp_relays_over_veth(vm): await vm._add_ubridge_connection(nio, 0) host_ifc, _ = vm._veth_names(0, 0) - vm._ubridge_hypervisor.assert_has_calls([ - call.send(f'docker create_veth "{host_ifc}" "{vm._veth_names(0, 0)[1]}"'), - call.send("bridge create bridge0"), - call.send(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'), - call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), - call.send("bridge start bridge0"), - ], any_order=True) + vm._ubridge_hypervisor.assert_has_calls( + [ + call.send(f'docker create_veth "{host_ifc}" "{vm._veth_names(0, 0)[1]}"'), + call.send("bridge create bridge0"), + call.send(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'), + call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), + call.send("bridge start bridge0"), + ], + any_order=True, + ) assert "bridge0" in vm._bridges @@ -199,13 +242,14 @@ async def test_add_ubridge_kernel_connection_cleans_up_on_failure(vm): # Link attach / detach # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_connect_nio_kernel_tolerates_bridge_create_race(vm): # Both endpoints create the per-link bridge concurrently; the loser must # verify (brctl show) instead of failing. async def send(command): - if 'brctl create' in command: + if "brctl create" in command: raise UbridgeError("Could not create bridge: File exists") vm._ubridge_send = AsyncioMagicMock(side_effect=send) @@ -223,7 +267,7 @@ async def test_connect_nio_kernel_tolerates_bridge_create_race(vm): async def test_connect_nio_kernel_create_failure_propagates(vm): async def send(command): - if 'brctl create' in command or 'brctl show' in command: + if "brctl create" in command or "brctl show" in command: raise UbridgeError("Could not create bridge: No such device") vm._ubridge_send = AsyncioMagicMock(side_effect=send) @@ -255,11 +299,15 @@ async def test_remove_kernel_nio(vm): await vm.adapter_remove_nio_binding(0) - vm._ubridge_send.assert_has_calls([ - call(f'link set "{host_ifc}" down'), - call(f'brctl delif "{BRIDGE}" "{host_ifc}"'), - call(f'brctl delete "{BRIDGE}"'), - ], any_order=True) + vm._ubridge_send.assert_has_calls( + [ + call(f'tc reset "{host_ifc}"'), + call(f'link set "{host_ifc}" down'), + call(f'brctl delif "{BRIDGE}" "{host_ifc}"'), + call(f'brctl delete "{BRIDGE}"'), + ], + any_order=True, + ) assert vm._ethernet_adapters[0].get_nio(0) is None # the veth itself stays: it is the adapter interface, not the link assert vm._kernel_veths[(0, 0)] == host_ifc @@ -273,7 +321,7 @@ async def test_remove_kernel_nio_tolerates_peer_winning_bridge_delete(vm): vm._ubridge_hypervisor = MagicMock() async def send(command): - if 'brctl delif' in command or 'brctl delete' in command: + if "brctl delif" in command or "brctl delete" in command: raise UbridgeError("busy") vm._ubridge_send = AsyncioMagicMock(side_effect=send) @@ -287,6 +335,7 @@ async def test_remove_kernel_nio_tolerates_peer_winning_bridge_delete(vm): # Carrier / suspend # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_set_adapter_carrier_kernel(vm): @@ -297,10 +346,12 @@ async def test_set_adapter_carrier_kernel(vm): await vm._set_adapter_carrier(0, True) await vm._set_adapter_carrier(0, False) - vm._ubridge_send.assert_has_calls([ - call(f'link set "{host_ifc}" up'), - call(f'link set "{host_ifc}" down'), - ]) + vm._ubridge_send.assert_has_calls( + [ + call(f'link set "{host_ifc}" up'), + call(f'link set "{host_ifc}" down'), + ] + ) # --------------------------------------------------------------------------- @@ -308,6 +359,7 @@ async def test_set_adapter_carrier_kernel(vm): # on a running node) # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_connect_nio_udp_relays_over_veth(vm): @@ -318,12 +370,15 @@ async def test_connect_nio_udp_relays_over_veth(vm): nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) await vm._connect_nio(0, nio) - vm._ubridge_send.assert_has_calls([ - call("bridge create bridge0"), - call(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'), - call("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), - call("bridge start bridge0"), - ], any_order=True) + vm._ubridge_send.assert_has_calls( + [ + call("bridge create bridge0"), + call(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'), + call("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), + call("bridge start bridge0"), + ], + any_order=True, + ) assert "bridge0" in vm._bridges @@ -348,18 +403,89 @@ async def test_connect_nio_udp_on_veth_is_idempotent(vm): # Guards and cleanup # --------------------------------------------------------------------------- + @pytest.mark.asyncio -async def test_update_nio_kernel_rejects_filters(vm): +async def test_update_nio_kernel_rejects_relay_only_filters(vm): + """ + The PUT route mutates the NIO in place (no create_nio), so the update + path carries its own guard against relay-only filter types. + """ vm._ubridge_hypervisor = MagicMock() nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) vm._ethernet_adapters[0].add_nio(0, nio) - nio.filters = {"loss": [50]} + nio.filters = {"frequency_drop": [10]} with pytest.raises(DockerError, match="kernel-datapath"): await vm.adapter_update_nio_binding(0, nio) +@pytest.mark.asyncio +async def test_update_nio_kernel_applies_netem(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + vm.status = "started" + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + nio.filters = {"delay": [50, 5], "packet_loss": [10], "corrupt": [2]} + + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss 10 corrupt 2') + + +@pytest.mark.asyncio +async def test_update_nio_kernel_clears_netem(vm): + """ + Filters dropped to empty must detach the qdisc (the veth survives link + changes); ENOENT (no qdisc was attached) is tolerated. + """ + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + vm.status = "started" + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + nio.filters = {} + + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc reset "{host_ifc}"') + + +@pytest.mark.asyncio +async def test_apply_netem_reset_tolerates_missing_qdisc(vm): + + vm._ubridge_hypervisor = MagicMock() + from gns3server.compute.ubridge.ubridge_error import UbridgeError + + async def refuse(command): + raise UbridgeError("207-Could not reset qdisc on gv0: No such file or directory") + + vm._ubridge_send = refuse + host_ifc, _ = vm._veth_names(0, 0) + # ENOENT means "already clean" — must not raise + await vm._ubridge_apply_netem(host_ifc, {}) + + +@pytest.mark.asyncio +async def test_apply_netem_reset_reraises_real_errors(vm): + + vm._ubridge_hypervisor = MagicMock() + from gns3server.compute.ubridge.ubridge_error import UbridgeError + + async def refuse(command): + raise UbridgeError("206-Could not reset qdisc on gv0: Operation not permitted") + + vm._ubridge_send = refuse + host_ifc, _ = vm._veth_names(0, 0) + with pytest.raises(UbridgeError): + await vm._ubridge_apply_netem(host_ifc, {}) + + @pytest.mark.asyncio async def test_update_nio_kernel_suspend_toggles_carrier(vm): @@ -451,9 +577,16 @@ async def test_connect_nio_kernel_restarts_capture(vm): # Markers (AF_PACKET taps on the veth host end) # --------------------------------------------------------------------------- + def _marker_spec(bpf="icmp", tag=1, enabled=True, direction=None, link_id="l1"): - return {"bpf": bpf, "tag": tag, "enabled": enabled, "direction": direction, - "data_link_type": "DLT_EN10MB", "link_id": link_id} + return { + "bpf": bpf, + "tag": tag, + "enabled": enabled, + "direction": direction, + "data_link_type": "DLT_EN10MB", + "link_id": link_id, + } @pytest.mark.asyncio diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index b47532de6..b9301c049 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -88,12 +88,46 @@ async def test_kernel_datapath_not_eligible_across_computes(project): @pytest.mark.asyncio -async def test_kernel_datapath_not_eligible_with_filters(project): +async def test_kernel_datapath_eligible_with_netem_filters(project): + """ + Impairment filters with a tc netem equivalent (delay, packet_loss, + corrupt) no longer disqualify the kernel datapath — they become one + netem qdisc per veth host end. + """ link, node1, node2 = await _kernel_link(project) - link._filters = {"delay": [10, 0]} + link._filters = {"delay": [10, 0], "packet_loss": [5], "corrupt": [1]} + assert link._kernel_datapath_eligible(node1, node2) is True + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_with_relay_only_filters(project): + """ + frequency_drop and bpf only exist in the uBridge userspace relay — a + link carrying one stays on the relay. + """ + + link, node1, node2 = await _kernel_link(project) + link._filters = {"delay": [10, 0], "frequency_drop": [7]} assert link._kernel_datapath_eligible(node1, node2) is False + link._filters = {"bpf": ["icmp"]} + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_eligible_when_suspended(project): + """ + A suspended link reports the synthetic frequency_drop emulation through + get_active_filters() — that is relay-only mechanics and must not flip a + kernel link to the relay on reopen/reset (suspend is carrier-driven). + """ + + link, node1, node2 = await _kernel_link(project) + link._suspended = True + assert link.get_active_filters() == {"frequency_drop": [-1]} + assert link._kernel_datapath_eligible(node1, node2) is True + @pytest.mark.asyncio async def test_kernel_datapath_eligible_with_markers(project): @@ -191,7 +225,7 @@ async def test_prepare_relay_link_allocates_udp_ports(project): compute1.get_ip_on_same_subnet.side_effect = subnet_callback - async def port_callback(path, data={}, **kwargs): + async def port_callback(path, data=None, **kwargs): response = MagicMock() response.json = {"udp_port": 1024} return response @@ -212,13 +246,29 @@ async def test_prepare_relay_link_allocates_udp_ports(project): @pytest.mark.asyncio -async def test_update_filters_rejected_on_kernel_link(project): +async def test_update_netem_filters_accepted_on_kernel_link(project): + + link, node1, node2 = await _kernel_link(project) + await link._prepare() + + await link.update_filters({"delay": [10, 0], "packet_loss": [5]}) + assert link.filters == {"delay": [10, 0], "packet_loss": [5]} + # both endpoints carry the filters (one netem qdisc per veth host end) + by_node = {entry[0].id: entry[3] for entry in (await link._prepare())} + assert by_node[node1.id]["filters"] == {"delay": [10, 0], "packet_loss": [5]} + assert by_node[node2.id]["filters"] == {"delay": [10, 0], "packet_loss": [5]} + + +@pytest.mark.asyncio +async def test_update_relay_only_filters_rejected_on_kernel_link(project): link, _n1, _n2 = await _kernel_link(project) await link._prepare() with pytest.raises(ControllerError, match="kernel-datapath"): - await link.update_filters({"delay": [10, 0]}) + await link.update_filters({"frequency_drop": [10]}) + with pytest.raises(ControllerError, match="kernel-datapath"): + await link.update_filters({"bpf": ["icmp"]}) @pytest.mark.asyncio diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index 032bff079..448c1b198 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -565,7 +565,7 @@ async def test_update_ethernet_switch_nio(project): "markers": {}, "filters": {}, }, - timeout=221, + timeout=120, )