mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-21 18:32:23 +03:00
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:
parent
bd1c205651
commit
2b45f29b2c
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)")
|
||||||
|
|
||||||
|
|||||||
@ -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]]:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user