feat(docker): bpf packet filter on kernel links via cls_bpf match-drop

The GNS3 bpf filter (one expression per line, any line matching drops the
packet) now runs on the kernel datapath: uBridge's tc bpf_drop compiles
each line with libpcap (same compiler as the relay filter) into classic
BPF, attaches it to the veth host end's clsact egress as a cls_bpf
classifier with a gact TC_ACT_SHOT action, and the kernel migrates the
cBPF to eBPF internally -- no CAP_BPF needed.

- reconcile is flush + one add per line at priorities 10, 11, ... (OR
  semantics, same as the relay's per-line filters); tc reset (link
  delete / clear) is the full restore in uBridge and clears them
- capability-gated: the compute probes "tc capabilities" once per uBridge
  process (cached, invalidated on stop); without cbpf=1 the filter apply
  fails with an actionable upgrade error, and old uBridge builds without
  the tc module are never touched when no bpf filter is present
- a line that compiled for the controller's tcpdump but not for the
  compute's uBridge libpcap is skipped with a warning, mirroring the
  relay path
- KERNEL_UNSUPPORTED_FILTERS shrinks to frequency_drop (its eBPF stateful
  classifier is still pending, spec part B)

e2e (22/22): bpf "icmp" = 100% loss; size-discriminating expression
("greater 150") passes 98-byte and drops 300-byte pings (real byte-level
match); two-line OR; coexistence with netem (delay 50 -> small pings
100.2 ms while big dropped); clear removes clsact; suspend/resume keeps
the drops; link delete + recreate leaves no residual filters.
This commit is contained in:
YueGuobin 2026-09-29 10:31:52 +08:00
parent bd1c205651
commit 2b45f29b2c
No known key found for this signature in database
7 changed files with 303 additions and 50 deletions

View File

@ -3,10 +3,15 @@ SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information. See LICENSE file for licensing information.
--> -->
> Frozen requirements spec for the **uBridge** project. The gns3-server side is > Frozen requirements spec for the **uBridge** project. Delivery status:
> NOT implemented yet — it lands once uBridge delivers this surface. Everything > parts **A** (netem keyword extensions, uBridge `feature/tc-netem-ext`) and
> marked *shipped* already exists in uBridge 1.2.3+ and is verified against > **C + D** (cBPF match-drop, full-restore idempotent `tc reset`,
> gns3-server branch `feat/docker-kernel-filters`. > `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 # uBridge kernel impairment: tc netem extensions + eBPF classifiers

View File

@ -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 | | 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) | | 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) | | 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 | | 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-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 1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based
suspend, crash-safe reconciliation. 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` tc netem qdisc per veth host end, pushed through uBridge's netlink `tc`
module. This also removes the relay delay filter's nanosleep bottleneck module. This also removes the relay delay filter's nanosleep bottleneck
(upstream #2827) from the kernel path. (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 ## Adapter interface types
@ -163,13 +169,13 @@ marker enable_kernel <if> <name> <on|off> # off = installed but silent
Impairment filters run **in the kernel** as tc netem qdiscs on the veth host 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): 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 | | `delay [ms, jitter]` | netem `delay X jitter Y` | jitter 0 omitted |
| `packet_loss [%]` | `loss P` | per direction (see below) | | `packet_loss [%]` | netem `loss P` | per direction (see below) |
| `corrupt [%]` | `corrupt P` | | | `corrupt [%]` | netem `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` (one line per expression, OR) | `tc bpf_drop add <if> <prio> "<expr>"` — 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 |
| `bpf` | — needs a tc classifier | 409 on kernel links; relay fallback (cBPF match-drop spec'd, same doc) | | `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: Semantics:
@ -189,14 +195,21 @@ Semantics:
* **Suspend** keeps the qdisc (carrier-driven loss); the first packet after * **Suspend** keeps the qdisc (carrier-driven loss); the first packet after
resume takes one extra delay interval (known netem idle-baseline behaviour) resume takes one extra delay interval (known netem idle-baseline behaviour)
and steady state is exact. and steady state is exact.
* A link carrying `frequency_drop` or `bpf` is wired on the **relay** (they * A link carrying `frequency_drop` is wired on the **relay** (its eBPF
only exist in uBridge's userspace filters); `available_filters` hides them stateful classifier is not delivered yet); `available_filters` hides it on
on kernel links and setting them returns 409 with a clear message. kernel links and setting it returns 409 with a clear message. The remaining
Kernel-side implementations are frozen in kernel-side roadmap is frozen in
[docs/design/ubridge-kernel-impairment-spec.md](../design/ubridge-kernel-impairment-spec.md) [docs/design/ubridge-kernel-impairment-spec.md](../design/ubridge-kernel-impairment-spec.md)
(eBPF stateful classifier for `frequency_drop` + quota/window/flow modes, (eBPF stateful classifier for `frequency_drop` + quota/window/flow modes,
classic-BPF match-drop for `bpf`, plus netem keyword extensions: rate, plus netem keyword extensions: rate, reorder, gemodel loss, jitter
reorder, gemodel loss, jitter distributions). 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 ## uBridge command surface
@ -209,14 +222,13 @@ brctl create / delete / addif / delif / show
link set <if> up|down link set <if> up|down
capture start_kernel / stop_kernel capture start_kernel / stop_kernel
marker add_kernel / delete_kernel / enable_kernel marker add_kernel / delete_kernel / enable_kernel
tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%>] [dup <%>] [corrupt <%>] / tc reset <if> tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%>] [dup <%>] [corrupt <%>]
tc bpf_drop add <if> <prio 10-99> "<expr>" / tc bpf_drop flush <if>
tc reset <if> # full restore: filters -> clsact -> root qdisc, idempotent
tc capabilities # "netem=<kw,...>;ebpf=0|1;cbpf=0|1", probed once per process
bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture 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 ## Configuration
```ini ```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 → 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 `delay 100` = 200.2 ms RTT (2× per direction) → `delay 50` = 100.2 ms
(atomic replace) → clear = baseline (qdisc detached); `packet_loss 30` = (atomic replace) → clear = baseline (qdisc detached); `packet_loss 30` =
48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop`/`bpf` = 48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop` = 409;
409; suspend with delay active = 100 % loss, resume keeps the qdisc suspend with delay active = 100 % loss, resume keeps the qdisc
(200.1 ms); node restart restores it (200.1 ms); link delete detaches it (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 (`/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/compute/docker/test_docker_kernel_datapath.py`,
`tests/controller/test_kernel_datapath_link.py`. `tests/controller/test_kernel_datapath_link.py`.

View File

@ -177,6 +177,11 @@ class DockerVM(BaseNode):
# container death. Kernel links enslave it into a Linux bridge, # container death. Kernel links enslave it into a Linux bridge,
# relay links attach it to the uBridge relay via AF_PACKET. # relay links attach it to the uBridge relay via AF_PACKET.
self._kernel_veths = {} 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: if adapters is None:
self.adapters = 1 self.adapters = 1
@ -1430,6 +1435,9 @@ class DockerVM(BaseNode):
self._console_websocket = None self._console_websocket = None
self._cleanup_console_resources() self._cleanup_console_resources()
await self._clean_servers() 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 # veth host ends live in the root namespace and survive container
# death — remove them while the uBridge control channel is still up. # death — remove them while the uBridge control channel is still up.
await self._remove_kernel_veths() await self._remove_kernel_veths()
@ -1935,8 +1943,10 @@ class DockerVM(BaseNode):
# taps) — the anchor is the interface, not a relay bridge. # taps) — the anchor is the interface, not a relay bridge.
await self._ubridge_apply_markers(host_ifc, nio) await self._ubridge_apply_markers(host_ifc, nio)
# Impairment filters become one tc netem qdisc on the veth host # 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_netem(host_ifc, nio.filters)
await self._ubridge_apply_bpf_drops(host_ifc, nio.filters)
return return
# Relay NIO. On a veth-backed adapter (the unified Docker interface) # Relay NIO. On a veth-backed adapter (the unified Docker interface)
@ -1998,9 +2008,11 @@ class DockerVM(BaseNode):
if self.ubridge: if self.ubridge:
if isinstance(nio, NIOBridge): if isinstance(nio, NIOBridge):
# The controller keeps relay-only filters off kernel links and # The controller keeps frequency_drop off kernel links (no
# create_nio rejects them on the POST path; this PUT path # kernel equivalent until the eBPF classifier lands) and
# mutates the NIO in place, so guard here too. # 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 {}) unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {})
if unsupported: if unsupported:
raise DockerError( raise DockerError(
@ -2014,8 +2026,10 @@ class DockerVM(BaseNode):
# the relay-datapath equivalent of the branch below. # the relay-datapath equivalent of the branch below.
await self._ubridge_apply_markers(host_ifc, nio) await self._ubridge_apply_markers(host_ifc, nio)
# netem set is an atomic replace, so a full re-apply # 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_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) await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
return return
@ -2172,6 +2186,78 @@ class DockerVM(BaseNode):
if "No such file" not in str(e): if "No such file" not in str(e):
raise 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=<kw,...>;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): async def _remove_kernel_markers(self, host_ifc):
""" """
Tear down every kernel marker anchored to *host_ifc*: the veth survives Tear down every kernel marker anchored to *host_ifc*: the veth survives

View File

@ -66,7 +66,6 @@ class TAPNIO(BaseModel):
class BridgeNIOType(str, Enum): class BridgeNIOType(str, Enum):
bridge = "nio_bridge" bridge = "nio_bridge"
@ -74,17 +73,19 @@ class BridgeNIO(BaseModel):
""" """
Kernel-datapath bridge Network Input/Output properties. The NIO tells the 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 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 wiring a uBridge UDP relay. Impairment filters run on the veth host end:
(delay, packet_loss, corrupt) run on the veth host end; the relay-only delay/packet_loss/corrupt as one tc netem qdisc, bpf as cls_bpf match-drop
kinds (frequency_drop, bpf) are rejected. Markers and packet capture are classifiers (needs a uBridge reporting cbpf). frequency_drop still has no
served by uBridge's AF_PACKET modules on the veth host end. kernel equivalent and is rejected. Markers and packet capture are served
by uBridge's AF_PACKET modules on the veth host end.
""" """
type: BridgeNIOType type: BridgeNIOType
bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5") bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5")
suspend: Optional[bool] = Field(None, description="Suspend the NIO") suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field( 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)") markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")

View File

@ -15,10 +15,12 @@ class FilterValidationError(Exception):
pass pass
# Packet filters with no kernel-datapath equivalent (no tc netem mapping): # Packet filters with no kernel-datapath equivalent yet: frequency_drop needs
# they only run in the uBridge userspace relay, so a link carrying one is # the eBPF stateful classifier (frozen in
# wired on the relay, and adding them to a kernel-datapath link is rejected. # docs/design/ubridge-kernel-impairment-spec.md, part B — not delivered),
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop", "bpf"}) # 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]]: def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:

View File

@ -28,6 +28,8 @@ from unittest.mock import MagicMock, call
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from unittest.mock import patch
from tests.utils import AsyncioMagicMock from tests.utils import AsyncioMagicMock
from gns3server.compute.docker import Docker 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]} assert nio.filters == {"delay": [50, 5], "packet_loss": [10]}
@pytest.mark.parametrize("filters", [{"frequency_drop": [10]}, {"bpf": ["icmp"]}]) def test_create_nio_bridge_accepts_bpf_filters(vm):
def test_create_nio_bridge_rejects_relay_only_filters(vm, filters): """
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"): 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): 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) 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 @pytest.mark.asyncio
async def test_update_nio_kernel_applies_netem(vm): async def test_update_nio_kernel_applies_netem(vm):

View File

@ -103,8 +103,9 @@ async def test_kernel_datapath_eligible_with_netem_filters(project):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_with_relay_only_filters(project): async def test_kernel_datapath_not_eligible_with_relay_only_filters(project):
""" """
frequency_drop and bpf only exist in the uBridge userspace relay — a frequency_drop only exists in the uBridge userspace relay (its eBPF
link carrying one stays on the relay. 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) 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 assert link._kernel_datapath_eligible(node1, node2) is False
link._filters = {"bpf": ["icmp"]} 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 @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"): with pytest.raises(ControllerError, match="kernel-datapath"):
await link.update_filters({"frequency_drop": [10]}) 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 @pytest.mark.asyncio