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

+84 -4
View File
@@ -26,6 +26,7 @@ from gns3server.config import Config
from gns3server.utils.packet_filter_validation import (
FilterValidationError,
filter_inactive_filters,
kernel_only_features,
validate_all_filters,
)
@@ -51,16 +52,22 @@ FILTERS = [
{
"type": "packet_loss",
"name": "Packet loss",
"description": "The percentage represents the chance for a packet to be lost",
"parameters": [{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}],
"description": "The percentage represents the chance for a packet to be lost. The optional correlation "
"(kernel-datapath links only) makes consecutive losses dependent, like a bursty real network",
"parameters": [
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
],
},
{
"type": "delay",
"name": "Delay",
"description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay",
"description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay, and "
"(kernel-datapath links only) shape the jitter with a distribution",
"parameters": [
{"name": "Latency", "minimum": 1, "maximum": 32767, "unit": "ms", "type": "int"},
{"name": "Jitter (-/+)", "minimum": 0, "maximum": 32767, "unit": "ms", "type": "int"},
{"name": "Distribution", "type": "str", "unit": "uniform|normal|pareto|paretonormal"},
],
},
{
@@ -75,6 +82,60 @@ FILTERS = [
"description": "This filter will drop any packet matching a BPF expression. Put one expression per line",
"parameters": [{"name": "Filters", "type": "text"}],
},
# Everything below runs on kernel-datapath links only (tc netem on the
# veth host end; the uBridge relay has no equivalent).
{
"type": "rate",
"name": "Bandwidth limit",
"description": "Shape the link to a maximum bandwidth, tc-style value (kernel-datapath links only)",
"parameters": [{"name": "Rate", "type": "str", "unit": "e.g. 512kbit, 10mbit"}],
},
{
"type": "reorder",
"name": "Reorder",
"description": "The percentage represents the chance for a packet to be reordered (held until a later "
"packet passes it). Requires the delay filter; kernel-datapath links only",
"parameters": [
{"name": "Reorder", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "Gap", "minimum": 1, "maximum": 1000, "type": "int", "unit": "packets"},
],
},
{
"type": "gemodel",
"name": "Gilbert-Elliot loss",
"description": "Bursty loss model: p is the loss chance in the bad state, r in the good state, 1-h the "
"chance of moving from good to bad. Mutually exclusive with packet loss; kernel-datapath links only",
"parameters": [
{"name": "p (bad-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "r (good-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "1-h (good-to-bad)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
],
},
{
"type": "duplicate",
"name": "Duplicate",
"description": "The percentage represents the chance for a packet to be duplicated; the optional "
"correlation makes consecutive duplicates dependent (kernel-datapath links only)",
"parameters": [
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
],
},
{
"type": "seed",
"name": "Random seed",
"description": "Make the netem random draws (loss, duplication, jitter) reproducible for repeated "
"experiments (kernel-datapath links only)",
"parameters": [{"name": "Seed", "minimum": 0, "maximum": 4294967295, "type": "int", "unit": ""}],
},
{
"type": "limit",
"name": "Queue limit",
"description": "Queue depth of the impairment qdisc in packets — raise it above the 1000 default when "
"combining a low rate with a long delay (kernel-datapath links only)",
"parameters": [{"name": "Limit", "minimum": 1, "maximum": 1000000, "type": "int", "unit": "packets"}],
},
]
@@ -247,6 +308,21 @@ class Link:
", ".join(sorted(unsupported))
)
)
elif self._created:
# The reverse guard: netem-extension filters have no relay
# equivalent. Only enforced on created links — while loading a
# project the datapath is not decided yet (a link that will be
# wired on the kernel datapath must accept them), and the relay
# prepare path drops what it cannot run with a warning.
conflicts = kernel_only_features(new_filters)
if conflicts:
raise ControllerError(
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
"veth host end); this link is wired on the uBridge relay — delete and "
"recreate the link to switch it to the kernel datapath".format(
", ".join(sorted(conflicts))
)
)
if new_filters != self.filters:
self._filters = new_filters
@@ -652,7 +728,11 @@ class Link:
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
# Relay links: hide the netem-extension types the relay cannot run
# (they would be rejected with 409 on update).
from gns3server.utils.packet_filter_validation import KERNEL_ONLY_FILTERS
return [f for f in FILTERS if f["type"] not in KERNEL_ONLY_FILTERS]
return []
def _get_filter_node(self):
+25 -9
View File
@@ -22,6 +22,7 @@ import logging
from gns3server.config import Config
from gns3server.utils.packet_filter_validation import (
KERNEL_UNSUPPORTED_FILTERS,
split_kernel_only_features,
validate_bpf_syntax,
)
@@ -111,10 +112,23 @@ class UDPLink(Link):
kernel = self.kernel_datapath
if kernel:
return self._filters, self._filters
# Relay datapath: the netem-extension filters (rate, reorder, gemodel…)
# have no relay equivalent. update_filters rejects them on created
# relay links, but a project loaded with such filters on a link that
# cannot be kernel-wired (cross-compute, non-docker node, relay-only
# filter mix…) reaches this point — drop what cannot run with a
# warning, exactly like invalid filters are dropped at load time.
relay_filters, dropped = split_kernel_only_features(self.get_active_filters())
if dropped:
log.warning(
"Link %s: dropping packet filter(s) %s — no kernel datapath available on this link (uBridge relay wiring)",
self._id,
", ".join(sorted(dropped)),
)
filter_node = self._get_filter_node()
return (
self.get_active_filters() if filter_node == node1 else {},
self.get_active_filters() if filter_node == node2 else {},
relay_filters if filter_node == node1 else {},
relay_filters if filter_node == node2 else {},
)
def _markers_for_node(self, node):
@@ -152,10 +166,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. 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.
packet_loss, corrupt, bpf via cls_bpf, and the netem extensions
rate/reorder/gemodel/duplicate/seed/limit) are served on the veth
host end; frequency_drop only exists in the uBridge relay and
disqualifies 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
@@ -214,9 +229,10 @@ 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.
# Filters (delay/packet_loss/corrupt plus the netem extensions
# rate/reorder/gemodel/duplicate/seed/limit — eligibility
# guarantees frequency_drop is absent) 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)