mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 13:35:31 +03:00
feat(compute): report uBridge tc capabilities and gate filters on them
Consume uBridge's ebpf_modes contract (feature/tc-window 40c36ae): a
stateful mode is usable iff ebpf=1 and its token is listed; builds
predating the field keep their shipped modes (nth/quota/flow), so the
pre-correction window semantics can never be installed silently.
- gns3server/utils/tc_capabilities.py: shared reply parser, mode
policy and filter-type mappings (compute and controller gate on the
same rules)
- gns3server/compute/ubridge/tc_probe.py: standalone probe spawning a
throwaway uBridge as the server's own user, cached by binary
identity (path/mtime/size - the version string cannot distinguish
rebuilds); failures cache as unknown
- GET /v3/compute/capabilities gains ubridge_tc {netem, ebpf,
ebpf_modes, cbpf}; the controller forwards it (Compute.capabilities)
and available_filters hides kernel-datapath filter types an endpoint
compute cannot run (both ends apply their own filters, so one
incapable compute hides the type); computes without a report keep
the full list and apply-time 409 stays the guard
- docker: _ubridge_apply_ebpf_drops gates per mode token; the cleanup
path only sends "off" for modes the build declares
- split DockerVM's kernel-datapath machinery (veth lifecycle, carrier,
marker primitives, tc filter translation, kernel NIO teardown) into
compute/docker/docker_kernel_datapath.py (DockerKernelDatapathMixin)
This commit is contained in:
parent
48dbdbe4a9
commit
b4e4739d52
@ -293,13 +293,23 @@ old uBridge without these commands simply stays on the relay datapath):
|
||||
|
||||
```
|
||||
tc capabilities
|
||||
100-netem=delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit;ebpf=1;cbpf=1
|
||||
100-netem=delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow
|
||||
```
|
||||
|
||||
`ebpf=0` when the load probe fails at startup (kernel < 5.1 or no CAP_BPF);
|
||||
`cbpf=0` when RTM_NEWTFILTER/cls_bpf is unavailable. gns3-server hides the
|
||||
corresponding filter types per capability.
|
||||
|
||||
`ebpf_modes` (added on uBridge `feature/tc-window`, commit 40c36ae) is a
|
||||
build fact, orthogonal to the runtime probes: the same binary flips `ebpf`
|
||||
between users and kernels while the mode set changes only with the binary.
|
||||
A mode is usable iff `ebpf=1` **and** its token is listed — gating
|
||||
`window_drop` on `ebpf=1` alone silently accepts the 4-arg form on a
|
||||
pre-correction build (`feature/tc-precision`: back-to-back windows, no
|
||||
gap). Builds that do not emit the field at all predate it and get legacy
|
||||
treatment; an incompatible mode revision renames its token
|
||||
(`window` → `window2`) rather than versioning in place.
|
||||
|
||||
---
|
||||
|
||||
## F. Test requirements
|
||||
@ -338,6 +348,14 @@ corresponding filter types per capability.
|
||||
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).
|
||||
* **Done** (capability reporting): the `ebpf_modes` contract is consumed
|
||||
end to end — the compute applies it per mode (window requires its token;
|
||||
pre-field builds keep their shipped modes), the compute
|
||||
`GET /capabilities` payload reports `ubridge_tc` through a standalone
|
||||
spawn-as-own-user probe cached by binary identity, and the controller's
|
||||
`available_filters` hides kernel-datapath filter types an endpoint
|
||||
compute reports it cannot run (computes without a report keep the full
|
||||
list; apply-time 409 stays the guard).
|
||||
* 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.
|
||||
|
||||
@ -293,6 +293,27 @@ Semantics:
|
||||
frames (on the relay, filter-vs-mark ordering determined visibility
|
||||
instead). Also note `tc qdisc show` cannot print a distribution's name
|
||||
(the kernel stores only the sampled table).
|
||||
* **eBPF mode tokens.** A stateful mode is usable iff `tc capabilities`
|
||||
reports `ebpf=1` **and** lists the mode's token in `ebpf_modes`
|
||||
(uBridge `feature/tc-window`+). Builds predating the field keep the modes
|
||||
that shipped with it — everything but `window`, whose semantics were
|
||||
corrected exactly when the field was added: gating on `ebpf=1` alone
|
||||
would silently install the broken back-to-back-window behaviour on a
|
||||
pre-correction build. Requesting an unusable mode fails the apply with an
|
||||
upgrade error; the cleanup ("off") path only touches modes the build
|
||||
declares.
|
||||
* **Capability reporting.** The compute exposes the probe through
|
||||
`GET /v3/compute/capabilities` as `ubridge_tc` (netem keyword list,
|
||||
`ebpf`/`cbpf` flags, and the usable `ebpf_modes` — legacy fallback
|
||||
applied), answered by a throwaway uBridge spawned as the server's own
|
||||
user and cached by binary identity — the runtime `ebpf` flag flips
|
||||
between users and kernels for the same binary, so a root-run probe would
|
||||
lie for a non-root server. The controller forwards it through
|
||||
`GET /v3/computes` and `available_filters` hides kernel-datapath filter
|
||||
types an endpoint compute reports it cannot run (both ends apply their
|
||||
own filters, so one incapable compute is enough). Computes that report
|
||||
nothing — older servers, failed probe — keep the full list;
|
||||
apply-time validation (409) stays the guard.
|
||||
|
||||
## uBridge command surface
|
||||
|
||||
@ -313,7 +334,7 @@ tc nth_drop <if> <n | off> # eBPF exact every-Nth (frequency_drop)
|
||||
tc quota_drop <if> <bytes> <pct> | off # eBPF byte cap (quota)
|
||||
tc window_drop <if> <start_ms> <outage_ms> <pct> [<period_ms> [<jitter_ms>]] | off # eBPF time window (window_drop)
|
||||
tc reset <if> # full restore: eBPF filter -> bpf_drops -> clsact -> root qdisc, idempotent
|
||||
tc capabilities # "netem=<kw,...>;ebpf=0|1;cbpf=0|1", probed once per process
|
||||
tc capabilities # "netem=<kw,...>;ebpf=0|1;cbpf=0|1[;ebpf_modes=<modes>]", probed once per process
|
||||
bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture
|
||||
```
|
||||
|
||||
|
||||
@ -25,19 +25,33 @@ from fastapi import APIRouter, Request
|
||||
|
||||
from gns3server import schemas
|
||||
from gns3server.compute import MODULES
|
||||
from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities
|
||||
from gns3server.utils.path import get_default_project_directory
|
||||
from gns3server.utils.tc_capabilities import usable_ebpf_modes
|
||||
from gns3server.version import __version__
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=schemas.Capabilities)
|
||||
def get_capabilities(request: Request) -> dict:
|
||||
async def get_capabilities(request: Request) -> dict:
|
||||
|
||||
node_types = []
|
||||
for module in MODULES:
|
||||
node_types.extend(module.node_types())
|
||||
|
||||
# Kernel-datapath packet filters: what this host's uBridge can run,
|
||||
# probed as the server's own user (None = unknown, old/missing uBridge).
|
||||
ubridge_tc = None
|
||||
caps = await probe_tc_capabilities()
|
||||
if caps:
|
||||
ubridge_tc = {
|
||||
"netem": [keyword for keyword in caps.get("netem", "").split(",") if keyword],
|
||||
"ebpf": caps.get("ebpf") == "1",
|
||||
"ebpf_modes": list(usable_ebpf_modes(caps)),
|
||||
"cbpf": caps.get("cbpf") == "1",
|
||||
}
|
||||
|
||||
# record the controller hostname or IP address
|
||||
if request.client:
|
||||
request.app.state.controller_host = request.client.host
|
||||
@ -49,4 +63,5 @@ def get_capabilities(request: Request) -> dict:
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"node_types": node_types,
|
||||
"ubridge_tc": ubridge_tc,
|
||||
}
|
||||
|
||||
607
gns3server/compute/docker/docker_kernel_datapath.py
Normal file
607
gns3server/compute/docker/docker_kernel_datapath.py
Normal file
@ -0,0 +1,607 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Kernel-datapath machinery for DockerVM, extracted from docker_vm.py: the
|
||||
veth pair lifecycle (deterministic names, creation, teardown together with
|
||||
the per-link kernel bridges), the adapter carrier control, the kernel
|
||||
marker primitives and the tc packet-filter translation (netem, classic-BPF
|
||||
match-drop, eBPF stateful drops). Every method runs with a DockerVM
|
||||
instance as ``self`` and reads the same state (_kernel_veths,
|
||||
_ubridge_tc_caps, the uBridge hypervisor through the BaseNode helpers).
|
||||
"""
|
||||
|
||||
import os
|
||||
import contextlib
|
||||
|
||||
from gns3server.utils.tc_capabilities import FILTER_EBPF_MODES, parse_tc_capabilities, usable_ebpf_modes
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError
|
||||
|
||||
from ..nios.nio_bridge import NIOBridge
|
||||
from .docker_error import DockerError
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerKernelDatapathMixin:
|
||||
"""
|
||||
The kernel-datapath half of a Docker node — consumed by DockerVM so
|
||||
docker_vm.py stays focused on the container lifecycle.
|
||||
"""
|
||||
|
||||
def _veth_names(self, adapter_number, port_number=0):
|
||||
"""
|
||||
Deterministic veth pair names for a kernel-datapath adapter port.
|
||||
The host end stays in the root namespace (enslaved to the per-link
|
||||
kernel bridge); the guest end is moved into the container namespace
|
||||
and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3``
|
||||
bridge/TAP name space and serve as search keys for stale-interface
|
||||
cleanup. 8 hex chars of the node id + adapter/port keep the names
|
||||
unique and within IFNAMSIZ (15).
|
||||
"""
|
||||
|
||||
suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}"
|
||||
return f"gv{suffix}", f"gc{suffix}"
|
||||
|
||||
async def _create_veth(self, adapter_number, port_number=0):
|
||||
"""
|
||||
Create the adapter's unified veth pair: the guest end moves into the
|
||||
container namespace as eth{N} (the role the TAP used to play on the
|
||||
relay path), the host end stays in the root namespace with carrier
|
||||
off until a link attaches. Both datapaths anchor here — kernel links
|
||||
enslave the host end into a Linux bridge; relay links attach it to
|
||||
the uBridge relay via AF_PACKET.
|
||||
"""
|
||||
|
||||
try:
|
||||
adapter = self._ethernet_adapters[adapter_number]
|
||||
except IndexError:
|
||||
raise DockerError(
|
||||
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
|
||||
name=self.name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
host_ifc, guest_ifc = self._veth_names(adapter_number, port_number)
|
||||
# The host end lives in the root namespace and survives container
|
||||
# death: best-effort removal of a stale pair from a previous run
|
||||
# (unclean stop or crash) before recreating it.
|
||||
for stale_ifc in (host_ifc, guest_ifc):
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{stale_ifc}"')
|
||||
|
||||
mac_address = self._adapter_mac_address(adapter_number)
|
||||
try:
|
||||
await self._ubridge_send(f'docker create_veth "{host_ifc}" "{guest_ifc}"')
|
||||
# create_veth leaves the host end UP; carrier must be off until
|
||||
# a link attaches (mirrors the old "bridge add_nio_tap ... off").
|
||||
await self._ubridge_send(f'link set "{host_ifc}" down')
|
||||
try:
|
||||
await self._ubridge_send(f"docker set_mac_addr {guest_ifc} {mac_address}")
|
||||
except UbridgeError:
|
||||
log.warning(f"Could not set MAC address {mac_address} on interface {guest_ifc}")
|
||||
|
||||
ifname = self._get_container_ifname(adapter_number)
|
||||
log.debug(f"Move container {self.name} adapter {guest_ifc} -> {ifname} in ns {self._namespace}")
|
||||
try:
|
||||
await self._ubridge_send(f"docker move_to_ns {guest_ifc} {self._namespace} {ifname}")
|
||||
except UbridgeError as e:
|
||||
raise UbridgeNamespaceError(e)
|
||||
except UbridgeNamespaceError:
|
||||
raise
|
||||
except Exception:
|
||||
# Don't leave a half-created pair behind (the next start would
|
||||
# fail on create_veth EEXIST until the stale sweep runs).
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{host_ifc}"')
|
||||
raise
|
||||
|
||||
adapter.host_ifc = host_ifc
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
async def _remove_kernel_veths(self):
|
||||
"""
|
||||
Delete the root-namespace veth host ends. Unlike relay TAPs (which die
|
||||
with the container network namespace) the host end outlives the
|
||||
container and must be removed explicitly. Deleting an enslaved veth
|
||||
detaches it from its bridge automatically.
|
||||
|
||||
The per-link kernel bridges go too: deleting a node (or closing a
|
||||
project) never runs the link-teardown path, so a bridge whose ports
|
||||
just disappeared would stay behind as an empty orphan. Both link
|
||||
endpoints run this — the peer's port still enslaved makes the delete
|
||||
fail with EBUSY (suppressed) and the last one wins, the same
|
||||
contract as _remove_kernel_nio. A node restart re-creates the bridge
|
||||
from the NIO in _connect_nio, so nothing is lost by deleting it.
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
for host_ifc in self._kernel_veths.values():
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{host_ifc}"')
|
||||
for adapter in self._ethernet_adapters:
|
||||
for nio in adapter.ports.values():
|
||||
if isinstance(nio, NIOBridge):
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
self._kernel_veths.clear()
|
||||
|
||||
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
|
||||
"""Replicate a Docker adapter's connection state on its host interface.
|
||||
|
||||
Kernel-datapath adapters toggle the veth host end's admin state (the
|
||||
container end loses carrier when its peer is down); relay adapters
|
||||
toggle the TAP carrier from the uBridge fd holder.
|
||||
"""
|
||||
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
if host_ifc is not None:
|
||||
state = "up" if connected else "down"
|
||||
await self._ubridge_send(f'link set "{host_ifc}" {state}')
|
||||
return
|
||||
|
||||
bridge_name = self._bridge_name(adapter_number, port_number)
|
||||
state = "on" if connected else "off"
|
||||
await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}")
|
||||
|
||||
def _kernel_marker_anchor(self, anchor):
|
||||
"""
|
||||
Whether *anchor* names one of this container's kernel-datapath veth
|
||||
host ends. Marker anchors are polymorphic: a relay bridge name on the
|
||||
relay datapath, the veth host interface on the kernel datapath. A veth
|
||||
name can never collide with a uBridge bridge name (relay bridges are
|
||||
``bridge{adapter}``, veths are ``gv…e…p…``).
|
||||
"""
|
||||
|
||||
return anchor in self._kernel_veths.values()
|
||||
|
||||
async def _ubridge_add_marker_filter(
|
||||
self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None
|
||||
):
|
||||
"""
|
||||
Kernel-datapath variant of the `mark` filter attach: ``marker
|
||||
add_kernel`` binds an AF_PACKET socket to the veth host end. Keyword
|
||||
pairs and semantics (match → MARK signal + pcap append) are identical
|
||||
to the relay ``bridge add_packet_filter … mark`` command.
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(bridge_name):
|
||||
self._validate_marker_name(name)
|
||||
cmd = 'marker add_kernel {name} {ifc} "{bpf}"'.format(name=name, ifc=bridge_name, bpf=bpf)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(data_link_type)
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
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,
|
||||
)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
"""
|
||||
Kernel-datapath variant of the marker removal: ``marker delete_kernel``
|
||||
(idempotent in uBridge — a no-op when the marker is already gone).
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(bridge_name):
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"marker delete_kernel {bridge_name} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove kernel marker '%s' from %s: %s", name, bridge_name, e)
|
||||
return
|
||||
await super()._ubridge_delete_marker_filter(bridge_name, name)
|
||||
|
||||
async def _ubridge_enable_marker_filter(self, anchor, name, state):
|
||||
"""
|
||||
Kernel-datapath variant of the marker on/off toggle: ``marker
|
||||
enable_kernel`` — installed but silent when off, pcap preserved.
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(anchor):
|
||||
await self._ubridge_send(f"marker enable_kernel {anchor} {name} {state}")
|
||||
return
|
||||
await super()._ubridge_enable_marker_filter(anchor, name, state)
|
||||
|
||||
@staticmethod
|
||||
def _netem_command_parts(filters):
|
||||
"""
|
||||
Build the ``tc netem set`` keyword sequence (in the frozen grammar
|
||||
order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate,
|
||||
limit, distribution, seed) plus the set of capability tokens the
|
||||
sequence needs beyond the original netem surface. Original-surface
|
||||
keywords (delay, jitter, loss, corrupt, dup) need no probe; the
|
||||
extension tokens (rate, reorder, gemodel, dist, seed, limit) are
|
||||
checked against ``tc capabilities`` by the caller. ``correl`` has no
|
||||
token of its own in the capabilities list, so it gates on "rate" as
|
||||
the netem-extension build marker.
|
||||
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...})
|
||||
|
||||
:returns: (keyword sequence, required extension tokens)
|
||||
"""
|
||||
|
||||
def values_of(key):
|
||||
# tolerate the legacy bare-value shape ({"packet_loss": 10})
|
||||
values = filters.get(key)
|
||||
if isinstance(values, (list, tuple)):
|
||||
return list(values)
|
||||
return [values] if values else []
|
||||
|
||||
parts = []
|
||||
ext_tokens = set()
|
||||
|
||||
delay = values_of("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])}")
|
||||
|
||||
gemodel = values_of("gemodel")
|
||||
if gemodel:
|
||||
segment = f"loss gemodel {int(gemodel[0])}"
|
||||
if len(gemodel) > 1:
|
||||
segment += f" {int(gemodel[1])}"
|
||||
if len(gemodel) > 2:
|
||||
segment += f" {int(gemodel[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("gemodel")
|
||||
else:
|
||||
loss = values_of("packet_loss")
|
||||
if loss and int(loss[0]):
|
||||
parts.append(f"loss {int(loss[0])}")
|
||||
if len(loss) > 1 and int(loss[1]):
|
||||
parts.append(f"correl {int(loss[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
duplicate = values_of("duplicate")
|
||||
if duplicate and int(duplicate[0]):
|
||||
parts.append(f"dup {int(duplicate[0])}")
|
||||
if len(duplicate) > 1 and int(duplicate[1]):
|
||||
parts.append(f"correl {int(duplicate[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
corrupt = values_of("corrupt")
|
||||
if corrupt and int(corrupt[0]):
|
||||
parts.append(f"corrupt {int(corrupt[0])}")
|
||||
|
||||
reorder = values_of("reorder")
|
||||
if reorder:
|
||||
segment = f"reorder {int(reorder[0])}"
|
||||
if len(reorder) > 1 and int(reorder[1]):
|
||||
segment += f" correl {int(reorder[1])}"
|
||||
if len(reorder) > 2 and int(reorder[2]):
|
||||
segment += f" gap {int(reorder[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("reorder")
|
||||
|
||||
rate = values_of("rate")
|
||||
if rate and str(rate[0]).strip():
|
||||
parts.append(f"rate {str(rate[0]).strip()}")
|
||||
ext_tokens.add("rate")
|
||||
|
||||
limit = values_of("limit")
|
||||
if limit and int(limit[0]):
|
||||
parts.append(f"limit {int(limit[0])}")
|
||||
ext_tokens.add("limit")
|
||||
|
||||
if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"):
|
||||
# "uniform" is the kernel default — emitting nothing keeps the
|
||||
# command compatible with the original netem surface.
|
||||
parts.append(f"distribution {str(delay[2]).strip().lower()}")
|
||||
ext_tokens.add("dist")
|
||||
|
||||
seed = values_of("seed")
|
||||
if seed and int(seed[0]):
|
||||
# 0 is the "disabled" convention everywhere else — treat it so
|
||||
# here too (the controller's inactive-filter pass drops it).
|
||||
parts.append(f"seed {int(seed[0])}")
|
||||
ext_tokens.add("seed")
|
||||
|
||||
return parts, ext_tokens
|
||||
|
||||
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 netem-expressible types (delay, packet_loss,
|
||||
corrupt and the netem extensions rate/reorder/gemodel/duplicate/
|
||||
seed/limit) 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).
|
||||
|
||||
The kernel's netem replace MERGES optional attributes (rate,
|
||||
correlation, reorder, corrupt, gemodel, distribution: absent attr =
|
||||
previous value kept), so re-applying a filter set with a parameter
|
||||
*removed* would silently keep the old value. A ``tc reset`` before
|
||||
every ``netem set`` makes the re-apply a true reconcile (reset also
|
||||
drops clsact and its bpf_drop filters — the caller re-applies them
|
||||
right after in the same flow). An empty filter set is the reset
|
||||
alone.
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter], ...})
|
||||
"""
|
||||
|
||||
parts, ext_tokens = self._netem_command_parts(filters)
|
||||
if parts and ext_tokens:
|
||||
# Extension keywords need the netem-extension uBridge. Plain
|
||||
# delay/loss/corrupt/dup never probes — an old uBridge serves
|
||||
# the original surface untouched (mirrors bpf_drop's guard).
|
||||
# Checked before the reset so a failed update leaves the
|
||||
# previously applied qdisc in place.
|
||||
caps = await self._ubridge_tc_capabilities()
|
||||
missing = ext_tokens - set(caps.get("netem", "").split(","))
|
||||
if missing:
|
||||
raise DockerError(
|
||||
"Packet filter(s) using {} need a uBridge with the netem extensions "
|
||||
"(tc capabilities reports '{}'); upgrade uBridge on this compute".format(
|
||||
", ".join(sorted(missing)), caps.get("netem") or "no tc support"
|
||||
)
|
||||
)
|
||||
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
|
||||
if not parts:
|
||||
return
|
||||
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
|
||||
|
||||
async def _ubridge_tc_capabilities(self):
|
||||
"""
|
||||
uBridge's tc-module feature report (``tc capabilities``), probed once
|
||||
per uBridge process and cached — the reply shape is
|
||||
``netem=<kw,...>;ebpf=0|1;cbpf=0|1[;ebpf_modes=<mode,...>]`` (parsed
|
||||
by parse_tc_capabilities). uBridge builds without the tc module
|
||||
answer "Unknown module" and old builds without the command answer
|
||||
"Unknown command": both mean no kernel filter support, cached as an
|
||||
empty dict.
|
||||
"""
|
||||
|
||||
if self._ubridge_tc_caps is None:
|
||||
caps = {}
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
try:
|
||||
reply = await self._ubridge_hypervisor.send("tc capabilities")
|
||||
except UbridgeError:
|
||||
reply = []
|
||||
caps = parse_tc_capabilities(reply[0] if reply else "")
|
||||
self._ubridge_tc_caps = caps
|
||||
return self._ubridge_tc_caps
|
||||
|
||||
async def _ubridge_apply_bpf_drops(self, host_ifc, filters):
|
||||
"""
|
||||
Translate the NIO's ``bpf`` filter (one expression per line, any line
|
||||
matching drops the packet) into cls_bpf classifiers on the veth host
|
||||
end's clsact egress: ``tc bpf_drop`` compiles each line with libpcap
|
||||
(same compiler as the relay's bpf filter) and drops matches with a
|
||||
gact TC_ACT_SHOT. Reconcile is always flush + re-add — same-prio
|
||||
resend replaces the filter node in uBridge, and the flush drops
|
||||
lines the user removed.
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary
|
||||
"""
|
||||
|
||||
bpf = filters.get("bpf")
|
||||
lines = [line.strip() for line in (bpf[0].split("\n") if bpf else []) if line.strip()]
|
||||
if not lines and self._ubridge_tc_caps is None:
|
||||
# Nothing to add and nothing was ever installed (no tc module
|
||||
# probed yet) — skip touching an old uBridge entirely.
|
||||
return
|
||||
caps = await self._ubridge_tc_capabilities()
|
||||
if not lines:
|
||||
if caps:
|
||||
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
|
||||
return
|
||||
if caps.get("cbpf") != "1":
|
||||
raise DockerError(
|
||||
"Packet filter 'bpf' on a kernel-datapath link needs a uBridge with cBPF support "
|
||||
"(tc capabilities reports no cbpf); upgrade uBridge on this compute or keep the "
|
||||
"link on the relay datapath"
|
||||
)
|
||||
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
|
||||
for offset, line in enumerate(lines):
|
||||
try:
|
||||
await self._ubridge_send(
|
||||
'tc bpf_drop add "{ifc}" {prio} "{expr}"'.format(ifc=host_ifc, prio=10 + offset, expr=line)
|
||||
)
|
||||
except UbridgeError as e:
|
||||
if "Cannot compile filter" not in str(e):
|
||||
raise
|
||||
# Mirror the relay path: a line that no longer compiles on
|
||||
# this uBridge's libpcap is skipped with a warning instead
|
||||
# of breaking the link (the controller already validated
|
||||
# the syntax with tcpdump at create/update time).
|
||||
message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {line}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
|
||||
async def _ubridge_apply_ebpf_drops(self, host_ifc, filters):
|
||||
"""
|
||||
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), 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. A mode is only sent when
|
||||
``tc capabilities`` declares its token (``ebpf_modes``); builds
|
||||
predating the field keep their shipped modes (everything but
|
||||
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. 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 (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
|
||||
"""
|
||||
|
||||
def values_of(key):
|
||||
values = filters.get(key)
|
||||
if isinstance(values, (list, tuple)):
|
||||
return list(values)
|
||||
return [values] if values else []
|
||||
|
||||
frequency = values_of("frequency_drop")
|
||||
quota = values_of("quota")
|
||||
window = values_of("window_drop")
|
||||
requested = {filter_type: values_of(filter_type) for filter_type in FILTER_EBPF_MODES}
|
||||
if not any(requested.values()) 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()
|
||||
# A mode is usable iff ebpf=1 and the build declares its token
|
||||
# (ebpf_modes); builds predating the field keep their shipped modes.
|
||||
modes = usable_ebpf_modes(caps)
|
||||
if not any(requested.values()):
|
||||
# Only modes this build declares can ever have been installed —
|
||||
# sending "off" for an undeclared mode would hit an unknown
|
||||
# command; "off" is idempotent in uBridge.
|
||||
for filter_type, mode in FILTER_EBPF_MODES.items():
|
||||
if mode in modes:
|
||||
await self._ubridge_send(f'tc {mode}_drop "{host_ifc}" off')
|
||||
return
|
||||
unsupported = sorted(f for f, values in requested.items() if values and FILTER_EBPF_MODES[f] not in modes)
|
||||
if unsupported:
|
||||
if not modes:
|
||||
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(unsupported))
|
||||
)
|
||||
raise DockerError(
|
||||
"Packet filter(s) {} on a kernel-datapath link need a newer uBridge (tc capabilities "
|
||||
"reports ebpf_modes={}); upgrade uBridge on this compute or keep the link on the "
|
||||
"relay datapath".format(", ".join(unsupported), ",".join(modes))
|
||||
)
|
||||
if frequency:
|
||||
# relay semantics: -1 = drop everything, N = every Nth packet
|
||||
nth = 1 if int(frequency[0]) == -1 else int(frequency[0])
|
||||
await self._ubridge_send(f'tc nth_drop "{host_ifc}" {nth}')
|
||||
elif "nth" in modes:
|
||||
await self._ubridge_send(f'tc nth_drop "{host_ifc}" off')
|
||||
if quota:
|
||||
await self._ubridge_send(
|
||||
'tc quota_drop "{ifc}" {bytes} {pct}'.format(ifc=host_ifc, bytes=int(quota[0]), pct=int(quota[1]))
|
||||
)
|
||||
elif "quota" in modes:
|
||||
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}')
|
||||
elif "window" in modes:
|
||||
await self._ubridge_send(f'tc window_drop "{host_ifc}" off')
|
||||
|
||||
async def _remove_kernel_markers(self, host_ifc):
|
||||
"""
|
||||
Tear down every kernel marker anchored to *host_ifc*: the veth survives
|
||||
link deletion (it is the adapter interface, not the link), so the
|
||||
AF_PACKET sockets must be closed explicitly — otherwise a deleted
|
||||
link's markers would keep sniffing and signaling. Mirrors the relay
|
||||
datapath where deleting the uBridge bridge silently drops its filters.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
for (name, link_id), anchor in list(self._marker_filter_bridges.items()):
|
||||
if anchor != host_ifc:
|
||||
continue
|
||||
self._marker_filter_bridges.pop((name, link_id))
|
||||
self._marker_specs.pop((name, link_id), None)
|
||||
await self._ubridge_delete_marker_filter(host_ifc, name)
|
||||
try:
|
||||
os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e)
|
||||
manager.unregister(self._id, name)
|
||||
|
||||
async def _remove_kernel_nio(self, nio, adapter_number, port_number=0):
|
||||
"""
|
||||
Detach a kernel-datapath adapter port from its per-link kernel bridge.
|
||||
Both endpoints run this concurrently: deleting a bridge that still has
|
||||
the peer's port enslaved fails with EBUSY, so the last endpoint to
|
||||
remove its port wins the deletion and the loser's failure is expected
|
||||
and suppressed.
|
||||
"""
|
||||
|
||||
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):
|
||||
await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"')
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
@ -48,11 +48,12 @@ from ..error import ImageMissingError
|
||||
from ..nios.nio_bridge import NIOBridge
|
||||
from ..nios.nio_udp import NIOUDP
|
||||
from .docker_error import DockerError, DockerHttp404Error, DockerHttp409Error
|
||||
from .docker_kernel_datapath import DockerKernelDatapathMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DockerVM(BaseNode):
|
||||
class DockerVM(DockerKernelDatapathMixin, BaseNode):
|
||||
"""
|
||||
Docker container implementation.
|
||||
|
||||
@ -1799,134 +1800,11 @@ class DockerVM(BaseNode):
|
||||
mac_address = custom_mac_address
|
||||
return mac_address
|
||||
|
||||
def _veth_names(self, adapter_number, port_number=0):
|
||||
"""
|
||||
Deterministic veth pair names for a kernel-datapath adapter port.
|
||||
The host end stays in the root namespace (enslaved to the per-link
|
||||
kernel bridge); the guest end is moved into the container namespace
|
||||
and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3``
|
||||
bridge/TAP name space and serve as search keys for stale-interface
|
||||
cleanup. 8 hex chars of the node id + adapter/port keep the names
|
||||
unique and within IFNAMSIZ (15).
|
||||
"""
|
||||
|
||||
suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}"
|
||||
return f"gv{suffix}", f"gc{suffix}"
|
||||
|
||||
async def _create_veth(self, adapter_number, port_number=0):
|
||||
"""
|
||||
Create the adapter's unified veth pair: the guest end moves into the
|
||||
container namespace as eth{N} (the role the TAP used to play on the
|
||||
relay path), the host end stays in the root namespace with carrier
|
||||
off until a link attaches. Both datapaths anchor here — kernel links
|
||||
enslave the host end into a Linux bridge; relay links attach it to
|
||||
the uBridge relay via AF_PACKET.
|
||||
"""
|
||||
|
||||
try:
|
||||
adapter = self._ethernet_adapters[adapter_number]
|
||||
except IndexError:
|
||||
raise DockerError(
|
||||
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
|
||||
name=self.name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
host_ifc, guest_ifc = self._veth_names(adapter_number, port_number)
|
||||
# The host end lives in the root namespace and survives container
|
||||
# death: best-effort removal of a stale pair from a previous run
|
||||
# (unclean stop or crash) before recreating it.
|
||||
for stale_ifc in (host_ifc, guest_ifc):
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{stale_ifc}"')
|
||||
|
||||
mac_address = self._adapter_mac_address(adapter_number)
|
||||
try:
|
||||
await self._ubridge_send(f'docker create_veth "{host_ifc}" "{guest_ifc}"')
|
||||
# create_veth leaves the host end UP; carrier must be off until
|
||||
# a link attaches (mirrors the old "bridge add_nio_tap ... off").
|
||||
await self._ubridge_send(f'link set "{host_ifc}" down')
|
||||
try:
|
||||
await self._ubridge_send(f"docker set_mac_addr {guest_ifc} {mac_address}")
|
||||
except UbridgeError:
|
||||
log.warning(f"Could not set MAC address {mac_address} on interface {guest_ifc}")
|
||||
|
||||
ifname = self._get_container_ifname(adapter_number)
|
||||
log.debug(f"Move container {self.name} adapter {guest_ifc} -> {ifname} in ns {self._namespace}")
|
||||
try:
|
||||
await self._ubridge_send(f"docker move_to_ns {guest_ifc} {self._namespace} {ifname}")
|
||||
except UbridgeError as e:
|
||||
raise UbridgeNamespaceError(e)
|
||||
except UbridgeNamespaceError:
|
||||
raise
|
||||
except Exception:
|
||||
# Don't leave a half-created pair behind (the next start would
|
||||
# fail on create_veth EEXIST until the stale sweep runs).
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{host_ifc}"')
|
||||
raise
|
||||
|
||||
adapter.host_ifc = host_ifc
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
async def _remove_kernel_veths(self):
|
||||
"""
|
||||
Delete the root-namespace veth host ends. Unlike relay TAPs (which die
|
||||
with the container network namespace) the host end outlives the
|
||||
container and must be removed explicitly. Deleting an enslaved veth
|
||||
detaches it from its bridge automatically.
|
||||
|
||||
The per-link kernel bridges go too: deleting a node (or closing a
|
||||
project) never runs the link-teardown path, so a bridge whose ports
|
||||
just disappeared would stay behind as an empty orphan. Both link
|
||||
endpoints run this — the peer's port still enslaved makes the delete
|
||||
fail with EBUSY (suppressed) and the last one wins, the same
|
||||
contract as _remove_kernel_nio. A node restart re-creates the bridge
|
||||
from the NIO in _connect_nio, so nothing is lost by deleting it.
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
for host_ifc in self._kernel_veths.values():
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'docker delete_veth "{host_ifc}"')
|
||||
for adapter in self._ethernet_adapters:
|
||||
for nio in adapter.ports.values():
|
||||
if isinstance(nio, NIOBridge):
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
self._kernel_veths.clear()
|
||||
|
||||
async def _get_namespace(self):
|
||||
|
||||
result = await self.manager.query("GET", f"containers/{self._cid}/json")
|
||||
return int(result["State"]["Pid"])
|
||||
|
||||
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
|
||||
"""Replicate a Docker adapter's connection state on its host interface.
|
||||
|
||||
Kernel-datapath adapters toggle the veth host end's admin state (the
|
||||
container end loses carrier when its peer is down); relay adapters
|
||||
toggle the TAP carrier from the uBridge fd holder.
|
||||
"""
|
||||
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
if host_ifc is not None:
|
||||
state = "up" if connected else "down"
|
||||
await self._ubridge_send(f'link set "{host_ifc}" {state}')
|
||||
return
|
||||
|
||||
bridge_name = self._bridge_name(adapter_number, port_number)
|
||||
state = "on" if connected else "off"
|
||||
await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}")
|
||||
|
||||
async def _connect_nio(self, adapter_number, nio, port_number=0):
|
||||
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
@ -2094,435 +1972,6 @@ class DockerVM(BaseNode):
|
||||
|
||||
log.debug(f"Docker VM '{self.name}' [{self.id}]: {adapter.host_ifc} removed from adapter {adapter_number}")
|
||||
|
||||
def _kernel_marker_anchor(self, anchor):
|
||||
"""
|
||||
Whether *anchor* names one of this container's kernel-datapath veth
|
||||
host ends. Marker anchors are polymorphic: a relay bridge name on the
|
||||
relay datapath, the veth host interface on the kernel datapath. A veth
|
||||
name can never collide with a uBridge bridge name (relay bridges are
|
||||
``bridge{adapter}``, veths are ``gv…e…p…``).
|
||||
"""
|
||||
|
||||
return anchor in self._kernel_veths.values()
|
||||
|
||||
async def _ubridge_add_marker_filter(
|
||||
self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None
|
||||
):
|
||||
"""
|
||||
Kernel-datapath variant of the `mark` filter attach: ``marker
|
||||
add_kernel`` binds an AF_PACKET socket to the veth host end. Keyword
|
||||
pairs and semantics (match → MARK signal + pcap append) are identical
|
||||
to the relay ``bridge add_packet_filter … mark`` command.
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(bridge_name):
|
||||
self._validate_marker_name(name)
|
||||
cmd = 'marker add_kernel {name} {ifc} "{bpf}"'.format(name=name, ifc=bridge_name, bpf=bpf)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(data_link_type)
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
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,
|
||||
)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
"""
|
||||
Kernel-datapath variant of the marker removal: ``marker delete_kernel``
|
||||
(idempotent in uBridge — a no-op when the marker is already gone).
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(bridge_name):
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"marker delete_kernel {bridge_name} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove kernel marker '%s' from %s: %s", name, bridge_name, e)
|
||||
return
|
||||
await super()._ubridge_delete_marker_filter(bridge_name, name)
|
||||
|
||||
async def _ubridge_enable_marker_filter(self, anchor, name, state):
|
||||
"""
|
||||
Kernel-datapath variant of the marker on/off toggle: ``marker
|
||||
enable_kernel`` — installed but silent when off, pcap preserved.
|
||||
"""
|
||||
|
||||
if self._kernel_marker_anchor(anchor):
|
||||
await self._ubridge_send(f"marker enable_kernel {anchor} {name} {state}")
|
||||
return
|
||||
await super()._ubridge_enable_marker_filter(anchor, name, state)
|
||||
|
||||
@staticmethod
|
||||
def _netem_command_parts(filters):
|
||||
"""
|
||||
Build the ``tc netem set`` keyword sequence (in the frozen grammar
|
||||
order: delay/jitter, loss|gemodel, dup, corrupt, reorder, rate,
|
||||
limit, distribution, seed) plus the set of capability tokens the
|
||||
sequence needs beyond the original netem surface. Original-surface
|
||||
keywords (delay, jitter, loss, corrupt, dup) need no probe; the
|
||||
extension tokens (rate, reorder, gemodel, dist, seed, limit) are
|
||||
checked against ``tc capabilities`` by the caller. ``correl`` has no
|
||||
token of its own in the capabilities list, so it gates on "rate" as
|
||||
the netem-extension build marker.
|
||||
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter, dist], ...})
|
||||
|
||||
:returns: (keyword sequence, required extension tokens)
|
||||
"""
|
||||
|
||||
def values_of(key):
|
||||
# tolerate the legacy bare-value shape ({"packet_loss": 10})
|
||||
values = filters.get(key)
|
||||
if isinstance(values, (list, tuple)):
|
||||
return list(values)
|
||||
return [values] if values else []
|
||||
|
||||
parts = []
|
||||
ext_tokens = set()
|
||||
|
||||
delay = values_of("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])}")
|
||||
|
||||
gemodel = values_of("gemodel")
|
||||
if gemodel:
|
||||
segment = f"loss gemodel {int(gemodel[0])}"
|
||||
if len(gemodel) > 1:
|
||||
segment += f" {int(gemodel[1])}"
|
||||
if len(gemodel) > 2:
|
||||
segment += f" {int(gemodel[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("gemodel")
|
||||
else:
|
||||
loss = values_of("packet_loss")
|
||||
if loss and int(loss[0]):
|
||||
parts.append(f"loss {int(loss[0])}")
|
||||
if len(loss) > 1 and int(loss[1]):
|
||||
parts.append(f"correl {int(loss[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
duplicate = values_of("duplicate")
|
||||
if duplicate and int(duplicate[0]):
|
||||
parts.append(f"dup {int(duplicate[0])}")
|
||||
if len(duplicate) > 1 and int(duplicate[1]):
|
||||
parts.append(f"correl {int(duplicate[1])}")
|
||||
ext_tokens.add("rate") # correl: extension build marker
|
||||
|
||||
corrupt = values_of("corrupt")
|
||||
if corrupt and int(corrupt[0]):
|
||||
parts.append(f"corrupt {int(corrupt[0])}")
|
||||
|
||||
reorder = values_of("reorder")
|
||||
if reorder:
|
||||
segment = f"reorder {int(reorder[0])}"
|
||||
if len(reorder) > 1 and int(reorder[1]):
|
||||
segment += f" correl {int(reorder[1])}"
|
||||
if len(reorder) > 2 and int(reorder[2]):
|
||||
segment += f" gap {int(reorder[2])}"
|
||||
parts.append(segment)
|
||||
ext_tokens.add("reorder")
|
||||
|
||||
rate = values_of("rate")
|
||||
if rate and str(rate[0]).strip():
|
||||
parts.append(f"rate {str(rate[0]).strip()}")
|
||||
ext_tokens.add("rate")
|
||||
|
||||
limit = values_of("limit")
|
||||
if limit and int(limit[0]):
|
||||
parts.append(f"limit {int(limit[0])}")
|
||||
ext_tokens.add("limit")
|
||||
|
||||
if delay and len(delay) > 2 and str(delay[2]).strip().lower() not in ("", "uniform"):
|
||||
# "uniform" is the kernel default — emitting nothing keeps the
|
||||
# command compatible with the original netem surface.
|
||||
parts.append(f"distribution {str(delay[2]).strip().lower()}")
|
||||
ext_tokens.add("dist")
|
||||
|
||||
seed = values_of("seed")
|
||||
if seed and int(seed[0]):
|
||||
# 0 is the "disabled" convention everywhere else — treat it so
|
||||
# here too (the controller's inactive-filter pass drops it).
|
||||
parts.append(f"seed {int(seed[0])}")
|
||||
ext_tokens.add("seed")
|
||||
|
||||
return parts, ext_tokens
|
||||
|
||||
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 netem-expressible types (delay, packet_loss,
|
||||
corrupt and the netem extensions rate/reorder/gemodel/duplicate/
|
||||
seed/limit) 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).
|
||||
|
||||
The kernel's netem replace MERGES optional attributes (rate,
|
||||
correlation, reorder, corrupt, gemodel, distribution: absent attr =
|
||||
previous value kept), so re-applying a filter set with a parameter
|
||||
*removed* would silently keep the old value. A ``tc reset`` before
|
||||
every ``netem set`` makes the re-apply a true reconcile (reset also
|
||||
drops clsact and its bpf_drop filters — the caller re-applies them
|
||||
right after in the same flow). An empty filter set is the reset
|
||||
alone.
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter], ...})
|
||||
"""
|
||||
|
||||
parts, ext_tokens = self._netem_command_parts(filters)
|
||||
if parts and ext_tokens:
|
||||
# Extension keywords need the netem-extension uBridge. Plain
|
||||
# delay/loss/corrupt/dup never probes — an old uBridge serves
|
||||
# the original surface untouched (mirrors bpf_drop's guard).
|
||||
# Checked before the reset so a failed update leaves the
|
||||
# previously applied qdisc in place.
|
||||
caps = await self._ubridge_tc_capabilities()
|
||||
missing = ext_tokens - set(caps.get("netem", "").split(","))
|
||||
if missing:
|
||||
raise DockerError(
|
||||
"Packet filter(s) using {} need a uBridge with the netem extensions "
|
||||
"(tc capabilities reports '{}'); upgrade uBridge on this compute".format(
|
||||
", ".join(sorted(missing)), caps.get("netem") or "no tc support"
|
||||
)
|
||||
)
|
||||
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
|
||||
if not parts:
|
||||
return
|
||||
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
|
||||
|
||||
async def _ubridge_tc_capabilities(self):
|
||||
"""
|
||||
uBridge's tc-module feature report (``tc capabilities``), probed once
|
||||
per uBridge process and cached — the reply shape is
|
||||
``netem=<kw,...>;ebpf=0|1;cbpf=0|1``. uBridge builds without the tc
|
||||
module answer "Unknown module" and old builds without the command
|
||||
answer "Unknown command": both mean no kernel filter support, cached
|
||||
as an empty dict.
|
||||
"""
|
||||
|
||||
if self._ubridge_tc_caps is None:
|
||||
caps = {}
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
try:
|
||||
reply = await self._ubridge_hypervisor.send("tc capabilities")
|
||||
except UbridgeError:
|
||||
reply = []
|
||||
for entry in (reply[0] if reply else "").split(";"):
|
||||
key, _, value = entry.partition("=")
|
||||
if key:
|
||||
caps[key.strip()] = value.strip()
|
||||
self._ubridge_tc_caps = caps
|
||||
return self._ubridge_tc_caps
|
||||
|
||||
async def _ubridge_apply_bpf_drops(self, host_ifc, filters):
|
||||
"""
|
||||
Translate the NIO's ``bpf`` filter (one expression per line, any line
|
||||
matching drops the packet) into cls_bpf classifiers on the veth host
|
||||
end's clsact egress: ``tc bpf_drop`` compiles each line with libpcap
|
||||
(same compiler as the relay's bpf filter) and drops matches with a
|
||||
gact TC_ACT_SHOT. Reconcile is always flush + re-add — same-prio
|
||||
resend replaces the filter node in uBridge, and the flush drops
|
||||
lines the user removed.
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary
|
||||
"""
|
||||
|
||||
bpf = filters.get("bpf")
|
||||
lines = [line.strip() for line in (bpf[0].split("\n") if bpf else []) if line.strip()]
|
||||
if not lines and self._ubridge_tc_caps is None:
|
||||
# Nothing to add and nothing was ever installed (no tc module
|
||||
# probed yet) — skip touching an old uBridge entirely.
|
||||
return
|
||||
caps = await self._ubridge_tc_capabilities()
|
||||
if not lines:
|
||||
if caps:
|
||||
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
|
||||
return
|
||||
if caps.get("cbpf") != "1":
|
||||
raise DockerError(
|
||||
"Packet filter 'bpf' on a kernel-datapath link needs a uBridge with cBPF support "
|
||||
"(tc capabilities reports no cbpf); upgrade uBridge on this compute or keep the "
|
||||
"link on the relay datapath"
|
||||
)
|
||||
await self._ubridge_send(f'tc bpf_drop flush "{host_ifc}"')
|
||||
for offset, line in enumerate(lines):
|
||||
try:
|
||||
await self._ubridge_send(
|
||||
'tc bpf_drop add "{ifc}" {prio} "{expr}"'.format(ifc=host_ifc, prio=10 + offset, expr=line)
|
||||
)
|
||||
except UbridgeError as e:
|
||||
if "Cannot compile filter" not in str(e):
|
||||
raise
|
||||
# Mirror the relay path: a line that no longer compiles on
|
||||
# this uBridge's libpcap is skipped with a warning instead
|
||||
# of breaking the link (the controller already validated
|
||||
# the syntax with tcpdump at create/update time).
|
||||
message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {line}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
|
||||
async def _ubridge_apply_ebpf_drops(self, host_ifc, filters):
|
||||
"""
|
||||
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), 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. 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 (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
|
||||
"""
|
||||
|
||||
def values_of(key):
|
||||
values = filters.get(key)
|
||||
if isinstance(values, (list, tuple)):
|
||||
return list(values)
|
||||
return [values] if values else []
|
||||
|
||||
frequency = values_of("frequency_drop")
|
||||
quota = values_of("quota")
|
||||
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 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", "window_drop") if f in filters))
|
||||
)
|
||||
)
|
||||
if frequency:
|
||||
# relay semantics: -1 = drop everything, N = every Nth packet
|
||||
nth = 1 if int(frequency[0]) == -1 else int(frequency[0])
|
||||
await self._ubridge_send(f'tc nth_drop "{host_ifc}" {nth}')
|
||||
else:
|
||||
await self._ubridge_send(f'tc nth_drop "{host_ifc}" off')
|
||||
if quota:
|
||||
await self._ubridge_send(
|
||||
'tc quota_drop "{ifc}" {bytes} {pct}'.format(ifc=host_ifc, bytes=int(quota[0]), pct=int(quota[1]))
|
||||
)
|
||||
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):
|
||||
"""
|
||||
Tear down every kernel marker anchored to *host_ifc*: the veth survives
|
||||
link deletion (it is the adapter interface, not the link), so the
|
||||
AF_PACKET sockets must be closed explicitly — otherwise a deleted
|
||||
link's markers would keep sniffing and signaling. Mirrors the relay
|
||||
datapath where deleting the uBridge bridge silently drops its filters.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
for (name, link_id), anchor in list(self._marker_filter_bridges.items()):
|
||||
if anchor != host_ifc:
|
||||
continue
|
||||
self._marker_filter_bridges.pop((name, link_id))
|
||||
self._marker_specs.pop((name, link_id), None)
|
||||
await self._ubridge_delete_marker_filter(host_ifc, name)
|
||||
try:
|
||||
os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e)
|
||||
manager.unregister(self._id, name)
|
||||
|
||||
async def _remove_kernel_nio(self, nio, adapter_number, port_number=0):
|
||||
"""
|
||||
Detach a kernel-datapath adapter port from its per-link kernel bridge.
|
||||
Both endpoints run this concurrently: deleting a bridge that still has
|
||||
the peer's port enslaved fails with EBUSY, so the last endpoint to
|
||||
remove its port wins the deletion and the loser's failure is expected
|
||||
and suppressed.
|
||||
"""
|
||||
|
||||
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):
|
||||
await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"')
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
|
||||
def get_nio(self, adapter_number, port_number=0):
|
||||
"""
|
||||
Gets an adapter NIO binding.
|
||||
|
||||
114
gns3server/compute/ubridge/tc_probe.py
Normal file
114
gns3server/compute/ubridge/tc_probe.py
Normal file
@ -0,0 +1,114 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Standalone ``tc capabilities`` probe.
|
||||
|
||||
Spawns the configured uBridge outside any node lifecycle, asks its tc module
|
||||
for the feature report and caches the answer by binary identity (path,
|
||||
mtime, size) — the same binary never answers differently, but replacing it
|
||||
must re-probe. This feeds the compute ``/capabilities`` payload so the
|
||||
controller and clients can gate kernel-datapath filter types on what this
|
||||
host's uBridge can actually run, as the server's own user — which is the
|
||||
point: the runtime ``ebpf`` flag flips between users and kernels for the
|
||||
same binary, so a probe run as root would lie for a non-root server.
|
||||
|
||||
A node's own probe (DockerVM._ubridge_tc_capabilities) stays per-process;
|
||||
this one only answers "what can this binary do here". Never raises: any
|
||||
failure (missing binary, old build answering "Unknown command", timeout)
|
||||
means unknown, reported as None and cached as such.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
import asyncio
|
||||
import tempfile
|
||||
import logging
|
||||
import contextlib
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.tc_capabilities import parse_tc_capabilities
|
||||
from .hypervisor import Hypervisor
|
||||
from .ubridge_error import UbridgeError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# binary identity -> parsed report (None = probed and unusable)
|
||||
_cache = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def probe_tc_capabilities(timeout: float = 15.0):
|
||||
"""
|
||||
Probe the configured uBridge's tc module once per binary identity.
|
||||
|
||||
:param timeout: overall timeout for spawn + ask, in seconds
|
||||
:returns: parsed ``tc capabilities`` dict, or None when unknown
|
||||
"""
|
||||
|
||||
config = Config.instance()
|
||||
path = shutil.which(config.settings.Server.ubridge_path)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
key = (path, stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
async with _lock:
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
caps = await _probe(path, config, timeout)
|
||||
_cache[key] = caps
|
||||
return caps
|
||||
|
||||
|
||||
async def _probe(path, config, timeout):
|
||||
"""
|
||||
Spawn a throwaway uBridge, ask ``tc capabilities``, tear it down.
|
||||
"""
|
||||
|
||||
working_dir = tempfile.mkdtemp(prefix="gns3-tc-probe-")
|
||||
hypervisor = Hypervisor(
|
||||
None,
|
||||
path,
|
||||
working_dir,
|
||||
config.settings.Server.ubridge_control_transport,
|
||||
config.settings.Server.host,
|
||||
str(uuid.uuid4()),
|
||||
)
|
||||
try:
|
||||
|
||||
async def ask():
|
||||
await hypervisor.start()
|
||||
await hypervisor.connect()
|
||||
reply = await hypervisor.send("tc capabilities")
|
||||
return parse_tc_capabilities(reply[0] if reply else "")
|
||||
|
||||
try:
|
||||
caps = await asyncio.wait_for(ask(), timeout=timeout)
|
||||
except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e:
|
||||
# Old uBridge builds answer "Unknown command"/"Unknown module";
|
||||
# anything else here is a broken install — either way: unknown.
|
||||
log.debug("uBridge tc capabilities probe failed: %s", e)
|
||||
return None
|
||||
return caps if caps else None
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await hypervisor.stop()
|
||||
shutil.rmtree(working_dir, ignore_errors=True)
|
||||
@ -92,7 +92,15 @@ class Compute:
|
||||
self._disk_usage_percent = 0
|
||||
self._last_error = None
|
||||
self._ssl_context = ssl_context
|
||||
self._capabilities = {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": []}
|
||||
self._capabilities = {
|
||||
"version": "",
|
||||
"platform": "",
|
||||
"cpus": 0,
|
||||
"memory": 0,
|
||||
"disk_size": 0,
|
||||
"node_types": [],
|
||||
"ubridge_tc": None,
|
||||
}
|
||||
self.name = name
|
||||
# Cache of interfaces on remote host
|
||||
self._interfaces_cache = None
|
||||
@ -288,6 +296,15 @@ class Compute:
|
||||
def disk_usage_percent(self):
|
||||
return self._disk_usage_percent
|
||||
|
||||
@property
|
||||
def capabilities(self) -> dict:
|
||||
"""
|
||||
Capabilities reported by this compute (fetched from GET /capabilities
|
||||
at connect time).
|
||||
"""
|
||||
|
||||
return self._capabilities
|
||||
|
||||
def asdict(self, topology_dump=False):
|
||||
"""
|
||||
:param topology_dump: Filter to keep only properties require for saving on disk
|
||||
|
||||
@ -736,11 +736,15 @@ class Link:
|
||||
filter_node = self._get_filter_node()
|
||||
if filter_node:
|
||||
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, quota and
|
||||
# window_drop.
|
||||
return FILTERS
|
||||
# Kernel-datapath links serve every filter type in principle:
|
||||
# netem for delay/loss/corrupt and the netem extensions,
|
||||
# cls_bpf for bpf, the eBPF classifier for frequency_drop,
|
||||
# quota and window_drop. What is offered is gated on the
|
||||
# endpoints' computes reporting a uBridge that can run each
|
||||
# type; computes that report nothing (older servers, failed
|
||||
# probe) keep the full list — update_filters still rejects
|
||||
# with 409 on apply.
|
||||
return [f for f in FILTERS if self._filter_supported_by_computes(f["type"])]
|
||||
# Relay links: hide the kernel-only types the relay cannot run
|
||||
# (they would be rejected with 409 on update).
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_ONLY_FILTERS
|
||||
@ -748,6 +752,37 @@ class Link:
|
||||
return [f for f in FILTERS if f["type"] not in KERNEL_ONLY_FILTERS]
|
||||
return []
|
||||
|
||||
def _filter_supported_by_computes(self, filter_type):
|
||||
"""
|
||||
Whether every endpoint's compute reports a uBridge able to run
|
||||
*filter_type* on a kernel-datapath link (both ends apply their own
|
||||
filters, so one incapable compute is enough to hide the type).
|
||||
Computes without a uBridge tc report impose no constraint.
|
||||
"""
|
||||
|
||||
from gns3server.utils.tc_capabilities import FILTER_EBPF_MODES, FILTER_NETEM_KEYWORDS
|
||||
|
||||
reported = []
|
||||
for side in self._nodes:
|
||||
capabilities = side["node"].compute.capabilities
|
||||
if isinstance(capabilities, dict) and capabilities.get("ubridge_tc"):
|
||||
reported.append(capabilities["ubridge_tc"])
|
||||
if not reported:
|
||||
return True
|
||||
for caps in reported:
|
||||
if filter_type in FILTER_EBPF_MODES:
|
||||
if FILTER_EBPF_MODES[filter_type] not in caps["ebpf_modes"]:
|
||||
return False
|
||||
elif filter_type == "bpf":
|
||||
if not caps["cbpf"]:
|
||||
return False
|
||||
elif filter_type in FILTER_NETEM_KEYWORDS:
|
||||
if FILTER_NETEM_KEYWORDS[filter_type] not in caps["netem"]:
|
||||
return False
|
||||
# the remaining types (delay, packet_loss, corrupt) run on any
|
||||
# build that reports tc capabilities at all
|
||||
return True
|
||||
|
||||
def _get_filter_node(self):
|
||||
"""
|
||||
Return the node where the filter will run
|
||||
|
||||
@ -15,13 +15,27 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .nodes import NodeType
|
||||
|
||||
|
||||
class UbridgeTcCapabilities(BaseModel):
|
||||
"""
|
||||
uBridge tc-module capabilities (kernel-datapath packet filters), probed
|
||||
by the compute as its own user.
|
||||
"""
|
||||
|
||||
netem: List[str] = Field(..., description="netem keywords supported by this uBridge's tc module")
|
||||
ebpf: bool = Field(..., description="Whether the eBPF stateful classifier loads on this host")
|
||||
ebpf_modes: List[str] = Field(
|
||||
..., description="eBPF classifier modes usable on this host (ebpf is on and the mode token is declared)"
|
||||
)
|
||||
cbpf: bool = Field(..., description="Whether classic-BPF match-drop classifiers are available")
|
||||
|
||||
|
||||
class Capabilities(BaseModel):
|
||||
"""
|
||||
Capabilities properties.
|
||||
@ -33,3 +47,6 @@ class Capabilities(BaseModel):
|
||||
cpus: int = Field(..., description="Number of CPUs on this compute")
|
||||
memory: int = Field(..., description="Amount of memory on this compute")
|
||||
disk_size: int = Field(..., description="Disk size on this compute")
|
||||
ubridge_tc: Optional[UbridgeTcCapabilities] = Field(
|
||||
None, description="uBridge tc-module capabilities; absent when the probe failed or uBridge has no tc module"
|
||||
)
|
||||
|
||||
@ -21,6 +21,7 @@ from typing import Any, List, Optional, Union
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
||||
|
||||
from .base import DateTimeModelMixin
|
||||
from .capabilities import UbridgeTcCapabilities
|
||||
from .nodes import NodeType
|
||||
|
||||
|
||||
@ -103,6 +104,9 @@ class Capabilities(BaseModel):
|
||||
cpus: int = Field(..., description="Number of CPUs on this compute")
|
||||
memory: int = Field(..., description="Amount of memory on this compute")
|
||||
disk_size: int = Field(..., description="Disk size on this compute")
|
||||
ubridge_tc: Optional[UbridgeTcCapabilities] = Field(
|
||||
None, description="uBridge tc-module capabilities; absent when the probe failed or uBridge has no tc module"
|
||||
)
|
||||
|
||||
|
||||
class Compute(DateTimeModelMixin, ComputeBase):
|
||||
|
||||
85
gns3server/utils/tc_capabilities.py
Normal file
85
gns3server/utils/tc_capabilities.py
Normal file
@ -0,0 +1,85 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
uBridge ``tc capabilities`` vocabulary: the reply parser and the server-side
|
||||
policy built on top of it. Shared by the compute side (the per-node probe in
|
||||
DockerVM and the standalone /capabilities probe) and the controller side
|
||||
(filter availability), so both gate on the same rules.
|
||||
|
||||
Reply shape (additive by contract — unknown keys pass through untouched):
|
||||
``netem=<kw,...>;ebpf=0|1;cbpf=0|1[;ebpf_modes=<mode,...>]``
|
||||
"""
|
||||
|
||||
# eBPF classifier modes that shipped before the ``ebpf_modes`` field existed
|
||||
# (uBridge feature/tc-precision): the field was added together with the
|
||||
# window-mode correction, so an ebpf=1 build that does not emit it has
|
||||
# exactly these. window is the only mode whose semantics changed after
|
||||
# release — it is never offered unless the build declares the token.
|
||||
LEGACY_EBPF_MODES = ("nth", "quota", "flow")
|
||||
|
||||
# GNS3 filter type -> eBPF classifier mode token
|
||||
FILTER_EBPF_MODES = {
|
||||
"frequency_drop": "nth",
|
||||
"quota": "quota",
|
||||
"window_drop": "window",
|
||||
}
|
||||
|
||||
# GNS3 filter type -> netem keyword (netem= token in the capabilities reply)
|
||||
FILTER_NETEM_KEYWORDS = {
|
||||
"rate": "rate",
|
||||
"reorder": "reorder",
|
||||
"gemodel": "gemodel",
|
||||
"duplicate": "dup",
|
||||
"seed": "seed",
|
||||
"limit": "limit",
|
||||
}
|
||||
|
||||
|
||||
def parse_tc_capabilities(reply):
|
||||
"""
|
||||
Parse the payload of a ``tc capabilities`` reply into a dict.
|
||||
|
||||
:param reply: reply payload (first reply line, no status code)
|
||||
:returns: dict of keys to values, both strings
|
||||
"""
|
||||
|
||||
caps = {}
|
||||
for entry in (reply or "").split(";"):
|
||||
key, _, value = entry.partition("=")
|
||||
if key:
|
||||
caps[key.strip()] = value.strip()
|
||||
return caps
|
||||
|
||||
|
||||
def usable_ebpf_modes(caps):
|
||||
"""
|
||||
The eBPF classifier modes this uBridge build can actually run: a mode is
|
||||
usable iff ``ebpf=1`` and its token is listed in ``ebpf_modes``. Builds
|
||||
that do not emit the field predate it and keep the modes that shipped
|
||||
with it (LEGACY_EBPF_MODES) — gating on ``ebpf=1`` alone would silently
|
||||
accept window_drop on the pre-correction build, which drops it into
|
||||
back-to-back windows.
|
||||
|
||||
:param caps: parsed ``tc capabilities`` dict
|
||||
:returns: tuple of usable mode tokens, in reply order
|
||||
"""
|
||||
|
||||
if caps.get("ebpf") != "1":
|
||||
return ()
|
||||
if "ebpf_modes" not in caps:
|
||||
return LEGACY_EBPF_MODES
|
||||
return tuple(mode for mode in caps["ebpf_modes"].split(",") if mode)
|
||||
@ -159,6 +159,7 @@ extend-immutable-calls = [
|
||||
"gns3server/api/routes/controller/users.py" = ["S105", "S106"]
|
||||
"gns3server/compute/base_node.py" = ["C901", "RUF019"]
|
||||
"gns3server/compute/docker/__init__.py" = ["B006", "C901"]
|
||||
"gns3server/compute/docker/docker_kernel_datapath.py" = ["C901"]
|
||||
"gns3server/compute/docker/docker_vm.py" = ["B006", "C901", "E731", "RUF003", "RUF006", "S108"]
|
||||
"gns3server/compute/docker/iol_docker_vm.py" = ["RUF002", "S108"]
|
||||
"gns3server/compute/docker/vendor_docker_vm.py" = ["RUF002", "S108"]
|
||||
|
||||
@ -29,8 +29,12 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestCapabilitiesRoutes:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform) -> None:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@ -54,10 +58,15 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
}
|
||||
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm) -> None:
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@ -81,4 +90,20 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
}
|
||||
|
||||
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
|
||||
async def fake_probe():
|
||||
return {"netem": "delay,rate", "ebpf": "1", "cbpf": "0", "ebpf_modes": "nth,quota,window,flow"}
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", fake_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["ubridge_tc"] == {
|
||||
"netem": ["delay", "rate"],
|
||||
"ebpf": True,
|
||||
"ebpf_modes": ["nth", "quota", "window", "flow"],
|
||||
"cbpf": False,
|
||||
}
|
||||
|
||||
@ -569,6 +569,66 @@ async def test_apply_window_drop_requires_ebpf_capability(vm):
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_window_drop_requires_mode_token(vm):
|
||||
"""
|
||||
ebpf=1 is not enough: a build that does not declare the window mode
|
||||
token (pre-correction feature/tc-precision semantics) must get a clear
|
||||
upgrade error instead of silently installing back-to-back windows.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth,quota")
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
with pytest.raises(DockerError, match="ebpf_modes=nth,quota"):
|
||||
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_legacy_build_keeps_shipped_modes(vm):
|
||||
"""
|
||||
A build predating the ebpf_modes field (feature/tc-precision) keeps the
|
||||
modes that shipped with it: nth/quota still apply, window does not.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes=None)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]})
|
||||
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}" off')
|
||||
|
||||
vm._ubridge_send.reset_mock()
|
||||
with pytest.raises(DockerError, match="newer uBridge"):
|
||||
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_mode_off_skips_undeclared_modes(vm):
|
||||
"""
|
||||
The cleanup path only sends "off" for modes this build declares — an
|
||||
undeclared mode's command would be an unknown command on that build.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor(ebpf="1", modes="nth")
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
|
||||
# A first apply with a filter forces the capability probe; the second
|
||||
# (filters removed) takes the cached caps down the off path.
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {"frequency_drop": [3]})
|
||||
vm._ubridge_send.reset_mock()
|
||||
await vm._ubridge_apply_ebpf_drops(host_ifc, {})
|
||||
|
||||
vm._ubridge_send.assert_any_call(f'tc nth_drop "{host_ifc}" off')
|
||||
sent = [call.args[0] for call in vm._ubridge_send.call_args_list]
|
||||
assert not any("quota_drop" in command or "window_drop" in command for command in sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_ebpf_drops_skips_old_ubridge_without_filters(vm):
|
||||
"""
|
||||
@ -605,11 +665,18 @@ async def test_apply_ebpf_drops_cleanup_only_probes_with_ebpf(vm):
|
||||
vm._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
def _caps_hypervisor(cbpf="1", ebpf="0"):
|
||||
"""A hypervisor mock whose direct send() answers `tc capabilities`."""
|
||||
def _caps_hypervisor(cbpf="1", ebpf="0", modes="nth,quota,window"):
|
||||
"""
|
||||
A hypervisor mock whose direct send() answers `tc capabilities`.
|
||||
modes=None emulates a build predating the ebpf_modes field.
|
||||
"""
|
||||
|
||||
reply = f"netem=delay;ebpf={ebpf};cbpf={cbpf}"
|
||||
if modes is not None:
|
||||
reply += f";ebpf_modes={modes}"
|
||||
hyp = MagicMock()
|
||||
hyp.is_running.return_value = True
|
||||
hyp.send = AsyncioMagicMock(return_value=[f"netem=delay;ebpf={ebpf};cbpf={cbpf}"])
|
||||
hyp.send = AsyncioMagicMock(return_value=[reply])
|
||||
return hyp
|
||||
|
||||
|
||||
@ -682,7 +749,7 @@ async def test_apply_bpf_drops_compile_failure_warns(vm):
|
||||
raise UbridgeError("209-Cannot compile filter 'icmp': can't parse filter expression: syntax error")
|
||||
|
||||
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
|
||||
with patch("gns3server.compute.docker.docker_vm.log.warning") as mock_log:
|
||||
with patch("gns3server.compute.docker.docker_kernel_datapath.log.warning") as mock_log:
|
||||
await vm._ubridge_apply_bpf_drops(host_ifc, {"bpf": ["icmp"]}) # must not raise
|
||||
assert mock_log.called
|
||||
|
||||
@ -708,7 +775,7 @@ async def test_ubridge_tc_capabilities_probed_once(vm):
|
||||
|
||||
vm._ubridge_hypervisor = _caps_hypervisor()
|
||||
caps = await vm._ubridge_tc_capabilities()
|
||||
assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1"}
|
||||
assert caps == {"netem": "delay", "ebpf": "0", "cbpf": "1", "ebpf_modes": "nth,quota,window"}
|
||||
await vm._ubridge_tc_capabilities()
|
||||
assert vm._ubridge_hypervisor.send.call_count == 1
|
||||
|
||||
|
||||
115
tests/compute/ubridge/test_tc_probe.py
Normal file
115
tests/compute/ubridge/test_tc_probe.py
Normal file
@ -0,0 +1,115 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
The standalone `tc capabilities` probe behind the compute /capabilities
|
||||
payload: spawn a throwaway uBridge, ask once, cache by binary identity.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gns3server.compute.ubridge import tc_probe
|
||||
from gns3server.compute.ubridge.tc_probe import probe_tc_capabilities
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class FakeHypervisor:
|
||||
"""Records spawns; answers `tc capabilities` with a canned reply."""
|
||||
|
||||
reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"]
|
||||
error = None
|
||||
spawned = 0
|
||||
|
||||
def __init__(self, project, path, working_dir, transport, host, node_id):
|
||||
|
||||
self.commands = []
|
||||
FakeHypervisor.spawned += 1
|
||||
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def send(self, command):
|
||||
|
||||
self.commands.append(command)
|
||||
if self.error:
|
||||
raise UbridgeError(self.error)
|
||||
if command == "tc capabilities":
|
||||
return FakeHypervisor.reply
|
||||
return ["OK"]
|
||||
|
||||
async def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def probe_env(monkeypatch, tmp_path):
|
||||
|
||||
FakeHypervisor.spawned = 0
|
||||
FakeHypervisor.error = None
|
||||
FakeHypervisor.reply = ["netem=delay,rate;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow"]
|
||||
tc_probe._cache.clear()
|
||||
# a real file on disk: the cache is keyed on its (path, mtime, size)
|
||||
binary = tmp_path / "ubridge"
|
||||
binary.write_bytes(b"")
|
||||
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: str(binary))
|
||||
monkeypatch.setattr(tc_probe, "Hypervisor", FakeHypervisor)
|
||||
|
||||
|
||||
async def test_probe_returns_parsed_report():
|
||||
|
||||
caps = await probe_tc_capabilities()
|
||||
assert caps == {
|
||||
"netem": "delay,rate",
|
||||
"ebpf": "1",
|
||||
"cbpf": "1",
|
||||
"ebpf_modes": "nth,quota,window,flow",
|
||||
}
|
||||
assert FakeHypervisor.spawned == 1
|
||||
|
||||
|
||||
async def test_probe_cached_by_binary_identity():
|
||||
|
||||
first = await probe_tc_capabilities()
|
||||
second = await probe_tc_capabilities()
|
||||
assert first is second
|
||||
assert FakeHypervisor.spawned == 1
|
||||
|
||||
|
||||
async def test_probe_failure_means_unknown():
|
||||
|
||||
FakeHypervisor.error = "Unknown command" # old uBridge build
|
||||
assert await probe_tc_capabilities() is None
|
||||
# the failure is cached too: no respawn loop on every /capabilities hit
|
||||
assert await probe_tc_capabilities() is None
|
||||
assert FakeHypervisor.spawned == 1
|
||||
|
||||
|
||||
async def test_probe_missing_binary_means_unknown(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(tc_probe.shutil, "which", lambda name: None)
|
||||
assert await probe_tc_capabilities() is None
|
||||
assert FakeHypervisor.spawned == 0
|
||||
|
||||
|
||||
async def test_probe_empty_reply_means_unknown():
|
||||
|
||||
FakeHypervisor.reply = []
|
||||
assert await probe_tc_capabilities() is None
|
||||
@ -381,7 +381,7 @@ async def test_json(compute):
|
||||
"disk_usage_percent": 0,
|
||||
"connected": True,
|
||||
"last_error": None,
|
||||
"capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": []},
|
||||
"capabilities": {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": [], "ubridge_tc": None},
|
||||
}
|
||||
assert compute.asdict(topology_dump=True) == {
|
||||
"compute_id": "my_compute_id",
|
||||
|
||||
@ -548,3 +548,45 @@ async def test_prepare_relay_link_strips_kernel_only_filters(project):
|
||||
# carries no filters — the relay filters one bridge)
|
||||
assert by_node[node1.id]["filters"] == {"frequency_drop": [7], "delay": [100, 20], "packet_loss": [5]}
|
||||
assert by_node[node2.id]["filters"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_filters_gated_by_compute_capabilities(project):
|
||||
"""
|
||||
Kernel links only offer the filter types the endpoints' computes report
|
||||
a uBridge able to run (ebpf_modes tokens, cbpf, netem keywords).
|
||||
"""
|
||||
|
||||
link, node1, _node2 = await _kernel_link(project)
|
||||
link._link_data = [{"type": "nio_bridge"}]
|
||||
node1.compute.capabilities = {
|
||||
"ubridge_tc": {
|
||||
"netem": ["delay", "jitter", "loss", "rate"],
|
||||
"ebpf": True,
|
||||
"ebpf_modes": ["nth", "quota"],
|
||||
"cbpf": False,
|
||||
}
|
||||
}
|
||||
types = [f["type"] for f in link.available_filters()]
|
||||
assert "frequency_drop" in types # nth declared
|
||||
assert "quota" in types # quota declared
|
||||
assert "window_drop" not in types # token missing
|
||||
assert "bpf" not in types # cbpf off
|
||||
assert "rate" in types # netem keyword present
|
||||
assert "gemodel" not in types # netem keyword missing
|
||||
assert "delay" in types # core netem runs on any tc-module build
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_filters_unconstrained_without_report(project):
|
||||
"""
|
||||
Computes that report no uBridge tc capabilities (older servers, failed
|
||||
probe) impose no constraint: the full list stays offered and apply-time
|
||||
validation is the guard.
|
||||
"""
|
||||
|
||||
from gns3server.controller.link import FILTERS
|
||||
|
||||
link, _node1, _node2 = await _kernel_link(project)
|
||||
link._link_data = [{"type": "nio_bridge"}]
|
||||
assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS]
|
||||
|
||||
77
tests/utils/test_tc_capabilities.py
Normal file
77
tests/utils/test_tc_capabilities.py
Normal file
@ -0,0 +1,77 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
The `tc capabilities` reply parser and the eBPF mode policy shared by the
|
||||
compute probe and the controller filter availability.
|
||||
"""
|
||||
|
||||
from gns3server.utils.tc_capabilities import (
|
||||
LEGACY_EBPF_MODES,
|
||||
parse_tc_capabilities,
|
||||
usable_ebpf_modes,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_full_reply():
|
||||
|
||||
caps = parse_tc_capabilities("netem=delay,jitter,loss;ebpf=1;cbpf=0;ebpf_modes=nth,quota,window,flow")
|
||||
assert caps == {
|
||||
"netem": "delay,jitter,loss",
|
||||
"ebpf": "1",
|
||||
"cbpf": "0",
|
||||
"ebpf_modes": "nth,quota,window,flow",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_legacy_reply_without_modes():
|
||||
"""A build predating the field (and unknown future keys) passes through."""
|
||||
|
||||
caps = parse_tc_capabilities("netem=delay;ebpf=1;cbpf=1")
|
||||
assert caps == {"netem": "delay", "ebpf": "1", "cbpf": "1"}
|
||||
|
||||
|
||||
def test_parse_empty_reply():
|
||||
|
||||
assert parse_tc_capabilities("") == {}
|
||||
assert parse_tc_capabilities(None) == {}
|
||||
|
||||
|
||||
def test_usable_modes_require_ebpf():
|
||||
"""The mode list is a build fact; ebpf is the runtime gate for all of them."""
|
||||
|
||||
assert usable_ebpf_modes({"ebpf": "0", "ebpf_modes": "nth,quota,window,flow"}) == ()
|
||||
assert usable_ebpf_modes({}) == ()
|
||||
|
||||
|
||||
def test_usable_modes_from_token_list():
|
||||
|
||||
caps = {"ebpf": "1", "ebpf_modes": "nth,quota,window,flow"}
|
||||
assert usable_ebpf_modes(caps) == ("nth", "quota", "window", "flow")
|
||||
|
||||
|
||||
def test_usable_modes_legacy_build_keeps_shipped_modes():
|
||||
"""No field: the modes that shipped before it — everything but window."""
|
||||
|
||||
assert usable_ebpf_modes({"ebpf": "1"}) == LEGACY_EBPF_MODES
|
||||
assert "window" not in LEGACY_EBPF_MODES
|
||||
|
||||
|
||||
def test_window_never_usable_without_token():
|
||||
"""The pre-correction build reported ebpf=1 with broken window semantics."""
|
||||
|
||||
assert "window" not in usable_ebpf_modes({"ebpf": "1"})
|
||||
assert "window" not in usable_ebpf_modes({"ebpf": "1", "ebpf_modes": "nth,quota"})
|
||||
Loading…
x
Reference in New Issue
Block a user