From c7ffe919dceee1bdb03c09a71beffbc115520f0b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 12 Oct 2026 09:37:01 +0800 Subject: [PATCH 01/18] feat: VPCS kernel datapath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A VPCS node's single adapter anchors on a persistent TAP the server creates (tap create + set_owner via uBridge) and the vpcs process attaches to by name: -e -d -n, the same server-creates/guest-attaches shape as QEMU's netdev. -n (a vpcs fork feature) keeps the guest's ip command from ifconfig'ing the anchor on the host; the vpcs build is probed once per binary identity (vpcs_tap capability, usage scan) and gates kernel links together with ubridge_tap — anything else keeps the node on the relay (the legacy local UDP tunnel on a stock binary, an AF_PACKET endpoint on the anchor with a capable one). VPCSVM takes KernelDatapathMixin: _connect_nio dispatches kernel (enslave into the per-link bridge) / relay-on-anchor / legacy, captures pick capture start_kernel vs the bridge engine, stop runs under an execute lock and terminates the process before deleting the anchor TAPs (a held device refuses deletion), and the duplicate-port guard backstops the controller's. The NIO routes accept nio_bridge and write suspend back on update. _delete_anchor_taps verifies each tap is gone from sysfs and retries a bounded three times: a holder can release its fd a moment after its process was reaped (vpcs's daemon parent kills its console child and exits first), so the unconditional delete raced EBUSY and leaked the anchor intermittently. The vpcs side needs IFNAMSIZ (16) instead of its own IFNAMESIZ (12) for the -d name buffer — anchor names are 14 chars and truncated to a nonexistent device, which TUNSETIFF then tries to create without CAP_NET_ADMIN (fixed in the vpcs fork). e2e (tests/e2e/test_vpcs_kernel_datapath.py): kernel and relay scenarios green against the real binary — real ICMP both ways, §E.1/§ E.2 on the anchors, delay/suspend/capture, link delete/re-create, node stop/restart self-heal, zero leftovers; the relay control passes with both the fork binary and a stock one. --- docs/features/docker-kernel-datapath.md | 2 +- docs/features/dynamips-kernel-datapath.md | 2 +- .../ethernet-switch-kernel-datapath.md | 5 +- docs/features/iol-docker-kernel-datapath.md | 2 +- docs/features/vpcs-kernel-datapath.md | 55 +++ gns3server/api/routes/compute/capabilities.py | 6 + gns3server/api/routes/compute/vpcs_nodes.py | 13 +- gns3server/compute/kernel_datapath.py | 23 ++ gns3server/compute/vpcs/vpcs_probe.py | 92 +++++ gns3server/compute/vpcs/vpcs_vm.py | 371 ++++++++++++++---- gns3server/controller/udp_link.py | 14 +- gns3server/schemas/controller/capabilities.py | 6 + gns3server/utils/kernel_anchor.py | 10 +- tests/api/routes/compute/test_capabilities.py | 24 ++ tests/compute/vpcs/test_vpcs_probe.py | 78 ++++ tests/compute/vpcs/test_vpcs_vm.py | 181 ++++++++- tests/controller/test_kernel_datapath_link.py | 80 +++- tests/e2e/harness.py | 71 +++- tests/e2e/test_vpcs_kernel_datapath.py | 260 ++++++++++++ tests/utils/test_kernel_anchor.py | 3 +- 20 files changed, 1204 insertions(+), 94 deletions(-) create mode 100644 docs/features/vpcs-kernel-datapath.md create mode 100644 gns3server/compute/vpcs/vpcs_probe.py create mode 100644 tests/compute/vpcs/test_vpcs_probe.py create mode 100644 tests/e2e/test_vpcs_kernel_datapath.py diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index f9e1f74b0..64a2f6079 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -97,7 +97,7 @@ Everything in the root namespace is built the same way: gns3-server creates the uBridge sits off the forwarding path — the dashed edges are control and observation, never data flow. The `qdisc install` edge is uBridge configuring the kernel over netlink; the kernel then runs those qdiscs in-flight on the host end's egress (they are stages of the transmit path, not packet consumers). The `frame copies` edge is the AF_PACKET tap: bound to the host end, the kernel clones every frame the interface sends or receives to uBridge's packet socket (the tcpdump mechanism) — read-only copies; the originals never leave the kernel. -Relay link on the same unified veth and the same single compute as above — what the link becomes when `enable_kernel_datapath` is off (cross-compute wiring and an endpoint that cannot anchor — VPCS, a cloud node, … — fall back too; across computes the UDP hop rides the real network instead of loopback, the copy count unchanged): +Relay link on the same unified veth and the same single compute as above — what the link becomes when `enable_kernel_datapath` is off (cross-compute wiring and an endpoint that cannot anchor — a cloud node, … — fall back too; across computes the UDP hop rides the real network instead of loopback, the copy count unchanged): ```mermaid flowchart LR diff --git a/docs/features/dynamips-kernel-datapath.md b/docs/features/dynamips-kernel-datapath.md index 4d433e779..72c2e6412 100644 --- a/docs/features/dynamips-kernel-datapath.md +++ b/docs/features/dynamips-kernel-datapath.md @@ -238,4 +238,4 @@ enable_kernel_datapath = True ## Roadmap -The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` — it absorbs the peer's anchor into its own kernel bridge, and switch-to-switch cascades with it), and so has the IOL runner container (`iol-docker-kernel-datapath.md`). Every router and switch node type now anchors; VPCS, the hub, the cloud and the NAT node stay on the relay by design. Cross-compute kernel links need VXLAN/GENEVE encapsulation — a separate, still-deferred project. +The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` — it absorbs the peer's anchor into its own kernel bridge, and switch-to-switch cascades with it), and so has the IOL runner container (`iol-docker-kernel-datapath.md`) and VPCS (`vpcs-kernel-datapath.md` — it attaches to a pre-created anchor with `-e -d`). Every router and switch node type now anchors, and so does VPCS; the hub, the cloud and the NAT node stay on the relay by design. Cross-compute kernel links need VXLAN/GENEVE encapsulation — a separate, still-deferred project. diff --git a/docs/features/ethernet-switch-kernel-datapath.md b/docs/features/ethernet-switch-kernel-datapath.md index d6bbbc76c..3a844bacc 100644 --- a/docs/features/ethernet-switch-kernel-datapath.md +++ b/docs/features/ethernet-switch-kernel-datapath.md @@ -37,8 +37,9 @@ on the relay: * **switch ↔ switch** cascades ride the kernel too — through a veth pair joining the two bridges (one end per switch, each side applying its own port mode to its own end); see the wire format below; -* anchor-less peers (VPCS, cloud, the Dynamips-hosted hub, generic - `GNS3_UNIX_SOCKET_NIO` containers); +* anchor-less peers (cloud, the Dynamips-hosted hub, generic + `GNS3_UNIX_SOCKET_NIO` containers, a VPCS binary without `-n` — see + `vpcs-kernel-datapath.md`); * cross-compute links, non-Ethernet ports: the common rules. **Relay port TAPs.** The switch's own relay-path port TAPs (the diff --git a/docs/features/iol-docker-kernel-datapath.md b/docs/features/iol-docker-kernel-datapath.md index 2b83f5648..6a9310d4e 100644 --- a/docs/features/iol-docker-kernel-datapath.md +++ b/docs/features/iol-docker-kernel-datapath.md @@ -274,4 +274,4 @@ The anchor lifecycle needs no configuration of its own — the capability probes ## Roadmap -Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized; with the IOL runner container anchored, every node type in scope can now anchor (hub, cloud, NAT and VPCS stay on the relay by decision), so the encapsulation work is all that remains. +Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized; with the IOL runner container anchored, every node type in scope can now anchor (hub, cloud and NAT stay on the relay by decision; VPCS anchors since `vpcs-kernel-datapath.md`), so the encapsulation work is all that remains. diff --git a/docs/features/vpcs-kernel-datapath.md b/docs/features/vpcs-kernel-datapath.md new file mode 100644 index 000000000..c0f49e362 --- /dev/null +++ b/docs/features/vpcs-kernel-datapath.md @@ -0,0 +1,55 @@ + + +> This documentation is AI-generated with reference to actual code and verified against real-kernel testing. AI can make mistakes — please verify against the source code when in doubt. + +# VPCS kernel datapath + +A VPCS node's single adapter anchors on a **persistent TAP** that the server creates and the vpcs process *attaches to* (`vpcs -e -d -n`), the same server-creates/guest-attaches shape as QEMU's netdev. Links between a VPCS node and any other anchoring node type on the same compute are wired in the kernel (per-link Linux bridge) instead of the uBridge UDP relay; the compute side is the shared `KernelDatapathMixin`, which is anchor-agnostic. + +## Architecture + +The anchor follows the shared naming contract of `gns3server/utils/kernel_anchor.py`: `gp{node_id[:8]}e0p0` (14 chars, within the kernel's 16-byte `IFNAMSIZ`). uBridge creates it at node start (`tap create`, `tap set_owner `, `link set down`) before the vpcs process is launched; vpcs opens it by name with `-e -d` and speaks its user-space IP stack through the tap fd. The per-link bridge, filters, capture, markers and suspend all ride the same mixin paths as every other anchor-backed node type — including the Ethernet switch fast path (a switch link absorbs the `gp` anchor into the switch's own bridge). + +```mermaid +flowchart LR + subgraph VA["vpcs process"] + ip["user-space IP stack"] + end + subgraph RootNS["root namespace"] + gpA["gp anchor TAP"] + BR["per-link bridge"] + end + ip <-->|"tap fd (-e -d)"| gpA + gpA <-->|"bridge port"| BR +``` + +## The three vpcs-side requirements + +The stock vpcs build cannot carry this datapath; three things have to be true of the binary, all verified against [the vpcs fork](https://github.com/GNS3/vpcs): + +* **`-n` (no host auto-configuration)** — without it, every guest `ip` command runs `ifconfig netmask up` on the *anchor itself*: the server owns the anchor's addresses and bridge membership, so the guest reconfiguring it ranges from noise (a failed `ifconfig` on a name that cannot resolve) to sabotage (an address on an L2-only anchor). `-n` also needs the `ifconfig` to follow the `-d` name when it does run — the stock build hardcodes `tap0`. +* **`-d` only in single-VPC mode** — `-i 1` is how the server always launches vpcs, so this is free. +* **`IFNAMSIZ`, not `IFNAMESIZ`** — upstream vpcs has a one-letter bug: `globle.h` defines its own `IFNAMESIZ 12`, `open_tap()` sizes its name buffer with it (`char dev[IFNAMESIZ]`) while the length guard checks the kernel's `IFNAMSIZ` (16). A 14-char anchor name passes the guard, overflows the 12-byte buffer, and reaches `TUNSETIFF` truncated to 12 chars — a nonexistent device, which the kernel tries to *create*, which needs `CAP_NET_ADMIN`, which the unprivileged vpcs does not have: `EPERM`, every time. The fork uses `IFNAMSIZ` consistently (worth an upstream PR; names up to 12 chars never trip it, which is why four years of `-d tap0` never noticed). + +## Gating and fallback + +A compute reports two capabilities for this (`/capabilities`, probed like every other kernel-datapath capability): `ubridge_tap` (uBridge's persistent-TAP module) and `vpcs_tap` (the binary answers `-n` in its usage — probed once per binary identity). A kernel link needs both strict-True on the compute; anything else keeps the node on the relay, where it always works. The node also decides its own datapath at start: without the probes it keeps the legacy local-UDP-tunnel shape (`-s/-c/-t` to the uBridge relay), with them it anchors and a relay link rides the anchor as an AF_PACKET endpoint — the relay-mode negative control covers both shapes. + +## Stop order and the delete race + +The vpcs process holds the anchor's fd, so stop is the QEMU order: terminate the process first, then `tap delete` — a held device refuses deletion and the suppressed delete would leak it. vpcs's daemon parent additionally kills its console child and exits *before* the child's fds are actually released, so the server can reap the tracked parent while the tap is still held: the shared `_delete_anchor_taps` now verifies each tap is gone from `/sys/class/net` and retries a bounded three times with a grace period (this race is not VPCS-specific — any holder that releases its fd a beat after its process was reaped hits it). + +## Verified (real kernel, real vpcs) + +The e2e scenario (`tests/e2e/test_vpcs_kernel_datapath.py`) drives two real VPCS nodes through the controller API and their TCP consoles: + +* kernel link reports `kernel_datapath`, anchors exist from node start, the per-link bridge enslaves exactly the two of them; real ICMP crosses the tap-fd leg both ways; +* the L2-anchor spec §E.1 (the anchor stays address-free — the assertion `-n` exists for) and §E.2 (a VPCS sends nothing spontaneously, so the silence window needs no guest shutdown); +* `delay 100` lands as netem on both anchors (RTT grows ~200 ms); suspend admin-downs the anchor and kills the traffic; a capture on the link writes a real pcap; +* link delete releases the anchor but it survives (the running vpcs holds it); re-creating the link re-enslaves it; +* node stop terminates vpcs, deletes the anchor and the orphan bridges; a restart recreates the TAP, rewires the link from the NIO and the reconfigured VPCS pings again; +* deleting the project leaves no anchors and no bridges behind; +* the relay negative control (isolated relay-configured instance) wires nothing into the kernel and still pings, with the relay filters served by the uBridge bridge engine — verified with both the fork binary (relay on the anchor) and a stock binary (the legacy UDP tunnel). diff --git a/gns3server/api/routes/compute/capabilities.py b/gns3server/api/routes/compute/capabilities.py index d0f969a81..e874b9a95 100644 --- a/gns3server/api/routes/compute/capabilities.py +++ b/gns3server/api/routes/compute/capabilities.py @@ -31,6 +31,7 @@ from gns3server.compute.ubridge.tc_probe import ( probe_tap_support, probe_tc_capabilities, ) +from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap from gns3server.utils.path import get_default_project_directory from gns3server.utils.tc_capabilities import usable_ebpf_modes from gns3server.version import __version__ @@ -70,6 +71,10 @@ async def get_capabilities(request: Request) -> dict: # anchor. None = unknown (probe failed or uBridge too old). ubridge_bridge_tap = await probe_bridge_tap_support() + # VPCS tap-mode anchoring (-e -d with -n): what lets a VPCS node carry a + # kernel link. None = unknown (probe failed or binary missing). + vpcs_tap = await probe_vpcs_tap() + # record the controller hostname or IP address if request.client: request.app.state.controller_host = request.client.host @@ -85,4 +90,5 @@ async def get_capabilities(request: Request) -> dict: "ubridge_tap": ubridge_tap, "ubridge_iol_tap": ubridge_iol_tap, "ubridge_bridge_tap": ubridge_bridge_tap, + "vpcs_tap": vpcs_tap, } diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 255ce33f6..9d0fc86be 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -175,16 +175,16 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def create_vpcs_node_nio( *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, - nio_data: schemas.UDPNIO, + nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], node: VPCSVM = Depends(dep_node), -) -> schemas.UDPNIO: +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Add a NIO (Network Input/Output) to the node. The adapter number on the VPCS node is always 0. @@ -198,16 +198,16 @@ async def create_vpcs_node_nio( @router.put( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def update_vpcs_node_nio( *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, - nio_data: schemas.UDPNIO, + nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], node: VPCSVM = Depends(dep_node), -) -> schemas.UDPNIO: +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Update a NIO (Network Input/Output) on the node. The adapter number on the VPCS node is always 0. @@ -217,6 +217,7 @@ async def update_vpcs_node_nio( nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters + nio.suspend = nio_data.suspend nio.markers = nio_data.markers or {} await node.port_update_nio_binding(port_number, nio) return nio.asdict() diff --git a/gns3server/compute/kernel_datapath.py b/gns3server/compute/kernel_datapath.py index 967d813bc..4c1836137 100644 --- a/gns3server/compute/kernel_datapath.py +++ b/gns3server/compute/kernel_datapath.py @@ -44,6 +44,7 @@ A node type using this mixin provides: failures surface as ``DockerError`` / ``QemuError`` rather than ``NodeError``. """ +import asyncio import contextlib import logging import os @@ -213,12 +214,34 @@ class KernelDatapathMixin: a held device answers EBADFD and the best-effort delete below would leak it. The sweep is suppressed per TAP — an anchor already gone is not an error — and the registry is cleared either way. + + A holder's fd can be released a moment after its process was + reaped (VPCS's daemon parent kills its child then exits — the + server reaps the parent before the child's fds are actually gone), + so the first delete races EBUSY. Every tap that is still on the + host afterwards (same-host sysfs check, zero cost on the happy + path) gets a small grace and up to three retries; a survivor is + warned about, not fatal. """ if self.ubridge: for tap in self._kernel_taps.values(): with contextlib.suppress(UbridgeError): await self._ubridge_send(f'tap delete "{tap}"') + for attempt in range(3): + if not os.path.exists(f"/sys/class/net/{tap}"): + break + await asyncio.sleep(0.3) + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tap delete "{tap}"') + else: + if os.path.exists(f"/sys/class/net/{tap}"): + log.warning( + "Node '%s' [%s]: anchor TAP %s survived its delete — a holder still has it open", + self._name, + self._id, + tap, + ) await self._remove_kernel_bridges() self._kernel_taps.clear() diff --git a/gns3server/compute/vpcs/vpcs_probe.py b/gns3server/compute/vpcs/vpcs_probe.py new file mode 100644 index 000000000..695a6ff63 --- /dev/null +++ b/gns3server/compute/vpcs/vpcs_probe.py @@ -0,0 +1,92 @@ +# +# 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 . +# + +""" +Standalone VPCS tap-mode probe. + +A VPCS node anchors a kernel-datapath link on a persistent TAP the vpcs +process attaches to by name (``-e -d ``). That shape needs a vpcs +build that also refuses to auto-configure the host side of the TAP (``-n``): +without the flag every ``ip`` command inside the guest runs +``ifconfig … up`` on the anchor — the server owns the anchor's +addresses and bridge membership, so a vpcs touching them ranges from noise +(an ifconfig on a name that cannot resolve) to sabotage (reconfiguring an +interface that does exist). + +The probe asks the configured binary for its usage (``vpcs -h``) and looks +for the ``-n`` line, caching the answer by binary identity (path, mtime, +size, ctime) exactly like the uBridge probes — the same binary never +answers differently, but replacing it must re-probe. This feeds the compute +``/capabilities`` payload (``vpcs_tap``) so the controller can gate VPCS +kernel links on it. Never raises: any failure (missing binary, timeout) +means unknown, reported as None and cached as such — the safe answer keeps +the node on the relay datapath. +""" + +import asyncio +import logging +import os +import shutil + +from gns3server.config import Config +from gns3server.utils.asyncio import subprocess_check_output + +log = logging.getLogger(__name__) + +# binary identity (path, mtime, size, ctime) -> whether -n is available +# (None = probed and unknown) +_cache: dict[tuple[str, int, int, int], bool | None] = {} +_lock = asyncio.Lock() + +# The stable tail of the -n usage line (the flag itself is wrapped in ANSI +# escapes, the description is not). +_NO_HOSTCONF_NEEDLE = "do not auto-configure" + + +async def probe_vpcs_tap(timeout: float = 5.0) -> bool | None: + """ + Whether this host's VPCS binary can anchor a kernel link (tap mode with + ``-n``), probed once per binary identity. + + :returns: True when the usage advertises ``-n``, False when it does not + (an upstream build), None when the probe failed (unknown binary). + """ + + config = Config.instance() + vpcs_path = config.settings.VPCS.vpcs_path + if not os.path.isabs(vpcs_path): + vpcs_path = shutil.which(vpcs_path) + if not vpcs_path or not os.path.isfile(vpcs_path): + return None + try: + stat = os.stat(vpcs_path) + key = (vpcs_path, stat.st_mtime_ns, stat.st_size, stat.st_ctime_ns) + except OSError: + return None + + async with _lock: + if key in _cache: + return _cache[key] + try: + output = await asyncio.wait_for(subprocess_check_output(vpcs_path, "-h"), timeout=timeout) + except (OSError, asyncio.TimeoutError, ValueError) as e: + log.debug("VPCS tap-mode probe failed: %s", e) + supported = None + else: + supported = _NO_HOSTCONF_NEEDLE in output + _cache[key] = supported + return supported diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index ec3dd88eb..79b9b772d 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -31,16 +31,20 @@ import sys from gns3server.utils import parse_version from gns3server.utils.asyncio import monitor_process, subprocess_check_output, wait_for_process_termination +from gns3server.utils.kernel_anchor import kernel_anchor_name from ..adapters.ethernet_adapter import EthernetAdapter from ..base_node import BaseNode +from ..kernel_datapath import KernelDatapathMixin +from ..nios.nio_bridge import NIOBridge from ..nios.nio_udp import NIOUDP from .vpcs_error import VPCSError +from .vpcs_probe import probe_vpcs_tap log = logging.getLogger(__name__) -class VPCSVM(BaseNode): +class VPCSVM(KernelDatapathMixin, BaseNode): module_name = "vpcs" """ @@ -62,6 +66,13 @@ class VPCSVM(BaseNode): self._vpcs_version = None self._started = False self._local_udp_tunnel = None + self._execute_lock = asyncio.Lock() + # Kernel-datapath state (the anchor is a persistent TAP uBridge + # creates and the vpcs process attaches to — see the datapath + # section below). + self._tap_datapath = False + self._kernel_taps = {} + self._ubridge_tc_caps = None # VPCS settings if startup_script is not None and not self.script_file: # We disallow override at startup @@ -72,6 +83,15 @@ class VPCSVM(BaseNode): def ethernet_adapter(self): return self._ethernet_adapter + @property + def _ethernet_adapters(self): + """ + The adapter list the kernel-datapath mixin walks (the per-link + bridge sweep): VPCS owns exactly one single-port adapter. + """ + + return [self._ethernet_adapter] + async def close(self): """ Closes this VPCS VM. @@ -89,10 +109,14 @@ class VPCSVM(BaseNode): self.manager.port_manager.release_udp_port(self._local_udp_tunnel[1].lport, self._project) self._local_udp_tunnel = None - await self._stop_ubridge() - + # stop() tears the datapath down in the right order (the vpcs + # process holds the anchor TAPs open — a held device refuses + # deletion and would leak); the else branch covers a node that is + # not running but may still hold a uBridge with anchors. if self.is_running(): - self._terminate_process() + await self.stop() + else: + await self._stop_ubridge() return True @@ -225,34 +249,51 @@ class VPCSVM(BaseNode): await self._check_requirements() if not self.is_running(): nio = self._ethernet_adapter.get_nio(0) - command = self._build_command() + # uBridge owns the anchor TAP and must be up before it is + # created — and before vpcs attaches to it. The whole setup runs + # inside the try so a failure at any step (probe refused, tap + # create, launch) takes the datapath down again: a node that + # reports stopped must not keep a uBridge process and a + # root-namespace TAP alive. + self._ubridge_tc_caps = None try: - log.info(f"Starting VPCS: {command}") - self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log") - log.info(f"Logging to {self._vpcs_stdout_file}") - flags = 0 - if sys.platform.startswith("win32"): - flags = subprocess.CREATE_NEW_PROCESS_GROUP - with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd: - self.command_line = " ".join(command) - self._process = await asyncio.create_subprocess_exec( - *command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags - ) - monitor_process(self._process, self._termination_callback) - await self._start_ubridge() - if nio: - await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio) + await self._prepare_tap_datapath() - await self.start_wrap_console() + command = self._build_command() + try: + log.info(f"Starting VPCS: {command}") + self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log") + log.info(f"Logging to {self._vpcs_stdout_file}") + flags = 0 + if sys.platform.startswith("win32"): + flags = subprocess.CREATE_NEW_PROCESS_GROUP + with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd: + self.command_line = " ".join(command) + self._process = await asyncio.create_subprocess_exec( + *command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags + ) + monitor_process(self._process, self._termination_callback) - log.info(f"VPCS instance {self.name} started PID={self._process.pid}") - self._started = True - self.status = "started" - except (OSError, subprocess.SubprocessError) as e: - vpcs_stdout = self.read_vpcs_stdout() - log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}") - raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}") + if nio: + # a link that outlived a stop is re-attached from its NIO + await self._connect_nio(0, nio) + await self._set_adapter_carrier(0, not nio.suspend) + + await self.start_wrap_console() + + log.info(f"VPCS instance {self.name} started PID={self._process.pid}") + self._started = True + self.status = "started" + except (OSError, subprocess.SubprocessError) as e: + vpcs_stdout = self.read_vpcs_stdout() + log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}") + raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}") + except Exception: + # uBridge was started (and the anchor TAP created) before the + # launch: don't leave either behind on a failed start. + await self._stop_ubridge() + raise async def _termination_callback(self, returncode): """ @@ -263,11 +304,9 @@ class VPCSVM(BaseNode): if self._started: log.info("VPCS process has stopped, return code: %d", returncode) - self._started = False - self.status = "stopped" - self._process = None - await self._stop_ubridge() - await super().stop() + # stop() tears the datapath down under the execute lock (an API + # stop may be running concurrently) and sets the status. + await self.stop() if returncode != 0: self.project.emit( "log.error", @@ -279,24 +318,156 @@ class VPCSVM(BaseNode): Stops the VPCS process. """ - await self._stop_ubridge() - if self.is_running(): - self._terminate_process() - if self._process.returncode is None: - try: - await wait_for_process_termination(self._process, timeout=3) - except asyncio.TimeoutError: - if self._process.returncode is None: - try: - self._process.kill() - except OSError as e: - log.error(f"Cannot stop the VPCS process: {e}") + async with self._execute_lock: + # Release the datapath once vpcs is gone: the process holds the + # anchor TAP open (it is the guest side), and uBridge's tap + # delete refuses a held device ("Device or resource busy") — + # deleting it while vpcs still ran leaked the persistent TAP, + # because that best-effort delete is suppressed. The process + # monitor calls stop() on its own when vpcs dies, so an API stop + # and a process-death stop run concurrently in the normal case; + # the lock serializes their TAP sweeps (the second one finds + # uBridge already gone and sweeps nothing). + if self.is_running(): + self._terminate_process() + if self._process.returncode is None: + try: + await wait_for_process_termination(self._process, timeout=3) + except asyncio.TimeoutError: if self._process.returncode is None: - log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running') + try: + self._process.kill() + except OSError as e: + log.error(f"Cannot stop the VPCS process: {e}") + if self._process.returncode is None: + log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running') - self._process = None - self._started = False - await super().stop() + self._process = None + self._started = False + await self._stop_ubridge() + await super().stop() + + # ------------------------------------------------------------------ + # Kernel datapath: the adapter anchor is a persistent TAP + # ------------------------------------------------------------------ + + def _tap_name(self, adapter_number, port_number=0): + """ + Deterministic anchor TAP name for the adapter port (the shared + utils.kernel_anchor naming contract — the controller names a peer's + anchor with the same function when an Ethernet switch absorbs it). + """ + + return kernel_anchor_name("vpcs", self._id, adapter_number, port_number) + + def _kernel_host_ifc(self, adapter_number, port_number=0): + """ + The persistent TAP this adapter owns, or None before the node + started (or on the legacy UDP relay datapath). + """ + + return self._kernel_taps.get((adapter_number, port_number)) + + def _kernel_anchors(self): + return set(self._kernel_taps.values()) + + def _kernel_error(self, message): + return VPCSError(message) + + async def _prepare_tap_datapath(self): + """ + Decide this start's datapath: a vpcs build with ``-n`` (no host + auto-configuration) and a uBridge with the persistent-TAP module + anchor the adapter on a persistent TAP the vpcs process attaches to + (``-e -d``). Anything else (upstream vpcs, old uBridge, no + CAP_NET_ADMIN) keeps the legacy UDP relay datapath — kernel links + are refused on it. + """ + + self._tap_datapath = False + self._kernel_taps.clear() + if not self.ubridge: + return + + vpcs_tap = await probe_vpcs_tap() + if vpcs_tap is not True: + # Without -n every guest `ip` command would ifconfig the anchor + # on the host — the server owns the anchor's addresses and + # bridge membership, so the relay datapath is the only safe + # shape. False (a build definitively without -n) is actionable + # for the user; None (probe failed) stays server-log-only. + log.warning( + "VPCS '%s': vpcs '%s' cannot anchor a kernel link (no -n support)", self._name, self._vpcs_path() + ) + if vpcs_tap is False: + self.project.emit( + "log.warning", + { + "message": f"VPCS '{self._name}': the vpcs binary does not support tap mode without host " + "auto-configuration (-n); this node runs on the legacy relay datapath and cannot carry " + "kernel links" + }, + ) + return + + probe_error = await self._probe_persistent_taps(f"gp{self._id.replace('-', '')[:8]}prob") + if probe_error is not None: + log.warning("VPCS '%s': uBridge cannot create persistent TAPs (%s)", self._name, probe_error) + self.project.emit( + "log.warning", + { + "message": f"VPCS '{self._name}': uBridge does not support persistent TAPs ({probe_error}); " + "this node runs on the legacy relay datapath and cannot carry kernel links" + }, + ) + return + + self._tap_datapath = True + # The shared lifecycle sweeps leftovers first, creates the TAP and + # hands it to this user (the unprivileged vpcs process opens it + # itself), leaving it down until a link attaches. + await self._create_anchor_taps([(0, 0)], set_owner=True) + + async def _stop_ubridge(self): + """ + Stops uBridge, removing the anchor TAP first: it lives in the root + namespace and outlives the process (the tap module needs the control + channel to delete it), and vpcs holding the other end keeps the + device alive until it exits — the caller must terminate vpcs first, + or the suppressed tap delete leaks the anchor. The next start + spawns a fresh uBridge, so the tc capabilities must be probed again. + """ + + await self._delete_anchor_taps() + self._ubridge_tc_caps = None + await super()._stop_ubridge() + + async def _connect_nio(self, port_number, nio): + """ + Attach a link's NIO to the adapter's TAP anchor: a kernel link + enslaves the TAP into the per-link kernel bridge, a relay link + attaches it to the uBridge relay bridge as an AF_PACKET endpoint. + The legacy datapath (no anchor) bridges the local UDP tunnel + instead. + """ + + bridge_name = f"VPCS-{self._id}" + if not self._tap_datapath: + if isinstance(nio, NIOBridge): + raise VPCSError( + f"VPCS '{self._name}' runs on the legacy relay datapath (no anchor TAP) " + "and cannot carry a kernel link" + ) + await self.add_ubridge_udp_connection(bridge_name, self._local_udp_tunnel[1], nio) + return + + anchor = self._kernel_host_ifc(0, port_number) + if anchor is None: + raise VPCSError(f"VPCS '{self._name}' has no anchor TAP interface; restart the node to attach a link to it") + if isinstance(nio, NIOBridge): + await self._kernel_attach(anchor, nio) + return + await self._relay_attach(anchor, bridge_name, nio) async def reload(self): """ @@ -386,8 +557,15 @@ class VPCSVM(BaseNode): if not self._ethernet_adapter.port_exists(port_number): raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}") + # The compute-side backstop of the controller's duplicate-port guard: + # a port carries at most one NIO, and overwriting the one a link + # still owns orphans that link's teardown (its host state leaks). + if self._ethernet_adapter.get_nio(port_number) is not None: + raise VPCSError(f"Port {port_number} of VPCS '{self._name}' already has a link") + if self.is_running(): - await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio) + await self._connect_nio(port_number, nio) + await self._set_adapter_carrier(0, not nio.suspend) self._ethernet_adapter.add_nio(port_number, nio) log.info(f'VPCS "{self._name}" [{self.id}]: {nio} added to port {port_number}') @@ -405,7 +583,19 @@ class VPCSVM(BaseNode): if not self._ethernet_adapter.port_exists(port_number): raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}") if self.is_running(): - await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio) + if self._tap_datapath and isinstance(nio, NIOBridge): + # Filters or markers changed on an attached kernel link: + # re-apply on the anchor, no re-enslaving. + anchor = self._kernel_host_ifc(0, port_number) + if anchor is not None: + await self._kernel_update(anchor, nio) + elif self._tap_datapath: + # Relay link on the TAP anchor: re-apply on its bridge. + await self._ubridge_apply_filters(f"VPCS-{self._id}", nio.filters) + await self._ubridge_apply_markers(f"VPCS-{self._id}", nio) + else: + await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio) + await self._set_adapter_carrier(0, not nio.suspend) async def port_remove_nio_binding(self, port_number): """ @@ -419,11 +609,16 @@ class VPCSVM(BaseNode): if not self._ethernet_adapter.port_exists(port_number): raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}") + nio = self._ethernet_adapter.get_nio(port_number) await self.stop_capture(port_number) if self.is_running(): - await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}")) + if self._tap_datapath and isinstance(nio, NIOBridge): + await self._remove_kernel_nio(nio, 0, port_number) + elif self._tap_datapath: + await self._relay_detach(f"VPCS-{self._id}") + else: + await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}")) - nio = self._ethernet_adapter.get_nio(port_number) if isinstance(nio, NIOUDP): self.manager.port_manager.release_udp_port(nio.lport, self._project) self._ethernet_adapter.remove_nio(port_number) @@ -461,9 +656,25 @@ class VPCSVM(BaseNode): nio.start_packet_capture(output_file) if self.ubridge: - await self._ubridge_send( - 'bridge start_capture {name} "{output_file}"'.format(name=f"VPCS-{self._id}", output_file=output_file) - ) + anchor = self._kernel_host_ifc(0, port_number) + if isinstance(nio, NIOBridge) and anchor is not None: + # Kernel link: capture on the adapter's TAP anchor (AF_PACKET, + # one capture per uBridge process — see _reserve_kernel_capture; + # a failed start rolls the port's flag back so the port cannot + # later stop the winner's capture). + try: + self._reserve_kernel_capture(anchor) + await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') + except Exception: + self._release_kernel_capture(anchor) + nio.stop_packet_capture() + raise + else: + await self._ubridge_send( + 'bridge start_capture {name} "{output_file}"'.format( + name=f"VPCS-{self._id}", output_file=output_file + ) + ) log.info(f"VPCS '{self.name}' [{self.id}]: starting packet capture on port {port_number}") @@ -480,7 +691,16 @@ class VPCSVM(BaseNode): nio.stop_packet_capture() if self.ubridge: - await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) + anchor = self._kernel_host_ifc(0, port_number) + if isinstance(nio, NIOBridge) and anchor is not None: + # Process-wide and argument-less: only the port owning the + # slot may stop it (a second port's stop would kill this + # capture while its own flag still says capturing). + if self._kernel_capture_owned_by(anchor): + await self._ubridge_send("capture stop_kernel") + self._release_kernel_capture(anchor) + else: + await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) log.info(f"VPCS '{self.name}' [{self.id}]: stopping packet capture on port {port_number}") @@ -511,6 +731,7 @@ class VPCSVM(BaseNode): tap mode options: -d vm device name, works only when -i is set to 1 + -n do not auto-configure the host tap interface hypervisor mode option: -H port run as the hypervisor listening on the tcp 'port' @@ -532,20 +753,28 @@ class VPCSVM(BaseNode): else: log.warning("The VPCS relay feature could not be disabled because the VPCS version is below 0.8b") - # use the local UDP tunnel to uBridge instead - if not self._local_udp_tunnel: - self._local_udp_tunnel = self._create_local_udp_tunnel() - nio = self._local_udp_tunnel[0] - if nio and isinstance(nio, NIOUDP): - # UDP tunnel - command.extend(["-s", str(nio.lport)]) # source UDP port - command.extend(["-c", str(nio.rport)]) # destination UDP port - try: - command.extend( - ["-t", socket.gethostbyname(nio.rhost)] - ) # destination host, we need to resolve the hostname because VPCS doesn't support it - except socket.gaierror as e: - raise VPCSError(f"Can't resolve hostname {nio.rhost}") + if self._tap_datapath: + # Kernel-datapath anchor: uBridge created the persistent TAP + # before the launch, vpcs attaches to it by name and must leave + # its host-side configuration alone (-n). + command.extend(["-e"]) # tap mode + command.extend(["-d", self._tap_name(0, 0)]) # the anchor TAP + command.extend(["-n"]) # no host auto-configuration on the anchor + else: + # use the local UDP tunnel to uBridge instead + if not self._local_udp_tunnel: + self._local_udp_tunnel = self._create_local_udp_tunnel() + nio = self._local_udp_tunnel[0] + if nio and isinstance(nio, NIOUDP): + # UDP tunnel + command.extend(["-s", str(nio.lport)]) # source UDP port + command.extend(["-c", str(nio.rport)]) # destination UDP port + try: + command.extend( + ["-t", socket.gethostbyname(nio.rhost)] + ) # destination host, we need to resolve the hostname because VPCS doesn't support it + except socket.gaierror as e: + raise VPCSError(f"Can't resolve hostname {nio.rhost}") if self.script_file: command.extend([os.path.basename(self.script_file)]) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 925cbd569..fd2dd8aab 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -221,7 +221,11 @@ class UDPLink(Link): be present or absent independently of the tap module. Dynamips Ethernet slot ports anchor on persistent TAPs the hypervisor opens (the tap module again — the Dynamips binary itself creates nothing), - so it gates like QEMU. A kernel link is an Ethernet segment, so + so it gates like QEMU. A VPCS node's single adapter anchors on a + persistent TAP the vpcs process attaches to by name (``-e -d``), + which needs the tap module plus a vpcs build that keeps its hands + off the host side of the TAP (``-n``) — its own capability on top + of the tap one. A kernel link is an Ethernet segment, so non-Ethernet ports (IOU serial, Dynamips serial/ATM/POS adapters) stay on the relay whatever the node's capabilities. """ @@ -291,6 +295,14 @@ class UDPLink(Link): # merely opens (nio create_tap) — the same tap-module capability # QEMU needs, not a Dynamips-specific one. return capabilities.get("ubridge_tap") is True + if node.node_type == "vpcs": + # The anchor is a uBridge-created TAP the vpcs process attaches + # to (-e -d): the tap module must exist, and the binary must + # support -n — without it every guest `ip` command would + # ifconfig the anchor on the host. Both strict True: an + # unreported one keeps the link on the relay, where it always + # works. + return capabilities.get("ubridge_tap") is True and capabilities.get("vpcs_tap") is True return False async def _prepare(self): diff --git a/gns3server/schemas/controller/capabilities.py b/gns3server/schemas/controller/capabilities.py index d8aaf591b..69fbf5444 100644 --- a/gns3server/schemas/controller/capabilities.py +++ b/gns3server/schemas/controller/capabilities.py @@ -67,3 +67,9 @@ class Capabilities(BaseModel): "(bridge delete_nio_tap, the swappable TAP leg an IOL runner container's per-port bridge " "needs to wire its links on the kernel datapath); absent when the probe failed", ) + vpcs_tap: Optional[bool] = Field( + None, + description="Whether the VPCS binary can anchor a kernel link (tap mode with -n, which " + "keeps the guest from auto-configuring the host side of the anchor TAP); absent when the " + "probe failed", + ) diff --git a/gns3server/utils/kernel_anchor.py b/gns3server/utils/kernel_anchor.py index c96cddffa..b5f39b784 100644 --- a/gns3server/utils/kernel_anchor.py +++ b/gns3server/utils/kernel_anchor.py @@ -22,10 +22,11 @@ deterministically: ``g{node_id[:8]}e{adapter}p{port}`` — ``gv`` for a Docker veth host end, ``gq`` for a QEMU TAP, ``gi`` for an IOU port TAP, ``gd`` for a Dynamips port TAP, ``gx`` for an IOL runner container's port TAP (a Docker node whose guest leg is unix sockets, so its anchor is a TAP -like IOU's, not a veth). The prefixes keep anchors out of the ``gns3`` -bridge/TAP name space and name-collision-free on a host; the 8 hex chars of -the node id plus adapter/port keep every name unique and within IFNAMSIZ -(15). +like IOU's, not a veth), ``gp`` for a VPCS TAP (the vpcs process attaches to +the pre-created anchor with ``-e -d`` instead of speaking UDP). The +prefixes keep anchors out of the ``gns3`` bridge/TAP name space and +name-collision-free on a host; the 8 hex chars of the node id plus +adapter/port keep every name unique and within IFNAMSIZ (15). The compute classes create their anchors with these helpers (single source of truth), and so does the controller when a switch link has to name the @@ -46,6 +47,7 @@ ANCHOR_PREFIX = { "qemu": "gq", "iou": "gi", "dynamips": "gd", + "vpcs": "gp", } diff --git a/tests/api/routes/compute/test_capabilities.py b/tests/api/routes/compute/test_capabilities.py index e0017cedf..c3fdf4eb4 100644 --- a/tests/api/routes/compute/test_capabilities.py +++ b/tests/api/routes/compute/test_capabilities.py @@ -44,6 +44,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -71,6 +72,7 @@ class TestCapabilitiesRoutes: "ubridge_tap": None, "ubridge_iol_tap": None, "ubridge_bridge_tap": None, + "vpcs_tap": None, } async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None: @@ -82,6 +84,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe) response = await compute_client.get(app.url_path_for("compute:get_capabilities")) assert response.status_code == status.HTTP_200_OK assert response.json() == { @@ -109,6 +112,7 @@ class TestCapabilitiesRoutes: "ubridge_tap": None, "ubridge_iol_tap": None, "ubridge_bridge_tap": None, + "vpcs_tap": None, } async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: @@ -120,6 +124,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_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"] == { @@ -141,6 +146,7 @@ class TestCapabilitiesRoutes: monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_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_tap"] is True @@ -175,6 +181,24 @@ class TestCapabilitiesRoutes: ) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_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_bridge_tap"] is True + + async def test_get_reports_vpcs_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None: + """ + The VPCS tap-mode capability is what makes a VPCS node + kernel-eligible, so it travels with the other compute capabilities. + """ + + async def fake_vpcs_tap_probe(): + return True + + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", fake_vpcs_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe) + monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe) + response = await compute_client.get(app.url_path_for("compute:get_capabilities")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["vpcs_tap"] is True diff --git a/tests/compute/vpcs/test_vpcs_probe.py b/tests/compute/vpcs/test_vpcs_probe.py new file mode 100644 index 000000000..1792df5a9 --- /dev/null +++ b/tests/compute/vpcs/test_vpcs_probe.py @@ -0,0 +1,78 @@ +# +# 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 . +# + +import os +import stat + +import pytest + +from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap + +pytestmark = pytest.mark.asyncio + + +def _fake_vpcs(path, usage): + path.write_text(f"#!/bin/sh\ncat <<'EOF'\n{usage}\nEOF\n") + mode = path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + os.chmod(path, mode) + + +async def test_probe_reports_dash_n_build(config, tmp_path): + + binary = tmp_path / "vpcs" + _fake_vpcs( + binary, + " -d device device name, works only when -i is set to 1\n" + " -n do not auto-configure the host tap interface\n", + ) + config.settings.VPCS.vpcs_path = str(binary) + assert await probe_vpcs_tap() is True + + +async def test_probe_reports_upstream_build(config, tmp_path): + + binary = tmp_path / "vpcs" + _fake_vpcs( + binary, + " -d device device name, works only when -i is set to 1\n", + ) + config.settings.VPCS.vpcs_path = str(binary) + assert await probe_vpcs_tap() is False + + +async def test_probe_reports_missing_binary(config, tmp_path): + + config.settings.VPCS.vpcs_path = str(tmp_path / "does-not-exist") + assert await probe_vpcs_tap() is None + + +async def test_probe_caches_by_binary_identity(config, tmp_path): + """ + The same binary answers once; replacing it (a different mtime/size) + re-probes. + """ + + binary = tmp_path / "vpcs" + _fake_vpcs(binary, " -n do not auto-configure\n") + config.settings.VPCS.vpcs_path = str(binary) + assert await probe_vpcs_tap() is True + + # replace with a build without the flag: the binary identity changed, + # so the cached True must not answer + _fake_vpcs(binary, " -d device device name\n") + os.utime(binary, (0, 0)) + assert await probe_vpcs_tap() is False diff --git a/tests/compute/vpcs/test_vpcs_vm.py b/tests/compute/vpcs/test_vpcs_vm.py index 4fb672aa6..0b6d386c4 100644 --- a/tests/compute/vpcs/test_vpcs_vm.py +++ b/tests/compute/vpcs/test_vpcs_vm.py @@ -45,7 +45,12 @@ async def vm(compute_project, manager, tmpdir, ubridge_path): vm._start_ubridge = AsyncioMagicMock() vm._ubridge_hypervisor = MagicMock() vm._ubridge_hypervisor.is_running.return_value = True - return vm + # The legacy-datapath tests run with an unknown probe answer (None): + # the tap datapath stays off without a project warning, exactly like a + # host whose probe failed. The tap-datapath tests patch the probe to + # True (or False, when they assert the fallback) on top. + with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=None)): + yield vm @pytest.mark.asyncio @@ -350,3 +355,177 @@ async def test_close(vm): await vm.start() await vm.close() assert vm.is_running() is False + + +# ---------------------------------------------------------------------- +# Kernel datapath (the adapter anchor is a persistent TAP the vpcs process +# attaches to with -e -d, kept away from host auto-configuration with -n) +# ---------------------------------------------------------------------- + + +async def _start_on_tap_datapath(vm): + """ + Start the node with both probes answering (a -n-capable vpcs build, a + uBridge with the tap module), returning the recorded uBridge commands. + """ + + # a startup.vpc left in the shared working directory by an earlier test + # would append its name to the command line (the script tests run in any + # order) — start from a clean one. + try: + os.remove(os.path.join(vm.working_dir, "startup.vpc")) + except FileNotFoundError: + pass + + commands = [] + + async def record_send(command): + commands.append(command) + + vm._ubridge_send = AsyncioMagicMock(side_effect=record_send) + process = MagicMock() + process.returncode = None + with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)): + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): + await vm.start() + return mock_exec, commands + + +@pytest.mark.asyncio +async def test_start_tap_datapath(vm): + """ + A -n-capable vpcs build on a tap-capable uBridge anchors the adapter on + a persistent TAP and speaks tap mode: -e -d -n, no UDP tunnel. + """ + + mock_exec, commands = await _start_on_tap_datapath(vm) + assert vm._tap_datapath is True + assert vm._kernel_taps == {(0, 0): "gp00010203e0p0"} + assert mock_exec.call_args[0] == ( + vm._vpcs_path(), + "-p", + str(vm._internal_console_port), + "-m", + "1", + "-i", + "1", + "-F", + "-R", + "-e", + "-d", + "gp00010203e0p0", + "-n", + ) + # the anchor was created through uBridge's tap module, owned by this user + assert 'tap create "gp00010203e0p0"' in commands + assert f"tap set_owner gp00010203e0p0 {os.getuid()}" in commands + # no local UDP tunnel on this datapath + assert vm._local_udp_tunnel is None + + +@pytest.mark.asyncio +async def test_start_legacy_datapath_when_binary_lacks_dash_n(vm): + """ + A vpcs build without -n cannot be kept away from the host side of the + anchor, so the node keeps the legacy UDP relay datapath. + """ + + process = MagicMock() + process.returncode = None + with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=False)): + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): + await vm.start() + + assert vm._tap_datapath is False + assert vm._kernel_taps == {} + command = mock_exec.call_args[0] + assert "-e" not in command + assert "-n" not in command + assert "-s" in command and "-c" in command and "-t" in command + + +@pytest.mark.asyncio +async def test_stop_terminates_process_before_deleting_taps(vm): + """ + The vpcs process holds the anchor TAP open: a held device refuses + deletion, so the TAP delete must follow the process termination (the + suppressed delete of the old order leaked the anchor). + """ + + calls = [] + process = MagicMock() + process.returncode = None + process.terminate.side_effect = lambda: calls.append("terminate") + + async def record_send(command): + commands.append(command) + + commands = [] + vm._ubridge_send = AsyncioMagicMock(side_effect=record_send) + with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)): + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): + with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"): + await vm.start() + + with asyncio_patch("gns3server.utils.asyncio.wait_for_process_termination"): + await vm.stop() + + assert calls == ["terminate"] + # every tap delete (the start-time sweep and the stop-time teardown) + # happened after the process termination + assert all(calls.index("terminate") < commands.index(c) for c in commands if c.startswith("tap delete")) + + +@pytest.mark.asyncio +async def test_add_nio_binding_duplicate_port_rejected(vm): + """ + A port carries at most one NIO: overwriting the one a link still owns + orphans that link's teardown. + """ + + nio = VPCS.instance().create_nio( + {"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}} + ) + await vm.port_add_nio_binding(0, nio) + with pytest.raises(VPCSError, match="already has a link"): + await vm.port_add_nio_binding(0, nio) + + +@pytest.mark.asyncio +async def test_kernel_nio_on_legacy_datapath_rejected(vm): + """ + A kernel link to a node running on the legacy relay datapath (no anchor) + is refused with a clear error instead of a late uBridge failure. + """ + + vm._process = MagicMock() + vm._process.returncode = None + vm._tap_datapath = False + nio = VPCS.instance().create_nio({"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {}}) + with pytest.raises(VPCSError, match="legacy relay datapath"): + await vm.port_add_nio_binding(0, nio) + + +@pytest.mark.asyncio +async def test_kernel_nio_attach_enslaves_the_anchor(vm): + """ + A kernel link enslaves the anchor into the per-link bridge and installs + the impairment filters the NIO carries — the same _kernel_attach flow + every anchor-backed node type uses. + """ + + await _start_on_tap_datapath(vm) + nio = VPCS.instance().create_nio( + {"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {"delay": [100]}} + ) + await vm.port_add_nio_binding(0, nio) + + sent = [call[0][0] for call in vm._ubridge_send.call_args_list] + assert 'brctl create "gns3testbridge"' in sent + assert 'brctl addif "gns3testbridge" "gp00010203e0p0"' in sent + assert any(c.startswith('tc netem set "gp00010203e0p0" delay 100') for c in sent) diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index 0f34d6ed6..1478cd9f4 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -123,10 +123,11 @@ async def _link(project, node1, node2): @pytest.mark.asyncio async def test_kernel_datapath_not_eligible_for_other_node_types(project): """ - A node type with no anchor of its own (VPCS, cloud, ...) keeps the relay - however capable the compute is — and a QEMU node on a compute whose - uBridge cannot create persistent TAPs does too, because it runs on the - legacy relay datapath there. + A node type with no anchor of its own (cloud, ...) keeps the relay + however capable the compute is — a QEMU node on a compute whose uBridge + cannot create persistent TAPs does too (it runs on the legacy relay + datapath there), and so does a VPCS node whose binary cannot anchor + (no -n support, so no vpcs_tap capability). """ compute = _tap_capable_compute(tap_support=None) @@ -227,6 +228,77 @@ async def test_dynamips_eligibility_follows_the_tap_capability(project): assert link._kernel_datapath_eligible(node1, node2) is False +@pytest.mark.asyncio +async def test_kernel_datapath_eligible_for_vpcs_with_tap_support(project): + """ + A VPCS node anchors on a persistent TAP the vpcs process attaches to + (-e -d), which needs the tap module plus a vpcs build with -n — both + capabilities reported, and mixable with docker/qemu/iou/dynamips + endpoints on the same compute. + """ + + compute = MagicMock() + compute.id = "compute-1" + compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True, "ubridge_iol_tap": True} + node1 = _node(project, compute, "pc1", node_type="vpcs") + node2 = _node(project, compute, "pc2", node_type="vpcs") + link = await _link(project, node1, node2) + assert link._kernel_datapath_eligible(node1, node2) is True + + for peer in ( + _node(project, compute, "docker1"), + _node(project, compute, "qemu1", node_type="qemu"), + _node(project, compute, "iou1", node_type="iou"), + _node(project, compute, "r1", node_type="dynamips"), + ): + link = await _link(project, node1, peer) + assert link._kernel_datapath_eligible(node1, peer) is True + + +@pytest.mark.asyncio +async def test_vpcs_eligible_for_the_switch_fast_path(project): + """ + A switch link absorbs the peer's anchor into the switch's own bridge, + so only the peer must be able to anchor — a capable VPCS does (the + controller names its gp anchor with the shared contract), an + incapable one stays on the switch's relay port. + """ + + switch = _node(project, _tap_capable_compute(), "sw1", node_type="ethernet_switch") + + compute = MagicMock() + compute.id = "compute-1" + compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True} + pc = _node(project, compute, "pc1", node_type="vpcs") + link = await _link(project, switch, pc) + assert link._kernel_datapath_eligible(switch, pc) is True + + incapable = MagicMock() + incapable.id = "compute-1" + incapable.capabilities = {"ubridge_tap": True} + pc = _node(project, incapable, "pc1", node_type="vpcs") + link = await _link(project, switch, pc) + assert link._kernel_datapath_eligible(switch, pc) is False + + +@pytest.mark.asyncio +async def test_vpcs_eligibility_needs_both_capabilities(project): + """ + The tap module and the vpcs -n build land independently: a compute + reporting only one of them keeps VPCS on the relay, and so does an + unreported/failed probe (strict True on both). + """ + + node2 = _node(project, _tap_capable_compute(), "docker1") + for capabilities in ({"vpcs_tap": True}, {"ubridge_tap": True, "vpcs_tap": None}, {"ubridge_tap": True}): + compute = MagicMock() + compute.id = "compute-1" + compute.capabilities = capabilities + node1 = _node(project, compute, "pc1", node_type="vpcs") + link = await _link(project, node1, node2) + assert link._kernel_datapath_eligible(node1, node2) is False + + @pytest.mark.asyncio async def test_kernel_datapath_not_eligible_for_dynamips_serial_ports(project): """ diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 5dc48b990..2b782c4c0 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -235,6 +235,21 @@ class Compute: }, ) + def create_vpcs_node(self, project_id, name, startup_script=None): + """A VPCS node: the real vpcs binary on this host behind the server's + TCP console (the driver sends ``ip``/``ping`` like a user would). A + kernel-datapath scenario additionally needs a build with ``-n`` + (no host auto-configuration) — see GNS3_E2E_VPCS and the ``vpcs_tap`` + capability.""" + properties = {"console_type": "telnet"} + if startup_script is not None: + properties["startup_script"] = startup_script + return self.call( + "POST", + f"/projects/{project_id}/nodes", + {"compute_id": "local", "name": name, "node_type": "vpcs", "properties": properties}, + ) + def create_link(self, project_id, a, b): return self.call( "POST", @@ -298,6 +313,9 @@ allocate_aux_console_ports = False mmap_support = False sparse_memory_support = False ghost_ios_support = False + +[VPCS] +vpcs_path = {vpcs} """ @@ -374,8 +392,17 @@ def _spawn_isolated(tmpdir, kernel): os.makedirs(os.path.join(tmpdir, "projects"), exist_ok=True) port = _free_port() + # GNS3_E2E_VPCS points at a vpcs build with -n (tap mode without host + # auto-configuration) — what the kernel-datapath scenario needs. The + # local fork build (where this feature is developed) is the default when + # present; otherwise the stock binary in PATH keeps the relay scenarios + # running (the kernel scenario skips itself on the vpcs_tap capability). + vpcs = os.environ.get("GNS3_E2E_VPCS") + if not vpcs: + fork_build = os.path.expanduser("~/myCode/GNS3/vpcs/src/vpcs") + vpcs = fork_build if os.path.isfile(fork_build) else (shutil.which("vpcs") or "vpcs") config = ISOLATED_CONFIG.format( - port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel + port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel, vpcs=vpcs ) config_path = os.path.join(tmpdir, "gns3_server.conf") with open(config_path, "w") as f: @@ -702,6 +729,13 @@ def ios_console(server, project_id, node_id): return IOSConsole(host, node["console"]) +def vpcs_console(server, project_id, node_id): + """The console of a VPCS node: the vpcs binary's own TCP daemon behind + the server-side console — same dumb driver (its prompt ``PC1>`` matches + the shared prompt regex).""" + return ios_console(server, project_id, node_id) + + def configure_ios( console, hostname, eth_ip=None, eth_ipv6=None, serial_ip=None, clock=False, eth_if="f1/0", serial_if="s2/0" ): @@ -769,6 +803,34 @@ def wait_ping(console, target, attempts=4, repeat=3, timeout=1): return result +def vpcs_ping(console, target, count=5, timeout=1): + """Run a VPCS ping; returns {success, avg, raw} (success in percent). + The reply lines are `` icmp_seq= ttl= time= ms`` in both + families (VPCS prints the same shape for v4 and v6 targets).""" + + out = console.run(f"ping {target} -c {count} -w {int(timeout * 1000)}", timeout=count * timeout + 25) + replies = re.findall(r"icmp_seq=\d+ ttl=\d+ time=([\d.]+) ms", out) + return { + "success": int(100 * len(replies) / count), + "avg": round(sum(float(rtt) for rtt in replies) / len(replies), 1) if replies else None, + "raw": out, + } + + +def wait_vpcs_ping(console, target, attempts=4, count=5, timeout=1): + """The VPCS twin of wait_ping: the first exchange of a fresh adjacency + is ARP (a lost first ping is legitimate), so retry until it fully + succeeds; returns the last result either way.""" + + result = None + for _ in range(attempts): + result = vpcs_ping(console, target, count=count, timeout=timeout) + if result["success"] == 100: + return result + time.sleep(1.5) + return result + + # --------------------------------------------------------------------------- # Docker guest driver (Docker Engine API over the daemon socket) # --------------------------------------------------------------------------- @@ -1330,6 +1392,13 @@ def qemu_anchor_name(node_id, adapter, port=0): return "gq" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}" +def vpcs_anchor_name(node_id, adapter=0, port=0): + """The VPCS node's anchor TAP name: one persistent TAP for the single + adapter, attached to by name through -e -d (see VPCSVM._tap_name and + utils.kernel_anchor).""" + return "gp" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}" + + def stage_iol_base_config(server): """ Copy the shipped iol-xe base config into an isolated instance's configs diff --git a/tests/e2e/test_vpcs_kernel_datapath.py b/tests/e2e/test_vpcs_kernel_datapath.py new file mode 100644 index 000000000..55c2d6da5 --- /dev/null +++ b/tests/e2e/test_vpcs_kernel_datapath.py @@ -0,0 +1,260 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# This program is free software and is redistributed under the same terms as gns3-server. +# See the LICENSE file for licensing information. +# + +""" +Live end-to-end test for the VPCS node's kernel datapath (pytest marker +``e2e``): two real VPCS nodes (the local vpcs binary, its own TCP console +driven like a user would), linked through the controller REST API. VPCS is +the node type whose guest leg attaches to a *pre-created* anchor: uBridge +creates the persistent TAP and hands it to this user, and the vpcs process +opens it by name (``-e -d``) while ``-n`` keeps its hands off the host side +of the anchor — the same server-creates/guest-attaches shape as QEMU. + +* two VPCS nodes on one compute report ``kernel_datapath`` when the + compute's uBridge can create persistent TAPs (``ubridge_tap``) and the + vpcs build has ``-n`` (``vpcs_tap``); the anchor (``gp`` TAP) exists from + node start, before any link, and the per-link bridge enslaves exactly the + two of them; +* real ICMP crosses the [vpcs tun fd ↔ anchor TAP] leg; +* the L2-anchor spec §E.1: with ``-n`` the anchor carries no address — vpcs + never ran its ``ifconfig`` on it; §E.2 comes free: a VPCS sends nothing + spontaneously (no CDP, no keepalive), so the silence window needs no + guest shutdown; +* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms; +* suspend admin-downs the anchor and kills the traffic; resume restores; +* a capture on the kernel link writes a real pcap; +* link delete releases the anchor from the bridge and it survives (the + running vpcs holds it open) — re-creating the link re-enslaves it; +* node stop terminates vpcs first, then deletes the anchor (a held TAP + refuses deletion — the order is the point); a restart recreates the TAP + and rewires the link from the NIO; +* deleting the project leaves no anchors and no bridges behind. + +``test_vpcs_relay_control`` is the negative control on an isolated +relay-configured instance: nothing is enslaved, no per-link bridge exists, +and the relay still carries the pings — with the legacy UDP tunnel on a +stock vpcs, or as an AF_PACKET endpoint on the anchor with a ``-n`` build +(the datapath a link rides is a server configuration, not a node property). +""" + +import time + +import pytest + +from tests.e2e import harness + +pytestmark = pytest.mark.e2e + +# VPCS has one adapter with one port; the kernel scenario addresses it. +ETH = (0, 0) + +PC1_IP = "10.1.1.1" +PC2_IP = "10.1.1.2" + + +def _configure(console, ip): + console.run("set pcname E2E-PC") + out = console.run(f"ip {ip}/24") + assert ip in out, out + + +def test_vpcs_kernel_datapath(): + server = harness.live_server(kernel=True) + compute = server.compute + caps = compute.capabilities() + if caps.get("ubridge_tap") is not True: + pytest.skip(f"this compute's uBridge cannot create persistent TAPs (ubridge_tap={caps.get('ubridge_tap')})") + if caps.get("vpcs_tap") is not True: + pytest.skip( + f"this compute's vpcs binary cannot anchor a kernel link (vpcs_tap={caps.get('vpcs_tap')}); " + "point GNS3_E2E_VPCS (or the server's VPCS.vpcs_path) at a vpcs build with -n" + ) + + project = compute.create_project("vpcs-e2e") + pid = project["project_id"] + a1 = a2 = None + try: + n1 = compute.create_vpcs_node(pid, "E2E-PC1") + n2 = compute.create_vpcs_node(pid, "E2E-PC2") + n1_id, n2_id = n1["node_id"], n2["node_id"] + a1 = harness.vpcs_anchor_name(n1_id, *ETH) + a2 = harness.vpcs_anchor_name(n2_id, *ETH) + + # The link is created while both nodes are stopped: the kernel NIO + # is bound with no anchors in existence yet and wired by node start + # (the deferred-wiring path, same as every anchor-backed node). + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + lid = link["link_id"] + bridge = harness.link_bridge_name(lid) + assert link["kernel_datapath"] is True, link + assert not harness.tap_exists(a1), "anchors must not exist before start" + + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start") + assert harness.wait_until(lambda: harness.tap_exists(a1) and harness.tap_exists(a2), timeout=15), (a1, a2) + c1 = harness.vpcs_console(server, pid, n1_id) + c2 = harness.vpcs_console(server, pid, n2_id) + + # NetworkManager can release an NM-managed TAP from its bridge after + # (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take + # the anchors out of its reach for the rest of the run (the restart + # below re-attaches them under a fresh device). + harness.unmanage_from_networkmanager(a1, a2) + + # Kernel objects: one persistent TAP per node, and the per-link + # bridge has exactly the two anchors as members. + assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) + + # L2-anchor spec §E.1: with -n the anchor stays address-free — this + # is the assertion the -n flag exists for (an upstream vpcs would + # have ifconfig'd the anchor on the guest's `ip` command below). + if harness.l2only_supported(): + harness.assert_pure_l2(a1) + harness.assert_pure_l2(a2) + harness.assert_pure_l2(bridge) + else: + print(".. uBridge without link l2only: skipping the §E.1 assertions") + harness.assert_forwarding(a1) + harness.assert_forwarding(a2) + + _configure(c1, PC1_IP) + _configure(c2, PC2_IP) + baseline = harness.wait_vpcs_ping(c1, PC2_IP) + assert baseline["success"] == 100, baseline["raw"] + reverse = harness.wait_vpcs_ping(c2, PC1_IP) + assert reverse["success"] == 100, reverse["raw"] + + # L2-anchor spec §E.2: a VPCS speaks only when spoken to (no + # platform chatter), so the silence window needs no guest shutdown — + # the two anchors and the bridge must be quiet 2.5 s after the pings. + if harness.l2only_supported(): + harness.assert_idle_silence(a1, a2, bridge) + else: + print(".. uBridge without link l2only: skipping the §E.2 assertion") + + # delay 100: netem on both anchors (each impairs one direction), + # one-way ~100 ms => RTT grows by ~200 ms + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}}) + assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), ( + harness.qdiscs(a1), + harness.qdiscs(a2), + ) + delayed = harness.wait_vpcs_ping(c1, PC2_IP) + assert delayed["success"] == 100, delayed["raw"] + assert delayed["avg"] >= 150, (baseline, delayed) + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}}) + assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) + fast = harness.wait_vpcs_ping(c1, PC2_IP) + assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast + + # suspend: anchor admin-down — the tun fd writes go nowhere and + # nothing crosses; resume restores + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True}) + assert not harness.tap_up(a1) + dead = harness.vpcs_ping(c1, PC2_IP, count=2, timeout=0.5) + assert dead["success"] == 0, dead["raw"] + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False}) + assert harness.tap_up(a1) + assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100 + + # capture: on the anchor, a real pcap + capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"}) + harness.vpcs_ping(c1, PC2_IP, count=3) + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop") + count, ethertypes = harness.pcap_records(capture["capture_file_path"]) + assert count >= 4, f"{capture['capture_file_path']}: {count} records" + assert "0800" in ethertypes, ethertypes + + # delete / re-create the kernel link: the anchor leaves the bridge + # but survives (the running vpcs holds it open), and re-creating + # re-enslaves it without touching the process. + compute.call("DELETE", f"/projects/{pid}/links/{lid}") + assert harness.bridge_members(bridge) is None + assert harness.tap_exists(a1) and harness.tap_exists(a2) + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + lid = link["link_id"] + bridge = harness.link_bridge_name(lid) + assert link["kernel_datapath"] is True, link + assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100 + + # node stop: vpcs is terminated first and only then the anchor is + # deleted (the process holds the TAP open — a held device refuses + # deletion and would leak); a restart recreates the TAP, rewires the + # link from the NIO that outlived it, and the reconfigured VPCS + # pings again. + print(".. stopping and restarting PC1") + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop") + assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1 + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + assert harness.wait_until(lambda: harness.tap_exists(a1), timeout=15), a1 + assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge) + c1.close() + c1 = harness.vpcs_console(server, pid, n1_id) + _configure(c1, PC1_IP) + assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100 + + c1.close() + c2.close() + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) + + # deleting the project closed the nodes: anchors and bridges go with them + assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), ( + a1, + a2, + ) + + +def test_vpcs_relay_control(): + """ + Negative control: the same topology with ``enable_kernel_datapath = + false`` (an isolated instance) wires nothing into the kernel — no + per-link bridge, no enslavement — and still pings, with the relay + filters served by the uBridge bridge engine. + """ + + server = harness.live_server(kernel=False) + compute = server.compute + + project = compute.create_project("vpcs-relay-e2e") + pid = project["project_id"] + try: + n1 = compute.create_vpcs_node(pid, "E2E-PC1") + n2 = compute.create_vpcs_node(pid, "E2E-PC2") + n1_id, n2_id = n1["node_id"], n2["node_id"] + + link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH)) + lid = link["link_id"] + assert link["kernel_datapath"] is False, link + + compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start") + c1 = harness.vpcs_console(server, pid, n1_id) + c2 = harness.vpcs_console(server, pid, n2_id) + _configure(c1, PC1_IP) + _configure(c2, PC2_IP) + + # no kernel wiring: the per-link bridge does not exist + assert harness.bridge_members(harness.link_bridge_name(lid)) is None + relay_ping = harness.wait_vpcs_ping(c1, PC2_IP) + assert relay_ping["success"] == 100, relay_ping["raw"] + + # the relay filters keep working: one uBridge bridge filters both + # directions, delay 100 => RTT >= 150 ms + compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}}) + delayed = harness.wait_vpcs_ping(c1, PC2_IP) + assert delayed["success"] == 100, delayed["raw"] + assert delayed["avg"] >= 150, delayed + + c1.close() + c2.close() + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) diff --git a/tests/utils/test_kernel_anchor.py b/tests/utils/test_kernel_anchor.py index 247237644..dda820b56 100644 --- a/tests/utils/test_kernel_anchor.py +++ b/tests/utils/test_kernel_anchor.py @@ -33,6 +33,7 @@ def test_every_node_type_has_its_own_prefix(): assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3" assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48" assert kernel_anchor_name("iol_docker", NODE_ID, 0, 0) == "gx00010203e0p0" + assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) == "gp00010203e0p0" def test_anchor_type_keys_iol_runner_containers_apart(): @@ -55,7 +56,7 @@ def test_anchor_type_keys_iol_runner_containers_apart(): def test_unknown_node_types_have_no_anchor(): assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None - assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None + assert kernel_anchor_name("cloud", NODE_ID, 0, 0) is None def test_names_fit_ifnamsiz(): From 3f2b44101fa43c9c31c4ff771aa36b0eba260e36 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 12 Oct 2026 17:26:46 +0800 Subject: [PATCH 02/18] style: apply ruff format to the VPCS datapath test --- tests/compute/vpcs/test_vpcs_vm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/compute/vpcs/test_vpcs_vm.py b/tests/compute/vpcs/test_vpcs_vm.py index 0b6d386c4..d5f6e25e3 100644 --- a/tests/compute/vpcs/test_vpcs_vm.py +++ b/tests/compute/vpcs/test_vpcs_vm.py @@ -520,9 +520,7 @@ async def test_kernel_nio_attach_enslaves_the_anchor(vm): """ await _start_on_tap_datapath(vm) - nio = VPCS.instance().create_nio( - {"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {"delay": [100]}} - ) + nio = VPCS.instance().create_nio({"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {"delay": [100]}}) await vm.port_add_nio_binding(0, nio) sent = [call[0][0] for call in vm._ubridge_send.call_args_list] From 031abf0e525bc31d32181628c515201b5585ed4d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 12 Oct 2026 20:09:31 +0800 Subject: [PATCH 03/18] docs: record the VPCS anchor startup sequence and capability model The ordered startup (uBridge creates the persistent TAP and hands it to the server's uid before vpcs is launched, vpcs attaches with -e -d), why the attach needs no privileges while creating a TAP does, that uBridge stays off the data path, and that the setcap in the vpcs install target is for standalone use only. --- docs/features/vpcs-kernel-datapath.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/features/vpcs-kernel-datapath.md b/docs/features/vpcs-kernel-datapath.md index c0f49e362..6339b53c1 100644 --- a/docs/features/vpcs-kernel-datapath.md +++ b/docs/features/vpcs-kernel-datapath.md @@ -11,7 +11,17 @@ A VPCS node's single adapter anchors on a **persistent TAP** that the server cre ## Architecture -The anchor follows the shared naming contract of `gns3server/utils/kernel_anchor.py`: `gp{node_id[:8]}e0p0` (14 chars, within the kernel's 16-byte `IFNAMSIZ`). uBridge creates it at node start (`tap create`, `tap set_owner `, `link set down`) before the vpcs process is launched; vpcs opens it by name with `-e -d` and speaks its user-space IP stack through the tap fd. The per-link bridge, filters, capture, markers and suspend all ride the same mixin paths as every other anchor-backed node type — including the Ethernet switch fast path (a switch link absorbs the `gp` anchor into the switch's own bridge). +The anchor follows the shared naming contract of `gns3server/utils/kernel_anchor.py`: `gp{node_id[:8]}e0p0` (14 chars, within the kernel's 16-byte `IFNAMSIZ`). The startup sequence is ordered — the TAP must exist before the process that reads it: + +``` +server → uBridge: tap create "gp70264b59e0p0" open /dev/net/tun + TUNSETIFF + TUNSETPERSIST; born administratively DOWN +server → uBridge: tap set_owner gp70264b59e0p0 hand the device to the server's own uid (vpcs is its child process) +server → vpcs: vpcs -e -d gp70264b59e0p0 -n tap mode, attach to that device, no host-side configuration +``` + +vpcs's `open_tap()` opens `/dev/net/tun` and issues `TUNSETIFF` with that name: the device already exists and its owner is vpcs's own uid, so the kernel takes the *attach* path — no privileges needed. (Only *creating* a tun/tap device needs `CAP_NET_ADMIN`; attaching to an owned one does not.) uBridge is never in the data path: a frame travels `vpcs read/write ↔ tap fd ↔ gp anchor ↔ per-link bridge ↔ peer anchor`, and every interface-level operation afterwards — carrier (`link set up/down`), bridge membership, tc filters, capture, markers — is the server driving uBridge over its control channel. The per-link bridge, filters, capture, markers and suspend all ride the same mixin paths as every other anchor-backed node type — including the Ethernet switch fast path (a switch link absorbs the `gp` anchor into the switch's own bridge). + +The `setcap cap_net_admin` in the vpcs install target covers *standalone* use, where vpcs creates and activates its own TAP: creation always needs `CAP_NET_ADMIN`, and so does the `open_tap()` IFF_UP activation. Under GNS3 the capability is unnecessary — the activation silently no-ops without it (the server drives the anchor's carrier itself), which is why the server-side gating never asks for it. ```mermaid flowchart LR From 90917c4a2c5836125612f5ac6cad61298306dab7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 13 Oct 2026 17:10:17 +0800 Subject: [PATCH 04/18] fix(docker): accept the pinned image id when the stores report different ids Docker ids are not comparable across image stores: for the same content a classic store reports the config digest while a containerd store (or an image loaded from an OCI archive) reports the manifest digest. The controller pins the id of its own daemon, so a compute whose store reports another id for that content answered ImageMissingError forever: every node creation streamed the image again (save/load round trip) without ever converging, then failed with "The image 'x' is missing" after the six retries. Docker 29 makes this easy to hit, since the id a daemon reports now depends on how the image entered it. The load endpoint now takes the tag and the digest pinned by the controller, and the compute records how its own daemon identifies the image it just received. Node creation accepts the pin through that pair, and only while the tag still resolves to the synced content: a tag moved to some other image, or an image the controller never pushed here, still counts as missing. The pair lives in the compute process, so a restarted compute syncs once more. Computes that predate the new endpoint parameters keep the previous behaviour, as do controllers that do not send them. --- gns3server/api/routes/compute/images.py | 18 ++++-- gns3server/compute/docker/__init__.py | 45 +++++++++++++- gns3server/compute/docker/docker_vm.py | 21 ++++--- gns3server/controller/node.py | 25 ++++++-- tests/api/routes/compute/test_images.py | 13 ++++ tests/compute/docker/test_docker.py | 32 ++++++++++ tests/compute/docker/test_docker_vm.py | 43 +++++++++++++ tests/controller/test_node.py | 81 ++++++++++++++++++++++++- 8 files changed, 259 insertions(+), 19 deletions(-) diff --git a/gns3server/api/routes/compute/images.py b/gns3server/api/routes/compute/images.py index beb2e9582..5e5f6410b 100644 --- a/gns3server/api/routes/compute/images.py +++ b/gns3server/api/routes/compute/images.py @@ -20,9 +20,9 @@ API routes for images. import os import urllib.parse -from typing import List +from typing import List, Optional -from fastapi import APIRouter, Body, HTTPException, Request, status +from fastapi import APIRouter, Body, HTTPException, Query, Request, status from fastapi.responses import FileResponse from gns3server.compute.docker import Docker @@ -54,13 +54,23 @@ async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pat @router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT) -async def load_docker_image(request: Request) -> None: +async def load_docker_image( + request: Request, + image: Optional[str] = Query(None, pattern=r"^\S+$", description="Tag of the image in the archive"), + digest: Optional[str] = Query( + None, pattern=r"^\S+$", description="Image id reported by the controller host daemon" + ), +) -> None: """ Load a Docker image into the Docker daemon from a docker save tar stream. + + The optional image tag and digest let the compute remember which image the + controller pushed and how the local daemon identifies it (see + Docker.load_image). """ docker_manager = Docker.instance() - await docker_manager.load_image(request.stream()) + await docker_manager.load_image(request.stream(), image=image, digest=digest) @router.get("/dynamips/images") diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index 183d39e00..18d180e2b 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -66,6 +66,11 @@ class Docker(BaseManager): self._session = None self._api_version = DOCKER_MINIMUM_API_VERSION self._host_checked = False + # Image name -> (controller digest, local id) for the images synced from + # the controller. The controller pins the id its own daemon reports, + # which is not comparable with the id this daemon reports for the same + # content (see load_image()). + self._image_id_translations = {} def _select_node_class(self, **kwargs): """ @@ -419,11 +424,16 @@ class Docker(BaseManager): progress_callback(f"Success pulling image {image}") @locking - async def load_image(self, stream, progress_callback=None): + async def load_image(self, stream, image=None, digest=None, progress_callback=None): """ Load a Docker image into the Docker daemon from a docker save tar stream :param stream: An async iterable of bytes (the tar produced by docker save) + :param image: Tag of the image in the archive. Together with digest, the + (controller digest, local id) pair is remembered so that node creation + can accept the digest the controller pinned for this image + :param digest: Image id as reported by the Docker daemon on the controller + host (the value the controller pins in the node create payload) :param progress_callback: A function that receive a log message about image load progress """ @@ -470,6 +480,39 @@ class Docker(BaseManager): if progress_callback: progress_callback("Docker image loaded") + if image and digest: + await self._remember_image_id_translation(image, digest) + + async def _remember_image_id_translation(self, image, controller_digest): + """ + Remember how the local daemon identifies an image that was just loaded + from the controller. + + Docker ids are not comparable across image stores: for the same content + a classic store reports the config digest, while a containerd store (or + an image loaded from an OCI archive) reports the manifest digest. The + controller pins the id of its own daemon, so recording the pair lets + DockerVM accept that pin for the image it actually received instead of + asking for another full sync on every node creation. + """ + + try: + image_infos = await self.query("GET", f"images/{image}/json") + except DockerError as e: + log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}") + return + local_id = image_infos.get("Id") + if local_id: + self._image_id_translations[image] = (controller_digest, local_id) + + def image_id_translation(self, image): + """ + :returns: the (controller digest, local id) pair recorded the last time + this image was loaded from the controller, None otherwise + """ + + return self._image_id_translations.get(image) + async def list_images(self): """ Gets Docker image list. diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index c5cbbc19c..3dcd7174d 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -591,15 +591,20 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): if image_infos is None: raise DockerError(f"Cannot get information for image '{self._image}', please try again.") - if self._image_digest and image_infos.get("Id") != self._image_digest: + local_id = image_infos.get("Id") + if self._image_digest and local_id != self._image_digest: # the tag exists but points to a different image (e.g. a moved :latest): - # report it as missing so the controller re-syncs the expected version - local_id = image_infos.get("Id") - log.info( - f"Image '{self._image}' version mismatch on this compute: " - f"local id '{local_id}' != expected '{self._image_digest}'" - ) - raise ImageMissingError(self._image) + # report it as missing so the controller re-syncs the expected version. + # Image ids are not comparable across image stores (a classic store + # reports the config digest where a containerd store, or an image + # loaded from an OCI archive, reports the manifest digest), so accept + # the pin for the image this compute received from the controller. + if self.manager.image_id_translation(self._image) != (self._image_digest, local_id): + log.info( + f"Image '{self._image}' version mismatch on this compute: " + f"local id '{local_id}' != expected '{self._image_digest}'" + ) + raise ImageMissingError(self._image) available_cpus = psutil.cpu_count(logical=True) if self._cpus > available_cpus: diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 6fd35d653..6022ff982 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -527,7 +527,9 @@ class Node: res = False if last_missing_image: try: - res = await self._upload_missing_image(self._node_type, last_missing_image) + res = await self._upload_missing_image( + self._node_type, last_missing_image, data.get("image_digest") + ) except ControllerError as upload_error: # For Docker the image is pulled from a registry: a # failed pull (unknown tag, no network, private image) @@ -1075,14 +1077,18 @@ class Node: f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs ) - async def _upload_missing_image(self, type, img): + async def _upload_missing_image(self, type, img, image_digest=None): """ Search an image on local computer and upload it to remote compute if the image exists + + :param image_digest: digest pinned for this image in the create payload, + forwarded so the compute can translate it (see + _sync_missing_docker_image) """ if self._node_type == "docker": - return await self._sync_missing_docker_image(img) + return await self._sync_missing_docker_image(img, image_digest) image = self._find_local_image(type, img) if image is None: @@ -1119,11 +1125,17 @@ class Node: if image_id: data["image_digest"] = image_id - async def _sync_missing_docker_image(self, image): + async def _sync_missing_docker_image(self, image, image_digest=None): """ Export a Docker image from the Docker daemon on the controller host and stream it to the remote compute, or ask the compute to pull it when the image is not available locally + + :param image_digest: digest pinned for this image in the create payload. + It is sent along with the archive so the compute can remember how its + own daemon identifies the image it received: image ids are not + comparable across image stores, so the pin alone would keep being + reported as a mismatch after every sync. """ from gns3server.compute.docker import Docker @@ -1147,8 +1159,11 @@ class Node: self.project.emit_notification( "log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"} ) + params = {"image": image} + if image_digest: + params["digest"] = image_digest try: - await self._compute.post("/docker/images/load", data=response.content, timeout=None) + await self._compute.post("/docker/images/load", data=response.content, timeout=None, params=params) finally: response.close() self.project.emit_notification( diff --git a/tests/api/routes/compute/test_images.py b/tests/api/routes/compute/test_images.py index f1a3da75e..8d4e0c5d5 100644 --- a/tests/api/routes/compute/test_images.py +++ b/tests/api/routes/compute/test_images.py @@ -54,3 +54,16 @@ class TestImagesRoutes: stream = mock.call_args[0][0] assert hasattr(stream, "__aiter__") assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_load_docker_image_with_image_and_digest(self, app: FastAPI, compute_client: AsyncClient) -> None: + + digest = "sha256:" + "a" * 64 + with asyncio_patch("gns3server.compute.docker.Docker.load_image") as mock: + response = await compute_client.post( + app.url_path_for("compute:load_docker_image"), + params={"image": "gns3/frr:latest", "digest": digest}, + content=b"docker-save-tar-bytes", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + # the tag and the digest pinned by the controller reach the manager + assert mock.call_args[1] == {"image": "gns3/frr:latest", "digest": digest} diff --git a/tests/compute/docker/test_docker.py b/tests/compute/docker/test_docker.py index ed3fb706e..5905c30ea 100644 --- a/tests/compute/docker/test_docker.py +++ b/tests/compute/docker/test_docker.py @@ -276,6 +276,38 @@ async def test_load_image(): assert any("Loaded image" in message for message in messages) +@pytest.mark.asyncio +async def test_load_image_records_image_id_translation(): + """ + The digest pinned by the controller is the id of its own daemon and is not + comparable with the id this daemon reports for the same content, so the pair + is remembered to accept the pin during node creation. + """ + + class Content: + def __init__(self): + self._chunks = [b'{"stream": "Loaded image: ubuntu:latest"}', b""] + + async def read(self, size): + return self._chunks.pop(0) + + response = MagicMock() + response.content = Content() + + async def tar_stream(): + yield b"tar-bytes" + + controller_digest = "sha256:" + "a" * 64 + local_id = "sha256:" + "b" * 64 + with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": local_id}) as query_mock: + await Docker.instance().load_image(tar_stream(), image="ubuntu:latest", digest=controller_digest) + + query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json") + assert Docker.instance().image_id_translation("ubuntu:latest") == (controller_digest, local_id) + assert Docker.instance().image_id_translation("nginx:latest") is None + + @pytest.mark.asyncio async def test_load_image_error(): diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index dbddda0ae..efc7b0437 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -538,6 +538,49 @@ async def test_create_image_digest_mismatch(compute_project, manager): query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json") +@pytest.mark.asyncio +async def test_create_image_digest_translated_for_this_daemon(compute_project, manager): + """ + Image ids are not comparable across image stores: the daemon on the + controller host may pin the config digest while this daemon reports the + manifest digest for the same content. An image this compute received from + the controller is accepted through the translation recorded at load time. + """ + + controller_digest = "sha256:" + "a" * 64 + local_id = "sha256:" + "b" * 64 + manager._image_id_translations["ubuntu:latest"] = (controller_digest, local_id) + + response = {"Id": local_id, "Warnings": []} + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = DockerVM( + "test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest=controller_digest + ) + await vm.create() + # the last query is the container creation: the translated pin let it through + assert mock.call_args[0] == ("POST", f"containers/create?name={vm.docker_name}") + assert vm._cid == local_id + + +@pytest.mark.asyncio +async def test_create_image_digest_translated_only_for_the_synced_content(compute_project, manager): + """ + A tag that now points to some other image on this daemon stays missing, even + when this image name was synced from the controller before. + """ + + controller_digest = "sha256:" + "a" * 64 + manager._image_id_translations["ubuntu:latest"] = (controller_digest, "sha256:" + "c" * 64) + + response = {"Id": "sha256:" + "b" * 64, "Warnings": []} + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest=controller_digest) + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as query_mock: + with pytest.raises(ImageMissingError, match="ubuntu:latest"): + await vm.create() + # only the image inspect happened: no container was created from the stale image + query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json") + + @pytest.mark.asyncio async def test_create_with_user(compute_project, manager): diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 788b560bb..f17eaea3d 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -1110,10 +1110,89 @@ async def test_sync_missing_docker_image_from_controller_daemon(compute, control with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as save_mock: assert await node._upload_missing_image("docker", "gns3/frr:latest") is True save_mock.assert_called_with("GET", "images/gns3/frr:latest/get", timeout=None) - compute.post.assert_called_with("/docker/images/load", data=response.content, timeout=None) + compute.post.assert_called_with( + "/docker/images/load", data=response.content, timeout=None, params={"image": "gns3/frr:latest"} + ) response.close.assert_called_once_with() +@pytest.mark.asyncio +async def test_sync_missing_docker_image_forwards_pinned_digest(compute, controller): + + project = Project(str(uuid.uuid4()), controller=controller) + node = Node( + project, compute, "demo", node_id=str(uuid.uuid4()), node_type="docker", properties={"image": "gns3/frr:latest"} + ) + + digest = "sha256:" + "a" * 64 + response = MagicMock() + response.content = MagicMock() + with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response): + assert await node._upload_missing_image("docker", "gns3/frr:latest", digest) is True + compute.post.assert_called_with( + "/docker/images/load", + data=response.content, + timeout=None, + params={"image": "gns3/frr:latest", "digest": digest}, + ) + + +@pytest.mark.asyncio +async def test_create_docker_node_syncs_missing_image_with_the_pinned_digest(compute, controller): + """ + When the compute reports a Docker image as missing, the create retries after + the sync and the load call carries the pinned digest, so the compute can + translate it into the id of its own daemon (image ids are not comparable + across image stores). + """ + + project = Project(str(uuid.uuid4()), controller=controller) + node = Node( + project, + compute, + "demo", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"image": "gns3/frr:latest", "adapters": 1}, + ) + + controller_image_id = "sha256:" + "a" * 64 + conflict = ComputeConflictError( + f"/projects/{project.id}/docker/nodes", + { + "message": "The image 'gns3/frr:latest' is missing", + "image": "gns3/frr:latest", + "exception": "ImageMissingError", + }, + ) + created = MagicMock() + created.status = 200 + created.json = {} + + # the create call, the image sync, then the create retry + responses = [conflict, MagicMock(), created] + + async def resp(*args, **kwargs): + result = responses.pop(0) + if isinstance(result, Exception): + raise result + return result + + compute.post = AsyncioMagicMock(side_effect=resp) + + load_response = MagicMock() + load_response.content = MagicMock() + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": controller_image_id}): + with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=load_response): + assert await node.create() is True + + # first call is the node creation, then the image sync, then the retry + sync_call = compute.post.call_args_list[1] + assert sync_call[0][0] == "/docker/images/load" + assert sync_call[1]["params"] == {"image": "gns3/frr:latest", "digest": controller_image_id} + assert compute.post.call_args_list[2][1]["data"]["image_digest"] == controller_image_id + + @pytest.mark.asyncio async def test_sync_missing_docker_image_pull_fallback(compute, controller): From e8c641bd08e4f13d8ad6aa7039c4685e3c2ecbcd Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 13 Oct 2026 20:18:29 +0700 Subject: [PATCH 05/18] docs: update comments regarding Ethernet switch replacement with Linux bridge --- gns3server/compute/builtin/builtin_node_factory.py | 2 +- gns3server/compute/dynamips/dynamips_factory.py | 2 +- gns3server/compute/dynamips/nodes/ethernet_switch.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gns3server/compute/builtin/builtin_node_factory.py b/gns3server/compute/builtin/builtin_node_factory.py index 082b78c54..7db8854cd 100644 --- a/gns3server/compute/builtin/builtin_node_factory.py +++ b/gns3server/compute/builtin/builtin_node_factory.py @@ -30,7 +30,7 @@ BUILTIN_NODES = {"cloud": Cloud, "nat": Nat, "ethernet_hub": EthernetHub, "ether class BuiltinNodeFactory: """ - Factory to create an builtin object based on the node type. + Factory to create a builtin object based on the node type. """ def __new__(cls, name, node_id, project, manager, node_type, **kwargs): diff --git a/gns3server/compute/dynamips/dynamips_factory.py b/gns3server/compute/dynamips/dynamips_factory.py index 4febe9b7d..be2f1b431 100644 --- a/gns3server/compute/dynamips/dynamips_factory.py +++ b/gns3server/compute/dynamips/dynamips_factory.py @@ -46,7 +46,7 @@ PLATFORMS = { DEVICES = { "atm_switch": ATMSwitch, "frame_relay_switch": FrameRelaySwitch, - "ethernet_switch": EthernetSwitch, + "ethernet_switch": EthernetSwitch, # The Dynamips Ethernet switch has been replaced by a Linux bridge "ethernet_hub": EthernetHub, } diff --git a/gns3server/compute/dynamips/nodes/ethernet_switch.py b/gns3server/compute/dynamips/nodes/ethernet_switch.py index 28ecacc46..eba94950c 100644 --- a/gns3server/compute/dynamips/nodes/ethernet_switch.py +++ b/gns3server/compute/dynamips/nodes/ethernet_switch.py @@ -19,6 +19,8 @@ Interface for Dynamips virtual Ethernet switch module ("ethsw"). http://github.com/GNS3/dynamips/blob/master/README.hypervisor#L558 """ +# Note: the Dynamips Ethernet switch has been replaced by a Linux bridge + import logging from gns3server.utils import parse_version From a839a90e65dd9b41a983d4b4de57a5d980f3186d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:31:48 +0000 Subject: [PATCH 06/18] Update setuptools requirement from >=61.0 to >=84.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v61.0.0...v84.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-version: 84.0.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 44df93d8d..8bff24442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=84.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] From 6bb44d0efcbd95835ac37ea5ce0bf4b0936e642c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:31:53 +0000 Subject: [PATCH 07/18] Update nornir-salt requirement from >=0.23.0 to >=0.23.4 Updates the requirements on [nornir-salt](https://github.com/dmulyalin/nornir-salt) to permit the latest version. - [Release notes](https://github.com/dmulyalin/nornir-salt/releases) - [Commits](https://github.com/dmulyalin/nornir-salt/compare/0.23.0...0.23.4) --- updated-dependencies: - dependency-name: nornir-salt dependency-version: 0.23.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 8b46655a4..25aaa5f1a 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -35,7 +35,7 @@ netmiko>=4.8.0 nornir>=3.5.0 nornir-netmiko>=1.0.1 nornir-utils>=0.2.0 -nornir-salt>=0.23.0 +nornir-salt>=0.23.4 # Environment & Configuration python-dotenv>=1.2.1 From 1ec79601acd07c0f8fd8c93a5a126aba3dd5c573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:32:04 +0000 Subject: [PATCH 08/18] Update langchain-aws requirement from >=1.3.1 to >=1.8.1 Updates the requirements on [langchain-aws](https://github.com/langchain-ai/langchain-aws) to permit the latest version. - [Release notes](https://github.com/langchain-ai/langchain-aws/releases) - [Commits](https://github.com/langchain-ai/langchain-aws/compare/langchain-aws==1.3.1...langchain-aws==1.8.1) --- updated-dependencies: - dependency-name: langchain-aws dependency-version: 1.8.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 8b46655a4..50910a1bb 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -18,7 +18,7 @@ langgraph-checkpoint-postgres>=3.0.4 langchain-openai>=1.1.10 langchain-anthropic>=1.3.4 langchain-google-genai>=4.2.1 -langchain-aws>=1.3.1 +langchain-aws>=1.8.1 langchain-ollama>=1.0.1 langchain-deepseek>=1.0.1 langchain-xai>=1.2.2 From af430439ce7be95634437a15ce33db9569d2d944 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:32:08 +0000 Subject: [PATCH 09/18] Update pyyaml requirement from >=6.0.0 to >=6.0.3 Updates the requirements on [pyyaml](https://github.com/yaml/pyyaml) to permit the latest version. - [Release notes](https://github.com/yaml/pyyaml/releases) - [Changelog](https://github.com/yaml/pyyaml/blob/6.0.3/CHANGES) - [Commits](https://github.com/yaml/pyyaml/compare/6.0...6.0.3) --- updated-dependencies: - dependency-name: pyyaml dependency-version: 6.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- ai-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-requirements.txt b/ai-requirements.txt index 8b46655a4..5f6bfad21 100644 --- a/ai-requirements.txt +++ b/ai-requirements.txt @@ -45,7 +45,7 @@ PyJWT>=2.10.1 psycopg-pool>=3.1.0 # Skills Repository Management -PyYAML>=6.0.0 +PyYAML>=6.0.3 GitPython>=3.1.0 # Note: httpx is only for testing (in dev-requirements.txt) From c0568dc25f79bf727599de54204800617d8479ae Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 00:14:41 +0800 Subject: [PATCH 10/18] fix: hide kernel-datapath interfaces from the cloud node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud node offered the kernel datapath's host-side interfaces — node anchors (gv/gq/gi/gd/gx/gp), switch cascade veth ends (gs) and cross-compute VXLAN devices (gvl) — in its interface list and even pre-filled them as ports on a freshly created cloud: none of them is flagged special and only "gns3*" names were filtered. These interfaces belong to the link or node that created them for the project's whole lifetime, so bridging a cloud to one would race its owner. Add is_kernel_datapath_interface() next to the naming contract in utils/kernel_anchor.py: exact-shape matching (prefix plus its fixed-length id-derived tail), so a user interface that merely starts with "gv" is never swallowed. The cloud keeps managed names out of its host-interface list and default ports mapping, while saved mappings keep working (the existence checks still see the full interface list). --- gns3server/compute/builtin/nodes/cloud.py | 11 +++++-- gns3server/utils/kernel_anchor.py | 34 +++++++++++++++++++++ tests/compute/builtin/nodes/test_cloud.py | 30 ++++++++++++++++++ tests/utils/test_kernel_anchor.py | 37 ++++++++++++++++++++++- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/gns3server/compute/builtin/nodes/cloud.py b/gns3server/compute/builtin/nodes/cloud.py index c1cfdc251..2ffeda579 100644 --- a/gns3server/compute/builtin/nodes/cloud.py +++ b/gns3server/compute/builtin/nodes/cloud.py @@ -21,6 +21,7 @@ import sys import gns3server.utils.asyncio import gns3server.utils.interfaces from gns3server.compute.ubridge.ubridge_error import UbridgeError +from gns3server.utils.kernel_anchor import is_kernel_datapath_interface from ...base_node import BaseNode from ...error import NodeError @@ -52,7 +53,9 @@ class Cloud(BaseNode): if not ports or len(ports) == 0: self._ports_mapping = [] for interface in self._interfaces(): - if not interface["special"]: + # kernel-datapath interfaces (node anchors, cascade veths, VXLAN + # devices) belong to their creating link or node, never the cloud + if not interface["special"] and not is_kernel_datapath_interface(interface["name"]): self._ports_mapping.append( { "interface": interface["name"], @@ -80,8 +83,10 @@ class Cloud(BaseNode): host_interfaces = [] network_interfaces = gns3server.utils.interfaces.interfaces() for interface in network_interfaces: - # Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges) - if interface["name"].lower().startswith("gns3"): + # Hide GNS3 internal interfaces: kernel bridges ("gns3*" names) and + # the kernel-datapath devices (node anchors, cascade veths, VXLAN + # devices) a link or node of some project owns + if interface["name"].lower().startswith("gns3") or is_kernel_datapath_interface(interface["name"]): continue host_interfaces.append( { diff --git a/gns3server/utils/kernel_anchor.py b/gns3server/utils/kernel_anchor.py index b5f39b784..7ca223f85 100644 --- a/gns3server/utils/kernel_anchor.py +++ b/gns3server/utils/kernel_anchor.py @@ -36,6 +36,7 @@ is what makes links to stopped nodes wireable. """ import logging +import re log = logging.getLogger(__name__) @@ -113,3 +114,36 @@ def kernel_cascade_names(link_id): stem = str(link_id).replace("-", "")[:10] return f"gs{stem}0", f"gs{stem}1" + + +# Host-side interfaces the kernel datapath owns outright, recognised by +# exact shape — the prefix plus its fixed-length id-derived tail — never by +# bare prefix, so a user interface that merely starts with "gv" or "gs" is +# not swallowed. Consumers keep these out of human-facing interface lists: +# each belongs to the link or node that created it for as long as the +# project exists (the names are deterministic, valid whether it runs or +# not), so nothing else may bridge to it. +_MANAGED_ANCHOR_PREFIXES = frozenset(ANCHOR_PREFIX.values()) +_MANAGED_ANCHOR_SUFFIX_SHAPE = re.compile(r"[0-9a-f]{8}e\d{1,2}p\d{1,2}") +_MANAGED_CASCADE_SHAPE = re.compile(r"gs[0-9a-f]{10}[01]") +# a cross-compute VXLAN device: "gvl" + 8 hex of the link id + 4 hex of the +# endpoint node id (the cross-compute VXLAN naming keeps its devices out of +# the anchor and "gns3" namespaces the same deterministic way) +_MANAGED_VXLAN_SHAPE = re.compile(r"gvl[0-9a-f]{12}") + + +def is_kernel_datapath_interface(name): + """ + Whether *name* is a host interface the kernel datapath manages by name — + a node anchor, a switch cascade veth end, or a cross-compute VXLAN + device. True for exactly the names the naming contract above can + produce: the cloud node (and any other interface picker) uses this to + refuse offering them, since bridging to one would race whatever link or + node already owns it. + """ + + # "gvl" begins with the docker anchor prefix "gv", so an anchor miss must + # still fall through to the VXLAN shape + if name[:2] in _MANAGED_ANCHOR_PREFIXES and _MANAGED_ANCHOR_SUFFIX_SHAPE.fullmatch(name[2:]): + return True + return _MANAGED_CASCADE_SHAPE.fullmatch(name) is not None or _MANAGED_VXLAN_SHAPE.fullmatch(name) is not None diff --git a/tests/compute/builtin/nodes/test_cloud.py b/tests/compute/builtin/nodes/test_cloud.py index 14cb7633d..180ab167d 100644 --- a/tests/compute/builtin/nodes/test_cloud.py +++ b/tests/compute/builtin/nodes/test_cloud.py @@ -156,6 +156,36 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager): } +async def test_kernel_datapath_interfaces_are_hidden(linux_platform, compute_project, manager): + """ + Kernel-datapath interfaces (node anchors, cascade veths, VXLAN devices) + are neither pre-filled as cloud ports nor listed as host interfaces, + while look-alike user interfaces survive both. + """ + + host_interfaces = [ + {"name": "eth0", "special": False, "type": "ethernet"}, + {"name": "gv00010203e0p0", "special": False, "type": "ethernet"}, # docker anchor + {"name": "gq00010203e0p0", "special": False, "type": "ethernet"}, # qemu anchor + {"name": "gi00010203e0p0", "special": False, "type": "ethernet"}, # iou anchor + {"name": "gd00010203e0p0", "special": False, "type": "ethernet"}, # dynamips anchor + {"name": "gx00010203e0p0", "special": False, "type": "ethernet"}, # iol runner anchor + {"name": "gp00010203e0p0", "special": False, "type": "ethernet"}, # vpcs anchor + {"name": "gs1a2b3c4d5e0", "special": False, "type": "ethernet"}, # cascade veth end + {"name": "gvl1a2b3c4d0001", "special": False, "type": "ethernet"}, # VXLAN device + {"name": "gv0", "special": False, "type": "ethernet"}, # look-alike user interface + {"name": "gs1a2b3c4d5e", "special": False, "type": "ethernet"}, # look-alike (no side digit) + {"name": "gvl1a2b3c4d5e", "special": False, "type": "ethernet"}, # look-alike (short tail) + ] + + with patch("gns3server.utils.interfaces.interfaces", return_value=host_interfaces): + cloud = Cloud("cloud1", str(uuid.uuid4()), compute_project, manager, ports=None) + interfaces = [interface["name"] for interface in cloud.asdict()["interfaces"]] + + assert [port["interface"] for port in cloud.ports_mapping] == ["eth0", "gv0", "gs1a2b3c4d5e", "gvl1a2b3c4d5e"] + assert interfaces == ["eth0", "gv0", "gs1a2b3c4d5e", "gvl1a2b3c4d5e"] + + async def test_update_port_mappings(on_gns3vm, compute_project): """ We don't allow an empty interface in the middle of port list diff --git a/tests/utils/test_kernel_anchor.py b/tests/utils/test_kernel_anchor.py index dda820b56..c9a298d23 100644 --- a/tests/utils/test_kernel_anchor.py +++ b/tests/utils/test_kernel_anchor.py @@ -21,7 +21,13 @@ Ethernet-switch link), plus the cascade veth naming for switch-to-switch links. """ -from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_anchor_type, kernel_cascade_names +from gns3server.utils.kernel_anchor import ( + anchor_suffix, + is_kernel_datapath_interface, + kernel_anchor_name, + kernel_anchor_type, + kernel_cascade_names, +) NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f" LINK_ID = "1a2b3c4d-5e6f-4a5b-8c9d-0e1f2a3b4c5d" @@ -86,3 +92,32 @@ def test_cascade_names_stay_out_of_the_node_anchor_namespace(): # cascade end is never mistaken for another node's anchor for end in kernel_cascade_names(LINK_ID): assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd", "gx")) + + +def test_every_name_the_contract_produces_is_a_managed_interface(): + for node_type in ("docker", "qemu", "iou", "dynamips", "iol_docker", "vpcs"): + assert is_kernel_datapath_interface(kernel_anchor_name(node_type, NODE_ID, 0, 0)) + for end in kernel_cascade_names(LINK_ID): + assert is_kernel_datapath_interface(end) + # a cross-compute VXLAN device: "gvl" + 8 hex of the link id + 4 hex of the node id + assert is_kernel_datapath_interface("gvl" + LINK_ID.replace("-", "")[:8] + NODE_ID.replace("-", "")[:4]) + + +def test_lookalike_interfaces_are_not_managed(): + # recognition is by exact shape, not bare prefix: user interfaces that + # merely start with a managed prefix stay visible to the cloud node + for name in ( + "eth0", + "g", + "gs", + "gvl", + "gv0", + "gp1", + "gv00010203e0p", # anchor without the port + "gv00010203Xe0p0", # non-hex node id + "gs1a2b3c4d5e", # cascade without the side digit + "gs1a2b3c4d5e2", # side digit other than 0/1 + "gvl1a2b3c4d5e", # VXLAN with a short tail + "GV00010203E0P0", # the contract only ever produces lowercase + ): + assert not is_kernel_datapath_interface(name) From 9e0d5050db7d00c1ca4050d30b034d2cec5ce7c1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 21:52:34 +0800 Subject: [PATCH 11/18] feat: concurrent kernel captures, one per interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uBridge's capture module now serves one AF_PACKET capture per interface, any number per process, and `capture stop_kernel` takes the interface it stops — this rides that (breaking, paired-binary) protocol change. The server-side single-slot tracker (_kernel_capture_ifc with its reserve/release/owned_by helpers) is gone: same-port duplicates were already refused by the NIO capturing flag, uBridge answers EALREADY on a same-interface duplicate, and per-interface stops removed the process- wide stop the ownership gating guarded against. The docker e2e drives ten concurrent captures on one node's uBridge and asserts a per-interface stop freezes exactly the stopped half while the live half keeps recording. --- docs/features/docker-kernel-datapath.md | 2 +- gns3server/compute/base_node.py | 4 - .../compute/builtin/nodes/ethernet_switch.py | 15 +-- gns3server/compute/docker/docker_vm.py | 19 ++-- gns3server/compute/dynamips/nodes/router.py | 16 +-- gns3server/compute/iou/iou_vm.py | 16 +-- gns3server/compute/kernel_datapath.py | 51 +-------- gns3server/compute/qemu/qemu_vm.py | 15 +-- gns3server/compute/vpcs/vpcs_vm.py | 15 +-- .../compute/test_ethernet_switch_nodes.py | 2 +- .../docker/test_docker_kernel_datapath.py | 63 ++++------- .../dynamips/test_dynamips_kernel_datapath.py | 2 +- tests/compute/iou/test_iou_kernel_datapath.py | 2 +- .../compute/qemu/test_qemu_kernel_datapath.py | 2 +- tests/e2e/test_docker_kernel_datapath.py | 102 ++++++++++++++++++ 15 files changed, 155 insertions(+), 171 deletions(-) diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index 64a2f6079..6e527dd99 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -243,7 +243,7 @@ The veth pairs already exist at attach time — every adapter is born as a veth ## Capture -Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel "" [dlt]` / `capture stop_kernel`. Single capture per uBridge process (second concurrent → EALREADY): the server tracks which anchor owns the slot, refuses a second port's capture with a clear error instead of leaving it wedged, and only the owning port's stop issues the process-wide `capture stop_kernel`. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge. +Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel "" [dlt]` / `capture stop_kernel `. One capture per interface, any number of interfaces concurrently — a node can capture several ports at once; a same-interface duplicate answers EALREADY, which the server's per-port `capturing` flag already refuses before uBridge sees it. Stops are per interface too: each port's stop names its own anchor, never touching a sibling port's capture. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge. ## Markers diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 9c53bb0aa..0913e1758 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1057,10 +1057,6 @@ class BaseNode: # than skipping them as "already installed". self._marker_filter_bridges.clear() self._marker_specs.clear() - # ...and so is the kernel capture, uBridge's single AF_PACKET slot - # (meaningful for the kernel-datapath node types; see - # KernelDatapathMixin._reserve_kernel_capture). - self._kernel_capture_ifc = None async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio): """ diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 6c11a2238..5989ccc7c 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -768,14 +768,11 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): anchor = self._kernel_ports.get(port_number) if anchor is not None: - # One capture per uBridge process — see - # _reserve_kernel_capture; a failed start rolls the port's - # flag back so it cannot later stop the winner's capture. + # One capture per interface; a failed start rolls the port's + # flag back. try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -797,12 +794,8 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): anchor = self._kernel_ports.get(port_number) if anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: ubridge_bridge = self._ubridge_bridge_name(port_number) await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 3dcd7174d..341f773ea 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -2012,16 +2012,13 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): if isinstance(nio, NIOBridge): # Kernel datapath: no relay bridge exists — capture via uBridge's # AF_PACKET module bound to the anchor (a veth host end for the - # standard container, a persistent TAP for an IOL runner). One - # capture per uBridge process: the slot is claimed per anchor, - # and a failed start rolls the port's capturing flag back so the - # port cannot later stop the winner's capture. + # standard container, a persistent TAP for an IOL runner). + # uBridge serves one capture per interface, any number of + # interfaces; a failed start rolls the port's capturing flag back. host_ifc = self._kernel_host_ifc(adapter_number, port_number) try: - self._reserve_kernel_capture(host_ifc) await self._ubridge_send(f'capture start_kernel {host_ifc} "{output_file}"') except Exception: - self._release_kernel_capture(host_ifc) nio.stop_packet_capture() raise return @@ -2043,13 +2040,11 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): raise DockerError("Cannot stop the packet capture: uBridge is not running") nio = self._ethernet_adapters[adapter_number].get_nio(port_number) if isinstance(nio, NIOBridge): - # Idempotent on the uBridge side (no active capture is OK), but - # process-wide and argument-less: only the port owning the slot - # may stop it, or a second port's stop would kill this capture. + # Per-interface stop: only this port's capture is stopped (the + # capturing flag above gates it, so uBridge's 208-on-idle- + # interface can only surface on a genuine desync). host_ifc = self._kernel_host_ifc(adapter_number, port_number) - if self._kernel_capture_owned_by(host_ifc): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(host_ifc) + await self._ubridge_send(f'capture stop_kernel {host_ifc}') return bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send(f"bridge stop_capture {bridge_name}") diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 439d9c10f..863d3eb4d 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -1491,15 +1491,11 @@ class Router(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(slot_number, port_number) if anchor is not None: - # One capture per uBridge process — see - # _reserve_kernel_capture; a failed start rolls the - # port's flag back so it cannot later stop the winner's - # capture. + # One capture per interface; a failed start rolls the + # port's flag back. try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise log.debug( @@ -1538,11 +1534,9 @@ class Router(KernelDatapathMixin, BaseNode): if isinstance(nio, NIOBridge): nio.stop_packet_capture() anchor = self._kernel_host_ifc(slot_number, port_number) - if self.ubridge and anchor is not None and self._kernel_capture_owned_by(anchor): - # Process-wide and argument-less: only the port owning the - # slot may stop it. - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + if self.ubridge and anchor is not None: + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') return await nio.stop_packet_capture() diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index f11cac8af..3ec1602a7 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1828,19 +1828,16 @@ class IOUVM(KernelDatapathMixin, BaseNode): if self.ubridge: if isinstance(nio, NIOBridge): # Kernel link: capture on the port's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). anchor = self._kernel_host_ifc(adapter_number, port_number) try: if anchor is None: raise self._kernel_error( f"Bay {adapter_number}/{port_number} of IOU '{self._name}' has no TAP anchor to capture on" ) - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -1869,13 +1866,10 @@ class IOUVM(KernelDatapathMixin, BaseNode): log.debug(f'IOU "{self._name}" [{self._id}]: stopping packet capture on {adapter_number}/{port_number}') if self.ubridge: if isinstance(nio, NIOBridge): - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). + # Per-interface stop: only this port's capture is stopped. anchor = self._kernel_host_ifc(adapter_number, port_number) - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + if anchor is not None: + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send( f"iol_bridge stop_capture {self._iol_bridge_name()} {adapter_number} {port_number}" diff --git a/gns3server/compute/kernel_datapath.py b/gns3server/compute/kernel_datapath.py index 4c1836137..f49275a45 100644 --- a/gns3server/compute/kernel_datapath.py +++ b/gns3server/compute/kernel_datapath.py @@ -264,52 +264,6 @@ class KernelDatapathMixin: return not (isinstance(nio, NIOBridge) and nio.bridge is None) - # The anchor whose kernel capture currently holds the uBridge process's - # single AF_PACKET capture slot (None = free; see _reserve_kernel_capture). - # Class-level default so every node type gets it without touching its - # __init__; BaseNode._stop_ubridge resets it when uBridge goes away. - _kernel_capture_ifc = None - - def _reserve_kernel_capture(self, anchor): - """ - Claim uBridge's single kernel-capture slot for *anchor*. - - uBridge serves one AF_PACKET capture per process (a static slot: a - second ``capture start_kernel`` answers EALREADY) while the server - models captures per port — a second kernel port of the same node is - refused here with a clear error. Without the claim the loser's port - would stay marked as capturing, and its stop would issue the - process-wide, argument-less ``capture stop_kernel``, killing the - winner's capture while its own flag still says capturing. - """ - - if self._kernel_capture_ifc is not None and self._kernel_capture_ifc != anchor: - raise self._kernel_error( - f"Cannot start the packet capture: this node's uBridge already captures " - f"{self._kernel_capture_ifc} (one kernel capture per node); stop that capture first" - ) - self._kernel_capture_ifc = anchor - - def _release_kernel_capture(self, anchor): - """ - Release the slot if *anchor* holds it: after a stop, or after a - failed start whose port must not stay marked as capturing. - """ - - if self._kernel_capture_ifc == anchor: - self._kernel_capture_ifc = None - - def _kernel_capture_owned_by(self, anchor): - """ - Whether *anchor* may issue the process-wide ``capture stop_kernel``: - only the port holding the slot — or a node whose slot is empty (a - capture predating the tracker), where stopping is the historical - best-effort behaviour. A second port must never reach it: the - command takes no interface and would stop the owner's capture. - """ - - return self._kernel_capture_ifc is None or self._kernel_capture_ifc == anchor - async def _kernel_attach(self, anchor, nio): """ Attach an anchor to the per-link kernel bridge carried by *nio* @@ -342,10 +296,7 @@ class KernelDatapathMixin: await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"') if nio.capturing: # Restore a capture that was active before a node restart - # (mirrors the relay path's start_capture). The slot is claimed - # like a fresh start: a second kernel port's replay is refused - # with a clear error instead of an EALREADY deep in the wiring. - self._reserve_kernel_capture(anchor) + # (mirrors the relay path's start_capture). await self._ubridge_send(f'capture start_kernel {anchor} "{nio.pcap_output_file}"') # Markers carried by the NIO attach to the anchor (AF_PACKET taps) — # the anchor is the interface, not a relay bridge. diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index dac2c219f..24e296fe5 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1758,14 +1758,11 @@ class QemuVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: # Kernel link: capture on the adapter's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -1792,12 +1789,8 @@ class QemuVM(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 79b9b772d..823b2a35c 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -659,14 +659,11 @@ class VPCSVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: # Kernel link: capture on the adapter's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -693,12 +690,8 @@ class VPCSVM(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 3069c45de..9d2d1c76d 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -792,7 +792,7 @@ class TestEthernetSwitchNodesRoutes: ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - node._ubridge_send.assert_any_call("capture stop_kernel") + node._ubridge_send.assert_any_call(f'capture stop_kernel {anchor}') async def test_ethernet_switch_batch_create_anchor_nio( self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index def336b51..107ad69e3 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -997,14 +997,16 @@ async def test_stop_capture_kernel(vm): await vm.stop_capture(0) assert nio.capturing is False - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc}') @pytest.mark.asyncio -async def test_start_capture_second_concurrent_fails(vm): +async def test_start_capture_ubridge_ealready_rolls_the_flag_back(vm): """ - uBridge's kernel capture is a singleton per process: a second concurrent - capture returns EALREADY and must surface to the caller. + uBridge answers EALREADY when its interface is already being captured + (the server's per-port flag should have refused earlier; this is the + desync net): the error must surface and the port must not stay marked + as capturing. """ async def send(command): @@ -1022,19 +1024,16 @@ async def test_start_capture_second_concurrent_fails(vm): await vm.start_capture(0, "/tmp/capture.pcap") # the failed start must not leave the port marked as capturing: a stuck - # flag refuses the retry and lets this port's stop kill the winner + # flag refuses the retry assert nio.capturing is False - assert vm._kernel_capture_ifc is None @pytest.mark.asyncio -async def test_start_capture_second_port_on_the_same_node_is_refused(vm): +async def test_capture_two_ports_at_once_and_stop_them_independently(vm): """ - uBridge serves one kernel capture per process while the server models - captures per port: a second port's capture must be refused with a clear - error and its flag rolled back — otherwise its stop would issue the - process-wide, argument-less ``capture stop_kernel`` and kill the first - port's capture while its own flag still says capturing. + uBridge serves one kernel capture per interface: two ports of the same + node capture concurrently, and each port's stop names only its own + anchor — a sibling port's capture is never touched. """ vm._ubridge_hypervisor = MagicMock() @@ -1051,48 +1050,22 @@ async def test_start_capture_second_port_on_the_same_node_is_refused(vm): vm._ethernet_adapters[1].add_nio(0, nio2) await vm.start_capture(0, "/tmp/capture.pcap") - - with pytest.raises(DockerError, match="one kernel capture per node"): - await vm.start_capture(1, "/tmp/capture2.pcap") + await vm.start_capture(1, "/tmp/capture2.pcap") assert nio1.capturing is True - assert nio2.capturing is False + assert nio2.capturing is True vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc1} "/tmp/capture.pcap"') - assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list) - - -@pytest.mark.asyncio -async def test_stop_capture_on_a_port_that_does_not_own_the_slot_skips_stop_kernel(vm): - """ - ``capture stop_kernel`` is process-wide and takes no interface: a stop on - a port whose flag says capturing while another port owns the slot must - not reach uBridge — it would stop the owner's capture. - """ - - vm._ubridge_hypervisor = MagicMock() - vm._ubridge_send = AsyncioMagicMock() - vm.status = "started" - vm._ethernet_adapters.append(type(vm._ethernet_adapters[0])()) - host_ifc1, _ = vm._veth_names(0, 0) - host_ifc2, _ = vm._veth_names(1, 0) - vm._kernel_veths[(0, 0)] = host_ifc1 - vm._kernel_veths[(1, 0)] = host_ifc2 - nio1 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) - nio2 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) - vm._ethernet_adapters[0].add_nio(0, nio1) - vm._ethernet_adapters[1].add_nio(0, nio2) - - await vm.start_capture(0, "/tmp/capture.pcap") # port 0 owns the slot - nio2.start_packet_capture("/tmp/capture2.pcap") # a stale second flag + vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc2} "/tmp/capture2.pcap"') await vm.stop_capture(1) + assert nio1.capturing is True assert nio2.capturing is False - assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list) + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc2}') + assert not any(f'stop_kernel {host_ifc1}' in str(c) for c in vm._ubridge_send.call_args_list) - # the owner's capture is untouched and still stoppable await vm.stop_capture(0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc1}') @pytest.mark.asyncio diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py index d1af1c808..230a6ca79 100644 --- a/tests/compute/dynamips/test_dynamips_kernel_datapath.py +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -581,7 +581,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(router): assert not any("bind_filter" in c for c in _hypervisor_commands(router)) await router.stop_capture(0, 0) - router._ubridge_send.assert_any_call("capture stop_kernel") + router._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') @pytest.mark.asyncio diff --git a/tests/compute/iou/test_iou_kernel_datapath.py b/tests/compute/iou/test_iou_kernel_datapath.py index 427f19502..0e6df1368 100644 --- a/tests/compute/iou/test_iou_kernel_datapath.py +++ b/tests/compute/iou/test_iou_kernel_datapath.py @@ -371,7 +371,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"') await vm.stop_capture(0, 0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') @pytest.mark.asyncio diff --git a/tests/compute/qemu/test_qemu_kernel_datapath.py b/tests/compute/qemu/test_qemu_kernel_datapath.py index 9c5c211c0..79709d3c9 100644 --- a/tests/compute/qemu/test_qemu_kernel_datapath.py +++ b/tests/compute/qemu/test_qemu_kernel_datapath.py @@ -328,7 +328,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"') await vm.stop_capture(0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP0}') @pytest.mark.asyncio diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index f3473ef55..d1ed9528d 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -316,6 +316,108 @@ def test_docker_kernel_fast_path(): assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge +def test_docker_kernel_ten_concurrent_captures(): + """Ten kernel captures at once on one node's uBridge. The capture side + picks the first local started node, so all ten links capture on node 1's + single uBridge process — the scale the old one-capture-per-process limit + could never reach. Each link gets its own traffic (its own subnet on its + own eth), and a partial stop must leave the untouched captures running.""" + server = harness.live_server(kernel=True) + compute = server.compute + image = harness.ensure_docker_image(server) + + project = compute.create_project("docker-multicap-e2e") + pid = project["project_id"] + try: + daemon = harness.DockerDaemon() + nodes = [] + for suffix, address in (("1", R1_IP), ("2", R2_IP)): + node = compute.create_docker_node(pid, f"MC-{suffix}", image, adapters=10) + harness.configure_docker_interfaces(node, address) + nodes.append(_resolve_container(daemon, node)) + n1, n2 = nodes + c1, c2 = n1["container_id"], n2["container_id"] + n1_id, n2_id = n1["node_id"], n2["node_id"] + + links = [compute.create_link(pid, (n1_id, i, 0), (n2_id, i, 0)) for i in range(10)] + assert all(link["kernel_datapath"] is True for link in links), links + + print(".. starting containers") + for node in nodes: + compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start") + harness.docker_wait_address(daemon, c1, R1_IP) + harness.docker_wait_address(daemon, c2, R2_IP) + + # ten captures before any of the per-link interfaces is addressed, so + # every later frame (up to and including the ARP each first ping + # triggers) lands in a pcap + captures = [ + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}) + for link in links + ] + + # eth1..eth9: one subnet each, wired by hand inside the guests; the + # settle window lets the guests' address-add chatter (each v4 addr + # fires two IGMP reports ~1 s apart) land inside an open capture + # instead of a later snapshot + for i in range(1, 10): + for container_id, host in ((c1, 1), (c2, 2)): + code, output = daemon.exec( + container_id, + ["sh", "-c", f"ip link set eth{i} up && ip addr add 10.20.{i}.{host}/24 dev eth{i}"], + ) + assert code == 0, output + time.sleep(2) + + def ping_link(i): + # link 0 rides the init.sh-addressed eth0; the rest their own subnet + if i == 0: + result = harness.docker_ping(daemon, c1, R2_IP, count=2) + assert result["loss"] == 0, result["raw"] + else: + code, output = daemon.exec(c1, ["ping", "-c", "2", "-W", "1", f"10.20.{i}.2"]) + assert code == 0, output + + for i in range(10): + ping_link(i) + time.sleep(0.5) + + paths = [capture["capture_file_path"] for capture in captures] + first_counts = [harness.pcap_records(path)[0] for path in paths] + # 2 ARP + 2 echo requests + 2 replies is the floor per link + assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts)) + print(f".. all 10 pcaps captured concurrently: {first_counts}") + + # stop half. A stop is synchronous (the reply follows the reader + # thread's join), so from its return that pcap can no longer grow — + # the post-stop counts are an exact-freeze baseline + for link in links[5:]: + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop") + stopped_base = [harness.pcap_records(paths[i])[0] for i in range(5, 10)] + + # fresh traffic on ALL segments: the stopped half must stay frozen + # even while its links carry frames, the live half must record them + for i in range(10): + ping_link(i) + time.sleep(0.5) + + for i in range(5, 10): + assert harness.pcap_records(paths[i])[0] == stopped_base[i - 5], f"stopped pcap {i} grew" + for i in range(5): + assert harness.pcap_records(paths[i])[0] >= first_counts[i] + 4, f"live pcap {i} did not grow" + print(".. per-interface stop froze exactly the stopped half") + + for link in links[:5]: + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop") + assert all( + compute.call("GET", f"/projects/{pid}/links/{link['link_id']}")["capturing"] is False for link in links + ) + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) + + def test_docker_kernel_filter_matrix(): """One filter type at a time on a live kernel link, each asserted by its effect on real traffic and its tc state. Types the uBridge on this From 97b55141349bee21583affff36800d0b290f6b88 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 22:09:53 +0800 Subject: [PATCH 12/18] style: zip() with strict= in the capture e2e assert message --- tests/e2e/test_docker_kernel_datapath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index d1ed9528d..ed75fff5c 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -385,7 +385,7 @@ def test_docker_kernel_ten_concurrent_captures(): paths = [capture["capture_file_path"] for capture in captures] first_counts = [harness.pcap_records(path)[0] for path in paths] # 2 ARP + 2 echo requests + 2 replies is the floor per link - assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts)) + assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts, strict=True)) print(f".. all 10 pcaps captured concurrently: {first_counts}") # stop half. A stop is synchronous (the reply follows the reader From 3e6a53c4b9bc8cd624dc4c4626a5fcd96c70b713 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 22:13:58 +0800 Subject: [PATCH 13/18] style: apply ruff format to the files this branch touches --- gns3server/compute/builtin/nodes/ethernet_switch.py | 2 +- gns3server/compute/docker/docker_vm.py | 2 +- gns3server/compute/dynamips/nodes/router.py | 2 +- gns3server/compute/iou/iou_vm.py | 2 +- gns3server/compute/qemu/qemu_vm.py | 2 +- gns3server/compute/vpcs/vpcs_vm.py | 2 +- tests/api/routes/compute/test_ethernet_switch_nodes.py | 2 +- tests/compute/docker/test_docker_kernel_datapath.py | 8 ++++---- tests/compute/dynamips/test_dynamips_kernel_datapath.py | 2 +- tests/compute/iou/test_iou_kernel_datapath.py | 2 +- tests/compute/qemu/test_qemu_kernel_datapath.py | 2 +- tests/e2e/test_docker_kernel_datapath.py | 4 +++- 12 files changed, 17 insertions(+), 15 deletions(-) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 5989ccc7c..9d8b55b55 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -795,7 +795,7 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): anchor = self._kernel_ports.get(port_number) if anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: ubridge_bridge = self._ubridge_bridge_name(port_number) await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 341f773ea..9e99c9104 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -2044,7 +2044,7 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): # capturing flag above gates it, so uBridge's 208-on-idle- # interface can only surface on a genuine desync). host_ifc = self._kernel_host_ifc(adapter_number, port_number) - await self._ubridge_send(f'capture stop_kernel {host_ifc}') + await self._ubridge_send(f"capture stop_kernel {host_ifc}") return bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send(f"bridge stop_capture {bridge_name}") diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 863d3eb4d..59a414851 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -1536,7 +1536,7 @@ class Router(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(slot_number, port_number) if self.ubridge and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") return await nio.stop_packet_capture() diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 3ec1602a7..679050f6f 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1869,7 +1869,7 @@ class IOUVM(KernelDatapathMixin, BaseNode): # Per-interface stop: only this port's capture is stopped. anchor = self._kernel_host_ifc(adapter_number, port_number) if anchor is not None: - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send( f"iol_bridge stop_capture {self._iol_bridge_name()} {adapter_number} {port_number}" diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index 24e296fe5..eb2f2eb79 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1790,7 +1790,7 @@ class QemuVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 823b2a35c..df9c31ab1 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -691,7 +691,7 @@ class VPCSVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 9d2d1c76d..2c03b698c 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -792,7 +792,7 @@ class TestEthernetSwitchNodesRoutes: ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - node._ubridge_send.assert_any_call(f'capture stop_kernel {anchor}') + node._ubridge_send.assert_any_call(f"capture stop_kernel {anchor}") async def test_ethernet_switch_batch_create_anchor_nio( self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 107ad69e3..abfc23220 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -997,7 +997,7 @@ async def test_stop_capture_kernel(vm): await vm.stop_capture(0) assert nio.capturing is False - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc}") @pytest.mark.asyncio @@ -1061,11 +1061,11 @@ async def test_capture_two_ports_at_once_and_stop_them_independently(vm): assert nio1.capturing is True assert nio2.capturing is False - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc2}') - assert not any(f'stop_kernel {host_ifc1}' in str(c) for c in vm._ubridge_send.call_args_list) + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc2}") + assert not any(f"stop_kernel {host_ifc1}" in str(c) for c in vm._ubridge_send.call_args_list) await vm.stop_capture(0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc1}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc1}") @pytest.mark.asyncio diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py index 230a6ca79..135a69f6e 100644 --- a/tests/compute/dynamips/test_dynamips_kernel_datapath.py +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -581,7 +581,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(router): assert not any("bind_filter" in c for c in _hypervisor_commands(router)) await router.stop_capture(0, 0) - router._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') + router._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}") @pytest.mark.asyncio diff --git a/tests/compute/iou/test_iou_kernel_datapath.py b/tests/compute/iou/test_iou_kernel_datapath.py index 0e6df1368..7121800e3 100644 --- a/tests/compute/iou/test_iou_kernel_datapath.py +++ b/tests/compute/iou/test_iou_kernel_datapath.py @@ -371,7 +371,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"') await vm.stop_capture(0, 0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}") @pytest.mark.asyncio diff --git a/tests/compute/qemu/test_qemu_kernel_datapath.py b/tests/compute/qemu/test_qemu_kernel_datapath.py index 79709d3c9..a7226c08b 100644 --- a/tests/compute/qemu/test_qemu_kernel_datapath.py +++ b/tests/compute/qemu/test_qemu_kernel_datapath.py @@ -328,7 +328,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"') await vm.stop_capture(0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP0}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP0}") @pytest.mark.asyncio diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index ed75fff5c..877ec02cf 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -352,7 +352,9 @@ def test_docker_kernel_ten_concurrent_captures(): # every later frame (up to and including the ARP each first ping # triggers) lands in a pcap captures = [ - compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}) + compute.call( + "POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) for link in links ] From 63873f6041ecd36ba135b5f8dabd51b66ccad576 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 00:50:46 +0800 Subject: [PATCH 14/18] =?UTF-8?q?docs:=20packet=20filters=20=E2=80=94=20pa?= =?UTF-8?q?rameter=20reference,=20per-filter=20guide=20and=20REST=20exampl?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the feature doc usable as a standalone reference: - Parameter reference: all 13 filters' parameters, ranges and units, the cross-filter rules (reorder+delay, gemodel/packet_loss exclusivity, period>=outage, distribution needs jitter), the zero-means-disabled convention, and the UI display names served by available_filters (the Web UI labels gemodel as "Gilbert-Elliot loss", never by type) - Per-filter guide: purpose, per-parameter meaning, caveats and a copyable example for each of the 13 types, including the four jitter distributions compared (uniform/normal/pareto/paretonormal) - REST surface: endpoint table with privileges, a filters PUT example, and common recipes (WAN latency, bursty loss via gemodel, rate cap, ICMP blackhole, scheduled outage) - Clarify gemodel's "one-way" mean: per direction, independent instances per endpoint, round trip compounds to 1-(1-mean)^2; one-directional loss is not expressible through the link filters dict - Fix an inaccuracy: available_filters is a dedicated endpoint's response, not a field of the link payload --- docs/features/packet-filters.md | 242 +++++++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 2 deletions(-) diff --git a/docs/features/packet-filters.md b/docs/features/packet-filters.md index e096dcfc0..56065a1b7 100644 --- a/docs/features/packet-filters.md +++ b/docs/features/packet-filters.md @@ -83,6 +83,214 @@ On the kernel datapath the 13 types ride three kernel objects on the anchor. The | `quota [bytes, %]` | `tc quota_drop ` — after the byte quota is consumed, each further packet drops with the given chance (100 = hard cutoff) | kernel-only type; needs `ebpf=1`; per-direction byte counters | | `window_drop [start, outage, %, period?, jitter?]` | `tc window_drop [ []]` — packets drop with the given chance inside `[start, start+outage)` | kernel-only type; needs `ebpf=1`; see the window semantics below | +## Parameter reference + +The API surface of the 13 types — parameters, ranges and cross-filter rules, validated by the controller before anything reaches uBridge (`gns3server/utils/packet_filter_validation.py`; the same data drives `available_filters`, which reports it per link). Trailing parameters are optional everywhere: a client sending only the first N values of a filter keeps working. **K** marks the kernel-datapath-only types; two further *parameters* are kernel-only and marked inline. The row order matches the Filter types table above. The **UI name** column is the display name served by `available_filters` (`gns3server/controller/link.py`, the `FILTERS` catalog) and shown by the Web UI — `gemodel` appears there as *Gilbert-Elliot loss*, not under its API type. + +| Filter | UI name | Parameters | Constraint | +|---|---|---|---| +| `delay` | Delay | Latency (1…32767 ms) · Jitter (0…32767 ms) · Distribution | Distribution kernel-only, needs Jitter > 0; the four shapes are compared in the Per-filter guide | +| `packet_loss` | Packet loss | Chance (0…100 %) · Correlation (0…100 %) | Correlation kernel-only; Chance ≲ Correlation collapses (see Semantics) | +| `corrupt` | Corrupt | Chance (0…100 %) | | +| `duplicate` **K** | Duplicate | Chance (0…100 %) · Correlation (0…100 %) | | +| `rate` **K** | Bandwidth limit | Rate — tc-style string, e.g. `"512kbit"` (≤ 100gbit) | integer + unit; bps-family units count bytes/s | +| `reorder` **K** | Reorder | Reorder (0…100 %) · Correlation (0…100 %) · Gap (1…1000 packets) | requires `delay` | +| `gemodel` **K** | Gilbert-Elliot loss | p (good-to-bad) · r (bad-to-good) · 1-h (bad-state loss) (each 0…100 %) | mutually exclusive with `packet_loss` | +| `seed` **K** | Random seed | Seed (0…4294967295) | pins the netem draws only | +| `limit` **K** | Queue limit | Limit (1…1000000 packets) | queue depth above the 1000 default | +| `bpf` | Berkeley Packet Filter (BPF) | Filters — text, one expression per line | any match drops | +| `frequency_drop` | Frequency drop | Frequency (−1…32767) | −1 drops everything; 0 disables | +| `quota` **K** | Byte quota | Quota (1…10¹⁵ bytes) · Chance (0…100 %) | both required; Chance 100 = hard cutoff | +| `window_drop` **K** | Time window drop | Start (0…10¹² ms) · Outage (1…10¹² ms) · Chance (0…100 %) · Period (1…10¹² ms) · Jitter (0…10⁹ ms) | 3/4/5 params = one-shot / recurring / randomized flap; Period ≥ Outage | + +Cross-filter rules (violations return 409): `reorder` requires `delay`; `gemodel` and `packet_loss` map to the same netem loss slot and are mutually exclusive; `window_drop` Period ≥ Outage; `delay` Distribution requires Jitter > 0; `rate` must be an integer plus a unit no larger than 100gbit. + +The disabled convention: a filter whose first value is 0 (or an empty string) reads as off and is skipped. Two exceptions — `delay [0, 0]` is the explicit off while `delay [0, X>0]` is a validation error (Latency ≥ 1), and `window_drop` is active whenever present (`Start = 0` means the outage begins immediately, not off). + +## Per-filter guide + +What each filter is for and what each parameter means. Ranges live in the Parameter reference above and are not repeated beyond the parameter names; mechanics live in the sections below. Order matches the Parameter reference table; (kernel-only) in a heading marks a type the relay cannot run. + +### `delay` — fixed latency with jitter + +**Purpose**: propagation delay — geographic distance, satellite hops; amplifying RTT to observe TCP window/BDP behaviour, protocol timers, interactive traffic; combined with `rate`, a narrowband high-latency path. + +| Parameter | Meaning | +|---|---| +| Latency (1…32767 ms) | fixed one-way delay added per direction — both endpoints apply it, so RTT ≈ 2×. Minimum 1; `[0, 0]` is the explicit off | +| Jitter (0…32767 ms) | per-packet offset drawn in Latency ± Jitter; omitted = 0 | +| Distribution | the shape of the jitter draw (kernel-only, needs Jitter > 0; default uniform) — see the table below | + +The four distributions, for Jitter = 5 ms: + +| Distribution | Shape | Effect | Use for | +|---|---|---|---| +| uniform (default) | flat rectangle | every offset in −5…+5 ms equally likely, hard bound | simplest model — worst case is exactly ±J | +| normal | bell (Gaussian) | most offsets near 0, extremes rare, ±5 almost never reached | mild, concentrated jitter — same-DC, premium lines | +| pareto | heavy tail (L shape) | most offsets ≈0 with occasional spikes several times the typical value | queueing spikes, congestion bursts; stress-testing timeout/retransmit logic | +| paretonormal | bell body + Pareto tail | concentrated majority with occasional spikes | closest to measured Internet/WAN jitter; the netem-recommended default | + +Real-network jitter is heavy-tailed because delay variation is mostly queueing — a buffer filling up produces one large spike, "calm most of the time, occasionally wild", exactly a bell body with a heavy tail. `normal` suffices for intra-DC paths; use `pareto` / `paretonormal` for the WAN. (Implementation aside: the kernel stores a pre-sampled lookup table, not the distribution's math — which is why `tc qdisc show` cannot print the name.) + +**Note**: the first packet after a suspend/resume takes one extra interval (netem idle baseline; steady state exact); a large delay combined with `rate` needs `limit` raised (see `limit`). + +**Example**: `{"delay": [80, 5, "paretonormal"]}` — 80 ± 5 ms per direction, heavy-tailed jitter. + +### `packet_loss` — random loss + +**Purpose**: wireless loss, congestion tail-drop; observing TCP backoff and retransmission, application retries, protocol tolerance. + +| Parameter | Meaning | +|---|---| +| Chance (0…100 %) | independent per-packet loss probability, per direction — a round trip measures ≈ 1−(1−P)² | +| Correlation (0…100 %, kernel-only) | consecutive-loss correlation — a loss raises the next packet's loss probability, forming bursts | + +**Note**: Chance ≲ Correlation collapses toward zero effective loss (kernel netem defect, quantified in Semantics) — for bursty loss at a chosen mean use `gemodel`. The relay strips this parameter; sending only the single chance argument keeps working. + +**Example**: `{"packet_loss": [2]}`; correlated `{"packet_loss": [10, 25]}` (10 < 25 sits in the collapse zone — measured ≈1.8 % effective; shown for syntax only). + +### `corrupt` — bit errors + +**Purpose**: degraded physical layer — CRC errors, wireless interference, failing optics; checksum coverage, retransmission, FEC. + +| Parameter | Meaning | +|---|---| +| Chance (0…100 %) | per-packet probability of content corruption. The damaged frame **passes** — the receiver's checksum does the dropping, and a capture on the link shows the damaged frame | + +**Example**: `{"corrupt": [5]}`. + +### `duplicate` — duplicated packets (kernel-only) + +**Purpose**: link-layer replay, transient loops, device buffer re-sends; protocol idempotence and duplicate handling. + +| Parameter | Meaning | +|---|---| +| Chance (0…100 %) | per-packet probability of being copied | +| Correlation (0…100 %) | consecutive-duplicate correlation (small rates under high correlation collapse the same way — see Semantics) | + +**Note**: the copy draws its own delay (under `delay 100` the duplicate arrives at a different time than the original). + +**Example**: `{"duplicate": [10]}`. + +### `rate` — bandwidth limit (kernel-only) + +**Purpose**: narrowband access lines (512k, 2M), bandwidth bottlenecks; QoS, queuing and bulk-transfer behaviour. + +| Parameter | Meaning | +|---|---| +| Rate | tc-style integer + unit: `bit`/`kbit`/`mbit`/`gbit` count bits/s, `bps`/`kbps`/`mbps` count bytes/s, ≤ 100gbit | + +**Note**: per-packet serialization time ≈ packet size / rate, applied once per direction → ≈2× on RTT; a large `rate` combined with a large `delay` needs `limit` raised (see `limit`). + +**Example**: `{"rate": ["512kbit"]}`. + +### `reorder` — out-of-order delivery (kernel-only) + +**Purpose**: multipath forwarding (ECMP, bonding), parallel switch fabrics; TCP SACK, reordering buffers, RACK. + +| Parameter | Meaning | +|---|---| +| Reorder (0…100 %) | the chance a packet is held back until a later packet has passed it | +| Correlation (0…100 %) | consecutive-reorder correlation | +| Gap (1…1000 packets) | reorder cadence — every gap-th packet sent immediately, the rest held back to create reordering; without it, purely probabilistic | + +**Note**: requires `delay` (validated with 409 — without a delay there is no timeline to hold against). + +**Example**: `{"delay": [50], "reorder": [25, 25, 5]}` — 25 % reordering at a 5-packet cadence on a 50 ms path. + +### `gemodel` — bursty loss, Gilbert-Elliot (kernel-only) + +**Purpose**: real loss comes in bursts — microburst congestion, transient memory exhaustion, wireless fades. Use it for "chosen mean rate + tunable burst shape"; it is immune to the correlation collapse. + +| Parameter | Meaning | +|---|---| +| p (good-to-bad, 0…100 %) | good→bad transition — per-packet chance of entering a burst; burst spacing averages ≈ 1/p packets | +| r (bad-to-good, 0…100 %) | bad→good transition — average burst length ≈ 1/r packets | +| 1−h (bad-state loss, 0…100 %) | per-packet loss chance inside the bad state (the good state loses nothing) | + +Steady-state mean one-way loss = **p/(p+r)×(1-h)** — "one-way" meaning per direction: each endpoint runs its own independent instance (uncorrelated state machines), so a round trip compounds to ≈ 1−(1−mean)²; loss in only one direction is not expressible through the link `filters` dict (it is pushed to both endpoints symmetrically — use in-guest netem for that). + +**Note**: mutually exclusive with `packet_loss` (one netem loss slot; 409). + +**Example**: `{"gemodel": [1, 19, 100]}` — 5 % mean loss, bursts of ≈5 packets fully lost, ≈100 packets apart. + +### `seed` — reproducible randomness (kernel-only) + +**Purpose**: reproducible experiments and A/B comparisons — same seed + same filter set → the identical loss/jitter/corruption/duplication sequence. + +| Parameter | Meaning | +|---|---| +| Seed (0…4294967295) | the netem PRNG seed | + +**Note**: pins the netem draws only — the eBPF percentage draws (quota, window) ride a PRNG seeded at program load, outside `seed`'s reach; any filter-set change resets schedules and counters. + +**Example**: `{"packet_loss": [10], "seed": [42]}`. + +### `limit` — impairment queue depth (kernel-only) + +**Purpose**: the netem queue's capacity — it exists for `rate` + `delay` combinations: the backlog grows toward the bandwidth-delay product, and past `limit` it tail-drops, losing packets no filter asked to lose. Alone it does essentially nothing (no delay, no backlog). + +| Parameter | Meaning | +|---|---| +| Limit (1…1000000 packets) | packets the queue can hold; 1000 default | + +Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. + +**Example**: `{"rate": ["10mbit"], "delay": [2000], "limit": [5000]}` — 10 Mbit/s × 2 s ≈ 2.5 MB ≈ 1700 full-size packets, past the 1000 default → 5000. + +### `bpf` — match-drop by content + +**Purpose**: selective blackholing — drop only ICMP (a ping blackhole), only BGP (`tcp port 179`) to watch sessions drop, OSPF hellos (`ip proto 89`) for convergence timing, VRRP advertisements (`proto 112`) to force a failover. + +| Parameter | Meaning | +|---|---| +| Filters | libpcap/tcpdump expression, **one per line**, any match drops. Each line is compiled with `tcpdump -d` before apply; a line that fails to compile on the compute is skipped with a warning | + +**Note**: the semantics are match-drop — only matches drop; "allow only X" is written `not `. Runs on both datapaths (cls_bpf on the kernel, libpcap in the relay). + +**Example**: `{"bpf": "icmp"}`; multi-line `{"bpf": "tcp port 179\nip proto 89"}`. + +### `frequency_drop` — deterministic periodic drops + +**Purpose**: deterministic loss with no dice — an exact drop cadence for worst paths: retransmission, FEC, fast-fail logic; also experiments needing a steadily advancing drop counter. + +| Parameter | Meaning | +|---|---| +| Frequency (−1…32767) | positive N = drop every Nth packet; −1 = drop everything; 0 = disabled | + +**Note**: the two datapaths count differently — the relay runs one counter shared by both directions (phase-locked); the kernel counts per direction (N=3 → 55.6 % round-trip, measured 57 %), exact atomic counters (see Semantics). + +**Example**: `{"frequency_drop": [3]}` — every 3rd packet. + +### `quota` — traffic quota (kernel-only) + +**Purpose**: quota/billing — a volume budget exhausted cuts or degrades the link; application resilience to "cut off mid-stream". + +| Parameter | Meaning | +|---|---| +| Quota (1…10¹⁵ bytes) | the byte budget, counted per direction | +| Chance (0…100 %) | per-packet drop chance after the quota is consumed — 100 = hard cutoff, lower = degraded service | + +**Note**: counting starts when the filter is applied; any filter-set change resets the counters (reconcile = reset + re-apply, see Semantics). Needs `ebpf=1`. + +**Example**: `{"quota": [1000000000, 100]}` — hard cut after 1 GB. + +### `window_drop` — scheduled outage (kernel-only) + +**Purpose**: planned outages (a maintenance window "link dies at T for M seconds"), recurring flaps (failing cable, wireless fades), link-flap convergence and failover testing. + +| Parameter | Meaning | +|---|---| +| Start (0…10¹² ms) | window start, relative to the moment of apply; 0 = begins immediately (valid and active) | +| Outage (1…10¹² ms) | window length | +| Chance (0…100 %) | per-packet drop chance inside the window — 100 = full outage, lower = degraded | +| Period (1…10¹² ms, optional) | recurring, ≥ Outage; omitted = one-shot | +| Jitter (0…10⁹ ms, optional) | each cycle's outage and period re-drawn uniformly in nominal ± Jitter (integer-ms grid) | + +**Note**: **any filter change, node restart or link reset restarts the schedule** — a one-shot window that has already passed must be re-armed: apply immediately before generating traffic, or use a Period. Both endpoints run their own schedule; a round trip survives only when both directions are outside their windows (see Semantics). Needs `ebpf=1` plus the uBridge mode token. + +**Example**: `{"window_drop": [1000, 1500, 100]}` — dead 1.5 s starting at t=1 s, one-shot; `{"window_drop": [0, 2000, 100, 10000, 500]}` — dead 2 s now, recurring every ~10 ± 0.5 s. + ## Semantics * **Both endpoints** receive the filter dict and attach one qdisc to *their* anchor. The qdisc's egress covers traffic entering that node, so every direction of the link is impaired exactly once — the same net effect as the relay, where both directions cross the single filtered bridge. A `delay 100` link measures ≈200 ms RTT; `packet_loss 30` measures ≈51 % round-trip (1 − 0.7²); `rate 512kbit` adds ≈2× the per-packet serialization time to the RTT. @@ -93,7 +301,6 @@ On the kernel datapath the 13 types ride three kernel objects on the anchor. The * **frequency_drop counting semantics differ per datapath.** The relay's single filtered bridge counts packets of BOTH directions through one counter; the kernel attaches one classifier per anchor, so each direction drops every Nth independently. For a round-trip measurement with every-Nth N: relay ≈ 1/N of frames (phase-locked), kernel = 1 − ((N−1)/N)² (e.g. N=3 → 55.6 % round-trip loss, measured 57 %). The kernel count is exact (atomic counter), unlike netem's stochastic loss. * **Classifier order** on clsact egress: the eBPF stateful filter runs at prio 1, `bpf_drop` expressions at 10–99 — a packet dropped by the stateful modes never reaches the expression drops, and dropped packets never reach netem. Within the stateful program the modes evaluate in the fixed order nth → quota → window → flow. * **window_drop semantics.** `start` is relative to the moment the filter is applied — and because reconcile re-sets the classifier on every apply (any filter change, node restart, link reset reloads the program after the netem reset), every such event **restarts the schedule**. 3 parameters = one single outage (`[start, start+outage)`), traffic passes before and after — the classic "link dies at T, comes back at T+outage". 4 parameters = recurring flap: `period ≥ outage` (validated), so the outage occupies `outage/period` of each cycle. 5 parameters = randomized flap: each cycle's outage and period are re-drawn uniformly in nominal ± `jitter` (integer-ms grid; jitter 0 draws nothing and equals the fixed schedule). Inside a window each packet drops with the given chance (100 = full outage, lower = degraded service during the window). Both endpoints run their own schedule, started ~simultaneously by the controller — a round trip survives only when **both** directions are outside their windows. Note `start = 0` (outage begins immediately) is a valid, active configuration — unlike other filters, a zero first parameter does not mean "disabled". A PUT re-sending an unchanged filters dict is normally a controller no-op, but one still carrying `window_drop` is reconciled anyway, so re-applying the same window re-arms a one-shot outage that has already passed (arm the filter immediately before generating traffic, or use a period for a repeatable event). -* **Validation** mirrors the tc grammar: `reorder` requires `delay`, `gemodel` and `packet_loss` are mutually exclusive (both map to the netem loss keyword), `distribution` requires jitter > 0, rate must be integer + unit ≤ 100gbit. * **Node restart** restores the qdisc from the NIO (like capture and markers); **link deletion** detaches it explicitly — the anchor survives the link and an orphaned qdisc would keep impairing the next one. * **Suspend** keeps the qdisc (carrier-driven loss); the first packet after resume takes one extra delay interval (known netem idle-baseline behaviour) and steady state is exact. * **bpf reconcile = flush + re-add** (`tc bpf_drop flush`, then one add per line at priorities 10, 11, …). `tc reset` (link delete / filter clear) is the full restore in uBridge: filters → clsact → root qdisc, idempotent. @@ -115,7 +322,38 @@ A fully-loaded example — `delay 100 jitter 10`, `packet_loss 10`, `corrupt 5`, ## REST surface -Filters travel on the link API: `PUT /v3/links/{link_id}` with a `filters` dict, validated by the controller (the dependency and exclusivity rules above return 409 on violation) and pushed to both endpoints on create, update and project reopen. Each link payload carries read-only `available_filters` — the per-type intersection of what both endpoint computes can run, per the capability reporting above. +Filters travel on the link API: a `filters` dict on the link object (`filter type → positional values`), validated by the controller (the cross-filter rules above return 409 on violation) and pushed to both endpoints on create, update and project reopen. + +| Method | Path | Purpose | Privilege | +|---|---|---|---| +| GET | `/v3/projects/{project_id}/links/{link_id}` | read the link incl. its `filters` | Link.Audit | +| PUT | `/v3/projects/{project_id}/links/{link_id}` | replace the `filters` dict | Link.Modify | +| GET | `/v3/projects/{project_id}/links/{link_id}/available_filters` | the per-link vocabulary with parameter names, ranges and units | Link.Audit | + +Setting filters is a plain link update — send only the `filters` property: + +```json +PUT /v3/projects/{project_id}/links/{link_id} +{ + "filters": { + "delay": [50, 5], + "packet_loss": [1] + } +} +``` + +`available_filters` is the authoritative per-link vocabulary: kernel-only types appear only on kernel-datapath links whose endpoint computes report a uBridge able to run them, and relay links list the five relay types only (per the capability reporting above). + +Common recipes — all values are per direction, so round trips double the delay and compound the loss (see Semantics): + +| Goal | `filters` value | +|---|---| +| WAN latency with jitter | `{"delay": [50, 5]}` | +| 1 % random loss | `{"packet_loss": [1]}` | +| Bursty ~5 % mean loss | `{"gemodel": [1, 19, 100]}` — mean = p/(p+r)×(1-h) = 5 % | +| 512 kbit/s cap | `{"rate": ["512kbit"]}` | +| Drop all ICMP | `{"bpf": "icmp"}` | +| 1.5 s full outage starting now, one-shot | `{"window_drop": [0, 1500, 100]}` | ## Where the chain sits From eda2d640ab6059383a722bcfbc27d6540d86983b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 01:08:53 +0800 Subject: [PATCH 15/18] =?UTF-8?q?docs:=20packet=20filters=20=E2=80=94=20de?= =?UTF-8?q?epen=20the=20per-filter=20guide=20with=20mechanism=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up enrichments to the Per-filter guide, one per filter, each answering the question a reader asks right after "what does it do": - window_drop: explicit recurrence formula ([Start + k*Period, +Outage), duty cycle) and ASCII timelines of the three forms (one-shot / recurring / jittered); Chance spelled out as an independent per-packet draw gated to the window; jitter explained as per-cycle re-draws that break phase-locking with protocol timers - quota: transparent until the budget is consumed; Chance equals packet_loss in draw mathematics but runs in the eBPF classifier stage (no correlation parameter, outside seed, invisible to captures) - limit: the phantom-loss symptom (throughput below the configured rate with rate+delay set = the 1000 default overflowing) - seed: reproduction also requires the same traffic pattern (draws are consumed per arriving packet); one pinned realization is not the distribution — statistical conclusions need several seeds - gemodel: scattered-vs-bursty timeline at the same 5% mean (fast retransmit vs RTO), and a parameter recipe (M and L -> r, p) - reorder: the mechanism is queue-jumping on the netem delay queue — hence the delay requirement, the limit overflow interaction and the delay-bounded reorder depth - rate: per-direction independent shaping (symmetric full duplex, no shared budget); asymmetric rates need in-guest netem - duplicate: real-world prototype (802.11 ARQ retransmissions), example values with the correlation-collapse anchors (10/25 -> ~1.8%, 25/25 -> ~16.7%) --- docs/features/packet-filters.md | 51 +++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/features/packet-filters.md b/docs/features/packet-filters.md index 56065a1b7..cc67ff230 100644 --- a/docs/features/packet-filters.md +++ b/docs/features/packet-filters.md @@ -161,16 +161,16 @@ Real-network jitter is heavy-tailed because delay variation is mostly queueing ### `duplicate` — duplicated packets (kernel-only) -**Purpose**: link-layer replay, transient loops, device buffer re-sends; protocol idempotence and duplicate handling. +**Purpose**: the real-world prototype is **wireless link-layer retransmission** — an 802.11 ARQ retransmit of a frame the receiver already got (the ACK was lost); 1–3 % duplicates are normal on WiFi captures. Transient L2 loops (the STP convergence window) and bonding failover also duplicate. Tests protocol idempotence and sequence-number/dedup handling (TCP is immune by sequence number; non-idempotent application logic executes twice). | Parameter | Meaning | |---|---| -| Chance (0…100 %) | per-packet probability of being copied | -| Correlation (0…100 %) | consecutive-duplicate correlation (small rates under high correlation collapse the same way — see Semantics) | +| Chance (0…100 %) | per-packet probability of being copied (the original continues; a copy is added) | +| Correlation (0…100 %) | consecutive-duplicate correlation — wireless retransmissions cluster in time (the marginal-signal periods); Chance ≲ Correlation collapses the same way, see Semantics | -**Note**: the copy draws its own delay (under `delay 100` the duplicate arrives at a different time than the original). +**Note**: the copy draws its own delay (under `delay 100` the duplicate arrives at a different time than the original); duplicates have no gemodel equivalent — for bursty duplication keep Chance at or above the Correlation. -**Example**: `{"duplicate": [10]}`. +**Example**: `{"duplicate": [2]}` — mild WiFi-like duplication; `{"duplicate": [10, 25]}` — syntax illustration (10 < 25 sits in the collapse zone, measured ≈1.8 % effective); `{"duplicate": [25, 25]}` — chance at the correlation does not collapse, ≈16.7 % effective (measured anchor). ### `rate` — bandwidth limit (kernel-only) @@ -180,7 +180,7 @@ Real-network jitter is heavy-tailed because delay variation is mostly queueing |---|---| | Rate | tc-style integer + unit: `bit`/`kbit`/`mbit`/`gbit` count bits/s, `bps`/`kbps`/`mbps` count bytes/s, ≤ 100gbit | -**Note**: per-packet serialization time ≈ packet size / rate, applied once per direction → ≈2× on RTT; a large `rate` combined with a large `delay` needs `limit` raised (see `limit`). +**Note**: the limit shapes **each direction independently** (symmetric full duplex — one shaper per endpoint, no shared budget: 10 Mbit/s each way simultaneously). Asymmetric rates (e.g. 20M down / 1M up) are not expressible through the link `filters` dict; use in-guest netem on each side. Per-packet serialization time ≈ packet size / rate, applied once per direction → ≈2× on RTT; a large `rate` combined with a large `delay` needs `limit` raised (see `limit`). **Example**: `{"rate": ["512kbit"]}`. @@ -194,7 +194,7 @@ Real-network jitter is heavy-tailed because delay variation is mostly queueing | Correlation (0…100 %) | consecutive-reorder correlation | | Gap (1…1000 packets) | reorder cadence — every gap-th packet sent immediately, the rest held back to create reordering; without it, purely probabilistic | -**Note**: requires `delay` (validated with 409 — without a delay there is no timeline to hold against). +**Note**: requires `delay` (validated with 409). The mechanism is **queue-jumping**: `delay` holds packets in the netem queue until due, and reorder lets the chosen packets skip the wait and transmit immediately, overtaking the packets still held in that same queue — the one `limit` bounds. Aggressive reorder + rate + delay overflows `limit` the same way (tail-drops no filter asked for), and the reorder depth is bounded by the delay (a packet is held at most `delay` ms). **Example**: `{"delay": [50], "reorder": [25, 25, 5]}` — 25 % reordering at a 5-packet cadence on a 50 ms path. @@ -210,7 +210,14 @@ Real-network jitter is heavy-tailed because delay variation is mostly queueing Steady-state mean one-way loss = **p/(p+r)×(1-h)** — "one-way" meaning per direction: each endpoint runs its own independent instance (uncorrelated state machines), so a round trip compounds to ≈ 1−(1−mean)²; loss in only one direction is not expressible through the link `filters` dict (it is pushed to both endpoints symmetrically — use in-guest netem for that). -**Note**: mutually exclusive with `packet_loss` (one netem loss slot; 409). +**Note**: mutually exclusive with `packet_loss` (one netem loss slot; 409). Same mean, different script — and the script is what protocols feel (scattered losses trigger TCP fast retransmit; a burst wiping a window stalls into RTO): + +``` +packet_loss [5] (independent): ──X───X────X───X────X───X── 5 % per packet, evenly scattered +gemodel [1, 19, 100] (bursty): ────────▒▒▒▒▒───────────▒▒▒── long clean runs, ≈5 packets die together ≈100 apart +``` + +Choosing parameters: pick the target mean M and average burst length L (packets) → r ≈ 1/L, p ≈ M·r/(1−M) (with 1-h = 100); lower 1-h to let some packets survive inside a burst. **Example**: `{"gemodel": [1, 19, 100]}` — 5 % mean loss, bursts of ≈5 packets fully lost, ≈100 packets apart. @@ -222,7 +229,7 @@ Steady-state mean one-way loss = **p/(p+r)×(1-h)** — "one-way" meaning per di |---|---| | Seed (0…4294967295) | the netem PRNG seed | -**Note**: pins the netem draws only — the eBPF percentage draws (quota, window) ride a PRNG seeded at program load, outside `seed`'s reach; any filter-set change resets schedules and counters. +**Note**: pins the netem draws only — the eBPF percentage draws (quota, window) ride a PRNG seeded at program load, outside `seed`'s reach; any filter-set change resets schedules and counters. Two more boundaries: reproduction also requires the same traffic pattern (draws are consumed per arriving packet — classifier-stage drops upstream change the sequence), and a fixed seed pins one realization, not the distribution — for statistical conclusions (average throughput under loss) run several seeds. **Example**: `{"packet_loss": [10], "seed": [42]}`. @@ -234,7 +241,7 @@ Steady-state mean one-way loss = **p/(p+r)×(1-h)** — "one-way" meaning per di |---|---| | Limit (1…1000000 packets) | packets the queue can hold; 1000 default | -Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. +Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. Symptom to watch for: with rate + delay set, measured throughput below the configured rate or loss no filter asked for usually means the 1000 default is overflowing — raise `limit`. **Example**: `{"rate": ["10mbit"], "delay": [2000], "limit": [5000]}` — 10 Mbit/s × 2 s ≈ 2.5 MB ≈ 1700 full-size packets, past the 1000 default → 5000. @@ -268,8 +275,8 @@ Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. | Parameter | Meaning | |---|---| -| Quota (1…10¹⁵ bytes) | the byte budget, counted per direction | -| Chance (0…100 %) | per-packet drop chance after the quota is consumed — 100 = hard cutoff, lower = degraded service | +| Quota (1…10¹⁵ bytes) | the byte budget, counted per direction — until it is consumed the filter is fully transparent (counting only) | +| Chance (0…100 %) | an independent per-packet drop chance after the quota is consumed — 100 = hard cutoff, lower = degraded service. The draw mathematics equal `packet_loss`, but it runs in the eBPF classifier stage: no Correlation parameter (immune to the correl collapse), outside `seed`'s reach, and its drops are invisible to captures/markers (see Semantics) | **Note**: counting starts when the filter is applied; any filter-set change resets the counters (reconcile = reset + re-apply, see Semantics). Needs `ebpf=1`. @@ -283,9 +290,23 @@ Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. |---|---| | Start (0…10¹² ms) | window start, relative to the moment of apply; 0 = begins immediately (valid and active) | | Outage (1…10¹² ms) | window length | -| Chance (0…100 %) | per-packet drop chance inside the window — 100 = full outage, lower = degraded | -| Period (1…10¹² ms, optional) | recurring, ≥ Outage; omitted = one-shot | -| Jitter (0…10⁹ ms, optional) | each cycle's outage and period re-drawn uniformly in nominal ± Jitter (integer-ms grid) | +| Chance (0…100 %) | an **independent per-packet draw** inside the window — equivalent to `packet_loss` gated to the window (per direction, a round trip compounds to ≈ 1−(1−C)²; 100 = full outage, lower = a degraded window) | +| Period (1…10¹² ms, optional) | recurring: outages at [Start + k·Period, Start + k·Period + Outage) for k = 0, 1, 2, …; duty cycle = Outage/Period; omitted = one-shot | +| Jitter (0…10⁹ ms, optional) | breaks the metronome phase: each cycle re-draws the outage length and the cycle gap independently, uniformly in nominal ± Jitter (integer-ms grid; 0 = the fixed schedule) — stops protocol timers from phase-locking with the outage | + +Timelines of the three forms (▒ = outage): + +``` +one-shot [1000, 1500, 100]: +pass ────────┬▒▒▒▒▒▒▒▒┬──────────────── (down t=1.0–2.5 s, passes before and after) + +recurring [1000, 1500, 100, 10000]: +pass ──┬▒down 1.5s▒┬───────────────┬▒down 1.5s▒┬────── (every 10 s, 15 % duty cycle) + t=1.0s 2.5s t=11.0s 12.5s + +jittered [0, 2000, 100, 10000, 500]: +▒~1.8s▒───── ~9.7s ─────▒~2.2s▒───── ~10.4s ─────▒~1.5s▒… (length and gap re-drawn per cycle within ±0.5 s) +``` **Note**: **any filter change, node restart or link reset restarts the schedule** — a one-shot window that has already passed must be re-armed: apply immediately before generating traffic, or use a Period. Both endpoints run their own schedule; a round trip survives only when both directions are outside their windows (see Semantics). Needs `ebpf=1` plus the uBridge mode token. From fa3dd827f947c5ee7e637e9c7b0e2ff710b3f3dd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 22:44:24 +0800 Subject: [PATCH 16/18] chore: add pre-commit hook running ruff and mypy checks on staged files Mirrors the fast static checks from CI (justfile lint + type-check): ruff check, ruff format --check and mypy (gns3server package only, matching the files = ["gns3server"] scope in pyproject.toml). Only staged Python files are checked; pytest and pip check are left to CI. Enable with: just install-hooks --- .githooks/pre-commit | 54 ++++++++++++++++++++++++++++++++++++++++++++ justfile | 5 ++++ 2 files changed, 59 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..7e367091d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Pre-commit checks mirroring the CI checks that are fast enough to run on +# every commit (justfile "lint" + "type-check"; pytest and pip check are not +# run here). Only staged Python files are checked. +# +# Install with: just install-hooks + +set -euo pipefail + +# Prefer the repo virtualenv, fall back to whatever python is on PATH +PYTHON="venv/bin/python" +if [ ! -x "$PYTHON" ]; then + PYTHON="python" +fi +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "pre-commit: no python interpreter found, skipping checks" >&2 + exit 0 +fi + +# Staged .py files (deletions excluded) +mapfile -t staged < <(git diff --cached --name-only --diff-filter=d -- '*.py') +if [ ${#staged[@]} -eq 0 ]; then + exit 0 +fi + +status=0 + +echo "pre-commit: ruff check" +"$PYTHON" -m ruff check --exit-non-zero-on-fix "${staged[@]}" || status=1 + +echo "pre-commit: ruff format --check" +"$PYTHON" -m ruff format --check --diff "${staged[@]}" || status=1 + +# mypy is configured with files = ["gns3server"] in pyproject.toml, i.e. CI +# never type-checks tests: only pass staged files from that package. +mypy_targets=() +for f in "${staged[@]}"; do + case "$f" in + gns3server/*) mypy_targets+=("$f") ;; + esac +done + +if [ ${#mypy_targets[@]} -gt 0 ]; then + echo "pre-commit: mypy" + "$PYTHON" -m mypy "${mypy_targets[@]}" || status=1 +fi + +if [ "$status" -ne 0 ]; then + echo "" + echo "pre-commit: checks failed. Fix the issues above (try 'just format')," >&2 + echo "or bypass with: git commit --no-verify" >&2 +fi + +exit "$status" diff --git a/justfile b/justfile index b07fefc81..c9dd64787 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,11 @@ install: python -m pip install --upgrade pip python -m pip install -e '.[ai-features,dev]' +# Install the git pre-commit hook (lint + type-check on staged files) +[group('dev')] +install-hooks: + git config core.hooksPath .githooks + # Apply ruff autofixes for the enforced rules [group('dev')] format: From 6734d2d666e0c2d9f3f977dba16199ed764835b7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 20:07:14 +0800 Subject: [PATCH 17/18] feat: add auto_connect flag and disconnect endpoint for computes Computes can now be flagged auto_connect in the database. The controller connects to flagged computes in the background at startup (reusing the one-second delayed fire-and-forget mechanism), and enabling the flag via PUT /computes/{id} triggers a background connection without the request ever waiting for it. Disabling the flag does not drop an existing connection. Add POST /computes/{id}/disconnect to tear a compute down manually: the notification stream and HTTP session are closed and every reconnection path (retry backoff, notification stream reconnect, lazy connection in http_query) is blocked until connect() is called again or the controller restarts with auto_connect enabled. Disconnecting a compute used by an opened project is rejected with 409 unless force=true; forcing only drops the controller-side control channel, nodes keep running unmanaged. The local compute cannot be disconnected. Also make test_compute_list create its own compute (it relied on rows from earlier tests and failed under reordering), and stop scheduling the delayed add_compute connection during tests: the timer leaked real connection attempts into other tests' event loops. --- gns3server/api/routes/controller/computes.py | 37 +++++ gns3server/controller/__init__.py | 31 ++-- gns3server/controller/compute.py | 53 +++++- gns3server/db/models/computes.py | 3 +- gns3server/db/repositories/computes.py | 4 + ...d3a9c2e5b1_add_auto_connect_to_computes.py | 26 +++ gns3server/schemas/controller/computes.py | 6 + gns3server/services/computes.py | 47 ++++++ tests/api/routes/controller/test_computes.py | 153 +++++++++++++++++- tests/controller/test_compute.py | 87 ++++++++++ 10 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py index 796a72063..c8de19a55 100644 --- a/gns3server/api/routes/controller/computes.py +++ b/gns3server/api/routes/controller/computes.py @@ -26,6 +26,7 @@ from fastapi import APIRouter, Body, Depends, Query, status import gns3server.db.models as models from gns3server import schemas from gns3server.controller import Controller +from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError from gns3server.db.repositories.computes import ComputesRepository from gns3server.db.repositories.rbac import RbacRepository from gns3server.services.computes import ComputesService @@ -80,6 +81,42 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None: await compute.connect(report_failed_connection=True) +@router.post( + "/{compute_id}/disconnect", + status_code=status.HTTP_204_NO_CONTENT, + responses={409: {"model": schemas.ErrorMessage, "description": "Compute is used by opened projects"}}, + # dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui +) +async def disconnect_compute(compute_id: Union[str, UUID], force: Optional[bool] = False) -> None: + """ + Disconnect from a compute on the controller. + + The compute is not reconnected until an explicit connection is requested, + or the controller restarts with auto_connect enabled for this compute. + + Rejected with 409 when an opened project still uses the compute, unless + force is true. Forcing does not stop anything on the compute: only the + controller-side control channel (notification stream and HTTP session) is + torn down, nodes keep running unmanaged. + + Required privilege: Compute.Audit + """ + + if str(compute_id) == "local": + raise ControllerBadRequestError("Cannot disconnect the local compute") + controller = Controller.instance() + compute = controller.get_compute(str(compute_id)) + if not force: + open_projects = controller.get_compute_open_projects(compute) + if open_projects: + project_names = ", ".join(f"'{project.name}'" for project in open_projects) + raise ControllerError( + f"Compute '{compute_id}' is used by opened projects ({project_names}), " + "disconnect anyway with force=true" + ) + await compute.disconnect() + + @router.get( "/{compute_id}", response_model=schemas.Compute, diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 075256d7e..b7fe5b4c9 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -201,10 +201,12 @@ class Controller: if computes: for c in computes: try: - # FIXME: Task exception was never retrieved await self.add_compute( compute_id=str(c.compute_id), - connect=False, + # computes flagged auto_connect connect in the + # background, one second after the controller started + connect=c.auto_connect, + wait_connection=False, **c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}), ssl_context=self._ssl_context, ) @@ -625,9 +627,12 @@ class Controller: if connect: if wait_connection: await compute.connect() - else: - # call compute.connect() later to give time to the controller to be fully started - asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute.connect())) + elif not hasattr(sys, "_called_from_test"): + # connect in the background to give time to the controller to be fully + # started; goes through _try_reconnect so connection errors don't die + # as unretrieved task exceptions. Skipped during tests: the timer would + # leak the connection attempt into another test's event loop. + asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute._try_reconnect())) self.notification.controller_emit("compute.created", compute.asdict()) return compute else: @@ -645,6 +650,17 @@ class Controller: if compute in project.computes: await project.close() + def get_compute_open_projects(self, compute): + """ + Return the opened projects using a compute. + + :returns: List of opened projects + """ + + return [ + project for project in self._projects.values() if compute in project.computes and project.status == "opened" + ] + def compute_has_open_project(self, compute): """ Check is compute has a project opened. @@ -652,10 +668,7 @@ class Controller: :returns: True if a project is open """ - for project in self._projects.values(): - if compute in project.computes and project.status == "opened": - return True - return False + return bool(self.get_compute_open_projects(compute)) async def delete_compute(self, compute_id): """ diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index bd0cf01d4..aa2095eca 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -61,6 +61,7 @@ class Compute: name=None, console_host=None, ssl_context=None, + auto_connect=False, ): self._http_session = None assert controller is not None @@ -80,6 +81,11 @@ class Compute: self._connected = False self._notifications = None self._closed = False # Close mean we are destroying the compute node + # True when the user asked to disconnect: blocks every reconnection + # path (retry backoff, notification stream reconnect, lazy connect) + # until connect() is called again + self._manual_disconnect = False + self._auto_connect = auto_connect self._controller = controller self._set_auth(user, password) self._cpu_usage_percent = 0 @@ -154,6 +160,11 @@ class Compute: async def update(self, **kwargs): + if "auto_connect" in kwargs: + # stored separately from the generic attributes below so that + # asdict() reports it (it is persisted in the database, the + # controller-side copy is only a mirror for notifications) + self._auto_connect = kwargs.pop("auto_connect") for kw in kwargs: if kw not in ("user", "password"): setattr(self, kw, kwargs[kw]) @@ -323,6 +334,7 @@ class Compute: "host": self._host, "port": self._port, "user": self._user, + "auto_connect": self._auto_connect, "connected": self._connected, "cpu_usage_percent": self._cpu_usage_percent, "memory_usage_percent": self._memory_usage_percent, @@ -368,7 +380,9 @@ class Compute: :param params: Optional dict of query parameters to append to the URL """ - if not self._connected and not dont_connect: + # a manually disconnected compute is not lazily reconnected either: the + # request fails like for any unreachable compute + if not self._connected and not self._manual_disconnect and not dont_connect: if self._id == "vm" and not self._controller.gns3vm.running: await self._controller.gns3vm.start() await self.connect() @@ -381,6 +395,9 @@ class Compute: """ We catch error during reconnect """ + + if self._manual_disconnect: + return # stop reconnecting, the compute was manually disconnected try: await self.connect() except ControllerError: @@ -413,6 +430,8 @@ class Compute: Check if remote server is accessible """ + # an explicit connection request cancels a manual disconnect + self._manual_disconnect = False if not self._connected and not self._closed and self.host: try: log.info(f"Connecting to compute '{self._id}'") @@ -474,6 +493,31 @@ class Compute: self._last_error = None self._controller.notification.controller_emit("compute.updated", self.asdict()) + async def disconnect(self): + """ + Manually disconnect from the compute. The controller stops reconnecting + (retry backoff, notification stream reconnect and lazy connection are + all blocked) until connect() is called again, or the controller + restarts with auto_connect enabled for this compute. + """ + + self._manual_disconnect = True + if self._notifications: + # Cancelling the notification task runs its finally block: the + # compute is marked disconnected and clients are notified. The + # flag set above keeps it from scheduling a reconnection. + self._notifications.cancel() + try: + await self._notifications + except asyncio.CancelledError: + pass + self._notifications = None + if self._http_session and not self._http_session.closed: + await self._http_session.close() + if self._connected: + self._connected = False + self._controller.notification.controller_emit("compute.updated", self.asdict()) + async def _connect_notification(self): """ Connect to the notification stream @@ -526,9 +570,12 @@ class Compute: # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) from gns3server.api.server import app - if not app.state.exiting and not hasattr(sys, "_called_from_test"): + # The reconnection goes through _try_reconnect (not connect + # directly) so that a manual disconnect landing within the 1 + # second window is still honoured by the time the timer fires + if not self._manual_disconnect and not app.state.exiting and not hasattr(sys, "_called_from_test"): log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") - asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) + asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self._try_reconnect())) def _getUrl(self, path): host = self._host diff --git a/gns3server/db/models/computes.py b/gns3server/db/models/computes.py index 4561e9f82..a2524f499 100644 --- a/gns3server/db/models/computes.py +++ b/gns3server/db/models/computes.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy import Column, Integer, String +from sqlalchemy import Boolean, Column, Integer, String, false from .base import GUID, BaseTable @@ -30,3 +30,4 @@ class Compute(BaseTable): port = Column(Integer) user = Column(String) password = Column(String) + auto_connect = Column(Boolean, nullable=False, server_default=false()) diff --git a/gns3server/db/repositories/computes.py b/gns3server/db/repositories/computes.py index 3ccbc668b..3b3c9971b 100644 --- a/gns3server/db/repositories/computes.py +++ b/gns3server/db/repositories/computes.py @@ -61,6 +61,7 @@ class ComputesRepository(BaseRepository): port=compute_create.port, user=compute_create.user, password=compute_create.password.get_secret_value() if compute_create.password else None, + auto_connect=bool(compute_create.auto_connect), ) self._db_session.add(db_compute) await self._db_session.commit() @@ -74,6 +75,9 @@ class ComputesRepository(BaseRepository): update_values = compute_update.model_dump(exclude_unset=True) if compute_update.password is not None: update_values["password"] = compute_update.password.get_secret_value() + if update_values.get("auto_connect") is None: + # the column is NOT NULL: treat an explicit null as "leave unchanged" + update_values.pop("auto_connect", None) query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values) diff --git a/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py b/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py new file mode 100644 index 000000000..f114e7bef --- /dev/null +++ b/gns3server/db_migrations/versions/f7d3a9c2e5b1_add_auto_connect_to_computes.py @@ -0,0 +1,26 @@ +"""add auto_connect to computes table + +Revision ID: f7d3a9c2e5b1 +Revises: d9e8a2b7c401 +Create Date: 2026-10-15 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f7d3a9c2e5b1" +down_revision = "d9e8a2b7c401" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column("computes", sa.Column("auto_connect", sa.Boolean(), nullable=False, server_default=sa.false())) + + +def downgrade() -> None: + + op.drop_column("computes", "auto_connect") diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py index 229a97660..f7c1fa791 100644 --- a/gns3server/schemas/controller/computes.py +++ b/gns3server/schemas/controller/computes.py @@ -49,6 +49,9 @@ class ComputeBase(BaseModel): user: Optional[str] = None password: Optional[SecretStr] = None name: Optional[str] = None + auto_connect: Optional[bool] = Field( + False, description="Whether to automatically connect to the compute when the controller starts" + ) model_config = ConfigDict(use_enum_values=True) @@ -87,6 +90,7 @@ class ComputeUpdate(ComputeBase): user: Optional[str] = None password: Optional[SecretStr] = None + auto_connect: Optional[bool] = None # no default: update schemas must not publish defaults model_config = ConfigDict( json_schema_extra={ "example": { @@ -107,6 +111,8 @@ class Compute(DateTimeModelMixin, ComputeBase): port: int = Field(..., gt=0, le=65535) compute_id: Union[str, uuid.UUID] name: str + # defaulted: topology files and the local compute dict may not carry it + auto_connect: bool = False connected: Optional[bool] = Field(None, description="Whether the controller is connected to the compute or not") cpu_usage_percent: Optional[float] = Field(None, description="CPU usage of the compute", ge=0, le=100) memory_usage_percent: Optional[float] = Field(None, description="Memory usage of the compute", ge=0, le=100) diff --git a/gns3server/services/computes.py b/gns3server/services/computes.py index ab328ca04..1b5e66e29 100644 --- a/gns3server/services/computes.py +++ b/gns3server/services/computes.py @@ -15,19 +15,44 @@ # along with this program. If not, see . +import asyncio from typing import List, Union from uuid import UUID import gns3server.db.models as models from gns3server import schemas from gns3server.controller import Controller +from gns3server.controller.compute import Compute from gns3server.controller.controller_error import ( ControllerBadRequestError, + ControllerError, ControllerNotFoundError, ) from gns3server.db.repositories.computes import ComputesRepository +async def _connect_in_background(compute: Compute) -> None: + + try: + await compute.connect() + except ControllerError: + # the connection failure is reported via compute.updated and the + # retry backoff is already scheduled by the connect attempt itself + pass + + +# fire-and-forget background connections: keep a reference so the task is not +# garbage-collected mid-flight, drop it once it is done +_background_connect_tasks: set[asyncio.Task] = set() + + +def _schedule_background_connect(compute: Compute) -> None: + + task = asyncio.ensure_future(_connect_in_background(compute)) + _background_connect_tasks.add(task) + task.add_done_callback(_background_connect_tasks.discard) + + class ComputesService: def __init__(self, computes_repo: ComputesRepository): @@ -50,6 +75,13 @@ class ComputesService: **compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}), ) self._controller.notification.controller_emit("compute.created", compute.asdict()) + + # auto_connect connects in the background — including when the compute + # was already registered on the controller — so the create request + # never waits for, or fails on, the connection. An explicit + # ?connect=true keeps its synchronous behavior via add_compute. + if compute_create.auto_connect and not compute.connected: + _schedule_background_connect(compute) return db_compute async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]: @@ -68,11 +100,26 @@ class ComputesService: ) -> models.Compute: compute = self._controller.get_compute(str(compute_id)) + + # remember the previous value to detect the false -> true transition below + previous_auto_connect = False + if compute_update.auto_connect: + previous_db_compute = await self._computes_repo.get_compute(compute_id) + if previous_db_compute: + previous_auto_connect = bool(previous_db_compute.auto_connect) + await compute.update(**compute_update.model_dump(exclude_unset=True)) db_compute = await self._computes_repo.update_compute(compute_id, compute_update) if not db_compute: raise ControllerNotFoundError(f"Compute '{compute_id}' not found") self._controller.notification.controller_emit("compute.updated", compute.asdict()) + + # Enabling auto_connect (false -> true) connects in the background: the + # request returns immediately and the connection status flips via + # compute.updated. The true -> true case is skipped on purpose so that + # saving an edit does not reconnect a manually disconnected compute. + if compute_update.auto_connect and not previous_auto_connect and not compute.connected: + _schedule_background_connect(compute) return db_compute async def delete_compute(self, compute_id: Union[str, UUID]) -> None: diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index 0558a62d7..08e2ec7df 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio import uuid from unittest.mock import MagicMock, patch @@ -22,6 +23,7 @@ import pytest from fastapi import FastAPI, status from httpx import AsyncClient +from gns3server.controller import Controller from gns3server.schemas.controller.computes import Compute pytestmark = pytest.mark.asyncio @@ -62,7 +64,7 @@ class TestComputeRoutes: for param, value in params.items(): assert response.json()[param] == value - async def test_compute_list(self, app: FastAPI, client: AsyncClient) -> None: + async def test_compute_list(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: response = await client.get(app.url_path_for("get_computes")) assert response.status_code == status.HTTP_200_OK @@ -263,3 +265,152 @@ class TestComputeFeatures: # response = await client.post(app.url_path_for("autoidlepc", compute_id=compute_id) + "/auto_idlepc", json=params) # assert mock.called # assert response.status_code == status.HTTP_200_OK + + +class TestComputeAutoConnect: + async def test_compute_create_with_auto_connect(self, app: FastAPI, client: AsyncClient) -> None: + + params = { + "protocol": "http", + "host": "localhost", + "port": 84, + "user": "julien", + "password": "secure", + "auto_connect": True, + } + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["auto_connect"] is True + # the create request returns immediately, the connection happens + # in the background + await asyncio.sleep(0) + assert connect_mock.call_count == 1 + + # persisted, not just echoed back + compute_id = response.json()["compute_id"] + response = await client.get(app.url_path_for("get_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + + async def test_compute_create_auto_connect_defaults_to_false(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 85, "user": "julien", "password": "secure"} + + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["auto_connect"] is False + + async def test_compute_update_enable_auto_connect_connects(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 86, "name": "auto-connect-target"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + response = await client.put( + app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + await asyncio.sleep(0) # give the background connection task a tick + assert connect_mock.call_count == 1 + + # saving the same value again must not reconnect: a manual + # disconnect has to survive an unrelated edit of the compute + response = await client.put( + app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True} + ) + assert response.status_code == status.HTTP_200_OK + await asyncio.sleep(0) + assert connect_mock.call_count == 1 + + async def test_compute_update_preserves_auto_connect(self, app: FastAPI, client: AsyncClient) -> None: + + params = { + "protocol": "http", + "host": "localhost", + "port": 87, + "user": "julien", + "password": "secure", + "auto_connect": True, + } + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + response = await client.put(app.url_path_for("update_compute", compute_id=compute_id), json={"name": "renamed"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "renamed" + + response = await client.get(app.url_path_for("get_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["auto_connect"] is True + + +class TestComputeDisconnect: + async def test_compute_disconnect(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 88, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert compute._manual_disconnect is True + assert compute.connected is False + + async def test_compute_disconnect_in_use_rejected(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 89, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + open_project = MagicMock() + open_project.name = "project-using-the-compute" + with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[open_project]): + response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id)) + assert response.status_code == status.HTTP_409_CONFLICT + # the offending project names are reported to the client + assert "project-using-the-compute" in response.json()["message"] + # the compute is left untouched + assert compute._manual_disconnect is False + assert compute.connected is True + + async def test_compute_disconnect_in_use_forced(self, app: FastAPI, client: AsyncClient) -> None: + + params = {"protocol": "http", "host": "localhost", "port": 90, "user": "julien", "password": "secure"} + response = await client.post(app.url_path_for("create_compute"), json=params) + assert response.status_code == status.HTTP_201_CREATED + compute_id = response.json()["compute_id"] + + compute = Controller.instance().get_compute(compute_id) + compute._connected = True + + with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[MagicMock()]): + response = await client.post( + app.url_path_for("disconnect_compute", compute_id=compute_id), params={"force": True} + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert compute._manual_disconnect is True + assert compute.connected is False + + async def test_compute_disconnect_local_rejected(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.post(app.url_path_for("disconnect_compute", compute_id="local")) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + async def test_compute_disconnect_unknown(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.post(app.url_path_for("disconnect_compute", compute_id=str(uuid.uuid4()))) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 6a8f3b27b..66fc638f9 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -29,11 +29,13 @@ from gns3server.api.server import app as gns3_app from gns3server.controller.compute import Compute from gns3server.controller.controller_error import ( ComputeConflictError, + ComputeError, ControllerError, ControllerNotFoundError, ControllerUnauthorizedError, ) from gns3server.controller.project import Project +from gns3server.version import __version__ from tests.utils import AsyncioMagicMock, asyncio_patch @@ -376,6 +378,7 @@ async def test_json(compute): "host": "example.com", "port": 84, "user": "test", + "auto_connect": False, "cpu_usage_percent": 0, "memory_usage_percent": 0, "disk_usage_percent": 0, @@ -696,3 +699,87 @@ async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, m actions = [c.args[0] for c in emit_mock.call_args_list] assert "compute.updated" in actions assert scheduled_delays == [5] # first exponential backoff delay + + +def test_auto_connect_constructor_default(controller): + + assert Compute("default_auto_connect", controller=controller).asdict()["auto_connect"] is False + assert Compute("enabled_auto_connect", controller=controller, auto_connect=True).asdict()["auto_connect"] is True + + +@pytest.mark.asyncio +async def test_update_auto_connect(compute, controller): + + compute._controller._notification = MagicMock() + compute._controller.save = MagicMock() + + assert compute.asdict()["auto_connect"] is False + await compute.update(auto_connect=True) + assert compute.asdict()["auto_connect"] is True + controller.notification.controller_emit.assert_called_with("compute.updated", compute.asdict()) + + +@pytest.mark.asyncio +async def test_disconnect_stops_the_notification_stream(compute, controller): + """ + disconnect() tears down the notification stream, marks the compute + disconnected and notifies clients — without scheduling any reconnection. + """ + + assert compute.connected is True + + compute._controller._notification = MagicMock() + started = asyncio.Event() + + async def fake_notification_stream(): + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise + + compute._notifications = asyncio.gather(asyncio.create_task(fake_notification_stream())) + await started.wait() + + await compute.disconnect() + assert compute._manual_disconnect is True + assert compute.connected is False + assert compute._notifications is None + compute._controller._notification.controller_emit.assert_called_with("compute.updated", compute.asdict()) + + +@pytest.mark.asyncio +async def test_manual_disconnect_blocks_reconnect_and_lazy_connect(compute): + """ + After a manual disconnect no reconnection path may bring the compute back: + not the retry backoff, not the lazy connection of http_query. + """ + + await compute.disconnect() + + with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock: + await compute._try_reconnect() + connect_mock.assert_not_called() + + with pytest.raises(ComputeError): + await compute.http_query("GET", "/capabilities") + connect_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_connect_cancels_manual_disconnect(compute, monkeypatch): + + compute._manual_disconnect = True + + async def fake_capabilities(method, path, **kwargs): + return SimpleNamespace(json={"version": __version__}) + + async def fake_notification_stream(): + return None + + monkeypatch.setattr(compute, "_run_http_query", fake_capabilities) + monkeypatch.setattr(compute, "_connect_notification", fake_notification_stream) + + await compute.connect() + assert compute._manual_disconnect is False + assert compute.connected is True From 7737630cb485c813187c76bceb6dce105653822d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 15 Oct 2026 23:02:53 +0800 Subject: [PATCH 18/18] fix(tests): derive head revision in the migration round-trip test The round-trip test builds its catalog from the models (which already carry the head schema), then stamps the revision that was newest when the test was written and replays everything above it. Any migration landing on top therefore ran its DDL against a schema it had not rolled back - the new computes.auto_connect migration failed with "duplicate column name: auto_connect". Stamp the current head instead and let the downgrade roll back every revision above the inventory one before the upgrade replays them. --- tests/services/test_image_reconciliation.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/services/test_image_reconciliation.py b/tests/services/test_image_reconciliation.py index ff90dd186..47beef1b6 100644 --- a/tests/services/test_image_reconciliation.py +++ b/tests/services/test_image_reconciliation.py @@ -401,6 +401,7 @@ async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config) async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config): from alembic import command from alembic.config import Config + from alembic.script import ScriptDirectory image_file(config) await scan(inventory) @@ -414,7 +415,12 @@ async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, c cfg = Config() cfg.set_main_option("script_location", "gns3server:db_migrations") cfg.attributes["connection"] = connection - command.stamp(cfg, "d9e8a2b7c401") + # The catalog is built from the models, so it already carries the head + # schema. Stamping head (not the revision that happened to be newest when + # this test was written) keeps the round-trip valid for later migrations: + # every revision above the inventory one is rolled back before it is + # replayed, instead of being applied on top of its own schema. + command.stamp(cfg, ScriptDirectory.from_config(cfg).get_current_head()) command.downgrade(cfg, "c7e4a9f1d2b6") assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1 assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]