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:
YueGuobin 2026-09-30 09:50:47 +08:00
parent 48dbdbe4a9
commit b4e4739d52
No known key found for this signature in database
18 changed files with 1280 additions and 571 deletions

View File

@ -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.

View File

@ -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
```

View File

@ -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,
}

View 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}"')

View File

@ -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.

View 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)

View File

@ -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

View File

@ -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

View File

@ -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"
)

View File

@ -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):

View 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)

View File

@ -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"]

View File

@ -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,
}

View File

@ -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

View 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

View File

@ -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",

View File

@ -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]

View 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"})