mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 22:11:14 +02:00
feat(docker): packet filters on kernel-datapath links via tc netem
Impairment filters with a netem equivalent (delay, packet_loss, corrupt) now run on the kernel datapath as one tc netem qdisc per veth host end, pushed through uBridge's netlink tc module (tc netem set / tc reset). This removes the relay delay filter's nanosleep bottleneck (upstream Both link endpoints carry the filters: each end's qdisc egress covers the traffic entering that container, so every direction is impaired exactly once -- the same net effect as the relay, where both directions cross the single filtered bridge. - controller: eligibility keys on relay-only types (frequency_drop, bpf) instead of any filter; kernel links push the real filters (the synthetic suspend frequency_drop no longer flips a suspended link to relay on reopen/reset); update_filters 409s only relay-only types; available_filters hides them on kernel links - compute: create_nio whitelists netem-capable filter types; the NIO update path keeps its own guard (PUT mutates the NIO in place); _ubridge_apply_netem translates the filter dict into one atomic netem-set (full re-apply IS the reconcile); node restart restores the qdisc, link deletion detaches it (the veth survives the link) - e2e (27/27): delay 100 -> 200.2 ms RTT, atomic replace 50 -> 100.2 ms, clear -> baseline, packet_loss 30 -> 48 % round-trip (expected 51 %), suspend/resume and node restart keep the qdisc, link delete leaves no residual impairment
This commit is contained in:
11 files changed
+459
-95
No files matched your search
@@ -361,8 +361,18 @@ class BaseManager:
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_bridge":
|
||||
nio = NIOBridge(nio_settings["bridge"])
|
||||
if nio_settings.get("filters"):
|
||||
raise ComputeError("Packet filters are not supported on kernel-datapath links")
|
||||
# Impairment filters with a tc netem equivalent run on the veth
|
||||
# host end; the relay-only types are rejected here (the controller
|
||||
# already keeps them off kernel links — this is the second guard).
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio_settings.get("filters") or {})
|
||||
if unsupported:
|
||||
raise ComputeError(
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link "
|
||||
"(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported)))
|
||||
)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
# Markers ride the NIO and attach to the veth host interface via
|
||||
# uBridge's AF_PACKET marker module (marker add_kernel).
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
|
||||
@@ -40,6 +40,7 @@ from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer
|
||||
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
|
||||
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
|
||||
from gns3server.utils.hostname import is_rfc1123_hostname_valid
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
from ..adapters.ethernet_adapter import EthernetAdapter
|
||||
from ..base_node import BaseNode
|
||||
@@ -1856,8 +1857,10 @@ class DockerVM(BaseNode):
|
||||
self._kernel_veths[(adapter_number, port_number)] = host_ifc
|
||||
log.debug(
|
||||
"Created veth adapter {adapter_number} port {port_number} with MAC address {mac_address} in namespace {namespace}".format(
|
||||
adapter_number=adapter_number, port_number=port_number,
|
||||
mac_address=mac_address, namespace=self._namespace,
|
||||
adapter_number=adapter_number,
|
||||
port_number=port_number,
|
||||
mac_address=mac_address,
|
||||
namespace=self._namespace,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1931,6 +1934,9 @@ class DockerVM(BaseNode):
|
||||
# Markers carried by the NIO attach to the veth host end (AF_PACKET
|
||||
# taps) — the anchor is the interface, not a relay bridge.
|
||||
await self._ubridge_apply_markers(host_ifc, nio)
|
||||
# Impairment filters become one tc netem qdisc on the veth host
|
||||
# end (restored here on node restart, like the capture above).
|
||||
await self._ubridge_apply_netem(host_ifc, nio.filters)
|
||||
return
|
||||
|
||||
# Relay NIO. On a veth-backed adapter (the unified Docker interface)
|
||||
@@ -1992,10 +1998,14 @@ class DockerVM(BaseNode):
|
||||
|
||||
if self.ubridge:
|
||||
if isinstance(nio, NIOBridge):
|
||||
if nio.filters:
|
||||
# The controller keeps relay-only filters off kernel links and
|
||||
# create_nio rejects them on the POST path; this PUT path
|
||||
# mutates the NIO in place, so guard here too.
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {})
|
||||
if unsupported:
|
||||
raise DockerError(
|
||||
"Packet filters are not supported on kernel-datapath links; "
|
||||
"delete and recreate the link to use them"
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link "
|
||||
"(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported)))
|
||||
)
|
||||
if self.status == "started":
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
@@ -2003,6 +2013,9 @@ class DockerVM(BaseNode):
|
||||
# Incremental marker reconcile on the veth anchor —
|
||||
# the relay-datapath equivalent of the branch below.
|
||||
await self._ubridge_apply_markers(host_ifc, nio)
|
||||
# netem set is an atomic replace, so a full re-apply
|
||||
# IS the incremental reconcile for impairment filters.
|
||||
await self._ubridge_apply_netem(host_ifc, nio.filters)
|
||||
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
|
||||
return
|
||||
|
||||
@@ -2085,7 +2098,14 @@ class DockerVM(BaseNode):
|
||||
await self._ubridge_send(cmd)
|
||||
return
|
||||
await super()._ubridge_add_marker_filter(
|
||||
bridge_name, name, bpf, pcap_path, tag=tag, link_id=link_id, direction=direction, data_link_type=data_link_type
|
||||
bridge_name,
|
||||
name,
|
||||
bpf,
|
||||
pcap_path,
|
||||
tag=tag,
|
||||
link_id=link_id,
|
||||
direction=direction,
|
||||
data_link_type=data_link_type,
|
||||
)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
@@ -2115,6 +2135,43 @@ class DockerVM(BaseNode):
|
||||
return
|
||||
await super()._ubridge_enable_marker_filter(anchor, name, state)
|
||||
|
||||
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``).
|
||||
|
||||
: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
|
||||
try:
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
except UbridgeError as e:
|
||||
# "No such file or directory" = no qdisc was attached: already
|
||||
# clean. Everything else is a real failure and must surface.
|
||||
if "No such file" not in str(e):
|
||||
raise
|
||||
|
||||
async def _remove_kernel_markers(self, host_ifc):
|
||||
"""
|
||||
Tear down every kernel marker anchored to *host_ifc*: the veth survives
|
||||
@@ -2154,6 +2211,12 @@ class DockerVM(BaseNode):
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
if host_ifc is not None:
|
||||
await self._remove_kernel_markers(host_ifc)
|
||||
# The veth survives link deletion, so an orphaned netem qdisc
|
||||
# would keep impairing whatever attaches to the adapter next
|
||||
# (kernel link or relay). Best-effort: ENOENT just means no
|
||||
# qdisc was attached.
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
if self.status == "started":
|
||||
await self._set_adapter_carrier(adapter_number, False, port_number)
|
||||
with contextlib.suppress(UbridgeError):
|
||||
|
||||
@@ -20,8 +20,9 @@ Interface for kernel-datapath bridge NIOs.
|
||||
|
||||
A NIO of this type instructs the node to enslave its adapter's veth host end
|
||||
into the named Linux kernel bridge instead of wiring a uBridge UDP relay.
|
||||
Frames then flow entirely in the kernel (veth -> bridge -> veth). Packet
|
||||
filters are not available on this datapath (they live in the uBridge relay);
|
||||
Frames then flow entirely in the kernel (veth -> bridge -> veth). Impairment
|
||||
filters ride the NIO and run as one tc netem qdisc per veth host end (the
|
||||
types without a netem equivalent — frequency_drop, bpf — stay relay-only);
|
||||
markers and packet capture are served by uBridge's AF_PACKET modules on the
|
||||
veth host end (marker add_kernel / capture start_kernel).
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user