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 committed 2026-10-06 00:21:08 +08:00
1 parent 94b5495683
commit 0a8d430d50
11 files changed
+459 -95

No files matched your search

+12 -2
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", {})
+69 -6
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):
+3 -2
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).
"""
+22 -7
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 []
+52 -14
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):
+7 -4
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)")
+8 -2
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.