mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
feat(docker): expose netem-extension packet filters on kernel links
Surface the netem extensions delivered in uBridge feature/tc-netem-ext as GNS3 filter types: rate (tc-style bandwidth string), reorder (needs delay), gemodel (Gilbert-Elliot bursty loss, exclusive with packet_loss), duplicate, seed and limit, plus the delay jitter distribution and the loss/dup correlation parameters. All are kernel-datapath only — the relay's packet-filter registry has no equivalent, so available_filters hides them on relay links, setting one on a created relay link returns 409, and the project-load path drops them with a warning (update_filters runs before the datapath is decided, so it cannot reject there). Validation mirrors the tc grammar: reorder requires delay, gemodel and packet_loss are mutually exclusive, a distribution requires jitter > 0, rate is integer+unit capped at 100gbit. The translation in DockerVM._netem_command_parts emits the frozen grammar order and gates extension keywords on the 'tc capabilities' netem token list (probed once per uBridge process; a plain delay/loss/corrupt/dup filter never probes, keeping old uBridge builds untouched; the correl suffix has no token and gates on 'rate' as the extension-build marker). Reconcile became reset-before-set: the kernel's netem change merges optional attributes (rate, correlation, reorder, corrupt, gemodel, distribution, seed keep their previous value when the new message omits them), so removing a parameter needs an explicit 'tc reset' — the bpf_drop filters are re-added right after in the same flow, so the clsact churn is invisible. The capability check runs before the reset so a failed update leaves the previous qdisc in place. e2e (isolated instance, kernel link): 24/24 — rate 512kbit adds exactly 2x the per-packet serialization to the RTT (45.5 ms for 1400-byte pings), paretonormal jitter + rate-cleared leak check, reorder gap 5, gemodel 50% bursty loss, duplicate, loss 30% correl 50%, seed 42 + limit 5000 visible in the qdisc dump, suspend/resume and node restart keep the filters, relay link rejects rate with 409, teardown leaves no netem. Full unit suite: 1916 passed.
This commit is contained in:
parent
b1eb0984bc
commit
d4b57d49fb
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -356,6 +356,20 @@ class BaseManager:
|
||||
except OSError as e:
|
||||
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(lport, rhost, rport)
|
||||
# The netem-extension filters (rate, reorder, gemodel…) have no
|
||||
# uBridge relay equivalent — reject them here (the controller
|
||||
# already keeps them off relay links; this is the second guard
|
||||
# for direct compute API use, mirroring the kernel-NIO guard).
|
||||
from gns3server.utils.packet_filter_validation import kernel_only_features
|
||||
|
||||
kernel_only = kernel_only_features(nio_settings.get("filters") or {})
|
||||
if kernel_only:
|
||||
raise ComputeError(
|
||||
"Packet filter(s) {} only run on a kernel-datapath link "
|
||||
"(tc netem on the veth host end); the uBridge relay has no equivalent".format(
|
||||
", ".join(sorted(kernel_only))
|
||||
)
|
||||
)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
|
||||
@ -1116,6 +1116,17 @@ class BaseNode:
|
||||
:param filters: Array of filter dictionary
|
||||
"""
|
||||
|
||||
# The netem-extension filters (rate, reorder, gemodel…) only exist on
|
||||
# the kernel datapath. The controller keeps them off relay links;
|
||||
# this is the second guard for direct compute API use.
|
||||
from gns3server.utils.packet_filter_validation import kernel_only_features
|
||||
|
||||
kernel_only = kernel_only_features(filters)
|
||||
if kernel_only:
|
||||
raise NodeError(
|
||||
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
|
||||
"veth host end); the uBridge relay has no equivalent".format(", ".join(sorted(kernel_only)))
|
||||
)
|
||||
await self._ubridge_send("bridge reset_packet_filters " + bridge_name)
|
||||
for packet_filter in self._build_filter_list(filters):
|
||||
cmd = f"bridge add_packet_filter {bridge_name} {packet_filter}"
|
||||
|
||||
@ -2156,35 +2156,143 @@ class DockerVM(BaseNode):
|
||||
return
|
||||
await super()._ubridge_enable_marker_filter(anchor, name, state)
|
||||
|
||||
@staticmethod
|
||||
def _netem_command_parts(filters):
|
||||
"""
|
||||
Build the ``tc netem set`` keyword sequence (in the frozen grammar
|
||||
order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate,
|
||||
limit, distribution, seed) plus the set of capability tokens the
|
||||
sequence needs beyond the original netem surface. Original-surface
|
||||
keywords (delay, jitter, loss, corrupt, dup) need no probe; the
|
||||
extension tokens (rate, reorder, gemodel, dist, seed, limit) are
|
||||
checked against ``tc capabilities`` by the caller. ``correl`` has no
|
||||
token of its own in the capabilities list, so it gates on "rate" as
|
||||
the netem-extension build marker.
|
||||
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...})
|
||||
|
||||
:returns: (keyword sequence, required extension tokens)
|
||||
"""
|
||||
|
||||
def values_of(key):
|
||||
# tolerate the legacy bare-value shape ({"packet_loss": 10})
|
||||
values = filters.get(key)
|
||||
if isinstance(values, (list, tuple)):
|
||||
return list(values)
|
||||
return [values] if values else []
|
||||
|
||||
parts = []
|
||||
ext_tokens = set()
|
||||
|
||||
delay = values_of("delay")
|
||||
if delay:
|
||||
parts.append(f"delay {int(delay[0])}")
|
||||
if len(delay) > 1 and int(delay[1]):
|
||||
parts.append(f"jitter {int(delay[1])}")
|
||||
|
||||
gemodel = values_of("gemodel")
|
||||
if gemodel:
|
||||
segment = f"loss gemodel {int(gemodel[0])}"
|
||||
if len(gemodel) > 1:
|
||||
segment += f" {int(gemodel[1])}"
|
||||
if len(gemodel) > 2:
|
||||
segment += f" {int(gemodel[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("gemodel")
|
||||
else:
|
||||
loss = values_of("packet_loss")
|
||||
if loss and int(loss[0]):
|
||||
parts.append(f"loss {int(loss[0])}")
|
||||
if len(loss) > 1 and int(loss[1]):
|
||||
parts.append(f"correl {int(loss[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
duplicate = values_of("duplicate")
|
||||
if duplicate and int(duplicate[0]):
|
||||
parts.append(f"dup {int(duplicate[0])}")
|
||||
if len(duplicate) > 1 and int(duplicate[1]):
|
||||
parts.append(f"correl {int(duplicate[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
corrupt = values_of("corrupt")
|
||||
if corrupt and int(corrupt[0]):
|
||||
parts.append(f"corrupt {int(corrupt[0])}")
|
||||
|
||||
reorder = values_of("reorder")
|
||||
if reorder:
|
||||
segment = f"reorder {int(reorder[0])}"
|
||||
if len(reorder) > 1 and int(reorder[1]):
|
||||
segment += f" correl {int(reorder[1])}"
|
||||
if len(reorder) > 2 and int(reorder[2]):
|
||||
segment += f" gap {int(reorder[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("reorder")
|
||||
|
||||
rate = values_of("rate")
|
||||
if rate and str(rate[0]).strip():
|
||||
parts.append(f"rate {str(rate[0]).strip()}")
|
||||
ext_tokens.add("rate")
|
||||
|
||||
limit = values_of("limit")
|
||||
if limit and int(limit[0]):
|
||||
parts.append(f"limit {int(limit[0])}")
|
||||
ext_tokens.add("limit")
|
||||
|
||||
if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"):
|
||||
# "uniform" is the kernel default — emitting nothing keeps the
|
||||
# command compatible with the original netem surface.
|
||||
parts.append(f"distribution {str(delay[2]).strip().lower()}")
|
||||
ext_tokens.add("dist")
|
||||
|
||||
seed = values_of("seed")
|
||||
if seed and int(seed[0]):
|
||||
# 0 is the "disabled" convention everywhere else — treat it so
|
||||
# here too (the controller's inactive-filter pass drops it).
|
||||
parts.append(f"seed {int(seed[0])}")
|
||||
ext_tokens.add("seed")
|
||||
|
||||
return parts, ext_tokens
|
||||
|
||||
async def _ubridge_apply_netem(self, host_ifc, filters):
|
||||
"""
|
||||
Translate the NIO's impairment filters into one tc netem qdisc on the
|
||||
veth host end. The supported types (delay, packet_loss, corrupt) merge
|
||||
into a single qdisc — its egress covers the traffic entering this
|
||||
container, and with both link endpoints applying theirs, every
|
||||
direction of the link is impaired exactly once (the same net effect
|
||||
as the relay, where both directions cross the single filtered bridge).
|
||||
``netem set`` atomically replaces whatever qdisc was attached, so a
|
||||
full re-apply on every NIO update *is* the incremental reconcile. An
|
||||
empty filter set detaches the qdisc (``tc reset``).
|
||||
veth host end. The netem-expressible types (delay, packet_loss,
|
||||
corrupt and the netem extensions rate/reorder/gemodel/duplicate/
|
||||
seed/limit) merge into a single qdisc — its egress covers the traffic
|
||||
entering this container, and with both link endpoints applying
|
||||
theirs, every direction of the link is impaired exactly once (the
|
||||
same net effect as the relay, where both directions cross the single
|
||||
filtered bridge).
|
||||
|
||||
The kernel's netem replace MERGES optional attributes (rate,
|
||||
correlation, reorder, corrupt, gemodel, distribution: absent attr =
|
||||
previous value kept), so re-applying a filter set with a parameter
|
||||
*removed* would silently keep the old value. A ``tc reset`` before
|
||||
every ``netem set`` makes the re-apply a true reconcile (reset also
|
||||
drops clsact and its bpf_drop filters — the caller re-applies them
|
||||
right after in the same flow). An empty filter set is the reset
|
||||
alone.
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter], ...})
|
||||
"""
|
||||
|
||||
parts = []
|
||||
delay = filters.get("delay")
|
||||
if delay:
|
||||
parts.append(f"delay {int(delay[0])}")
|
||||
if len(delay) > 1 and int(delay[1]):
|
||||
parts.append(f"jitter {int(delay[1])}")
|
||||
for filter_type, netem_kw in (("packet_loss", "loss"), ("corrupt", "corrupt")):
|
||||
values = filters.get(filter_type)
|
||||
if values and int(values[0]):
|
||||
parts.append(f"{netem_kw} {int(values[0])}")
|
||||
if parts:
|
||||
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
|
||||
return
|
||||
parts, ext_tokens = self._netem_command_parts(filters)
|
||||
if parts and ext_tokens:
|
||||
# Extension keywords need the netem-extension uBridge. Plain
|
||||
# delay/loss/corrupt/dup never probes — an old uBridge serves
|
||||
# the original surface untouched (mirrors bpf_drop's guard).
|
||||
# Checked before the reset so a failed update leaves the
|
||||
# previously applied qdisc in place.
|
||||
caps = await self._ubridge_tc_capabilities()
|
||||
missing = ext_tokens - set(caps.get("netem", "").split(","))
|
||||
if missing:
|
||||
raise DockerError(
|
||||
"Packet filter(s) using {} need a uBridge with the netem extensions "
|
||||
"(tc capabilities reports '{}'); upgrade uBridge on this compute".format(
|
||||
", ".join(sorted(missing)), caps.get("netem") or "no tc support"
|
||||
)
|
||||
)
|
||||
try:
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
except UbridgeError as e:
|
||||
@ -2192,6 +2300,9 @@ class DockerVM(BaseNode):
|
||||
# clean. Everything else is a real failure and must surface.
|
||||
if "No such file" not in str(e):
|
||||
raise
|
||||
if not parts:
|
||||
return
|
||||
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
|
||||
|
||||
async def _ubridge_tc_capabilities(self):
|
||||
"""
|
||||
|
||||
@ -26,6 +26,7 @@ from gns3server.config import Config
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
FilterValidationError,
|
||||
filter_inactive_filters,
|
||||
kernel_only_features,
|
||||
validate_all_filters,
|
||||
)
|
||||
|
||||
@ -51,16 +52,22 @@ FILTERS = [
|
||||
{
|
||||
"type": "packet_loss",
|
||||
"name": "Packet loss",
|
||||
"description": "The percentage represents the chance for a packet to be lost",
|
||||
"parameters": [{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}],
|
||||
"description": "The percentage represents the chance for a packet to be lost. The optional correlation "
|
||||
"(kernel-datapath links only) makes consecutive losses dependent, like a bursty real network",
|
||||
"parameters": [
|
||||
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "delay",
|
||||
"name": "Delay",
|
||||
"description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay",
|
||||
"description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay, and "
|
||||
"(kernel-datapath links only) shape the jitter with a distribution",
|
||||
"parameters": [
|
||||
{"name": "Latency", "minimum": 1, "maximum": 32767, "unit": "ms", "type": "int"},
|
||||
{"name": "Jitter (-/+)", "minimum": 0, "maximum": 32767, "unit": "ms", "type": "int"},
|
||||
{"name": "Distribution", "type": "str", "unit": "uniform|normal|pareto|paretonormal"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -75,6 +82,60 @@ FILTERS = [
|
||||
"description": "This filter will drop any packet matching a BPF expression. Put one expression per line",
|
||||
"parameters": [{"name": "Filters", "type": "text"}],
|
||||
},
|
||||
# Everything below runs on kernel-datapath links only (tc netem on the
|
||||
# veth host end; the uBridge relay has no equivalent).
|
||||
{
|
||||
"type": "rate",
|
||||
"name": "Bandwidth limit",
|
||||
"description": "Shape the link to a maximum bandwidth, tc-style value (kernel-datapath links only)",
|
||||
"parameters": [{"name": "Rate", "type": "str", "unit": "e.g. 512kbit, 10mbit"}],
|
||||
},
|
||||
{
|
||||
"type": "reorder",
|
||||
"name": "Reorder",
|
||||
"description": "The percentage represents the chance for a packet to be reordered (held until a later "
|
||||
"packet passes it). Requires the delay filter; kernel-datapath links only",
|
||||
"parameters": [
|
||||
{"name": "Reorder", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "Gap", "minimum": 1, "maximum": 1000, "type": "int", "unit": "packets"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "gemodel",
|
||||
"name": "Gilbert-Elliot loss",
|
||||
"description": "Bursty loss model: p is the loss chance in the bad state, r in the good state, 1-h the "
|
||||
"chance of moving from good to bad. Mutually exclusive with packet loss; kernel-datapath links only",
|
||||
"parameters": [
|
||||
{"name": "p (bad-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "r (good-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "1-h (good-to-bad)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "duplicate",
|
||||
"name": "Duplicate",
|
||||
"description": "The percentage represents the chance for a packet to be duplicated; the optional "
|
||||
"correlation makes consecutive duplicates dependent (kernel-datapath links only)",
|
||||
"parameters": [
|
||||
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "seed",
|
||||
"name": "Random seed",
|
||||
"description": "Make the netem random draws (loss, duplication, jitter) reproducible for repeated "
|
||||
"experiments (kernel-datapath links only)",
|
||||
"parameters": [{"name": "Seed", "minimum": 0, "maximum": 4294967295, "type": "int", "unit": ""}],
|
||||
},
|
||||
{
|
||||
"type": "limit",
|
||||
"name": "Queue limit",
|
||||
"description": "Queue depth of the impairment qdisc in packets — raise it above the 1000 default when "
|
||||
"combining a low rate with a long delay (kernel-datapath links only)",
|
||||
"parameters": [{"name": "Limit", "minimum": 1, "maximum": 1000000, "type": "int", "unit": "packets"}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -247,6 +308,21 @@ class Link:
|
||||
", ".join(sorted(unsupported))
|
||||
)
|
||||
)
|
||||
elif self._created:
|
||||
# The reverse guard: netem-extension filters have no relay
|
||||
# equivalent. Only enforced on created links — while loading a
|
||||
# project the datapath is not decided yet (a link that will be
|
||||
# wired on the kernel datapath must accept them), and the relay
|
||||
# prepare path drops what it cannot run with a warning.
|
||||
conflicts = kernel_only_features(new_filters)
|
||||
if conflicts:
|
||||
raise ControllerError(
|
||||
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
|
||||
"veth host end); this link is wired on the uBridge relay — delete and "
|
||||
"recreate the link to switch it to the kernel datapath".format(
|
||||
", ".join(sorted(conflicts))
|
||||
)
|
||||
)
|
||||
|
||||
if new_filters != self.filters:
|
||||
self._filters = new_filters
|
||||
@ -652,7 +728,11 @@ class Link:
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
return [f for f in FILTERS if f["type"] not in KERNEL_UNSUPPORTED_FILTERS]
|
||||
return FILTERS
|
||||
# Relay links: hide the netem-extension types the relay cannot run
|
||||
# (they would be rejected with 409 on update).
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_ONLY_FILTERS
|
||||
|
||||
return [f for f in FILTERS if f["type"] not in KERNEL_ONLY_FILTERS]
|
||||
return []
|
||||
|
||||
def _get_filter_node(self):
|
||||
|
||||
@ -22,6 +22,7 @@ import logging
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
KERNEL_UNSUPPORTED_FILTERS,
|
||||
split_kernel_only_features,
|
||||
validate_bpf_syntax,
|
||||
)
|
||||
|
||||
@ -111,10 +112,23 @@ class UDPLink(Link):
|
||||
kernel = self.kernel_datapath
|
||||
if kernel:
|
||||
return self._filters, self._filters
|
||||
# Relay datapath: the netem-extension filters (rate, reorder, gemodel…)
|
||||
# have no relay equivalent. update_filters rejects them on created
|
||||
# relay links, but a project loaded with such filters on a link that
|
||||
# cannot be kernel-wired (cross-compute, non-docker node, relay-only
|
||||
# filter mix…) reaches this point — drop what cannot run with a
|
||||
# warning, exactly like invalid filters are dropped at load time.
|
||||
relay_filters, dropped = split_kernel_only_features(self.get_active_filters())
|
||||
if dropped:
|
||||
log.warning(
|
||||
"Link %s: dropping packet filter(s) %s — no kernel datapath available on this link (uBridge relay wiring)",
|
||||
self._id,
|
||||
", ".join(sorted(dropped)),
|
||||
)
|
||||
filter_node = self._get_filter_node()
|
||||
return (
|
||||
self.get_active_filters() if filter_node == node1 else {},
|
||||
self.get_active_filters() if filter_node == node2 else {},
|
||||
relay_filters if filter_node == node1 else {},
|
||||
relay_filters if filter_node == node2 else {},
|
||||
)
|
||||
|
||||
def _markers_for_node(self, node):
|
||||
@ -152,10 +166,11 @@ class UDPLink(Link):
|
||||
Whether this link can be wired on the kernel datapath (veth pairs
|
||||
enslaved into a per-link Linux bridge) instead of the uBridge UDP
|
||||
relay. Impairment filters with a tc netem equivalent (delay,
|
||||
packet_loss, corrupt) are served on the veth host end; the others
|
||||
(frequency_drop, bpf) only exist in the uBridge relay and disqualify
|
||||
the kernel path. Capture and markers are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
packet_loss, corrupt, bpf via cls_bpf, and the netem extensions
|
||||
rate/reorder/gemodel/duplicate/seed/limit) are served on the veth
|
||||
host end; frequency_drop only exists in the uBridge relay and
|
||||
disqualifies the kernel path. Capture and markers are served by
|
||||
uBridge's AF_PACKET modules on the veth host end.
|
||||
|
||||
Docker adapters are born as veth pairs (unified interface), so the
|
||||
datapath is a runtime decision — links attach to running containers
|
||||
@ -214,9 +229,10 @@ class UDPLink(Link):
|
||||
# Markers ride the NIO like on the relay datapath, routed by
|
||||
# capture node; they attach to the veth host end via uBridge's
|
||||
# AF_PACKET marker module instead of a relay `mark` filter.
|
||||
# Filters (delay/packet_loss/corrupt — eligibility guarantees no
|
||||
# other type is present) become one tc netem qdisc per veth host
|
||||
# end, pushed to both endpoints.
|
||||
# Filters (delay/packet_loss/corrupt plus the netem extensions
|
||||
# rate/reorder/gemodel/duplicate/seed/limit — eligibility
|
||||
# guarantees frequency_drop is absent) become one tc netem qdisc
|
||||
# per veth host end, pushed to both endpoints.
|
||||
bridge_name = "gns3" + self._id.replace("-", "")[:11]
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
@ -35,7 +35,11 @@ class UDPNIO(BaseModel):
|
||||
rhost: str = Field(..., description="Remote host")
|
||||
rport: int = Field(..., gt=0, le=65535, description="Remote port")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
description="Packet filters (relay-supported types only: delay/packet_loss/corrupt/frequency_drop/bpf — "
|
||||
"the netem extensions need a kernel-datapath NIO)",
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
|
||||
|
||||
|
||||
@ -74,10 +78,12 @@ class BridgeNIO(BaseModel):
|
||||
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
|
||||
node to enslave its veth host end into the named kernel bridge instead of
|
||||
wiring a uBridge UDP relay. Impairment filters run on the veth host end:
|
||||
delay/packet_loss/corrupt as one tc netem qdisc, bpf as cls_bpf match-drop
|
||||
classifiers (needs a uBridge reporting cbpf). frequency_drop still has no
|
||||
kernel equivalent and is rejected. Markers and packet capture are served
|
||||
by uBridge's AF_PACKET modules on the veth host end.
|
||||
delay/packet_loss/corrupt and the netem extensions (rate, reorder,
|
||||
gemodel, duplicate, seed, limit, jitter distributions, loss correlation)
|
||||
as one tc netem qdisc, bpf as cls_bpf match-drop classifiers (needs a
|
||||
uBridge reporting cbpf). frequency_drop still has no kernel equivalent
|
||||
and is rejected. Markers and packet capture are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
"""
|
||||
|
||||
type: BridgeNIOType
|
||||
@ -85,7 +91,8 @@ class BridgeNIO(BaseModel):
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
description="Packet filters served on the veth host end (delay/packet_loss/corrupt via netem, bpf via cls_bpf)",
|
||||
description="Packet filters served on the veth host end (delay/packet_loss/corrupt and the netem extensions "
|
||||
"via one tc netem qdisc, bpf via cls_bpf)",
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
|
||||
|
||||
|
||||
@ -3,8 +3,9 @@ Packet filter parameter validation utilities.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -22,6 +23,29 @@ class FilterValidationError(Exception):
|
||||
# cBPF match-drop classifier (tc bpf_drop) when uBridge reports cbpf=1.
|
||||
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop"})
|
||||
|
||||
# Packet filters with no uBridge *relay* equivalent: the tc netem extensions
|
||||
# (uBridge feature/tc-netem-ext) serve them on the kernel datapath only. The
|
||||
# relay's packet-filter registry only knows frequency_drop / packet_loss /
|
||||
# delay / corrupt / bpf / mark.
|
||||
KERNEL_ONLY_FILTERS = frozenset({"rate", "reorder", "gemodel", "duplicate", "seed", "limit"})
|
||||
|
||||
# Jitter distributions embedded in the netem-extension uBridge (tc_netem_dist).
|
||||
NETEM_DISTRIBUTIONS = ("uniform", "normal", "pareto", "paretonormal")
|
||||
|
||||
# Bandwidth values follow the tc netem grammar: integer + unit, capped at
|
||||
# 100gbit (uBridge NETEM_RATE_MAX_BPS). bps-family units are bytes/s.
|
||||
_RATE_RE = re.compile(r"^(\d+)(bit|kbit|mbit|gbit|bps|kbps|mbps)$", re.IGNORECASE)
|
||||
_RATE_UNITS = {
|
||||
"bit": 1,
|
||||
"kbit": 1000,
|
||||
"mbit": 1000**2,
|
||||
"gbit": 1000**3,
|
||||
"bps": 8,
|
||||
"kbps": 8000,
|
||||
"mbps": 8000 * 1000,
|
||||
}
|
||||
_RATE_MAX_BITS = 100 * 1000**3
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
@ -73,6 +97,64 @@ def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
return {"valid": False, "error": f"BPF validation error: {e!s}"}
|
||||
|
||||
|
||||
def _validate_distribution_value(value: Any, name: str) -> str:
|
||||
"""
|
||||
Validate a netem jitter distribution name (delay filter, 3rd parameter).
|
||||
Returns the normalized (lowercase) name; an empty value is treated as
|
||||
"not set" and returns "".
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise FilterValidationError(f"{name} must be one of {', '.join(NETEM_DISTRIBUTIONS)} (or empty), got: {value}")
|
||||
value = value.strip().lower()
|
||||
if not value:
|
||||
return "" # empty = not set
|
||||
if value not in NETEM_DISTRIBUTIONS:
|
||||
raise FilterValidationError(f"{name} must be one of {', '.join(NETEM_DISTRIBUTIONS)} (or empty), got: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_rate_value(value: Any, name: str) -> None:
|
||||
"""
|
||||
Validate a tc-style bandwidth value (rate filter), e.g. "512kbit".
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise FilterValidationError(f"{name} must be a string like '512kbit', got: {value}")
|
||||
match = _RATE_RE.match(value.strip())
|
||||
if not match:
|
||||
raise FilterValidationError(
|
||||
f"{name} must be an integer plus a unit (bit, kbit, mbit, gbit, bps, kbps, mbps), got: {value}"
|
||||
)
|
||||
if int(match.group(1)) * _RATE_UNITS[match.group(2).lower()] > _RATE_MAX_BITS:
|
||||
raise FilterValidationError(f"{name} exceeds the 100gbit maximum, got: {value}")
|
||||
|
||||
|
||||
def _validate_int_parameter(filter_type: str, rules: dict, i: int, value: Any) -> None:
|
||||
"""
|
||||
Integer parameter validation: type coercion plus range check.
|
||||
"""
|
||||
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
int_value = int(value)
|
||||
else:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be an integer, got: {value}"
|
||||
)
|
||||
|
||||
# Range validation
|
||||
min_val, max_val = rules["ranges"][i]
|
||||
if int_value < min_val or int_value > max_val:
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be between "
|
||||
f"{min_val} and {max_val} {rules['units'][i]}, got: {int_value}"
|
||||
)
|
||||
|
||||
|
||||
def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
"""
|
||||
Validate packet filter parameters.
|
||||
@ -85,33 +167,73 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
FilterValidationError: If parameters are invalid
|
||||
"""
|
||||
|
||||
# Define validation rules based on ubridge implementation
|
||||
# Define validation rules based on ubridge implementation. params_count is
|
||||
# (min, max): trailing parameters are optional (clients sending only the
|
||||
# first N fields keep working). String parameters are declared per index
|
||||
# in "string_params" with their own validators.
|
||||
VALIDATION_RULES: Dict[str, Dict[str, Any]] = {
|
||||
"frequency_drop": {
|
||||
"params_count": 1,
|
||||
"params_count": (1, 1),
|
||||
"ranges": [(-1, 32767)], # min, max
|
||||
"names": ["Frequency"],
|
||||
"units": ["th packet"],
|
||||
},
|
||||
"packet_loss": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"delay": {
|
||||
"params_count": 2, # latency, jitter
|
||||
"ranges": [(1, 32767), (0, 32767)], # ubridge rejects latency <= 0
|
||||
"names": ["Latency", "Jitter"],
|
||||
"units": ["ms", "ms"],
|
||||
"packet_loss": {
|
||||
# 2nd parameter (loss correlation) needs the netem-extension
|
||||
# uBridge and only runs on the kernel datapath.
|
||||
"params_count": (1, 2),
|
||||
"ranges": [(0, 100), (0, 100)],
|
||||
"names": ["Chance", "Correlation"],
|
||||
"units": ["%", "%"],
|
||||
},
|
||||
"corrupt": {"params_count": 1, "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"bpf": {"params_count": 1, "is_text": True, "names": ["Filters"]},
|
||||
"delay": {
|
||||
# 3rd parameter (jitter distribution) needs the netem-extension
|
||||
# uBridge and only runs on the kernel datapath.
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(1, 32767), (0, 32767)], # ubridge rejects latency <= 0
|
||||
"names": ["Latency", "Jitter", "Distribution"],
|
||||
"units": ["ms", "ms", ""],
|
||||
"string_params": {2: _validate_distribution_value},
|
||||
},
|
||||
"corrupt": {"params_count": (1, 1), "ranges": [(0, 100)], "names": ["Chance"], "units": ["%"]},
|
||||
"duplicate": {
|
||||
"params_count": (1, 2),
|
||||
"ranges": [(0, 100), (0, 100)],
|
||||
"names": ["Chance", "Correlation"],
|
||||
"units": ["%", "%"],
|
||||
},
|
||||
"rate": {"params_count": (1, 1), "ranges": [], "names": ["Rate"], "string_params": {0: _validate_rate_value}},
|
||||
"reorder": {
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(0, 100), (0, 100), (1, 1000)],
|
||||
"names": ["Reorder", "Correlation", "Gap"],
|
||||
"units": ["%", "%", "packets"],
|
||||
},
|
||||
"gemodel": {
|
||||
# Gilbert-Elliot loss model, tc semantics: p = loss probability in
|
||||
# the bad state, r = loss probability in the good state, 1-h =
|
||||
# probability of moving from the good to the bad state.
|
||||
"params_count": (1, 3),
|
||||
"ranges": [(0, 100), (0, 100), (0, 100)],
|
||||
"names": ["p (bad-state loss)", "r (good-state loss)", "1-h (good-to-bad transition)"],
|
||||
"units": ["%", "%", "%"],
|
||||
},
|
||||
"seed": {"params_count": (1, 1), "ranges": [(0, 4294967295)], "names": ["Seed"], "units": [""]},
|
||||
"limit": {"params_count": (1, 1), "ranges": [(1, 1000000)], "names": ["Limit"], "units": ["packets"]},
|
||||
"bpf": {"params_count": (1, 1), "is_text": True, "names": ["Filters"]},
|
||||
}
|
||||
|
||||
if filter_type not in VALIDATION_RULES:
|
||||
raise FilterValidationError(f"Unknown filter type: {filter_type}")
|
||||
|
||||
rules = VALIDATION_RULES[filter_type]
|
||||
string_params = rules.get("string_params", {})
|
||||
|
||||
# Check parameter count
|
||||
if len(values) != rules["params_count"]:
|
||||
raise FilterValidationError(f"{filter_type} expects {rules['params_count']} parameter(s), got {len(values)}")
|
||||
min_count, max_count = rules["params_count"]
|
||||
if not (min_count <= len(values) <= max_count):
|
||||
expected = str(min_count) if min_count == max_count else f"{min_count} to {max_count}"
|
||||
raise FilterValidationError(f"{filter_type} expects {expected} parameter(s), got {len(values)}")
|
||||
|
||||
# Validate each parameter
|
||||
for i, value in enumerate(values):
|
||||
@ -136,26 +258,19 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None:
|
||||
f"{filter_type} parameter {rules['names'][i]} line {line_num + 1} "
|
||||
f"has invalid syntax: {bpf_result['error']}"
|
||||
)
|
||||
elif i in string_params:
|
||||
string_params[i](value, f"{filter_type} parameter {rules['names'][i]}")
|
||||
else:
|
||||
# Integer parameter validation
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
int_value = int(value)
|
||||
else:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be an integer, got: {value}"
|
||||
)
|
||||
_validate_int_parameter(filter_type, rules, i, value)
|
||||
|
||||
# Range validation
|
||||
min_val, max_val = rules["ranges"][i]
|
||||
if int_value < min_val or int_value > max_val:
|
||||
raise FilterValidationError(
|
||||
f"{filter_type} parameter {rules['names'][i]} must be between "
|
||||
f"{min_val} and {max_val} {rules['units'][i]}, got: {int_value}"
|
||||
)
|
||||
if filter_type == "delay" and len(values) >= 3 and isinstance(values[2], str) and values[2].strip():
|
||||
# The distribution shapes the jitter draw — without jitter it does
|
||||
# nothing (the kernel silently stays uniform). values[1] has already
|
||||
# passed integer validation at this point.
|
||||
if int(values[1]) <= 0:
|
||||
raise FilterValidationError(
|
||||
"delay parameter Distribution requires a Jitter > 0 (the distribution shapes the jitter draw)"
|
||||
)
|
||||
|
||||
|
||||
def filter_inactive_filters(filters: Dict[str, List[Any]]) -> Dict[str, List[Any]]:
|
||||
@ -221,9 +336,60 @@ def filter_inactive_filters(filters: Dict[str, List[Any]]) -> Dict[str, List[Any
|
||||
return active_filters
|
||||
|
||||
|
||||
def split_kernel_only_features(filters: Dict[str, List[Any]]) -> Tuple[Dict[str, List[Any]], Set[str]]:
|
||||
"""
|
||||
Split a filter set into what the uBridge *relay* can run and what needs
|
||||
the kernel datapath (tc netem on the veth host end).
|
||||
|
||||
Kernel-only are the whole types in KERNEL_ONLY_FILTERS plus two
|
||||
parameters: the delay jitter distribution (3rd) and the packet_loss
|
||||
correlation (2nd) — the relay's delay/packet_loss filters take no such
|
||||
argument.
|
||||
|
||||
:returns: (relay-safe filters copy, names of the dropped features)
|
||||
"""
|
||||
|
||||
dropped: Set[str] = set()
|
||||
clean: Dict[str, List[Any]] = {}
|
||||
for filter_type, values in (filters or {}).items():
|
||||
if filter_type in KERNEL_ONLY_FILTERS:
|
||||
dropped.add(filter_type)
|
||||
continue
|
||||
# tolerate the legacy bare-value shape ({"packet_loss": 10})
|
||||
if isinstance(values, (list, tuple)):
|
||||
values = list(values)
|
||||
else:
|
||||
values = [values] if values else []
|
||||
if filter_type == "delay" and len(values) >= 3 and str(values[2]).strip():
|
||||
dropped.add("delay distribution")
|
||||
values = values[:2]
|
||||
elif filter_type == "packet_loss" and len(values) >= 2:
|
||||
try:
|
||||
correl = int(values[1])
|
||||
except (ValueError, TypeError):
|
||||
correl = 0
|
||||
if correl:
|
||||
dropped.add("packet_loss correlation")
|
||||
values = values[:1]
|
||||
clean[filter_type] = values
|
||||
return clean, dropped
|
||||
|
||||
|
||||
def kernel_only_features(filters: Dict[str, List[Any]]) -> Set[str]:
|
||||
"""
|
||||
Names of the features in *filters* that only run on the kernel datapath
|
||||
(see split_kernel_only_features).
|
||||
"""
|
||||
|
||||
return split_kernel_only_features(filters)[1]
|
||||
|
||||
|
||||
def validate_all_filters(filters: Dict[str, List[Any]]) -> None:
|
||||
"""
|
||||
Validate all packet filters.
|
||||
Validate all packet filters, including cross-filter dependencies that
|
||||
mirror the tc netem grammar (reorder requires delay; gemodel and
|
||||
packet_loss both translate to the netem loss keyword and are mutually
|
||||
exclusive).
|
||||
|
||||
Args:
|
||||
filters: Dictionary mapping filter types to their values
|
||||
@ -240,3 +406,8 @@ def validate_all_filters(filters: Dict[str, List[Any]]) -> None:
|
||||
continue
|
||||
|
||||
validate_filter_parameters(filter_type, values)
|
||||
|
||||
if "reorder" in filters and "delay" not in filters:
|
||||
raise FilterValidationError("reorder requires delay")
|
||||
if "gemodel" in filters and "packet_loss" in filters:
|
||||
raise FilterValidationError("gemodel and packet_loss are mutually exclusive (both map to the netem loss keyword)")
|
||||
|
||||
@ -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]}}
|
||||
)
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -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"] == {}
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user