mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
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:
13 files changed
+1012
-108
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,7 +35,11 @@ class UDPNIO(BaseModel):
|
||||
rhost: str = Field(..., description="Remote host")
|
||||
rport: int = Field(..., gt=0, le=65535, description="Remote port")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
description="Packet filters (relay-supported types only: delay/packet_loss/corrupt/frequency_drop/bpf — "
|
||||
"the netem extensions need a kernel-datapath NIO)",
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
|
||||
|
||||
|
||||
@@ -74,10 +78,12 @@ 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. Impairment filters run on the veth host end:
|
||||
delay/packet_loss/corrupt as one tc netem qdisc, bpf as cls_bpf match-drop
|
||||
classifiers (needs a uBridge reporting cbpf). frequency_drop still has no
|
||||
kernel equivalent and is rejected. Markers and packet capture are served
|
||||
by uBridge's AF_PACKET modules on the veth host end.
|
||||
delay/packet_loss/corrupt and the netem extensions (rate, reorder,
|
||||
gemodel, duplicate, seed, limit, jitter distributions, loss correlation)
|
||||
as one tc netem qdisc, bpf as cls_bpf match-drop classifiers (needs a
|
||||
uBridge reporting cbpf). frequency_drop still has no kernel equivalent
|
||||
and is rejected. Markers and packet capture are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
"""
|
||||
|
||||
type: BridgeNIOType
|
||||
@@ -85,7 +91,8 @@ class BridgeNIO(BaseModel):
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
description="Packet filters served on the veth host end (delay/packet_loss/corrupt via netem, bpf via cls_bpf)",
|
||||
description="Packet filters served on the veth host end (delay/packet_loss/corrupt and the netem extensions "
|
||||
"via one tc netem qdisc, bpf via cls_bpf)",
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ Packet filter parameter validation utilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +23,29 @@ class FilterValidationError(Exception):
|
||||
# cBPF match-drop classifier (tc bpf_drop) when uBridge reports cbpf=1.
|
||||
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop"})
|
||||
|
||||
# Packet filters with no uBridge *relay* equivalent: the tc netem extensions
|
||||
# (uBridge feature/tc-netem-ext) serve them on the kernel datapath only. The
|
||||
# relay's packet-filter registry only knows frequency_drop / packet_loss /
|
||||
# delay / corrupt / bpf / mark.
|
||||
KERNEL_ONLY_FILTERS = frozenset({"rate", "reorder", "gemodel", "duplicate", "seed", "limit"})
|
||||
|
||||
# Jitter distributions embedded in the netem-extension uBridge (tc_netem_dist).
|
||||
NETEM_DISTRIBUTIONS = ("uniform", "normal", "pareto", "paretonormal")
|
||||
|
||||
# Bandwidth values follow the tc netem grammar: integer + unit, capped at
|
||||
# 100gbit (uBridge NETEM_RATE_MAX_BPS). bps-family units are bytes/s.
|
||||
_RATE_RE = re.compile(r"^(\d+)(bit|kbit|mbit|gbit|bps|kbps|mbps)$", re.IGNORECASE)
|
||||
_RATE_UNITS = {
|
||||
"bit": 1,
|
||||
"kbit": 1000,
|
||||
"mbit": 1000**2,
|
||||
"gbit": 1000**3,
|
||||
"bps": 8,
|
||||
"kbps": 8000,
|
||||
"mbps": 8000 * 1000,
|
||||
}
|
||||
_RATE_MAX_BITS = 100 * 1000**3
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
@@ -73,6 +97,64 @@ def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
return {"valid": False, "error": f"BPF validation error: {e!s}"}
|
||||
|
||||
|
||||
def _validate_distribution_value(value: Any, name: str) -> str:
|
||||
"""
|
||||
Validate a netem jitter distribution name (delay filter, 3rd parameter).
|
||||
Returns the normalized (lowercase) name; an empty value is treated as
|
||||
"not set" and returns "".
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise FilterValidationError(f"{name} must be one of {', '.join(NETEM_DISTRIBUTIONS)} (or empty), got: {value}")
|
||||
value = value.strip().lower()
|
||||
if not value:
|
||||
return "" # empty = not set
|
||||
if value not in NETEM_DISTRIBUTIONS:
|
||||
raise FilterValidationError(f"{name} must be one of {', '.join(NETEM_DISTRIBUTIONS)} (or empty), got: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_rate_value(value: Any, name: str) -> None:
|
||||
"""
|
||||
Validate a tc-style bandwidth value (rate filter), e.g. "512kbit".
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise FilterValidationError(f"{name} must be a string like '512kbit', got: {value}")
|
||||
match = _RATE_RE.match(value.strip())
|
||||
if not match:
|
||||
raise FilterValidationError(
|
||||
f"{name} must be an integer plus a unit (bit, kbit, mbit, gbit, bps, kbps, mbps), got: {value}"
|
||||
)
|
||||
if int(match.group(1)) * _RATE_UNITS[match.group(2).lower()] > _RATE_MAX_BITS:
|
||||
raise FilterValidationError(f"{name} exceeds the 100gbit maximum, got: {value}")
|
||||
|
||||
|
||||
def _validate_int_parameter(filter_type: str, rules: dict, i: int, value: Any) -> None:
|
||||
"""
|
||||
Integer parameter validation: type coercion plus range check.
|
||||
"""
|
||||
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
int_value = int(value)
|
||||
else:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be an integer, got: {value}"
|
||||
)
|
||||
|
||||
# Range validation
|
||||
min_val, max_val = rules["ranges"][i]
|
||||
if int_value < min_val or int_value > max_val:
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be between "
|
||||
f"{min_val} and {max_val} {rules['units'][i]}, got: {int_value}"
|
||||
)
|
||||
|
||||
|
||||
def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
"""
|
||||
Validate packet filter parameters.
|
||||
@@ -85,33 +167,73 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
FilterValidationError: If parameters are invalid
|
||||
"""
|
||||
|
||||
# Define validation rules based on ubridge implementation
|
||||
# Define validation rules based on ubridge implementation. params_count is
|
||||
# (min, max): trailing parameters are optional (clients sending only the
|
||||
# first N fields keep working). String parameters are declared per index
|
||||
# in "string_params" with their own validators.
|
||||
VALIDATION_RULES: Dict[str, Dict[str, Any]] = {
|
||||
"frequency_drop": {
|
||||
"params_count": 1,
|
||||
"params_count": (1, 1),
|
||||
"ranges": [(-1, 32767)], # min, max
|
||||
"names": ["Frequency"],
|
||||
"units": ["th packet"],
|
||||
},
|
||||
"packet_loss": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"delay": {
|
||||
"params_count": 2, # latency, jitter
|
||||
"ranges": [(1, 32767), (0, 32767)], # ubridge rejects latency <= 0
|
||||
"names": ["Latency", "Jitter"],
|
||||
"units": ["ms", "ms"],
|
||||
"packet_loss": {
|
||||
# 2nd parameter (loss correlation) needs the netem-extension
|
||||
# uBridge and only runs on the kernel datapath.
|
||||
"params_count": (1, 2),
|
||||
"ranges": [(0, 100), (0, 100)],
|
||||
"names": ["Chance", "Correlation"],
|
||||
"units": ["%", "%"],
|
||||
},
|
||||
"corrupt": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"bpf": {"params_count": 1, "is_text": True, "names": ["Filters"]},
|
||||
"delay": {
|
||||
# 3rd parameter (jitter distribution) needs the netem-extension
|
||||
# uBridge and only runs on the kernel datapath.
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(1, 32767), (0, 32767)], # ubridge rejects latency <= 0
|
||||
"names": ["Latency", "Jitter", "Distribution"],
|
||||
"units": ["ms", "ms", ""],
|
||||
"string_params": {2: _validate_distribution_value},
|
||||
},
|
||||
"corrupt": {"params_count": (1, 1), "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"duplicate": {
|
||||
"params_count": (1, 2),
|
||||
"ranges": [(0, 100), (0, 100)],
|
||||
"names": ["Chance", "Correlation"],
|
||||
"units": ["%", "%"],
|
||||
},
|
||||
"rate": {"params_count": (1, 1), "ranges": [], "names": ["Rate"], "string_params": {0: _validate_rate_value}},
|
||||
"reorder": {
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(0, 100), (0, 100), (1, 1000)],
|
||||
"names": ["Reorder", "Correlation", "Gap"],
|
||||
"units": ["%", "%", "packets"],
|
||||
},
|
||||
"gemodel": {
|
||||
# Gilbert-Elliot loss model, tc semantics: p = loss probability in
|
||||
# the bad state, r = loss probability in the good state, 1-h =
|
||||
# probability of moving from the good to the bad state.
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(0, 100), (0, 100), (0, 100)],
|
||||
"names": ["p (bad-state loss)", "r (good-state loss)", "1-h (good-to-bad transition)"],
|
||||
"units": ["%", "%", "%"],
|
||||
},
|
||||
"seed": {"params_count": (1, 1), "ranges": [(0, 4294967295)], "names": ["Seed"], "units": [""]},
|
||||
"limit": {"params_count": (1, 1), "ranges": [(1, 1000000)], "names": ["Limit"], "units": ["packets"]},
|
||||
"bpf": {"params_count": (1, 1), "is_text": True, "names": ["Filters"]},
|
||||
}
|
||||
|
||||
if filter_type not in VALIDATION_RULES:
|
||||
raise FilterValidationError(f"Unknown filter type: {filter_type}")
|
||||
|
||||
rules = VALIDATION_RULES[filter_type]
|
||||
string_params = rules.get("string_params", {})
|
||||
|
||||
# Check parameter count
|
||||
if len(values) != rules["params_count"]:
|
||||
raise FilterValidationError(f"{filter_type} expects {rules['params_count']} parameter(s), got {len(values)}")
|
||||
min_count, max_count = rules["params_count"]
|
||||
if not (min_count <= len(values) <= max_count):
|
||||
expected = str(min_count) if min_count == max_count else f"{min_count} to {max_count}"
|
||||
raise FilterValidationError(f"{filter_type} expects {expected} parameter(s), got {len(values)}")
|
||||
|
||||
# Validate each parameter
|
||||
for i, value in enumerate(values):
|
||||
@@ -136,26 +258,19 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
f"{filter_type} parameter {rules['names'][i]} line {line_num + 1} "
|
||||
f"has invalid syntax: {bpf_result['error']}"
|
||||
)
|
||||
elif i in string_params:
|
||||
string_params[i](value, f"{filter_type} parameter {rules['names'][i]}")
|
||||
else:
|
||||
# Integer parameter validation
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
int_value = int(value)
|
||||
else:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be an integer, got: {value}"
|
||||
)
|
||||
_validate_int_parameter(filter_type, rules, i, value)
|
||||
|
||||
# Range validation
|
||||
min_val, max_val = rules["ranges"][i]
|
||||
if int_value < min_val or int_value > max_val:
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be between "
|
||||
f"{min_val} and {max_val} {rules['units'][i]}, got: {int_value}"
|
||||
)
|
||||
if filter_type == "delay" and len(values) >= 3 and isinstance(values[2], str) and values[2].strip():
|
||||
# The distribution shapes the jitter draw — without jitter it does
|
||||
# nothing (the kernel silently stays uniform). values[1] has already
|
||||
# passed integer validation at this point.
|
||||
if int(values[1]) <= 0:
|
||||
raise FilterValidationError(
|
||||
"delay parameter Distribution requires a Jitter > 0 (the distribution shapes the jitter draw)"
|
||||
)
|
||||
|
||||
|
||||
def filter_inactive_filters(filters: Dict[str, List[Any]]) -> Dict[str, List[Any]]:
|
||||
@@ -221,9 +336,60 @@ def filter_inactive_filters(filters: Dict[str, List[Any]]) -> Dict[str, List[Any
|
||||
return active_filters
|
||||
|
||||
|
||||
def split_kernel_only_features(filters: Dict[str, List[Any]]) -> Tuple[Dict[str, List[Any]], Set[str]]:
|
||||
"""
|
||||
Split a filter set into what the uBridge *relay* can run and what needs
|
||||
the kernel datapath (tc netem on the veth host end).
|
||||
|
||||
Kernel-only are the whole types in KERNEL_ONLY_FILTERS plus two
|
||||
parameters: the delay jitter distribution (3rd) and the packet_loss
|
||||
correlation (2nd) — the relay's delay/packet_loss filters take no such
|
||||
argument.
|
||||
|
||||
:returns: (relay-safe filters copy, names of the dropped features)
|
||||
"""
|
||||
|
||||
dropped: Set[str] = set()
|
||||
clean: Dict[str, List[Any]] = {}
|
||||
for filter_type, values in (filters or {}).items():
|
||||
if filter_type in KERNEL_ONLY_FILTERS:
|
||||
dropped.add(filter_type)
|
||||
continue
|
||||
# tolerate the legacy bare-value shape ({"packet_loss": 10})
|
||||
if isinstance(values, (list, tuple)):
|
||||
values = list(values)
|
||||
else:
|
||||
values = [values] if values else []
|
||||
if filter_type == "delay" and len(values) >= 3 and str(values[2]).strip():
|
||||
dropped.add("delay distribution")
|
||||
values = values[:2]
|
||||
elif filter_type == "packet_loss" and len(values) >= 2:
|
||||
try:
|
||||
correl = int(values[1])
|
||||
except (ValueError, TypeError):
|
||||
correl = 0
|
||||
if correl:
|
||||
dropped.add("packet_loss correlation")
|
||||
values = values[:1]
|
||||
clean[filter_type] = values
|
||||
return clean, dropped
|
||||
|
||||
|
||||
def kernel_only_features(filters: Dict[str, List[Any]]) -> Set[str]:
|
||||
"""
|
||||
Names of the features in *filters* that only run on the kernel datapath
|
||||
(see split_kernel_only_features).
|
||||
"""
|
||||
|
||||
return split_kernel_only_features(filters)[1]
|
||||
|
||||
|
||||
def validate_all_filters(filters: Dict[str, List[Any]]) -> None:
|
||||
"""
|
||||
Validate all packet filters.
|
||||
Validate all packet filters, including cross-filter dependencies that
|
||||
mirror the tc netem grammar (reorder requires delay; gemodel and
|
||||
packet_loss both translate to the netem loss keyword and are mutually
|
||||
exclusive).
|
||||
|
||||
Args:
|
||||
filters: Dictionary mapping filter types to their values
|
||||
@@ -240,3 +406,8 @@ def validate_all_filters(filters: Dict[str, List[Any]]) -> None:
|
||||
continue
|
||||
|
||||
validate_filter_parameters(filter_type, values)
|
||||
|
||||
if "reorder" in filters and "delay" not in filters:
|
||||
raise FilterValidationError("reorder requires delay")
|
||||
if "gemodel" in filters and "packet_loss" in filters:
|
||||
raise FilterValidationError("gemodel and packet_loss are mutually exclusive (both map to the netem loss keyword)")
|
||||
Reference in new issue
Block a user