mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
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:
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
|
||||
@@ -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]}
|
||||
Reference in new issue
Block a user