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):
"""
+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)
+13 -6
View File
@@ -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)")
+203 -32
View File
@@ -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)")