diff --git a/docs/design/ubridge-kernel-impairment-spec.md b/docs/design/ubridge-kernel-impairment-spec.md index 9999456b1..52b36399a 100644 --- a/docs/design/ubridge-kernel-impairment-spec.md +++ b/docs/design/ubridge-kernel-impairment-spec.md @@ -3,10 +3,15 @@ SPDX-License-Identifier: CC-BY-SA-4.0 See LICENSE file for licensing information. --> -> Frozen requirements spec for the **uBridge** project. The gns3-server side is -> NOT implemented yet — it lands once uBridge delivers this surface. Everything -> marked *shipped* already exists in uBridge 1.2.3+ and is verified against -> gns3-server branch `feat/docker-kernel-filters`. +> Frozen requirements spec for the **uBridge** project. Delivery status: +> parts **A** (netem keyword extensions, uBridge `feature/tc-netem-ext`) and +> **C + D** (cBPF match-drop, full-restore idempotent `tc reset`, +> `tc capabilities` with `cbpf` probe — uBridge `feature/tc-bpf-drop`) are +> **delivered and integrated** on gns3-server branch +> `feat/docker-kernel-bpf-drop`. Part **B** (eBPF stateful classifier) is +> still pending. The originally shipped netem surface (delay/jitter/loss/ +> dup/corrupt) exists in uBridge 1.2.3+ and is verified against gns3-server +> branch `feat/docker-kernel-filters`. # uBridge kernel impairment: tc netem extensions + eBPF classifiers diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index a8a2e6da6..38764531b 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -24,12 +24,13 @@ 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 | tc netem on the veth host end (delay / packet_loss / corrupt); frequency_drop & bpf stay relay-only | +| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt); bpf as cls_bpf match-drop classifiers; frequency_drop stays relay-only (eBPF classifier pending) | | Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end | -The work landed in five stages on branch stack `feat/docker-kernel-datapath` → +The work landed in six stages on branch stack `feat/docker-kernel-datapath` → `feat/docker-kernel-capture` → `feat/docker-kernel-markers` → -`feat/docker-veth-everywhere` → `feat/docker-kernel-filters`: +`feat/docker-veth-everywhere` → `feat/docker-kernel-filters` → +`feat/docker-kernel-bpf-drop`: 1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based suspend, crash-safe reconciliation. @@ -43,6 +44,11 @@ The work landed in five stages on branch stack `feat/docker-kernel-datapath` → 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. +6. **bpf match-drop** — the `bpf` filter (any line matching drops) runs as + cls_bpf classifiers on the veth host end's clsact egress (`tc bpf_drop`: + uBridge pcap-compiles the line to classic BPF, the kernel migrates it to + eBPF internally — no CAP_BPF needed). Requires a uBridge reporting + `cbpf=1` via `tc capabilities`; older builds get a clear upgrade error. ## Adapter interface types @@ -163,13 +169,13 @@ marker enable_kernel # off = installed but silent 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 | +| GNS3 filter | kernel implementation | 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 (eBPF classifier spec'd: [ubridge-kernel-impairment-spec](../design/ubridge-kernel-impairment-spec.md)) | -| `bpf` | — needs a tc classifier | 409 on kernel links; relay fallback (cBPF match-drop spec'd, same doc) | +| `delay [ms, jitter]` | netem `delay X jitter Y` | jitter 0 omitted | +| `packet_loss [%]` | netem `loss P` | per direction (see below) | +| `corrupt [%]` | netem `corrupt P` | | +| `bpf` (one line per expression, OR) | `tc bpf_drop add ""` — cls_bpf on clsact egress + gact drop | needs uBridge `cbpf=1` (`tc capabilities`, probed once per uBridge process); a line that fails to compile on the compute is skipped with a warning, mirroring the relay | +| `frequency_drop` | — not yet (eBPF stateful classifier spec'd: [ubridge-kernel-impairment-spec](../design/ubridge-kernel-impairment-spec.md), part B pending) | 409 on kernel links; relay fallback | Semantics: @@ -189,14 +195,21 @@ Semantics: * **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. - Kernel-side implementations are frozen in +* A link carrying `frequency_drop` is wired on the **relay** (its eBPF + stateful classifier is not delivered yet); `available_filters` hides it on + kernel links and setting it returns 409 with a clear message. The remaining + kernel-side roadmap is frozen in [docs/design/ubridge-kernel-impairment-spec.md](../design/ubridge-kernel-impairment-spec.md) (eBPF stateful classifier for `frequency_drop` + quota/window/flow modes, - classic-BPF match-drop for `bpf`, plus netem keyword extensions: rate, - reorder, gemodel loss, jitter distributions). + plus netem keyword extensions: rate, reorder, gemodel loss, jitter + distributions — parts C and D of that spec are delivered). +* **bpf reconcile = flush + re-add** (`tc bpf_drop flush`, then one add per + line at priorities 10, 11, …). `tc reset` (link delete / filter clear) is + the full restore in uBridge: filters → clsact → root qdisc, idempotent. +* **Observability caveat:** clsact drops happen before the AF_PACKET tap + points — markers/captures on the same veth do not observe cls_bpf-dropped + frames (on the relay, filter-vs-mark ordering determined visibility + instead). ## uBridge command surface @@ -209,14 +222,13 @@ 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 +tc netem set [delay ] [jitter ] [loss <%>] [dup <%>] [corrupt <%>] +tc bpf_drop add "" / tc bpf_drop flush +tc reset # full restore: filters -> clsact -> root qdisc, idempotent +tc capabilities # "netem=;ebpf=0|1;cbpf=0|1", probed once per process 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 @@ -239,10 +251,18 @@ 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 +48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop` = 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: +impairment. + +bpf match-drop (same setup, 22/22 checks): `bpf "icmp"` = 100 % loss on both +ends' clsact; size-discriminating expression (`greater 150`) = small pings +pass / 300-byte pings dropped (real byte-level BPF match); two-line OR; +coexistence with netem (delay 50 → small pings 100.2 ms while big dropped); +clear removes clsact entirely; suspend/resume keeps the drops; link delete + +recreate leaves no residual filters; `frequency_drop` still 409; +`available_filters` shows `bpf` and hides `frequency_drop`. Unit tests: `tests/compute/docker/test_docker_kernel_datapath.py`, `tests/controller/test_kernel_datapath_link.py`. diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 921bcfc0e..63ca5a089 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -177,6 +177,11 @@ class DockerVM(BaseNode): # container death. Kernel links enslave it into a Linux bridge, # relay links attach it to the uBridge relay via AF_PACKET. self._kernel_veths = {} + # uBridge tc-module capability report, probed lazily once per uBridge + # process (None = not probed yet, {} = no tc support, else the parsed + # "tc capabilities" reply). Invalidated on stop: the next start runs a + # fresh uBridge process which may differ (upgrade/downgrade). + self._ubridge_tc_caps = None if adapters is None: self.adapters = 1 @@ -1430,6 +1435,9 @@ class DockerVM(BaseNode): self._console_websocket = None self._cleanup_console_resources() await self._clean_servers() + # The next start spawns a fresh uBridge process — its capabilities + # must be re-probed (see _ubridge_tc_capabilities). + self._ubridge_tc_caps = None # veth host ends live in the root namespace and survive container # death — remove them while the uBridge control channel is still up. await self._remove_kernel_veths() @@ -1935,8 +1943,10 @@ class DockerVM(BaseNode): # 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). + # end (restored here on node restart, like the capture above); + # bpf expressions become cls_bpf match-drop classifiers. await self._ubridge_apply_netem(host_ifc, nio.filters) + await self._ubridge_apply_bpf_drops(host_ifc, nio.filters) return # Relay NIO. On a veth-backed adapter (the unified Docker interface) @@ -1998,9 +2008,11 @@ class DockerVM(BaseNode): if self.ubridge: if isinstance(nio, NIOBridge): - # 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. + # The controller keeps frequency_drop off kernel links (no + # kernel equivalent until the eBPF classifier lands) and + # create_nio rejects it on the POST path; this PUT path + # mutates the NIO in place, so guard here too. bpf is fine — + # it becomes cls_bpf match-drop classifiers (cbpf-probed). unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {}) if unsupported: raise DockerError( @@ -2014,8 +2026,10 @@ class DockerVM(BaseNode): # 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. + # IS the incremental reconcile for impairment filters; + # bpf drops reconcile as flush + re-add. await self._ubridge_apply_netem(host_ifc, nio.filters) + await self._ubridge_apply_bpf_drops(host_ifc, nio.filters) await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) return @@ -2172,6 +2186,78 @@ class DockerVM(BaseNode): if "No such file" not in str(e): raise + 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 _remove_kernel_markers(self, host_ifc): """ Tear down every kernel marker anchored to *host_ifc*: the veth survives diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 53d9dc585..13e403af4 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -66,7 +66,6 @@ class TAPNIO(BaseModel): class BridgeNIOType(str, Enum): - bridge = "nio_bridge" @@ -74,17 +73,19 @@ 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. 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. + wiring a uBridge UDP relay. Impairment filters run on the veth host end: + delay/packet_loss/corrupt as one tc netem qdisc, bpf as cls_bpf match-drop + classifiers (needs a uBridge reporting cbpf). frequency_drop still has no + kernel equivalent and is 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 served by tc netem on the veth host end (delay/packet_loss/corrupt)" + None, + description="Packet filters served on the veth host end (delay/packet_loss/corrupt via netem, bpf via cls_bpf)", ) 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 57bcdba60..6a0337552 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -15,10 +15,12 @@ class FilterValidationError(Exception): pass -# 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"}) +# Packet filters with no kernel-datapath equivalent yet: frequency_drop needs +# the eBPF stateful classifier (frozen in +# docs/design/ubridge-kernel-impairment-spec.md, part B — not delivered), +# so it still only runs in the uBridge userspace relay. bpf is served by the +# cBPF match-drop classifier (tc bpf_drop) when uBridge reports cbpf=1. +KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop"}) def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]: diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 53f57d9a4..a467a21c9 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -28,6 +28,8 @@ from unittest.mock import MagicMock, call import pytest import pytest_asyncio +from unittest.mock import patch + from tests.utils import AsyncioMagicMock from gns3server.compute.docker import Docker @@ -88,11 +90,22 @@ def test_create_nio_bridge_accepts_netem_filters(vm): 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): +def test_create_nio_bridge_accepts_bpf_filters(vm): + """ + bpf expressions run on the kernel datapath as cls_bpf match-drop + classifiers (uBridge tc bpf_drop); only frequency_drop has no kernel + equivalent yet. + """ + + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"bpf": ["icmp"]}}) + assert isinstance(nio, NIOBridge) + assert nio.filters == {"bpf": ["icmp"]} + + +def test_create_nio_bridge_rejects_relay_only_filters(vm): with pytest.raises(ComputeError, match="kernel-datapath"): - vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": filters}) + vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"frequency_drop": [10]}}) def test_create_nio_bridge_accepts_markers(vm): @@ -420,6 +433,114 @@ async def test_update_nio_kernel_rejects_relay_only_filters(vm): await vm.adapter_update_nio_binding(0, nio) +def _caps_hypervisor(cbpf="1"): + """A hypervisor mock whose direct send() answers `tc capabilities`.""" + hyp = MagicMock() + hyp.is_running.return_value = True + hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf=0;cbpf={cbpf}"]) + return hyp + + +@pytest.mark.asyncio +async def test_update_nio_kernel_applies_bpf_drops(vm): + """ + bpf lines reconcile as flush + one bpf_drop add per line at successive + priorities (any line matching drops — OR semantics like the relay). + """ + + vm._ubridge_hypervisor = _caps_hypervisor() + 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 = {"bpf": ["icmp\nudp port 53"]} + + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc bpf_drop flush "{host_ifc}"') + vm._ubridge_send.assert_any_call(f'tc bpf_drop add "{host_ifc}" 10 "icmp"') + vm._ubridge_send.assert_any_call(f'tc bpf_drop add "{host_ifc}" 11 "udp port 53"') + + +@pytest.mark.asyncio +async def test_update_nio_kernel_flushes_bpf_drops_when_removed(vm): + + vm._ubridge_hypervisor = _caps_hypervisor() + 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 = {"bpf": ["icmp"]} + + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.reset_mock() + nio.filters = {} + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc bpf_drop flush "{host_ifc}"') + for c in vm._ubridge_send.call_args_list: + assert "bpf_drop add" not in c.args[0] + + +@pytest.mark.asyncio +async def test_apply_bpf_drops_requires_cbpf_capability(vm): + + vm._ubridge_hypervisor = _caps_hypervisor(cbpf="0") + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + with pytest.raises(DockerError, match="upgrade uBridge"): + await vm._ubridge_apply_bpf_drops(host_ifc, {"bpf": ["icmp"]}) + + +@pytest.mark.asyncio +async def test_apply_bpf_drops_compile_failure_warns(vm): + """ + A line that compiles for the controller's tcpdump but not for this + uBridge's libpcap is skipped with a warning — mirrors the relay path. + """ + + vm._ubridge_hypervisor = _caps_hypervisor() + host_ifc, _ = vm._veth_names(0, 0) + + async def send(command): + if "bpf_drop add" in command: + 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: + await vm._ubridge_apply_bpf_drops(host_ifc, {"bpf": ["icmp"]}) # must not raise + assert mock_log.called + + +@pytest.mark.asyncio +async def test_apply_bpf_drops_skips_old_ubridge_without_lines(vm): + """ + No bpf lines and no capability probe yet: nothing was ever installed on + an old uBridge, so it must not be touched at all (no unknown-module error). + """ + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + await vm._ubridge_apply_bpf_drops(host_ifc, {}) + vm._ubridge_send.assert_not_called() + vm._ubridge_hypervisor.send.assert_not_called() + + +@pytest.mark.asyncio +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"} + await vm._ubridge_tc_capabilities() + assert vm._ubridge_hypervisor.send.call_count == 1 + + @pytest.mark.asyncio async def test_update_nio_kernel_applies_netem(vm): diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index b9301c049..87770a86e 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -103,8 +103,9 @@ async def test_kernel_datapath_eligible_with_netem_filters(project): @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. + frequency_drop only exists in the uBridge userspace relay (its eBPF + classifier is not delivered yet) — a link carrying one stays on the + relay. bpf is kernel-capable (cls_bpf match-drop) and eligible. """ link, node1, node2 = await _kernel_link(project) @@ -112,7 +113,7 @@ async def test_kernel_datapath_not_eligible_with_relay_only_filters(project): assert link._kernel_datapath_eligible(node1, node2) is False link._filters = {"bpf": ["icmp"]} - assert link._kernel_datapath_eligible(node1, node2) is False + assert link._kernel_datapath_eligible(node1, node2) is True @pytest.mark.asyncio @@ -267,8 +268,25 @@ async def test_update_relay_only_filters_rejected_on_kernel_link(project): with pytest.raises(ControllerError, match="kernel-datapath"): await link.update_filters({"frequency_drop": [10]}) - with pytest.raises(ControllerError, match="kernel-datapath"): - await link.update_filters({"bpf": ["icmp"]}) + + +@pytest.mark.asyncio +async def test_update_bpf_filters_accepted_on_kernel_link(project): + """ + bpf expressions ride the kernel NIO to BOTH endpoints (each end's + cls_bpf classifiers cover the traffic entering that container — the + same both-directions-once net effect as the relay's single filtered + bridge). + """ + + link, node1, node2 = await _kernel_link(project) + await link._prepare() + + await link.update_filters({"bpf": ["icmp\nudp port 53"]}) + assert link.filters == {"bpf": ["icmp\nudp port 53"]} + by_node = {entry[0].id: entry[3] for entry in (await link._prepare())} + assert by_node[node1.id]["filters"] == {"bpf": ["icmp\nudp port 53"]} + assert by_node[node2.id]["filters"] == {"bpf": ["icmp\nudp port 53"]} @pytest.mark.asyncio