gns3-server/gns3server/compute/docker/docker_kernel_datapath.py
YueGuobin c3c025ab2a
fix: resolve the code-review findings on the kernel datapath
Compute:
- Track the anchor that owns uBridge's single AF_PACKET capture slot: a
  second kernel port's capture is refused with a clear error (its flag
  rolled back), a failed start no longer wedges the port, and only the
  owning port's stop issues the process-wide `capture stop_kernel`
  (docker/qemu/iou/dynamips/switch, via the mixin's slot helpers).
- Unwind failed starts instead of leaking uBridge and the anchor TAPs:
  QEMU's stop body runs under try/finally, QEMU and IOU start the datapath
  inside the launch try, Dynamips undoes a half-attached port (the retry
  used to be short-circuited into a silent black hole), and Docker deletes
  the veth pair when the namespace move fails.
- Restore the relay filter guard on IOU (kernel-only filters raise
  NodeError, an uncompilable BPF line degrades to a warning), validate the
  eBPF/quota/window/bpf filter shapes before touching the bridge (short or
  non-numeric values surfaced as 500s after partial state), and refuse a
  second NIO on an occupied Docker/QEMU port (the guard IOU and Dynamips
  already had).
- Probe fixes: `_probe_bridge_tap` survives a failing uBridge start, and
  the probe caches key on ctime too, so a chmod/setcap remedy re-probes.

Controller:
- Parse GNS3_UNIX_SOCKET_NIO like the compute does (line-anchored, truthy
  value) and mirror the compute's class selection in kernel_anchor_type.
- Read partial capability reports defensively; make BridgeNIO.bridge
  required-but-nullable so an omitted key is a 422 rather than a KeyError.
- Strip the packet_loss correlation slot even when it is zero: the relay
  filter takes exactly one argument and rejected `packet_loss 10 0`.

Ethernet switch: skip ports whose absorbed anchor does not exist yet (the
join is deferred by design) and roll the ports_mapping back when the VLAN
reconcile fails, so a retry still diffs against the old mapping.

Agent: reject device config entries without a device_name instead of
silently dropping them (a malformed batch used to execute partially while
reporting success).

Also split the netem builder to clear C901 and drop the two per-file ruff
ignores the PR had added, keeping the remove-only policy.
2026-10-06 19:55:21 +08:00

169 lines
7.5 KiB
Python

#
# 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/>.
"""
Docker's half of the kernel datapath: the veth pair lifecycle (deterministic
names, creation, teardown) plus the three hooks that tell the shared
KernelDatapathMixin which host interface plays the anchor role. Everything
datapath-agnostic — tc filters, the uBridge tc-capability probe, kernel
markers, attach/detach, adapter carrier — lives in
``gns3server.compute.kernel_datapath`` and is shared with QEMU.
"""
import contextlib
import logging
from gns3server.compute.kernel_datapath import KernelDatapathMixin
from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name
from .docker_error import DockerError
log = logging.getLogger(__name__)
class DockerKernelDatapathMixin(KernelDatapathMixin):
"""
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; the host end's name is the shared anchor naming contract
(utils.kernel_anchor), which the controller also uses to name a
peer's anchor for an Ethernet-switch link.
"""
return (
kernel_anchor_name("docker", self._id, adapter_number, port_number),
f"gc{anchor_suffix(self._id, adapter_number, port_number)}",
)
# ------------------------------------------------------------------
# Anchor hooks (see KernelDatapathMixin)
# ------------------------------------------------------------------
def _kernel_host_ifc(self, adapter_number, port_number=0):
"""
The veth host end of an adapter port, or None for an adapter without
one (unix-socket vendor containers).
"""
return self._kernel_veths.get((adapter_number, port_number))
def _kernel_anchors(self):
"""
Every veth host end this container currently owns.
"""
return set(self._kernel_veths.values())
def _relay_carrier_bridge(self, adapter_number, port_number=0):
"""
The uBridge relay bridge whose TAP carrier replicates the adapter
state — only reachable for adapters without a veth host end.
"""
return self._bridge_name(adapter_number, port_number)
def _kernel_error(self, message):
return DockerError(message)
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(f"Adapter {adapter_number} doesn't exist on Docker container '{self.name}'")
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 Exception:
# Don't leave a half-created pair behind (the next start would
# fail on create_veth EEXIST until the stale sweep runs). The
# namespace failure cleans up the same way — the bare raise
# preserves its type for the caller's UbridgeNamespaceError
# handling (docker_vm.start converts it into a failed start).
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(
f"Created veth adapter {adapter_number} port {port_number} with MAC address {mac_address} in 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 (``_remove_kernel_bridges``), since
deleting a node or closing a project never runs the link-teardown
path. Both link endpoints run this — the peer's port still enslaved
makes the bridge 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 the attach path, 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}"')
await self._remove_kernel_bridges()
self._kernel_veths.clear()