feat(docker): eBPF stateful packet filters on kernel links

Consume the eBPF impairment classifier delivered in uBridge
feature/tc-precision: frequency_drop becomes the exact every-Nth mode
(tc nth_drop; -1 = drop everything maps to every 1st) and the new
kernel-only quota type is the byte-cap mode (tc quota_drop, 100 = hard
cutoff). KERNEL_UNSUPPORTED_FILTERS is gone entirely — kernel/relay
eligibility is now purely topological. Both modes gate on ebpf=1 from
the per-process tc capabilities probe (error names the setcap fix); the
cleanup path (modes off) only talks to the eBPF commands when the probe
reported them. Reconcile is a plain re-set: the netem reset-before-set
already detaches the program and clears uBridge's per-interface
registry, so a mode set after it is a fresh load+attach.

Documented semantic difference: the relay counts both directions
through one bridge counter, the kernel counts per direction — N=3
round-trip loss is 1-(2/3)^2 = 55.6% on kernel vs ~1/N on relay
(measured 57%). window_drop is NOT exposed: the delivered
implementation advances the window with no gap, making 'outside the
window packets pass' unreachable (needs outage/period fields —
recorded in the spec); flow_drop awaits a parameter-shape decision.

Also fixes a pre-existing restart bug the e2e exposed: _bridges (relay
bridge names) was never cleared on stop, so restarting a node with a
relay link skipped 'bridge create' and failed on add_nio_udp.

e2e (isolated instance): 13/13 — pref-1 tc_impair classifier on both
veths, 57% round-trip for N=3, -1 = exact 100%, quota hard cutoff,
delay coexistence, clean teardown, suspend/resume, node restart,
relay-leg frequency_drop regression through an Ethernet switch. Full
suite: 1925 passed.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:08 +08:00
1 parent d4b57d49fb
commit 0c4ec8c38c
11 files changed
+449 -159

No files matched your search

@@ -34,7 +34,7 @@ from tests.utils import AsyncioMagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from gns3server.compute.compute_error import ComputeError
from gns3server.compute.nios.nio_udp import NIOUDP
from gns3server.compute.nios.nio_bridge import NIOBridge
@@ -102,16 +102,28 @@ def test_create_nio_bridge_accepts_bpf_filters(vm):
assert nio.filters == {"bpf": ["icmp"]}
def test_create_nio_bridge_rejects_relay_only_filters(vm):
def test_create_nio_bridge_accepts_frequency_drop(vm):
"""
frequency_drop runs on the kernel datapath as the eBPF classifier's
exact every-Nth mode — every filter type is accepted on a kernel NIO.
"""
with pytest.raises(ComputeError, match="kernel-datapath"):
vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"frequency_drop": [10]}})
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"frequency_drop": [10]}})
assert isinstance(nio, NIOBridge)
assert nio.filters == {"frequency_drop": [10]}
def test_create_nio_bridge_accepts_quota(vm):
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"quota": [1000000, 100]}})
assert isinstance(nio, NIOBridge)
assert nio.filters == {"quota": [1000000, 100]}
def test_create_nio_bridge_accepts_markers(vm):
"""
Markers ride the kernel NIO (attached to the veth host end via uBridge's
AF_PACKET marker module); only filters are rejected.
AF_PACKET marker module).
"""
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "markers": {"m": {"bpf": "icmp"}}})
@@ -421,26 +433,134 @@ async def test_connect_nio_udp_on_veth_is_idempotent(vm):
@pytest.mark.asyncio
async def test_update_nio_kernel_rejects_relay_only_filters(vm):
async def test_update_nio_kernel_applies_ebpf_drops(vm):
"""
The PUT route mutates the NIO in place (no create_nio), so the update
path carries its own guard against relay-only filter types.
frequency_drop becomes the eBPF every-Nth mode and quota the byte-cap
mode, applied after the netem/bpf pass on the same NIO update.
"""
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 = {"frequency_drop": [3], "quota": [1000000, 50]}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" 3')
vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" 1000000 50')
@pytest.mark.asyncio
async def test_update_nio_kernel_ebpf_drop_everything(vm):
"""
frequency_drop -1 (drop everything) maps to every 1st packet.
"""
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 = {"frequency_drop": [-1]}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" 1')
# the quota mode is explicitly off (not left to chance)
vm._ubridge_send.assert_any_call(f'tc quota_drop "{host_ifc}" off')
@pytest.mark.asyncio
async def test_update_nio_kernel_turns_ebpf_modes_off(vm):
"""
A re-apply with the stateful filters removed must turn both modes off
(the netem reset already detached the program; the offs are idempotent).
"""
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 = {"frequency_drop": [3]}
await vm.adapter_update_nio_binding(0, nio)
vm._ubridge_send.reset_mock()
nio.filters = {"delay": [50]}
await vm.adapter_update_nio_binding(0, nio)
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')
for c in vm._ubridge_send.call_args_list:
assert "nth_drop" not in c.args[0] or "off" in c.args[0]
@pytest.mark.asyncio
async def test_apply_ebpf_drops_requires_ebpf_capability(vm):
"""
An old or non-capped uBridge (ebpf=0) gets a clear upgrade error naming
the setcap fix.
"""
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, {"frequency_drop": [3]})
vm._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_apply_ebpf_drops_skips_old_ubridge_without_filters(vm):
"""
No stateful filters and no capability probe yet: nothing was ever
installed on an old uBridge, so it must not be touched at all.
"""
vm._ubridge_hypervisor = MagicMock()
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"frequency_drop": [10]}
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
with pytest.raises(DockerError, match="kernel-datapath"):
await vm.adapter_update_nio_binding(0, nio)
await vm._ubridge_apply_ebpf_drops(host_ifc, {})
vm._ubridge_send.assert_not_called()
vm._ubridge_hypervisor.send.assert_not_called()
def _caps_hypervisor(cbpf="1"):
@pytest.mark.asyncio
async def test_apply_ebpf_drops_cleanup_only_probes_with_ebpf(vm):
"""
The filter-removal path (modes off) only talks to the eBPF commands
when the capability probe reported them — a netem-ext uBridge without
the eBPF module must not see an unknown command.
"""
hyp = MagicMock()
hyp.is_running.return_value = True
hyp.send = AsyncioMagicMock(return_value=["netem=delay,rate;ebpf=0;cbpf=1"])
vm._ubridge_hypervisor = hyp
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
vm._ubridge_tc_caps = None # force the probe (something was set before)
await vm._ubridge_apply_ebpf_drops(host_ifc, {})
vm._ubridge_send.assert_not_called()
def _caps_hypervisor(cbpf="1", ebpf="0"):
"""A hypervisor mock whose direct send() answers `tc capabilities`."""
hyp = MagicMock()
hyp.is_running.return_value = True
hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf=0;cbpf={cbpf}"])
hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf={ebpf};cbpf={cbpf}"])
return hyp
@@ -1027,3 +1147,25 @@ def test_create_nio_udp_rejects_kernel_only_filters(vm):
vm.manager.create_nio(
{"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1", "filters": {"gemodel": [100, 0, 30]}}
)
@pytest.mark.asyncio
async def test_stop_clears_relay_bridge_registry(vm):
"""
The uBridge process dies with every bridge it hosted — stop() must drop
the relay bridge names, or the next start skips "bridge create" (the
gate checks the name's presence) and fails on add_nio_udp.
"""
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm._bridges.add("bridge1")
vm._ubridge_tc_caps = {"netem": "delay"}
async def query(method, path, *args, **kwargs):
raise DockerHttp404Error("no such container")
vm.manager.query = AsyncioMagicMock(side_effect=query)
await vm.stop()
assert vm._bridges == set()
assert vm._ubridge_tc_caps is None
+45 -23
View File
@@ -68,6 +68,21 @@ def _mock_compute_http(compute):
compute.post.side_effect = port_callback
async def _relay_link(project):
"""A docker↔qemu link (mixed node types) — never kernel-eligible, so the
relay _prepare path runs (UDP ports reserved through the mocked compute)."""
compute = MagicMock()
compute.id = "compute-1"
_mock_compute_http(compute)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "qemu1", node_type="qemu")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link, node1, node2
@pytest.mark.asyncio
async def test_kernel_datapath_eligible(project):
@@ -117,18 +132,16 @@ async def test_kernel_datapath_eligible_with_netem_filters(project):
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_with_relay_only_filters(project):
async def test_kernel_datapath_eligible_with_frequency_drop(project):
"""
frequency_drop only exists in the uBridge userspace relay (its eBPF
classifier is not delivered yet) — a link carrying one stays on the
relay. bpf is kernel-capable (cls_bpf match-drop) and eligible.
frequency_drop runs on the kernel datapath too (the eBPF classifier's
exact every-Nth mode) — no filter type forces the relay anymore; only
topology does (mixed node types, cross-compute, unix-socket
containers, config off).
"""
link, node1, node2 = await _kernel_link(project)
link._filters = {"delay": [10, 0], "frequency_drop": [7]}
assert link._kernel_datapath_eligible(node1, node2) is False
link._filters = {"bpf": ["icmp"]}
link._filters = {"delay": [10, 0], "frequency_drop": [7], "quota": [1000000, 100]}
assert link._kernel_datapath_eligible(node1, node2) is True
@@ -277,13 +290,20 @@ async def test_update_netem_filters_accepted_on_kernel_link(project):
@pytest.mark.asyncio
async def test_update_relay_only_filters_rejected_on_kernel_link(project):
async def test_update_frequency_drop_accepted_on_kernel_link(project):
"""
frequency_drop rides the kernel NIO to both endpoints like every other
filter type — the compute translates it to the eBPF every-Nth mode.
"""
link, _n1, _n2 = await _kernel_link(project)
link, node1, node2 = await _kernel_link(project)
await link._prepare()
with pytest.raises(ControllerError, match="kernel-datapath"):
await link.update_filters({"frequency_drop": [10]})
await link.update_filters({"frequency_drop": [10]})
assert link.filters == {"frequency_drop": [10]}
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == {"frequency_drop": [10]}
assert by_node[node2.id]["filters"] == {"frequency_drop": [10]}
@pytest.mark.asyncio
@@ -449,14 +469,12 @@ 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 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.)
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.)
"""
link, _n1, _n2 = await _kernel_link(project)
_mock_compute_http(link._nodes[0]["node"].compute)
link._filters = {"frequency_drop": [7]}
link, _n1, _n2 = await _relay_link(project)
await link._prepare()
assert link.kernel_datapath is False
link._created = True # the computes hold the UDP NIOs
@@ -464,6 +482,10 @@ 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"]})
# the stored filters are untouched
assert link.filters == {}
# frequency_drop still runs on the relay (its uBridge userspace filter)
await link.update_filters({"frequency_drop": [7]})
assert link.filters == {"frequency_drop": [7]}
@@ -484,13 +506,13 @@ async def test_update_kernel_only_filters_accepted_before_creation(project):
@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.
A relay-wired link (mixed node types) built from a topology carrying
extension filters drops them with a warning instead of pushing an
unknown filter type to uBridge. frequency_drop stays — the relay's
userspace filter still serves it.
"""
link, node1, node2 = await _kernel_link(project)
_mock_compute_http(link._nodes[0]["node"].compute)
link, node1, node2 = await _relay_link(project)
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
@@ -366,3 +366,20 @@ class TestNetemExtensionFilters:
def test_kernel_only_features_empty(self):
assert split_kernel_only_features({}) == ({}, set())
assert split_kernel_only_features(None) == ({}, set())
def test_quota_valid_and_invalid(self):
validate_filter_parameters("quota", [1000000, 100])
validate_filter_parameters("quota", [1, 0])
with pytest.raises(FilterValidationError, match="Quota"):
validate_filter_parameters("quota", [0, 100])
with pytest.raises(FilterValidationError, match="Chance"):
validate_filter_parameters("quota", [1000, 101])
with pytest.raises(FilterValidationError, match="expects 2 parameter"):
validate_filter_parameters("quota", [1000])
def test_quota_is_kernel_only(self):
clean, dropped = split_kernel_only_features({"quota": [1000, 50], "frequency_drop": [7]})
assert dropped == {"quota"}
# frequency_drop runs on both datapaths (relay userspace filter /
# eBPF every-Nth) and stays
assert clean == {"frequency_drop": [7]}