From 316e7002c10458130d192fafe5dae2625aea0bfd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 30 Sep 2026 01:28:59 +0800 Subject: [PATCH] feat(docker): expose the eBPF time-window filter on kernel links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uBridge's window mode gained the semantics the B.2 spec asked for — `tc window_drop [ []]`: a single outage that traffic passes before *and* after, recurring flaps with period >= outage, and a per-cycle jitter — so the kernel-only `window_drop` filter type is now exposed: - validation mirrors the tc grammar (3-5 params, ranges, period >= outage, jitter cap) and adds window_drop to KERNEL_ONLY_FILTERS; filter_inactive_filters keeps start=0 — unlike every other filter a zero first parameter means "the outage starts now", not "disabled". - `_ubridge_apply_ebpf_drops` sends `tc window_drop` (positional pass-through) and turns the mode off explicitly when absent, under the same ebpf=1 gate. - FILTERS entry (Start/Outage/Chance/Period/Jitter) + available_filters. The schedule is relative to the apply: reconcile re-sets the classifier on every apply, so any filter change, node restart or link reset restarts the window. Verified on a two-container kernel link, 15/15: a single outage [1000, 1500, 100] arrives per-seq as [0..4, 13..19] (passing before the window, dropped inside, passing after); recurring 1000/2000 ms = 52 % round trip (duty cycle); period == outage = permanent outage; period < outage = 409 before uBridge; jittered flap keeps the ~50 % duty cycle; 30 % chance on a permanent window = 53 % round trip against the per-direction theory 1-0.7^2 = 51 %; coexist with netem; node restart restores; available_filters kernel vs relay (409 + hidden); teardown clean. P6b re-run as a regression on the same uBridge build: 13/13. Worth recording for the next rebuild: the first delivered window program loaded as root but reported ebpf=0 through gns3-server — the verifier's non-root path loses the loop counter's bound, treats the catch-up loop as unbounded and rejects the *whole* program (E2BIG, 8193 pending states), which also took frequency_drop and quota down, with ebpf=0 as the only symptom. Smaller trip caps did not help; the loop had to go (uBridge 572ca0c), on top of a Makefile header-dependency fix (53e34bb) that had been shipping a stale program object. --- docs/design/ubridge-kernel-impairment-spec.md | 74 ++++++++++++------- docs/features/docker-kernel-datapath.md | 71 +++++++++++++++++- gns3server/compute/docker/docker_vm.py | 36 +++++++-- gns3server/controller/link.py | 20 ++++- gns3server/utils/packet_filter_validation.py | 32 ++++++-- .../docker/test_docker_kernel_datapath.py | 49 ++++++++++++ tests/controller/test_kernel_datapath_link.py | 33 ++++++++- tests/utils/test_packet_filter_validation.py | 33 +++++++++ 8 files changed, 300 insertions(+), 48 deletions(-) diff --git a/docs/design/ubridge-kernel-impairment-spec.md b/docs/design/ubridge-kernel-impairment-spec.md index ad795cc2e..454879a45 100644 --- a/docs/design/ubridge-kernel-impairment-spec.md +++ b/docs/design/ubridge-kernel-impairment-spec.md @@ -9,11 +9,12 @@ See LICENSE file for licensing information. > idempotent `tc reset`, `tc capabilities` — uBridge `feature/tc-bpf-drop`) > and **B (eBPF stateful classifier — uBridge `feature/tc-precision` > 59e2b38, including the non-root setcap load fix)** are consumed by -> gns3-server on the branch stack ending at `feat/docker-kernel-ebpf-drops` -> (B: `frequency_drop` → `tc nth_drop`, the new kernel-only `quota` type → -> `tc quota_drop`). 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`. +> gns3-server on the branch stack ending at `feat/docker-kernel-window-drop` +> (B: `frequency_drop` → `tc nth_drop`, the kernel-only `quota` type → +> `tc quota_drop`, and the kernel-only `window_drop` type → `tc window_drop` +> against uBridge `feature/tc-window`). 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 @@ -27,19 +28,32 @@ See LICENSE file for licensing information. > optional attribute, the server-side reset-before-set can be dropped; the > two behaviours are compatible either way. > -> **Deviation found during B integration:** `window_drop`'s implementation -> advances the window start by one length on expiry with no gap, making the -> windows back-to-back — after the first `start_ms` the "outside the window -> packets pass" behaviour described in B.2 is unreachable (inside ≡ always). -> gns3-server therefore does **not** expose window_drop yet; it needs either -> a second length field (outage vs period) or a next-start semantics fix on -> the uBridge side. `flow_drop` is delivered and functional but not yet -> exposed as a GNS3 filter type (its mask/target parameter shape needs a -> UX decision). Additionally, the delivered B.1 program had to fold the -> flow-hash L4 port reads to constant offsets (IHL==20 only) because the -> verifier prohibits variable packet-pointer arithmetic for non-root — -> even with CAP_BPF (Spectre-mitigation gating by uid); documented in -> uBridge's doc/tc.md. +> **Deviation found during B integration, resolved uBridge-side +> (2026-09-29):** `window_drop` as first delivered advanced the window +> start by one length on expiry with no gap, making the windows +> back-to-back — after the first `start_ms` the "outside the window +> packets pass" behaviour described in B.2 was unreachable (inside ≡ +> always), so gns3-server did not expose it. uBridge now takes +> `tc window_drop [ +> []]`: 4-arg = single window (pass before *and after*), +> 5-arg = recurring outage with `period ≥ outage`, 6-arg adds a uniform +> ±jitter re-drawn per cycle (0 = exactly the fixed schedule) for +> randomized flap. The program advances whole cycles in a handful of +> straight-line steps — **no loop at all**, because the verifier's +> non-root path (the one production ubridge runs under: `CAP_BPF`, never +> uid 0) cannot keep a loop counter's bound and rejects the *whole* +> program with `E2BIG` past its jump-sequence budget, which took all four +> modes down on the gns3-server host while the same binary verified fine +> as root; uBridge's `doc/tc.md` has the trace. The steps are resumable by +> each further packet, so the +> B.2 semantics are now reachable; no caller ever depended on the old +> back-to-back form (it was never exposed). `flow_drop` is delivered and +> functional but not yet exposed as a GNS3 filter type (its mask/target +> parameter shape needs a UX decision). Additionally, the delivered B.1 +> program had to fold the flow-hash L4 port reads to constant offsets +> (IHL==20 only) because the verifier prohibits variable packet-pointer +> arithmetic for non-root — even with CAP_BPF (Spectre-mitigation gating +> by uid); documented in uBridge's doc/tc.md. # uBridge kernel impairment: tc netem extensions + eBPF classifiers @@ -176,7 +190,7 @@ no program reload on parameter changes. ``` tc nth_drop tc quota_drop | off -tc window_drop | off +tc window_drop [ []] | off tc flow_drop | off ``` @@ -189,10 +203,15 @@ tc flow_drop | off hash = Jenkins/equal-fold over the selected header fields modulo `flow_target` == remainder 0 → drop. Server documents: per-flow drop only (a filter cannot delay; delay stays netem's job). -* Window semantics: `win_start_ns`/`win_len_ns` define a recurring window on - the monotonic clock — inside the window drop with `win_pct`; outside pass. - The server computes the first start; the program advances by `len` when - `now >= start + len` (single writer — the CFG update — so this is safe). +* Window semantics: `win_start_ns`/`win_len_ns` define a window on the + monotonic clock — inside the window drop with `win_pct`; outside pass. + The program computes the first start (command time + `start_ms`) and then + advances whole cycles, catching up in a bounded loop when it fell behind + (single writer — the CFG update — so this is safe). Omitting `period` + makes it a **single** window: after `start + outage` nothing drops. With + `period ≥ outage` the outage recurs; `jitter` re-draws each cycle's + outage and period uniformly in nominal ± jitter (0 = fixed schedule), on + the integer-ms grid so the multiply-shift draw cannot overflow. ### B.3 Capability requirement @@ -312,8 +331,13 @@ corresponding filter types per capability. kernel-only `quota` type → `tc quota_drop`, both keyed on `ebpf=1` from the per-process `tc capabilities` probe. `KERNEL_UNSUPPORTED_FILTERS` is gone entirely: kernel/relay eligibility is purely topological now. - Not yet exposed: `window_drop` (implementation deviation above), - `flow_drop` (parameter-shape UX decision). +* **Done** (`feat/docker-kernel-window-drop`): `window_drop` is exposed + with the corrected semantics (uBridge `feature/tc-window`) as the + kernel-only `window_drop` filter type + `[start, outage, pct, period?, jitter?]` → `tc window_drop`; the server + mirrors the grammar's `period ≥ outage` rule and treats `start = 0` as + active (an immediate outage). + Not yet exposed: `flow_drop` (parameter-shape UX decision). * 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 0f5bacf48..426485902 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -24,14 +24,14 @@ 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 / duplicate + the netem extensions rate, reorder, gemodel, seed, limit, jitter distributions, loss correlation); bpf as cls_bpf match-drop classifiers; frequency_drop and quota as the eBPF stateful classifier — every filter type has a kernel equivalent | +| 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, quota and window_drop as the eBPF stateful classifier — every filter type has a kernel equivalent | | Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end | -The work landed in eight stages on branch stack `feat/docker-kernel-datapath` → +The work landed in nine 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-netem-ext` → -`feat/docker-kernel-ebpf-drops`: +`feat/docker-kernel-ebpf-drops` → `feat/docker-kernel-window-drop`: 1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based suspend, crash-safe reconciliation. @@ -66,6 +66,14 @@ The work landed in eight stages on branch stack `feat/docker-kernel-datapath` non-root server process, the production credential shape). With this, no filter type forces the relay anymore: kernel/relay is a purely topological choice. +9. **window_drop flaps** — the eBPF classifier's time-window mode + (`tc window_drop`, uBridge `feature/tc-window`): a single outage that + passes traffic before and after, a recurring flap with `period ≥ outage`, + and an optional per-cycle jitter that randomizes each beat's outage and + period. The originally delivered back-to-back window implementation + (which made "outside the window packets pass" unreachable) was fixed + uBridge-side; this stage exposes the corrected semantics as the + kernel-only `window_drop` filter type. ## Adapter interface types @@ -201,6 +209,7 @@ ends (uBridge `tc netem set`, raw netlink — no `tc` binary needed): | `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 [N]` | `tc nth_drop N` — the eBPF stateful classifier's exact every-Nth mode (clsact egress prio 1) | needs uBridge `ebpf=1`; -1 = drop everything → every 1st; per-direction counters (see below) | | `quota [bytes, %]` | `tc quota_drop ` — after the byte quota is consumed, each further packet drops with the given chance (100 = hard cutoff) | kernel-only type; needs `ebpf=1`; per-direction byte counters | +| `window_drop [start, outage, %, period?, jitter?]` | `tc window_drop [ []]` — packets drop with the given chance inside `[start, start+outage)` | kernel-only type; needs `ebpf=1`; see the window semantics below | Semantics: @@ -241,7 +250,25 @@ Semantics: * **Classifier order** on clsact egress: the eBPF stateful filter runs at prio 1, `bpf_drop` expressions at 10–99 — a packet dropped by the stateful modes never reaches the expression drops, and dropped packets - never reach netem. + never reach netem. Within the stateful program the modes evaluate in the + fixed order nth → quota → window. +* **window_drop semantics.** `start` is relative to the moment the filter + is applied — and because reconcile re-sets the classifier on every apply + (any filter change, node restart, link reset reloads the program after + the netem reset), every such event **restarts the schedule**. 3 + parameters = one single outage (`[start, start+outage)`), traffic passes + before and after — the classic "link dies at T, comes back at T+outage". + 4 parameters = recurring flap: `period ≥ outage` (validated), so the + outage occupies `outage/period` of each cycle. 5 parameters = randomized + flap: each cycle's outage and period are re-drawn uniformly in nominal + ± `jitter` (integer-ms grid; jitter 0 draws nothing and equals the fixed + schedule). Inside a window each packet drops with the given chance (100 + = full outage, lower = degraded service during the window). Both + endpoints run their own schedule, started ~simultaneously by the + controller — a round trip survives only when **both** directions are + outside their windows. Note `start = 0` (outage begins immediately) is a + valid, active configuration — unlike other filters, a zero first + parameter does not mean "disabled". * **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 + @@ -278,6 +305,7 @@ tc netem set [delay ] [jitter ] [loss <%> [correl <%>] | loss gemod tc bpf_drop add "" / tc bpf_drop flush tc nth_drop # eBPF exact every-Nth (frequency_drop) tc quota_drop | off # eBPF byte cap (quota) +tc window_drop [ []] | off # eBPF time window (window_drop) tc reset # full restore: eBPF filter -> bpf_drops -> clsact -> root qdisc, idempotent tc capabilities # "netem=;ebpf=0|1;cbpf=0|1", probed once per process bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture @@ -358,3 +386,38 @@ node restart with a relay link skipped `bridge create` and failed. 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`. + +window_drop (same setup, 15/15 checks, against uBridge `feature/tc-window`): +a single outage `[1000, 1500, 100]` delivers **per-seq evidence** of the B.2 +semantics — one ping per 200 ms arrives as `[0,1,2,3,4, 13…19]`: passing +before the window, dropped inside it, passing again after; a recurring flap +`[300, 1000, 100, 2000]` measures 52 % round-trip loss (the 1000/2000 duty +cycle); `period == outage` is a permanent outage (100 %); `period < outage` +is a 409 before uBridge sees it; a jittered flap +`[0, 1000, 100, 2000, 400]` keeps the ~50 % duty cycle (62 % sampled over +4 cycles — the per-cycle re-draw widens the variance by design); a 30 % +chance on a permanent window measures 53 % round trip against the +per-direction theory 1 − 0.7² = 51 % (the same arithmetic as +`packet_loss 30`); `start = 0` is stored as an *active* filter; the window +coexists with netem (`delay 50` + a permanent window = 100 % inside the +outage); clearing removes the classifier; a node restart restores the +schedule on the fresh veth; `available_filters` shows window_drop on kernel +links and a relay link both hides it and 409s it; teardown leaves no filter +or qdisc behind. The P6b e2e was re-run as a regression on the same uBridge +build (13/13 — nth/quota unaffected by the de-looping below). + +The uBridge-side window work took two fixes that are worth recording, both +found by *this* integration rather than by unit tests: the delivered +catch-up loop tripped the verifier's 8192 **jump-sequence** budget — on the +non-root path the verifier drops the loop counter's bound +(`R4=scalar(smax=umax32=0xfffff086)`), treats the loop as unbounded and +unrolls it until the pending-state limit, so the program was rejected +wholesale (`E2BIG`, `The sequence of 8193 jumps is too complex`) and +`tc capabilities` reported `ebpf=0` — taking `frequency_drop` and `quota` +down with it, since the classifier loads as one program; lowering the trip +cap did not help (128 trips failed identically), removing the loop did +(572ca0c: 16-step unroll + outer guard, zero back edges). A latent Makefile +header-dependency gap (53e34bb) had been shipping the *old* truncated +program object — the kernel answered `jump out of range from insn 9 to +421` with `processed 0 insns`. Both matter to anyone rebuilding uBridge: +`ebpf=1` from a direct probe is the only trustworthy signal. diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 62316dd7b..0dd0dadd4 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1950,7 +1950,8 @@ class DockerVM(BaseNode): # Impairment filters become one tc netem qdisc on the veth host # end (restored here on node restart, like the capture above); # bpf expressions become cls_bpf match-drop classifiers and - # frequency_drop/quota become the eBPF stateful classifier. + # frequency_drop/quota/window_drop become the eBPF stateful + # classifier. await self._ubridge_apply_netem(host_ifc, nio.filters) await self._ubridge_apply_bpf_drops(host_ifc, nio.filters) await self._ubridge_apply_ebpf_drops(host_ifc, nio.filters) @@ -2379,20 +2380,28 @@ class DockerVM(BaseNode): Translate the NIO's stateful filters into uBridge's eBPF impairment classifier on the veth host end's clsact egress (prio 1, below bpf_drop's 10-99): ``frequency_drop`` becomes the exact every-Nth - mode (``tc nth_drop``; -1 = drop everything maps to every 1st), and - the kernel-only ``quota`` type the byte-cap mode (``tc quota_drop``). + mode (``tc nth_drop``; -1 = drop everything maps to every 1st), the + kernel-only ``quota`` type the byte-cap mode (``tc quota_drop``), + and the kernel-only ``window_drop`` type the time-window mode + (``tc window_drop``: single outage, or recurring flaps with an + optional per-cycle jitter). The program evaluates the modes in the + fixed order nth → quota → window. Reconcile is a plain re-set on every apply: the preceding netem apply resets the interface (which detaches the program and drops uBridge's per-interface registry entry), so a mode set re-loads the program from scratch, and a mode left absent is turned off - explicitly (idempotent) so a removed filter stops dropping. + explicitly (idempotent) so a removed filter stops dropping. For + window_drop this also means the schedule restarts on every apply: + the first window opens Start-ms from the moment of this call. Semantic difference vs the relay, documented server-side: the relay counts packets of BOTH directions through its single filtered bridge, while each veth end's classifier counts only the traffic entering that container — with both endpoints applying theirs, each - direction drops every Nth independently. + direction drops every Nth independently (their window schedules + start ~simultaneously, so a round trip survives only when both + directions are outside their windows). :param host_ifc: veth host-end interface name :param filters: NIO filters dictionary @@ -2406,24 +2415,28 @@ class DockerVM(BaseNode): frequency = values_of("frequency_drop") quota = values_of("quota") - if not frequency and not quota and self._ubridge_tc_caps is None: + window = values_of("window_drop") + if not frequency and not quota and not window and self._ubridge_tc_caps is None: # Nothing to set and nothing was ever installed (no probe yet) — # skip touching an old uBridge entirely. return caps = await self._ubridge_tc_capabilities() - if not frequency and not quota: + if not frequency and not quota and not window: if caps.get("ebpf") == "1": # Only when the eBPF module answers can anything be # installed; "off" is idempotent in uBridge. await self._ubridge_send(f'tc nth_drop "{host_ifc}" off') await self._ubridge_send(f'tc quota_drop "{host_ifc}" off') + await self._ubridge_send(f'tc window_drop "{host_ifc}" off') return if caps.get("ebpf") != "1": raise DockerError( "Packet filter(s) {} on a kernel-datapath link need a uBridge with eBPF support " "(tc capabilities reports no ebpf; setcap cap_bpf,cap_net_admin,cap_net_raw=ep on " "the uBridge binary); upgrade uBridge on this compute or keep the link on the " - "relay datapath".format(", ".join(sorted(f for f in ("frequency_drop", "quota") if f in filters))) + "relay datapath".format( + ", ".join(sorted(f for f in ("frequency_drop", "quota", "window_drop") if f in filters)) + ) ) if frequency: # relay semantics: -1 = drop everything, N = every Nth packet @@ -2437,6 +2450,13 @@ class DockerVM(BaseNode): ) else: await self._ubridge_send(f'tc quota_drop "{host_ifc}" off') + if window: + # [start, outage, chance, period?, jitter?] — start is relative + # to this apply (the schedule restarts on every reconcile). + params = " ".join(str(int(v)) for v in window) + await self._ubridge_send(f'tc window_drop "{host_ifc}" {params}') + else: + await self._ubridge_send(f'tc window_drop "{host_ifc}" off') async def _remove_kernel_markers(self, host_ifc): """ diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 0b69981a4..a6ff17822 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -147,6 +147,20 @@ FILTERS = [ {"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, ], }, + { + "type": "window_drop", + "name": "Time window drop", + "description": "Drop packets with the given chance inside a time window starting Start ms from the " + "moment the filter is applied — a single outage (traffic passes before and after), or with a " + "period recurring flaps whose per-cycle timing a jitter randomizes (kernel-datapath links only)", + "parameters": [ + {"name": "Start", "minimum": 0, "maximum": 1000000000000, "type": "int", "unit": "ms"}, + {"name": "Outage", "minimum": 1, "maximum": 1000000000000, "type": "int", "unit": "ms"}, + {"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, + {"name": "Period", "minimum": 1, "maximum": 1000000000000, "type": "int", "unit": "ms"}, + {"name": "Jitter", "minimum": 0, "maximum": 1000000000, "type": "int", "unit": "ms"}, + ], + }, ] @@ -308,7 +322,8 @@ class Link: raise ControllerError(f"Invalid packet filter parameters: {e!s}") if self._created and not self.kernel_datapath: - # Kernel-only filters (the netem extensions, the eBPF quota mode) + # Kernel-only filters (the netem extensions, the eBPF quota and + # window modes) # 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 @@ -723,7 +738,8 @@ class Link: if self.kernel_datapath: # Kernel-datapath links serve every filter type: netem for # delay/loss/corrupt and the netem extensions, cls_bpf for - # bpf, the eBPF classifier for frequency_drop and quota. + # bpf, the eBPF classifier for frequency_drop, quota and + # window_drop. return FILTERS # Relay links: hide the kernel-only types the relay cannot run # (they would be rejected with 409 on update). diff --git a/gns3server/utils/packet_filter_validation.py b/gns3server/utils/packet_filter_validation.py index c986453ed..43f6a3fb2 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -24,10 +24,10 @@ class FilterValidationError(Exception): # kernel/relay choice is purely topological (same compute, docker/docker). # # Packet filters with no uBridge *relay* equivalent: the netem extensions -# plus the eBPF quota mode run 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", "quota"}) +# plus the eBPF quota/window modes run 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", "quota", "window_drop"}) # Jitter distributions embedded in the netem-extension uBridge (tc_netem_dist). NETEM_DISTRIBUTIONS = ("uniform", "normal", "pareto", "paretonormal") @@ -229,6 +229,19 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None: "names": ["Quota", "Chance"], "units": ["bytes", "%"], }, + "window_drop": { + # eBPF stateful classifier (uBridge tc window_drop): packets drop + # with the given chance inside [start, start+outage) measured from + # the moment the filter is applied — a single outage, traffic + # passes before AND after. A period makes the outage recur every + # cycle (period >= outage); a jitter re-draws each cycle's outage + # and period uniformly in nominal ± jitter (0 = the fixed + # schedule). Kernel-datapath only. + "params_count": (3, 5), + "ranges": [(0, 10**12), (1, 10**12), (0, 100), (1, 10**12), (0, 10**9)], + "names": ["Start", "Outage", "Chance", "Period", "Jitter"], + "units": ["ms", "ms", "%", "ms", "ms"], + }, "bpf": {"params_count": (1, 1), "is_text": True, "names": ["Filters"]}, } @@ -335,6 +348,12 @@ def filter_inactive_filters(filters: Dict[str, List[Any]]) -> Dict[str, List[Any else: # latency>0, normal configuration active_filters[filter_type] = values + # window_drop's first parameter is a start offset: 0 means "the + # outage starts now", not "disabled" — the filter is active whenever + # present (an outage of 0 is rejected by validation, so there is no + # natural zero-value encoding for "off"). + elif filter_type == "window_drop": + active_filters[filter_type] = values # For other filters, skip if first value is 0 or empty string (means "disabled") elif values[0] != 0 and values[0] != "": active_filters[filter_type] = values @@ -398,7 +417,7 @@ def validate_all_filters(filters: Dict[str, List[Any]]) -> None: 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). + exclusive) and the eBPF window grammar (period >= outage). Args: filters: Dictionary mapping filter types to their values @@ -420,3 +439,6 @@ def validate_all_filters(filters: Dict[str, List[Any]]) -> None: 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)") + window = filters.get("window_drop") + if window and len(window) >= 4 and int(window[3]) < int(window[1]): + raise FilterValidationError("window_drop period must be greater than or equal to the outage length") diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 14fd81679..4f3001e33 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -497,6 +497,7 @@ async def test_update_nio_kernel_turns_ebpf_modes_off(vm): vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" off') vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" off') + vm._ubridge_send.assert_any_call(f'tc window_drop "{host_ifc}" off') for c in vm._ubridge_send.call_args_list: assert "nth_drop" not in c.args[0] or "off" in c.args[0] @@ -520,6 +521,54 @@ async def test_apply_ebpf_drops_requires_ebpf_capability(vm): vm._ubridge_send.assert_not_called() +@pytest.mark.asyncio +async def test_update_nio_kernel_applies_window_drop(vm): + """ + window_drop becomes the eBPF time-window mode: the positional + [start, outage, chance, period, jitter] maps 1:1 onto the tc command + (trailing optionals omitted), and an absent window is turned off. + """ + + vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1") + 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 = {"window_drop": [0, 2000, 100]} + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc window_drop "{host_ifc}" 0 2000 100') + + vm._ubridge_send.reset_mock() + nio.filters = {"window_drop": [500, 800, 100, 2400, 300]} + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'tc window_drop "{host_ifc}" 500 800 100 2400 300') + + # only the window mode present: nth/quota are explicitly off + vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" off') + vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" off') + + +@pytest.mark.asyncio +async def test_apply_window_drop_requires_ebpf_capability(vm): + """ + The window mode shares the eBPF capability gate (error names setcap). + """ + + hyp = MagicMock() + hyp.is_running.return_value = True + hyp.send = AsyncioMagicMock(return_value=["netem=delay;ebpf=0;cbpf=1"]) + vm._ubridge_hypervisor = hyp + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + + with pytest.raises(DockerError, match="setcap cap_bpf"): + await vm._ubridge_apply_ebpf_drops(host_ifc, {"window_drop": [0, 2000, 100]}) + vm._ubridge_send.assert_not_called() + + @pytest.mark.asyncio async def test_apply_ebpf_drops_skips_old_ubridge_without_filters(vm): """ diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index a55a6aa52..f6ee38410 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -428,7 +428,7 @@ async def test_update_suspend_kernel_link_sends_no_synthetic_filter(project): 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 kernel-eligible (kernel/relay is a purely topological choice). """ link, node1, node2 = await _kernel_link(project) @@ -441,6 +441,8 @@ async def test_kernel_datapath_eligible_with_netem_extension_filters(project): "gemodel": [100, 0, 30], "seed": [42], "limit": [5000], + "quota": [1000000, 100], + "window_drop": [0, 800, 100, 2400, 200], } # gemodel/packet_loss are mutually exclusive — drop packet_loss for the # eligibility check (validation rejects the mix elsewhere) @@ -469,9 +471,9 @@ async def test_update_netem_extension_filters_accepted_on_kernel_link(project): @pytest.mark.asyncio async def test_update_kernel_only_filters_rejected_on_relay_link(project): """ - Netem-extension / quota filters have no relay equivalent: setting them - on a created relay link is a 409. (The link is relay-wired because the - endpoints are mixed node types.) + Netem-extension / quota / window_drop filters have no relay equivalent: + setting them on a created relay link is a 409. (The link is relay-wired + because the endpoints are mixed node types.) """ link, _n1, _n2 = await _relay_link(project) @@ -481,6 +483,11 @@ async def test_update_kernel_only_filters_rejected_on_relay_link(project): with pytest.raises(ControllerError, match="kernel-datapath"): await link.update_filters({"rate": ["512kbit"]}) + with pytest.raises(ControllerError, match="kernel-datapath"): + await link.update_filters({"window_drop": [0, 800, 100, 2400]}) + with pytest.raises(ControllerError, match="period must be greater than or equal"): + # cross-parameter rule mirrors the tc window grammar + await link.update_filters({"window_drop": [0, 2000, 100, 1000]}) # the stored filters are untouched assert link.filters == {} @@ -489,6 +496,24 @@ async def test_update_kernel_only_filters_rejected_on_relay_link(project): assert link.filters == {"frequency_drop": [7]} +@pytest.mark.asyncio +async def test_update_window_drop_accepted_on_kernel_link(project): + """ + window_drop rides the kernel NIO to both endpoints — including the + start=0 form (an immediate outage is NOT an "inactive" filter). + """ + + link, node1, node2 = await _kernel_link(project) + await link._prepare() + + filters = {"window_drop": [0, 800, 100, 2400, 200]} + 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_accepted_before_creation(project): """ diff --git a/tests/utils/test_packet_filter_validation.py b/tests/utils/test_packet_filter_validation.py index 83fcf2faf..d196e9d3e 100644 --- a/tests/utils/test_packet_filter_validation.py +++ b/tests/utils/test_packet_filter_validation.py @@ -383,3 +383,36 @@ class TestNetemExtensionFilters: # frequency_drop runs on both datapaths (relay userspace filter / # eBPF every-Nth) and stays assert clean == {"frequency_drop": [7]} + + def test_window_drop_valid_and_invalid(self): + # the three delivered forms: single outage, recurring, jittered + validate_filter_parameters("window_drop", [0, 2000, 100]) + validate_filter_parameters("window_drop", [500, 800, 100, 2400]) + validate_filter_parameters("window_drop", [500, 800, 100, 2400, 300]) + with pytest.raises(FilterValidationError, match="expects 3 to 5"): + validate_filter_parameters("window_drop", [500, 800]) + with pytest.raises(FilterValidationError, match="expects 3 to 5"): + validate_filter_parameters("window_drop", [500, 800, 100, 2400, 300, 1]) + with pytest.raises(FilterValidationError, match="Outage"): + validate_filter_parameters("window_drop", [0, 0, 100]) + with pytest.raises(FilterValidationError, match="Chance"): + validate_filter_parameters("window_drop", [0, 2000, 101]) + with pytest.raises(FilterValidationError, match="Jitter"): + validate_filter_parameters("window_drop", [0, 2000, 100, 2400, 10**10]) + + def test_window_drop_is_kernel_only(self): + clean, dropped = split_kernel_only_features({"window_drop": [0, 800, 100, 2400], "delay": [10, 0]}) + assert dropped == {"window_drop"} + assert clean == {"delay": [10, 0]} + + def test_window_drop_period_must_cover_outage(self): + with pytest.raises(FilterValidationError, match="period must be greater than or equal"): + validate_all_filters({"window_drop": [0, 2000, 100, 1000]}) + # period == outage is accepted (uBridge allows it); shorter is not + validate_all_filters({"window_drop": [0, 2000, 100, 2000]}) + + def test_window_drop_start_zero_is_active(self): + # start=0 means "the outage starts now" — unlike other filters a + # zero first parameter must NOT be treated as "disabled" + active = filter_inactive_filters({"window_drop": [0, 2000, 100]}) + assert active == {"window_drop": [0, 2000, 100]}