mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +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).
|
||||
"""
|
||||
|
||||
@@ -116,8 +116,9 @@ class Link:
|
||||
"""
|
||||
Whether this link is wired on the kernel datapath (veth pairs
|
||||
enslaved into a per-link Linux bridge — no uBridge relay in the
|
||||
forwarding path, so filters and markers are unavailable; capture is
|
||||
served by uBridge's AF_PACKET module).
|
||||
forwarding path). Impairment filters run as tc netem on the veth
|
||||
host end; markers and capture are served by uBridge's AF_PACKET
|
||||
module.
|
||||
"""
|
||||
return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or []))
|
||||
|
||||
@@ -234,11 +235,18 @@ class Link:
|
||||
except FilterValidationError as e:
|
||||
raise ControllerError(f"Invalid packet filter parameters: {e!s}")
|
||||
|
||||
if new_filters and self.kernel_datapath:
|
||||
raise ControllerError(
|
||||
"Packet filters are not supported on kernel-datapath links (no uBridge relay "
|
||||
"in the forwarding path); delete and recreate the link to use filters"
|
||||
)
|
||||
if self.kernel_datapath:
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(new_filters or {})
|
||||
if unsupported:
|
||||
raise ControllerError(
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link (no uBridge relay "
|
||||
"in the forwarding path; delay, packet loss and corrupt are served by tc "
|
||||
"netem on the veth); delete and recreate the link to use them".format(
|
||||
", ".join(sorted(unsupported))
|
||||
)
|
||||
)
|
||||
|
||||
if new_filters != self.filters:
|
||||
self._filters = new_filters
|
||||
@@ -637,6 +645,13 @@ class Link:
|
||||
"""
|
||||
filter_node = self._get_filter_node()
|
||||
if filter_node:
|
||||
if self.kernel_datapath:
|
||||
# Kernel-datapath links serve filters via tc netem on the
|
||||
# veth host end; types without a netem equivalent (which
|
||||
# only run in the uBridge relay) are hidden from the picker.
|
||||
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
|
||||
return []
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.packet_filter_validation import validate_bpf_syntax
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
KERNEL_UNSUPPORTED_FILTERS,
|
||||
validate_bpf_syntax,
|
||||
)
|
||||
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import _UNSET, Link
|
||||
@@ -82,19 +85,32 @@ class UDPLink(Link):
|
||||
"""Whether NIO creation is waiting for missing node images."""
|
||||
return self._deferred
|
||||
|
||||
def _get_node_filters(self, node1, node2):
|
||||
def _get_node_filters(self, node1, node2, kernel=None):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
|
||||
:param kernel: explicit datapath decision for the caller's context.
|
||||
``_prepare`` must pass it: it clears ``_link_data`` (the source
|
||||
of the ``kernel_datapath`` property) before rebuilding the NIO
|
||||
specs, so deriving the datapath there would read the previous
|
||||
run's. Callers operating on established link data (``update``)
|
||||
derive it from the property.
|
||||
|
||||
:returns: Tuple of (node1_filters, node2_filters)
|
||||
"""
|
||||
# Kernel-datapath links implement suspend natively (interface carrier
|
||||
# driven by the NIO suspend flag). The synthetic frequency_drop filter
|
||||
# that get_active_filters() injects for suspended links is a
|
||||
# relay-datapath emulation mechanism — sending it would trip the
|
||||
# compute-side kernel guard, so kernel links always push empty filters.
|
||||
if self.kernel_datapath:
|
||||
return {}, {}
|
||||
# relay-datapath emulation mechanism, so kernel links push the *real*
|
||||
# filters straight from storage. Both endpoints receive them: each
|
||||
# end's compute attaches one tc netem qdisc to its veth host end
|
||||
# (egress = traffic entering that container), 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.
|
||||
if kernel is None:
|
||||
kernel = self.kernel_datapath
|
||||
if kernel:
|
||||
return self._filters, self._filters
|
||||
filter_node = self._get_filter_node()
|
||||
return (
|
||||
self.get_active_filters() if filter_node == node1 else {},
|
||||
@@ -135,9 +151,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. The kernel path has no userspace relay, so filters — which
|
||||
live in the relay — disqualify it. Markers and capture are served
|
||||
by uBridge's AF_PACKET modules on the veth host end.
|
||||
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.
|
||||
|
||||
Docker adapters are born as veth pairs (unified interface), so the
|
||||
datapath is a runtime decision — links attach to running containers
|
||||
@@ -152,7 +170,11 @@ class UDPLink(Link):
|
||||
return False
|
||||
if node1.compute.id != node2.compute.id:
|
||||
return False
|
||||
if self.get_active_filters():
|
||||
# Look at the *stored* filters, not get_active_filters(): a suspended
|
||||
# link reports the synthetic frequency_drop emulation, which is
|
||||
# relay-only mechanics and must not flip a kernel link to the relay
|
||||
# on reopen/reset.
|
||||
if KERNEL_UNSUPPORTED_FILTERS.intersection(self._filters or {}):
|
||||
return False
|
||||
if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2):
|
||||
return False
|
||||
@@ -192,11 +214,27 @@ 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.
|
||||
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)
|
||||
self._link_data = [
|
||||
{"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node1_markers, "suspend": self._suspended},
|
||||
{"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node2_markers, "suspend": self._suspended},
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
},
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"filters": node2_filters,
|
||||
"markers": node2_markers,
|
||||
"suspend": self._suspended,
|
||||
},
|
||||
]
|
||||
return [
|
||||
(node1, adapter_number1, port_number1, self._link_data[0]),
|
||||
@@ -223,7 +261,7 @@ class UDPLink(Link):
|
||||
_allocate_port(node1.compute), _allocate_port(node2.compute)
|
||||
)
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=False)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
# Build the tunnel specs for both sides. Index 0 is always node1 so
|
||||
@@ -331,7 +369,7 @@ class UDPLink(Link):
|
||||
self._link_data[1]["suspend"] = self._suspended
|
||||
if node2.node_type != "ethernet_hub":
|
||||
await node2.put(
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120
|
||||
)
|
||||
|
||||
async def delete(self):
|
||||
|
||||
@@ -74,15 +74,18 @@ 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. ``filters`` must stay empty on this datapath
|
||||
(rejected server-side); markers and packet capture are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
wiring a uBridge UDP relay. Impairment filters with a tc netem equivalent
|
||||
(delay, packet_loss, corrupt) run on the veth host end; the relay-only
|
||||
kinds (frequency_drop, bpf) are rejected. Markers and packet capture are
|
||||
served by uBridge's AF_PACKET modules on the veth host end.
|
||||
"""
|
||||
|
||||
type: BridgeNIOType
|
||||
bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters (unsupported, must be empty)")
|
||||
filters: Optional[dict] = Field(
|
||||
None, description="Packet filters served by tc netem on the veth host end (delay/packet_loss/corrupt)"
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Packet filter parameter validation utilities.
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +15,13 @@ class FilterValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Any]:
|
||||
# Packet filters with no kernel-datapath equivalent (no tc netem mapping):
|
||||
# they only run in the uBridge userspace relay, so a link carrying one is
|
||||
# wired on the relay, and adding them to a kernel-datapath link is rejected.
|
||||
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop", "bpf"})
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Validate BPF filter expression syntax using tcpdump.
|
||||
|
||||
|
||||
Reference in new issue
Block a user