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 2026-09-29 18:14:54 +08:00
parent b1eb0984bc
commit d4b57d49fb
No known key found for this signature in database
13 changed files with 1012 additions and 108 deletions

View File

@ -4,14 +4,29 @@ See LICENSE file for licensing information.
-->
> Frozen requirements spec for the **uBridge** project. Delivery status:
> parts **A** (netem keyword extensions, uBridge `feature/tc-netem-ext`) and
> **C + D** (cBPF match-drop, full-restore idempotent `tc reset`,
> `tc capabilities` with `cbpf` probe — uBridge `feature/tc-bpf-drop`) are
> **delivered and integrated** on gns3-server branch
> `feat/docker-kernel-bpf-drop`. Part **B** (eBPF stateful classifier) is
> still pending. The originally shipped netem surface (delay/jitter/loss/
> dup/corrupt) exists in uBridge 1.2.3+ and is verified against gns3-server
> branch `feat/docker-kernel-filters`.
> parts **A** (netem keyword extensions, uBridge `feature/tc-netem-ext`), **C**
> (cBPF match-drop) and **D + E** (full-restore idempotent `tc reset`,
> `tc capabilities` — uBridge `feature/tc-bpf-drop`) are **delivered and
> integrated** on gns3-server branches `feat/docker-kernel-bpf-drop` (C/D/E)
> and `feat/docker-kernel-netem-ext` (A: rate/reorder/gemodel/duplicate/
> seed/limit/distributions/correlation exposed as GNS3 filter types).
> Part **B** (eBPF stateful classifier) is delivered in the uBridge
> `feature/tc-precision` branch but **not yet consumed** by gns3-server.
> The originally shipped netem surface (delay/jitter/loss/dup/corrupt)
> exists in uBridge 1.2.3+ and is verified against gns3-server branch
> `feat/docker-kernel-filters`.
>
> **Deviation found during integration (part A/D):** the delivered
> `tc netem set` uses NLM_F_REPLACE, but the *kernel's* netem change merges
> optional attributes — rate, correlation, reorder, corrupt, gemodel,
> distribution and seed keep their previous value when the new message
> omits them, so "netem set atomically replaces" (D's reconcile
> assumption) does not hold for parameter *removals*. gns3-server works
> around it by sending `tc reset` before every `netem set` (the bpf_drop
> filters are re-added right after in the same flow, so clsact churn is
> invisible). If uBridge later emits explicit zero/absent clears for every
> optional attribute, the server-side reset-before-set can be dropped; the
> two behaviours are compatible either way.
# uBridge kernel impairment: tc netem extensions + eBPF classifiers
@ -272,11 +287,17 @@ corresponding filter types per capability.
## gns3-server alignment (informational — not uBridge scope)
* P6a types map 1:1 onto new GNS3 filter entries (rate, reorder, gemodel…),
translated in `DockerVM._ubridge_apply_netem`.
* `frequency_drop` / `bpf` move out of `KERNEL_UNSUPPORTED_FILTERS` once
`tc capabilities` reports `ebpf=1` / `cbpf=1` (per-compute probe, cached);
without the capability the link stays relay-wired exactly as today.
* **Done** (`feat/docker-kernel-netem-ext`): the P6a types map 1:1 onto new
GNS3 filter entries (rate, reorder, gemodel, duplicate, seed, limit, plus
the delay `distribution` and loss/dup `correlation` parameters), translated
in `DockerVM._ubridge_apply_netem`; extension keywords are gated on the
`tc capabilities` netem token list (old uBridge builds are never probed
for the original surface).
* **Done** (`feat/docker-kernel-bpf-drop`): `bpf` runs as cBPF match-drop
(`KERNEL_UNSUPPORTED_FILTERS` shrank to `frequency_drop`).
* `frequency_drop` moves out of `KERNEL_UNSUPPORTED_FILTERS` once the eBPF
classifier (part B, uBridge `feature/tc-precision`) is consumed on the
server side, keyed on `ebpf=1` per compute.
* Every veth end owns one qdisc + its filters: per-direction impairment is
an architectural freebie to expose later (API `direction` field), aligned
with marker `dir` semantics.

View File

@ -24,13 +24,13 @@ Frames never leave the kernel.
| Link | uBridge bridge, TAP fd ↔ UDP, userspace copy | per-link kernel bridge `gns3{link_id[:11]}`, zero-copy |
| Relay latency | ~0.3 ms RTT | ~0.05 ms RTT (measured) |
| Link attach at runtime | yes (relay attaches the pre-existing TAP) | yes (brctl addif / add_nio_ethernet — interface untouched) |
| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt); bpf as cls_bpf match-drop classifiers; frequency_drop stays relay-only (eBPF classifier pending) |
| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt / duplicate + the netem extensions rate, reorder, gemodel, seed, limit, jitter distributions, loss correlation); bpf as cls_bpf match-drop classifiers; frequency_drop stays relay-only (eBPF classifier pending) |
| Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end |
The work landed in six stages on branch stack `feat/docker-kernel-datapath` →
The work landed in seven stages on branch stack `feat/docker-kernel-datapath` →
`feat/docker-kernel-capture` → `feat/docker-kernel-markers` →
`feat/docker-veth-everywhere` → `feat/docker-kernel-filters` →
`feat/docker-kernel-bpf-drop`:
`feat/docker-kernel-bpf-drop` → `feat/docker-kernel-netem-ext`:
1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based
suspend, crash-safe reconciliation.
@ -49,6 +49,14 @@ The work landed in six stages on branch stack `feat/docker-kernel-datapath` →
uBridge pcap-compiles the line to classic BPF, the kernel migrates it to
eBPF internally — no CAP_BPF needed). Requires a uBridge reporting
`cbpf=1` via `tc capabilities`; older builds get a clear upgrade error.
7. **netem extensions** — the netem-extension surface delivered in uBridge
`feature/tc-netem-ext` is exposed as GNS3 filter types: `rate`,
`reorder`, `gemodel`, `duplicate`, `seed`, `limit`, plus the delay
`distribution` and loss/dup `correlation` parameters. Kernel-datapath
links only (the relay has no equivalent); capability-gated per uBridge
process. Reconcile became reset-before-set: the kernel's netem replace
merges optional attributes, so removing a parameter needs the explicit
reset.
## Adapter interface types
@ -171,9 +179,15 @@ ends (uBridge `tc netem set`, raw netlink — no `tc` binary needed):
| GNS3 filter | kernel implementation | Notes |
|---|---|---|
| `delay [ms, jitter]` | netem `delay X jitter Y` | jitter 0 omitted |
| `packet_loss [%]` | netem `loss P` | per direction (see below) |
| `delay [ms, jitter, dist]` | netem `delay X jitter Y [distribution D]` | jitter 0 omitted; dist ∈ uniform/normal/pareto/paretonormal (kernel-only parameter) |
| `packet_loss [%, correl]` | netem `loss P [correl C]` | per direction (see below); correlation is a kernel-only parameter |
| `corrupt [%]` | netem `corrupt P` | |
| `duplicate [%, correl]` | netem `dup P [correl C]` | kernel-only type |
| `rate ["512kbit"]` | netem `rate B` | tc-style integer+unit (bit/kbit/mbit/gbit/bps/kbps/mbps, ≤100gbit); kernel-only type |
| `reorder [%, correl, gap]` | netem `reorder P [correl C] [gap G]` | requires `delay`; kernel-only type |
| `gemodel [p, r, 1-h]` | netem `loss gemodel P R H` | Gilbert-Elliot bursty loss; mutually exclusive with `packet_loss`; kernel-only type |
| `seed [u32]` | netem `seed N` | reproducible random draws; kernel-only type |
| `limit [pkts]` | netem `limit N` | queue depth above the 1000 default (rate+delay BDP); kernel-only type |
| `bpf` (one line per expression, OR) | `tc bpf_drop add <if> <prio> "<expr>"` — cls_bpf on clsact egress + gact drop | needs uBridge `cbpf=1` (`tc capabilities`, probed once per uBridge process); a line that fails to compile on the compute is skipped with a warning, mirroring the relay |
| `frequency_drop` | — not yet (eBPF stateful classifier spec'd: [ubridge-kernel-impairment-spec](../design/ubridge-kernel-impairment-spec.md), part B pending) | 409 on kernel links; relay fallback |
@ -184,32 +198,45 @@ Semantics:
so every direction of the link is impaired exactly once — the same net
effect as the relay, where both directions cross the single filtered
bridge. A `delay 100` link measures ≈200 ms RTT; `packet_loss 30`
measures ≈51 % round-trip (1 − 0.7²).
* **Reconcile = full re-apply.** `netem set` is an atomic replace (NLM_F_REPLACE),
so every NIO update just rebuilds the qdisc from the current filters; no
per-parameter diffing. An empty filter set detaches it (`tc reset`,
ENOENT-tolerated).
measures ≈51 % round-trip (1 − 0.7²); `rate 512kbit` adds ≈2× the
per-packet serialization time to the RTT.
* **Reconcile = reset + full re-apply.** The kernel's netem replace MERGES
optional attributes (rate, correlation, reorder, corrupt, gemodel,
distribution: an absent attr keeps its previous value), so re-applying a
filter set with a parameter *removed* would silently keep the old value.
Every apply therefore starts with `tc reset` (also drops clsact and its
bpf_drop filters — re-added right after in the same flow) followed by one
`netem set` built from the current filters; no per-parameter diffing. An
empty filter set is the reset alone (ENOENT-tolerated).
* **Extension gating.** The netem-extension keywords (rate, reorder,
gemodel, dist, seed, limit, and the correl suffixes) probe
`tc capabilities` once per uBridge process and require the tokens;
a plain delay/loss/corrupt/dup filter never probes — an old uBridge
serves the original surface untouched.
* **kernel-only vs relay-only.** The extension types have no uBridge relay
equivalent: `available_filters` hides them on relay links, setting one on
a created relay link returns 409, and a project loaded with such filters
on a link that cannot be kernel-wired drops them with a warning (invalid
filters are dropped the same way at load). Symmetrically,
`frequency_drop` stays relay-only.
* **Validation** mirrors the tc grammar: `reorder` requires `delay`,
`gemodel` and `packet_loss` are mutually exclusive (both map to the netem
loss keyword), `distribution` requires jitter > 0, rate must be integer +
unit ≤ 100gbit.
* **Node restart** restores the qdisc from the NIO (like capture and markers);
**link deletion** detaches it explicitly — the veth survives the link and an
orphaned qdisc would keep impairing the next one.
* **Suspend** keeps the qdisc (carrier-driven loss); the first packet after
resume takes one extra delay interval (known netem idle-baseline behaviour)
and steady state is exact.
* A link carrying `frequency_drop` is wired on the **relay** (its eBPF
stateful classifier is not delivered yet); `available_filters` hides it on
kernel links and setting it returns 409 with a clear message. The remaining
kernel-side roadmap is frozen in
[docs/design/ubridge-kernel-impairment-spec.md](../design/ubridge-kernel-impairment-spec.md)
(eBPF stateful classifier for `frequency_drop` + quota/window/flow modes,
plus netem keyword extensions: rate, reorder, gemodel loss, jitter
distributions — parts C and D of that spec are delivered).
* **bpf reconcile = flush + re-add** (`tc bpf_drop flush`, then one add per
line at priorities 10, 11, …). `tc reset` (link delete / filter clear) is
the full restore in uBridge: filters → clsact → root qdisc, idempotent.
* **Observability caveat:** clsact drops happen before the AF_PACKET tap
points — markers/captures on the same veth do not observe cls_bpf-dropped
frames (on the relay, filter-vs-mark ordering determined visibility
instead).
instead). Also note `tc qdisc show` cannot print a distribution's name
(the kernel stores only the sampled table).
## uBridge command surface
@ -222,7 +249,9 @@ brctl create / delete / addif / delif / show
link set <if> up|down
capture start_kernel / stop_kernel
marker add_kernel / delete_kernel / enable_kernel
tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%>] [dup <%>] [corrupt <%>]
tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%> [correl <%>] | loss gemodel <p> <r> <1-h>]
[dup <%> [correl <%>]] [corrupt <%>] [reorder <%> [correl <%>] [gap <n>]]
[rate <bw>] [limit <pkts>] [distribution uniform|normal|pareto|paretonormal] [seed <u32>]
tc bpf_drop add <if> <prio 10-99> "<expr>" / tc bpf_drop flush <if>
tc reset <if> # full restore: filters -> clsact -> root qdisc, idempotent
tc capabilities # "netem=<kw,...>;ebpf=0|1;cbpf=0|1", probed once per process
@ -266,3 +295,23 @@ recreate leaves no residual filters; `frequency_drop` still 409;
`available_filters` shows `bpf` and hides `frequency_drop`. Unit tests:
`tests/compute/docker/test_docker_kernel_datapath.py`,
`tests/controller/test_kernel_datapath_link.py`.
netem extensions (same setup, 24/24 checks): `rate 512kbit` on both veths,
1400-byte pings measure 45.5 ms RTT = baseline + 2× the 22.2 ms
serialization (exact); `delay 100 20 paretonormal` applies delay+jitter and
clears the previous rate (leak detector for the kernel's merge-on-replace);
`reorder 25 gap 5` visible in the qdisc dump and traffic passes;
`gemodel 100/0/30` = 50 % bursty round-trip loss with the previous reorder
cleared; `duplicate 50` link alive; `packet_loss 30 correl 50` shows
`loss 30% 50%`; `seed 42` + `limit 5000` both visible in the dump, RTT
exactly 2×50 ms; suspend/resume and node restart keep the filters; a
docker↔ethernet-switch relay link rejects `rate` with 409 and its
`available_filters` hides the kernel-only types while the kernel link shows
them all (minus frequency_drop); `reorder` without `delay` and
`gemodel`+`packet_loss` are 409s; teardown leaves no netem on either veth.
Also verified live: a relay attach (`bridge add_nio_ethernet`) on the
admin-down veth host end fails in libpcap — the relay path now brings the
interface up before attaching. Unit tests:
`tests/utils/test_packet_filter_validation.py` (P6a class),
`tests/compute/docker/test_docker_kernel_datapath.py`,
`tests/controller/test_kernel_datapath_link.py`.

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)

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}"

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

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):

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)

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

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

View File

@ -846,3 +846,184 @@ def test_veth_names_deterministic_and_bounded(vm):
# multi-port adapters can reach two-digit adapter/port numbers
long_host, long_guest = vm._veth_names(12, 8)
assert len(long_host) <= 15 and len(long_guest) <= 15
# ---------------------------------------------------------------------------
# P6a: netem extensions (rate / reorder / gemodel / duplicate / seed / limit /
# distribution / correlation) — translation and capability gating
# ---------------------------------------------------------------------------
def _caps_hypervisor_netem(tokens="delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit"):
"""A hypervisor mock whose direct send() answers `tc capabilities` with
the full netem-extension surface."""
hyp = MagicMock()
hyp.is_running.return_value = True
hyp.send = AsyncioMagicMock(return_value=[f"netem={tokens};ebpf=1;cbpf=1"])
return hyp
@pytest.mark.asyncio
async def test_update_nio_kernel_applies_netem_extensions(vm):
"""
The netem-extension filters merge into the single netem qdisc command,
in the frozen grammar order.
"""
vm._ubridge_hypervisor = _caps_hypervisor_netem()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
host_ifc, _ = vm._veth_names(0, 0)
vm._kernel_veths[(0, 0)] = host_ifc
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {
"delay": [100, 20, "normal"],
"packet_loss": [5, 25],
"duplicate": [10, 50],
"corrupt": [2],
"reorder": [25, 50, 5],
"rate": ["512kbit"],
"limit": [5000],
"seed": [42],
}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(
f'tc netem set "{host_ifc}" delay 100 jitter 20 loss 5 correl 25 dup 10 correl 50 corrupt 2 '
"reorder 25 correl 50 gap 5 rate 512kbit limit 5000 distribution normal seed 42"
)
@pytest.mark.asyncio
async def test_update_nio_kernel_applies_gemodel(vm):
"""
gemodel replaces the plain loss keyword and is mutually exclusive with
packet_loss (the controller validates; the translation just prefers
gemodel when both somehow arrive).
"""
vm._ubridge_hypervisor = _caps_hypervisor_netem()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
host_ifc, _ = vm._veth_names(0, 0)
vm._kernel_veths[(0, 0)] = host_ifc
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"delay": [50, 5], "gemodel": [100, 0, 30]}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss gemodel 100 0 30')
@pytest.mark.asyncio
async def test_apply_netem_extension_needs_capability(vm):
"""
Extension keywords on an old uBridge (no tc capabilities / no netem-ext
tokens) raise a clear upgrade error instead of a raw parse failure.
"""
# old uBridge: no tc module at all -> empty capabilities
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
vm._ubridge_hypervisor.send = AsyncioMagicMock(side_effect=UbridgeError("201-Unknown command"))
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
with pytest.raises(DockerError, match="netem extensions"):
await vm._ubridge_apply_netem(host_ifc, {"rate": ["512kbit"]})
vm._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_apply_netem_extension_partial_capability(vm):
"""
A uBridge that reports some but not all needed tokens fails on the
missing ones (per-token check, not just build detection).
"""
vm._ubridge_hypervisor = _caps_hypervisor_netem(tokens="delay,jitter,loss,dup,corrupt")
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
with pytest.raises(DockerError, match="rate, reorder"):
await vm._ubridge_apply_netem(host_ifc, {"delay": [50], "reorder": [25], "rate": ["1mbit"]})
@pytest.mark.asyncio
async def test_apply_netem_plain_surface_never_probes(vm):
"""
Original-surface filters (delay/loss/corrupt, and plain dup) must not
touch the capability probe at all — an old uBridge serves them as-is.
The reset-before-set is unconditional (kernel netem replace merges
optional attrs, so a removed parameter would otherwise leak).
"""
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
await vm._ubridge_apply_netem(host_ifc, {"delay": [50, 5], "packet_loss": [10], "duplicate": [10], "corrupt": [2]})
assert vm._ubridge_send.call_args_list == [
call(f'tc reset "{host_ifc}"'),
call(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss 10 dup 10 corrupt 2'),
]
vm._ubridge_hypervisor.send.assert_not_called()
@pytest.mark.asyncio
async def test_apply_netem_reset_leak_on_removal(vm):
"""
A re-apply with a parameter REMOVED (rate gone, delay kept) must reset
first: the kernel's netem change merges optional attributes, so a bare
replace would keep the old rate shaping forever.
"""
vm._ubridge_hypervisor = _caps_hypervisor_netem()
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
await vm._ubridge_apply_netem(host_ifc, {"delay": [50], "rate": ["1mbit"]})
vm._ubridge_send.reset_mock()
await vm._ubridge_apply_netem(host_ifc, {"delay": [50]})
assert vm._ubridge_send.call_args_list == [
call(f'tc reset "{host_ifc}"'),
call(f'tc netem set "{host_ifc}" delay 50'),
]
@pytest.mark.asyncio
async def test_apply_netem_correl_gates_on_extension_build(vm):
"""
Loss/dup correlation is an extension (the capabilities string has no
correl token) — it gates on the netem-extension build marker "rate".
"""
vm._ubridge_hypervisor = _caps_hypervisor_netem(tokens="delay,jitter,loss,dup,corrupt")
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
with pytest.raises(DockerError, match="netem extensions"):
await vm._ubridge_apply_netem(host_ifc, {"packet_loss": [5, 25]})
def test_create_nio_bridge_accepts_netem_extension_filters(vm):
nio = vm.manager.create_nio(
{"type": "nio_bridge", "bridge": BRIDGE, "filters": {"rate": ["512kbit"], "reorder": [25, 0, 5]}}
)
assert isinstance(nio, NIOBridge)
assert nio.filters == {"rate": ["512kbit"], "reorder": [25, 0, 5]}
def test_create_nio_udp_rejects_kernel_only_filters(vm):
"""
The relay NIO rejects netem-extension filter types (second guard behind
the controller).
"""
with pytest.raises(ComputeError, match="kernel-datapath"):
vm.manager.create_nio(
{"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"gemodel": [100, 0, 30]}}
)

View File

@ -169,6 +169,20 @@ async def test_ubridge_apply_filters(node):
node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 latency 10")
@pytest.mark.asyncio
async def test_ubridge_apply_filters_rejects_kernel_only(node):
"""
Netem-extension filters have no relay equivalent: applying them on a
relay bridge must fail loudly instead of pushing an unknown filter type
to uBridge.
"""
node._ubridge_send = AsyncioMagicMock()
with pytest.raises(NodeError, match="kernel-datapath"):
await node._ubridge_apply_filters("VPCS-10", {"delay": [100, 10], "rate": ["512kbit"]})
node._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_ubridge_apply_bpf_filters(node):

View File

@ -52,6 +52,22 @@ async def _kernel_link(project, **kwargs):
return link, node1, node2
def _mock_compute_http(compute):
"""Wire the async compute HTTP callbacks the relay _prepare path needs."""
async def subnet_callback(other):
return ("192.168.1.1", "192.168.1.2")
compute.get_ip_on_same_subnet.side_effect = subnet_callback
async def port_callback(path, data=None, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1024}
return response
compute.post.side_effect = port_callback
@pytest.mark.asyncio
async def test_kernel_datapath_eligible(project):
@ -380,3 +396,108 @@ async def test_update_suspend_kernel_link_sends_no_synthetic_filter(project):
for body in bodies:
assert body["filters"] == {}
assert body["suspend"] is True
# ---------------------------------------------------------------------------
# P6a: netem-extension filters (rate / reorder / gemodel / duplicate / seed /
# limit / distribution / correlation)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_with_netem_extension_filters(project):
"""
All netem-extension types have a tc netem equivalent — they keep the
link kernel-eligible (only frequency_drop still forces the relay).
"""
link, node1, node2 = await _kernel_link(project)
link._filters = {
"delay": [100, 20, "normal"],
"packet_loss": [5, 25],
"duplicate": [10],
"reorder": [25, 0, 5],
"rate": ["512kbit"],
"gemodel": [100, 0, 30],
"seed": [42],
"limit": [5000],
}
# gemodel/packet_loss are mutually exclusive — drop packet_loss for the
# eligibility check (validation rejects the mix elsewhere)
link._filters.pop("packet_loss")
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_update_netem_extension_filters_accepted_on_kernel_link(project):
"""
The extension filters ride the kernel NIO to both endpoints like the
base netem surface.
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
filters = {"delay": [100, 20, "normal"], "rate": ["512kbit"], "reorder": [25, 0, 5]}
await link.update_filters(filters)
assert link.filters == filters
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == filters
assert by_node[node2.id]["filters"] == filters
@pytest.mark.asyncio
async def test_update_kernel_only_filters_rejected_on_relay_link(project):
"""
Netem-extension filters have no relay equivalent: setting them on a
created relay link is a 409. (frequency_drop in the stored filters
forces this link onto the relay datapath.)
"""
link, _n1, _n2 = await _kernel_link(project)
_mock_compute_http(link._nodes[0]["node"].compute)
link._filters = {"frequency_drop": [7]}
await link._prepare()
assert link.kernel_datapath is False
link._created = True # the computes hold the UDP NIOs
with pytest.raises(ControllerError, match="kernel-datapath"):
await link.update_filters({"rate": ["512kbit"]})
# the stored filters are untouched
assert link.filters == {"frequency_drop": [7]}
@pytest.mark.asyncio
async def test_update_kernel_only_filters_accepted_before_creation(project):
"""
While loading a project the datapath is not decided yet (update_filters
runs before _prepare) — a link that will be kernel-wired must accept the
extension filters. Only _prepare decides, dropping what the relay cannot
run.
"""
link, _n1, _n2 = await _kernel_link(project)
await link.update_filters({"rate": ["512kbit"], "reorder": [25, 0, 5], "delay": [100, 10]})
assert link.filters["rate"] == ["512kbit"]
@pytest.mark.asyncio
async def test_prepare_relay_link_strips_kernel_only_filters(project):
"""
A relay-wired link (here: frequency_drop in the stored filters) built
from a topology carrying extension filters drops them with a warning
instead of pushing an unknown filter type to uBridge.
"""
link, node1, node2 = await _kernel_link(project)
_mock_compute_http(link._nodes[0]["node"].compute)
link._filters = {"frequency_drop": [7], "rate": ["512kbit"], "delay": [100, 20, "normal"], "packet_loss": [5, 25]}
entries = await link._prepare()
assert link.kernel_datapath is False
by_node = {entry[0].id: entry[3] for entry in entries}
assert by_node[node1.id]["type"] == "nio_udp"
# the relay's filter node keeps what it can run; rate, the distribution
# and the loss correlation are kernel-only and stripped (the far side
# carries no filters — the relay filters one bridge)
assert by_node[node1.id]["filters"] == {"frequency_drop": [7], "delay": [100, 20], "packet_loss": [5]}
assert by_node[node2.id]["filters"] == {}

View File

@ -7,6 +7,7 @@ import pytest
from gns3server.utils.packet_filter_validation import (
FilterValidationError,
filter_inactive_filters,
split_kernel_only_features,
validate_all_filters,
validate_filter_parameters,
)
@ -131,9 +132,11 @@ class TestPacketFilterValidation:
with pytest.raises(FilterValidationError, match="expects 1 parameter"):
validate_filter_parameters("frequency_drop", [1, 2])
# delay expects 2 parameters
with pytest.raises(FilterValidationError, match="expects 2 parameter"):
validate_filter_parameters("delay", [100])
# delay takes 1 to 3 parameters (latency, optional jitter,
# optional distribution) — latency alone is valid, a 4th is not
validate_filter_parameters("delay", [100])
with pytest.raises(FilterValidationError, match="expects 1 to 3 parameter"):
validate_filter_parameters("delay", [100, 50, "normal", "extra"])
def test_string_to_int_conversion(self):
"""Test string to integer conversion."""
@ -258,3 +261,108 @@ class TestFilterInactiveFilters:
"""Test None filters."""
result = filter_inactive_filters(None)
assert result == {}
class TestNetemExtensionFilters:
"""P6a: the netem-extension filter types (rate, reorder, gemodel,
duplicate, seed, limit) and the kernel-only parameter extensions."""
def test_rate_valid(self):
for value in ("512kbit", "10mbit", "1gbit", "64000bps", "8000kbps", "100mbps", "1544bit"):
validate_filter_parameters("rate", [value])
def test_rate_invalid(self):
# bare number, unknown unit, float, over 100gbit
for value in ("512", "512kbits", "0.5mbit", "101gbit", "", "mbit"):
with pytest.raises(FilterValidationError, match="Rate"):
validate_filter_parameters("rate", [value])
def test_rate_rejects_non_string(self):
with pytest.raises(FilterValidationError, match="must be a string"):
validate_filter_parameters("rate", [512000])
def test_reorder_valid(self):
validate_filter_parameters("reorder", [25])
validate_filter_parameters("reorder", [25, 50])
validate_filter_parameters("reorder", [25, 50, 5])
def test_reorder_invalid_ranges(self):
with pytest.raises(FilterValidationError, match="Reorder"):
validate_filter_parameters("reorder", [101])
with pytest.raises(FilterValidationError, match="Gap"):
validate_filter_parameters("reorder", [25, 0, 0]) # gap < 1
with pytest.raises(FilterValidationError, match="Gap"):
validate_filter_parameters("reorder", [25, 0, 1001])
def test_reorder_requires_delay(self):
with pytest.raises(FilterValidationError, match="reorder requires delay"):
validate_all_filters({"reorder": [25]})
# with delay present it passes
validate_all_filters({"reorder": [25, 0, 5], "delay": [100, 10]})
def test_gemodel_valid_and_exclusive(self):
validate_filter_parameters("gemodel", [100])
validate_filter_parameters("gemodel", [100, 0])
validate_filter_parameters("gemodel", [100, 0, 30])
with pytest.raises(FilterValidationError, match="mutually exclusive"):
validate_all_filters({"gemodel": [100, 0, 30], "packet_loss": [10]})
def test_gemodel_invalid_range(self):
with pytest.raises(FilterValidationError, match="bad-state"):
validate_filter_parameters("gemodel", [101, 0, 30])
def test_duplicate_valid(self):
validate_filter_parameters("duplicate", [10])
validate_filter_parameters("duplicate", [10, 25])
def test_seed_and_limit(self):
validate_filter_parameters("seed", [42])
validate_filter_parameters("seed", [4294967295])
with pytest.raises(FilterValidationError, match="Seed"):
validate_filter_parameters("seed", [4294967296])
validate_filter_parameters("limit", [5000])
with pytest.raises(FilterValidationError, match="Limit"):
validate_filter_parameters("limit", [0])
with pytest.raises(FilterValidationError, match="Limit"):
validate_filter_parameters("limit", [1000001])
def test_delay_distribution(self):
for dist in ("uniform", "normal", "pareto", "paretonormal"):
validate_filter_parameters("delay", [100, 20, dist])
with pytest.raises(FilterValidationError, match="Distribution"):
validate_filter_parameters("delay", [100, 20, "poisson"])
# distribution without jitter does nothing -> rejected
with pytest.raises(FilterValidationError, match="requires a Jitter"):
validate_filter_parameters("delay", [100, 0, "normal"])
# empty distribution = not set
validate_filter_parameters("delay", [100, 20, ""])
def test_packet_loss_correlation(self):
validate_filter_parameters("packet_loss", [10])
validate_filter_parameters("packet_loss", [10, 25])
with pytest.raises(FilterValidationError, match="Correlation"):
validate_filter_parameters("packet_loss", [10, 101])
def test_kernel_only_features(self):
clean, dropped = split_kernel_only_features(
{
"delay": [100, 20, "normal"],
"packet_loss": [10, 25],
"rate": ["512kbit"],
"corrupt": [2],
}
)
assert dropped == {"rate", "delay distribution", "packet_loss correlation"}
assert clean == {"delay": [100, 20], "packet_loss": [10], "corrupt": [2]}
def test_kernel_only_features_all_relay_safe(self):
clean, dropped = split_kernel_only_features(
{"delay": [100, 20, ""], "packet_loss": [10, 0], "corrupt": [2], "bpf": ["icmp"]}
)
assert dropped == set()
# zero correlation and empty distribution are kept as-is (no-ops)
assert clean == {"delay": [100, 20, ""], "packet_loss": [10, 0], "corrupt": [2], "bpf": ["icmp"]}
def test_kernel_only_features_empty(self):
assert split_kernel_only_features({}) == ({}, set())
assert split_kernel_only_features(None) == ({}, set())