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
This commit is contained in:
YueGuobin 2026-09-28 20:07:21 +08:00
parent 94b5495683
commit 0a8d430d50
No known key found for this signature in database
11 changed files with 459 additions and 95 deletions

View File

@ -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 <if> <name> <on|off> # 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 <if> up|down
capture start_kernel / stop_kernel
marker add_kernel / delete_kernel / enable_kernel
tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%>] [dup <%>] [corrupt <%>] / tc reset <if>
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`.

View File

@ -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", {})

View File

@ -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):

View File

@ -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).
"""

View File

@ -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 []

View File

@ -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):

View File

@ -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)")

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -565,7 +565,7 @@ async def test_update_ethernet_switch_nio(project):
"markers": {},
"filters": {},
},
timeout=221,
timeout=120,
)