feat(docker): expose netem-extension packet filters on kernel links

Surface the netem extensions delivered in uBridge feature/tc-netem-ext
as GNS3 filter types: rate (tc-style bandwidth string), reorder (needs
delay), gemodel (Gilbert-Elliot bursty loss, exclusive with
packet_loss), duplicate, seed and limit, plus the delay jitter
distribution and the loss/dup correlation parameters. All are
kernel-datapath only — the relay's packet-filter registry has no
equivalent, so available_filters hides them on relay links, setting one
on a created relay link returns 409, and the project-load path drops
them with a warning (update_filters runs before the datapath is
decided, so it cannot reject there). Validation mirrors the tc grammar:
reorder requires delay, gemodel and packet_loss are mutually exclusive,
a distribution requires jitter > 0, rate is integer+unit capped at
100gbit.

The translation in DockerVM._netem_command_parts emits the frozen
grammar order and gates extension keywords on the 'tc capabilities'
netem token list (probed once per uBridge process; a plain
delay/loss/corrupt/dup filter never probes, keeping old uBridge builds
untouched; the correl suffix has no token and gates on 'rate' as the
extension-build marker).

Reconcile became reset-before-set: the kernel's netem change merges
optional attributes (rate, correlation, reorder, corrupt, gemodel,
distribution, seed keep their previous value when the new message omits
them), so removing a parameter needs an explicit 'tc reset' — the
bpf_drop filters are re-added right after in the same flow, so the
clsact churn is invisible. The capability check runs before the reset
so a failed update leaves the previous qdisc in place.

e2e (isolated instance, kernel link): 24/24 — rate 512kbit adds exactly
2x the per-packet serialization to the RTT (45.5 ms for 1400-byte
pings), paretonormal jitter + rate-cleared leak check, reorder gap 5,
gemodel 50% bursty loss, duplicate, loss 30% correl 50%, seed 42 +
limit 5000 visible in the qdisc dump, suspend/resume and node restart
keep the filters, relay link rejects rate with 409, teardown leaves no
netem. Full unit suite: 1916 passed.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:08 +08:00
1 parent b1eb0984bc
commit d4b57d49fb
13 files changed
+1012 -108

No files matched your search

+14
View File
@@ -356,6 +356,20 @@ class BaseManager:
except OSError as e:
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
nio = NIOUDP(lport, rhost, rport)
# The netem-extension filters (rate, reorder, gemodel…) have no
# uBridge relay equivalent — reject them here (the controller
# already keeps them off relay links; this is the second guard
# for direct compute API use, mirroring the kernel-NIO guard).
from gns3server.utils.packet_filter_validation import kernel_only_features
kernel_only = kernel_only_features(nio_settings.get("filters") or {})
if kernel_only:
raise ComputeError(
"Packet filter(s) {} only run on a kernel-datapath link "
"(tc netem on the veth host end); the uBridge relay has no equivalent".format(
", ".join(sorted(kernel_only))
)
)
nio.filters = nio_settings.get("filters", {})
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
+11
View File
@@ -1116,6 +1116,17 @@ class BaseNode:
:param filters: Array of filter dictionary
"""
# The netem-extension filters (rate, reorder, gemodel…) only exist on
# the kernel datapath. The controller keeps them off relay links;
# this is the second guard for direct compute API use.
from gns3server.utils.packet_filter_validation import kernel_only_features
kernel_only = kernel_only_features(filters)
if kernel_only:
raise NodeError(
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
"veth host end); the uBridge relay has no equivalent".format(", ".join(sorted(kernel_only)))
)
await self._ubridge_send("bridge reset_packet_filters " + bridge_name)
for packet_filter in self._build_filter_list(filters):
cmd = f"bridge add_packet_filter {bridge_name} {packet_filter}"
+132 -21
View File
@@ -2156,35 +2156,143 @@ class DockerVM(BaseNode):
return
await super()._ubridge_enable_marker_filter(anchor, name, state)
@staticmethod
def _netem_command_parts(filters):
"""
Build the ``tc netem set`` keyword sequence (in the frozen grammar
order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate,
limit, distribution, seed) plus the set of capability tokens the
sequence needs beyond the original netem surface. Original-surface
keywords (delay, jitter, loss, corrupt, dup) need no probe; the
extension tokens (rate, reorder, gemodel, dist, seed, limit) are
checked against ``tc capabilities`` by the caller. ``correl`` has no
token of its own in the capabilities list, so it gates on "rate" as
the netem-extension build marker.
:param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...})
:returns: (keyword sequence, required extension tokens)
"""
def values_of(key):
# tolerate the legacy bare-value shape ({"packet_loss": 10})
values = filters.get(key)
if isinstance(values, (list, tuple)):
return list(values)
return [values] if values else []
parts = []
ext_tokens = set()
delay = values_of("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])}")
gemodel = values_of("gemodel")
if gemodel:
segment = f"loss gemodel {int(gemodel[0])}"
if len(gemodel) > 1:
segment += f" {int(gemodel[1])}"
if len(gemodel) > 2:
segment += f" {int(gemodel[2])}"
parts.append(segment)
ext_tokens.add("gemodel")
else:
loss = values_of("packet_loss")
if loss and int(loss[0]):
parts.append(f"loss {int(loss[0])}")
if len(loss) > 1 and int(loss[1]):
parts.append(f"correl {int(loss[1])}")
ext_tokens.add("rate") # correl: extension build marker
duplicate = values_of("duplicate")
if duplicate and int(duplicate[0]):
parts.append(f"dup {int(duplicate[0])}")
if len(duplicate) > 1 and int(duplicate[1]):
parts.append(f"correl {int(duplicate[1])}")
ext_tokens.add("rate") # correl: extension build marker
corrupt = values_of("corrupt")
if corrupt and int(corrupt[0]):
parts.append(f"corrupt {int(corrupt[0])}")
reorder = values_of("reorder")
if reorder:
segment = f"reorder {int(reorder[0])}"
if len(reorder) > 1 and int(reorder[1]):
segment += f" correl {int(reorder[1])}"
if len(reorder) > 2 and int(reorder[2]):
segment += f" gap {int(reorder[2])}"
parts.append(segment)
ext_tokens.add("reorder")
rate = values_of("rate")
if rate and str(rate[0]).strip():
parts.append(f"rate {str(rate[0]).strip()}")
ext_tokens.add("rate")
limit = values_of("limit")
if limit and int(limit[0]):
parts.append(f"limit {int(limit[0])}")
ext_tokens.add("limit")
if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"):
# "uniform" is the kernel default — emitting nothing keeps the
# command compatible with the original netem surface.
parts.append(f"distribution {str(delay[2]).strip().lower()}")
ext_tokens.add("dist")
seed = values_of("seed")
if seed and int(seed[0]):
# 0 is the "disabled" convention everywhere else — treat it so
# here too (the controller's inactive-filter pass drops it).
parts.append(f"seed {int(seed[0])}")
ext_tokens.add("seed")
return parts, ext_tokens
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``).
veth host end. The netem-expressible types (delay, packet_loss,
corrupt and the netem extensions rate/reorder/gemodel/duplicate/
seed/limit) 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).
The kernel's netem replace MERGES optional attributes (rate,
correlation, reorder, corrupt, gemodel, distribution: absent attr =
previous value kept), so re-applying a filter set with a parameter
*removed* would silently keep the old value. A ``tc reset`` before
every ``netem set`` makes the re-apply a true reconcile (reset also
drops clsact and its bpf_drop filters — the caller re-applies them
right after in the same flow). An empty filter set is the reset
alone.
: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
parts, ext_tokens = self._netem_command_parts(filters)
if parts and ext_tokens:
# Extension keywords need the netem-extension uBridge. Plain
# delay/loss/corrupt/dup never probes — an old uBridge serves
# the original surface untouched (mirrors bpf_drop's guard).
# Checked before the reset so a failed update leaves the
# previously applied qdisc in place.
caps = await self._ubridge_tc_capabilities()
missing = ext_tokens - set(caps.get("netem", "").split(","))
if missing:
raise DockerError(
"Packet filter(s) using {} need a uBridge with the netem extensions "
"(tc capabilities reports '{}'); upgrade uBridge on this compute".format(
", ".join(sorted(missing)), caps.get("netem") or "no tc support"
)
)
try:
await self._ubridge_send(f'tc reset "{host_ifc}"')
except UbridgeError as e:
@@ -2192,6 +2300,9 @@ class DockerVM(BaseNode):
# clean. Everything else is a real failure and must surface.
if "No such file" not in str(e):
raise
if not parts:
return
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
async def _ubridge_tc_capabilities(self):
"""