diff --git a/docs/design/ubridge-kernel-impairment-spec.md b/docs/design/ubridge-kernel-impairment-spec.md index 52b36399a..356b3a5b4 100644 --- a/docs/design/ubridge-kernel-impairment-spec.md +++ b/docs/design/ubridge-kernel-impairment-spec.md @@ -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. diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index 38764531b..fad28bbb7 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -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 ""` — 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 up|down capture start_kernel / stop_kernel marker add_kernel / delete_kernel / enable_kernel -tc netem set [delay ] [jitter ] [loss <%>] [dup <%>] [corrupt <%>] +tc netem set [delay ] [jitter ] [loss <%> [correl <%>] | loss gemodel

<1-h>] + [dup <%> [correl <%>]] [corrupt <%>] [reorder <%> [correl <%>] [gap ]] + [rate ] [limit ] [distribution uniform|normal|pareto|paretonormal] [seed ] tc bpf_drop add "" / tc bpf_drop flush tc reset # full restore: filters -> clsact -> root qdisc, idempotent tc capabilities # "netem=;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`. diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 30a6fc1c3..0205003fd 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -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) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 74f6a028c..18f4cfdc3 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -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}" diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 2c95b23ee..641b46f10 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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): """ diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index fdfaa1162..2bfa1dc32 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -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): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 494bfc976..e943dba46 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -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) diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 13e403af4..cfc9fd43f 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -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)") diff --git a/gns3server/utils/packet_filter_validation.py b/gns3server/utils/packet_filter_validation.py index 6a0337552..7f2f6d0e4 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -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)") diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 6cf1f2ca1..1868ee577 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -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]}} + ) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 9d4e15374..7e0c34601 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -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): diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index 87770a86e..645ab32f1 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -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"] == {} diff --git a/tests/utils/test_packet_filter_validation.py b/tests/utils/test_packet_filter_validation.py index 462e23a4d..034114f65 100644 --- a/tests/utils/test_packet_filter_validation.py +++ b/tests/utils/test_packet_filter_validation.py @@ -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())