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

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