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