diff --git a/.claude/skills/gns3-documentation/SKILL.md b/.claude/skills/gns3-documentation/SKILL.md
index 8772e1f07..485de4945 100644
--- a/.claude/skills/gns3-documentation/SKILL.md
+++ b/.claude/skills/gns3-documentation/SKILL.md
@@ -1,7 +1,7 @@
---
name: documentation
description: Use this skill when creating or updating technical documentation under docs/ directory for GNS3 server
-version: 3.0.0
+version: 3.1.0
---
# GNS3 Server Technical Documentation Standard
@@ -39,7 +39,7 @@ Code details are left to the codebase — readers can use AI to find implementat
## What to Include
-- Mermaid diagrams for architecture and flow (GitHub renders natively)
+- Mermaid diagrams for architecture and flow (GitHub renders natively) — see Mermaid Diagram Rules below
- `graph` for component relationships
- `sequenceDiagram` for request/response flows
- `flowchart` for data processing steps
@@ -47,6 +47,26 @@ Code details are left to the codebase — readers can use AI to find implementat
- Request/response JSON examples (for interfaces)
- Performance data (only measured numbers, no estimates)
+## Mermaid Diagram Rules
+
+Draw only the diagram kinds the story needs — each kind answers exactly one reader question, in reading order:
+
+- **Anatomy — "what exists, and who owns it?"** — `flowchart LR` with the fewest nodes that show ownership: one subgraph per ownership boundary (a process, a namespace, userspace vs kernel), anything shared between owners a bare node inside the enclosing boundary. The steady-state path is bidirectional solid (`<-->`) edges labeled with the carrying technology, never with a direction; each observer/configurator gets one dashed sideband edge per role.
+- **Comparison — "how does X differ from Y?"** — the mirror of its counterpart diagram: same scenario, same node ids, same concrete names, one variable changed. Draw one direction with unidirectional `-->` and state in prose that the reverse is the mirror image.
+- **Control sequence — "how is it built / torn down?"** — `sequenceDiagram` spanning every layer (API → coordinator → per-node handlers → the managed process): local steps are self-messages, concurrent fan-out is a `par / and / end` block, each message to the bottom layer is one real command in real order, and a closing `Note` names the steady state.
+- **Walkthrough — "how does one item traverse it?"** — `flowchart LR` as a linear pipeline: one subgraph per stage, titled with that stage's role in this trace (`X — ingress`), sideband dashed edges (reverse-direction role, observation point) anchored at the stage they belong to.
+
+Whatever the kind:
+
+- **One step per node, one short line per label.** If a label needs ` ` lines to explain itself, split the node into smaller steps instead of packing text — needing many words means the flow needs further decomposition.
+- **Details belong to prose.** Parentheticals, exceptions, and parameter lists go to the surrounding text or the section that owns the topic; a diagram never duplicates what prose already says.
+- **Define jargon before the first diagram.** A Term | Meaning table placed above the diagrams covers the domain terms they use (interface names, kernel mechanisms), so readers don't have to scan forward.
+- **Edge style is semantics.** Solid arrows carry the main flow (data plane); dashed `-.->` edges are sideband relationships (taps, control installs). A node with multiple sideband roles gets one dashed edge per role, each labeled with a single function — and edge *direction* matters too: a config edge points from the configurere to its target, an observation edge from the target to the observer.
+- **Decision points are diamonds with labeled outcomes**, not text-packed rectangles spelling out every branch inside one node.
+- **A comparison diagram changes exactly one variable.** When two diagrams contrast approaches (kernel vs relay, old vs new), everything else — scenario, topology, node names — stays identical between them; a difference that isn't the point belongs in a prose aside, not in the picture.
+- **Numbered crossings stay consistent.** When kernel/user or process-boundary copies are the point of a diagram, number the crossing edges (`copy N: user→kernel`) and keep one numbering scheme across every diagram in the document.
+- **Concrete names instantiate stated rules.** Every interface/bridge/instance name appearing in a diagram instantiates a deterministic naming rule stated in prose before the first diagram — never invent ad-hoc names.
+
## What to Skip
- Code snippets — let readers search the codebase
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/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 42916ecf5..321d5de8f 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- uses: actions/checkout@v7
@@ -34,9 +34,10 @@ jobs:
python -m pip install .[ai-features,dev]
- name: Install sharkd (marker replay engine)
- run: |
- sudo apt-get update
- sudo apt-get install -y wireshark-common
+ uses: awalsh128/cache-apt-pkgs-action@latest
+ with:
+ packages: wireshark-common
+ version: linux-amd64
- name: Run all checks
run: |
diff --git a/CHANGELOG b/CHANGELOG
index 14731bb43..c3e9f94b8 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,112 @@
# Change Log
+## 3.1.0a7 21/10/2026
+
+* Bundle web-ui v3.1.0a7
+* Change project creator on import
+* feat: add QEMU support documentation and enforce minimum version requirement
+* fix: SPICE socket binding failure
+* fix(cloud): keep ports_mapping getter and setter adjacent
+* feat(cloud): attach TAP interfaces to existing Linux bridges
+* feat(qemu): support SATA CD-ROM interfaces
+* fix: fetch remote-compute marker pcaps in tag replay
+* feat(qemu): support two CD/DVD images and consistent ISO handling
+* feat: default vxlan_dstport to the IANA VXLAN port 4789
+* feat: cross-compute VXLAN kernel datapath
+* fix: type User.username as str via Mapped-style column
+* fix: refuse Docker node updates that recreate a running container
+* fix: roll back a node update the compute refused
+* fix(docker): give each VNC node its own X display
+* fix: dial per-node console hosts in MCP device config tools
+* feat: add auto_connect flag and disconnect endpoint for computes
+* feat: concurrent kernel captures, one per interface
+* fix: hide kernel-datapath interfaces from the cloud node
+* fix(docker): accept the pinned image id when the stores report different ids
+* feat: VPCS kernel datapath
+* feat: add support for Python 3.15
+* fix: enable Layer 1 keepalives by default. Ref #2975
+* fix: update error logging to handle exceptions in WebSockets
+* fix(deps): update paramiko and netmiko versions to address security vulnerability
+* refactor: remove unused link filter schemas and fix test_add_v8_version_appliance
+* fix(controller): stop flooding clients with transitional node statuses
+* fix: remove incorrectly merged code
+* refactor(mcp): drop the raw drawing_create tool
+* feat(agent): share Web UI compatible drawing builders between MCP and copilot
+* feat(mcp): add drawing_create_text tool with Web UI compatible SVG assembly
+* fix(deps): install the SQLAlchemy asyncio module so the 'greenlet' library is installed
+* fix(rbac): annotate query variables with Select type for better type checking
+* fix: drop Python 3.10 and add Python 3.15 support
+* feat: add optional ETag/If-Match concurrency for projects, nodes and links
+* feat: add response models for endpoints returning untyped objects
+* feat(api): generate unique readable OpenAPI operationIds
+* feat(api): type link filters and available_filters response
+* fix(links): reserve ports on add, accept port name and auto, add port_in_use code
+* feat(api): add strict_names and batch node and link creation
+* feat(api): return node and add wait option on node lifecycle calls
+* feat(api): add Snapshot.created and type NodeFile timestamps as date-time
+* fix(api): derive OpenAPI version from package, add servers, hide AI stub routes
+* feat(api): add Notification schema and document notification streams
+* feat(api): add discriminators to link iface, appliance version and template settings schemas
+* feat(api): return token lifetimes and document API keys as bearer tokens
+* feat(api): add machine-readable error codes and align OpenAPI error schemas
+* fix(api): declare binary and streaming bodies in OpenAPI
+* fix(api): return 200 instead of 201 for ACL endpoints, IOU license update
+* feat(api): add exact-match name filter to list endpoints
+* fix(console): keep SSH web console open after terminal resize
+* fix: resolve the code-review findings on the kernel datapath
+* fix(image-manager): Fix type-check errors in image uploads and appliance installation
+* fix(templates): Fixed template test formatting to pass CI
+* fix(iou): keep BridgeNIO filters across the NIO update route
+* fix: repair imports lost in the 3.1 rebase and meet the new lint baseline
+* fix(tools): merge same-device entries before device command execution
+* fix(compute): a passive absorbed anchor no longer resets the switch's tc state
+* fix(esw): harden the cascade end the create race leaves unhardened
+* fix(compute): refuse a duplicate port NIO and wire before bookkeeping
+* fix(controller): bind and release link members on their live ports
+* fix(controller): keep port links across a port-list rebuild
+* feat(controller): let IOL runner containers take the kernel datapath
+* feat(iol): anchor IOL runner containers on persistent TAPs
+* feat(compute): probe uBridge's swappable bridge TAP leg
+* fix(controller): keep IOL runner containers off the kernel datapath
+* fix(qemu): release the datapath under the execution lock
+* fix(qemu): release the datapath after the process is gone
+* feat(esw): wire peer anchors straight into the switch bridge
+* feat(controller): offer Dynamips the kernel datapath
+* feat(dynamips): anchor every Ethernet port on a persistent TAP
+* fix(mcp): let link_list return kernel_datapath
+* fix(schemas): one definition of the capabilities payload
+* fix(iou): carry suspend from the NIO update body
+* feat(controller): offer IOU the kernel datapath
+* feat(ubridge): probe iol_bridge's TAP-port support
+* feat(iou): anchor every Ethernet bay/unit on a persistent TAP
+* fix(qemu): build a command line for a headless node
+* feat(controller): offer QEMU the kernel datapath, gated per compute
+* feat(qemu): anchor QEMU adapters on a persistent TAP
+* refactor(compute): share the kernel-datapath machinery across node types
+* fix(controller): re-arm window_drop and expose kernel_datapath
+* feat(mcp): accept project/node names as tool locators
+* feat(controller): expose kernel_datapath on the link payload
+* feat(compute): report uBridge tc capabilities and gate filters on them
+* fix(docker): delete the per-link kernel bridge when a node stops
+* feat(docker): expose the eBPF time-window filter on kernel links
+* feat(docker): eBPF stateful packet filters on kernel links
+* feat(docker): expose netem-extension packet filters on kernel links
+* fix(docker): bring the veth host end up before a relay attach
+* feat(docker): bpf packet filter on kernel links via cls_bpf match-drop
+* feat(docker): packet filters on kernel-datapath links via tc netem
+* feat(docker): unify adapter interfaces on veth, allow kernel links at runtime
+* feat(docker): traffic-insight markers on kernel-datapath links via AF_PACKET
+* feat(docker): packet capture on kernel-datapath links via AF_PACKET
+* fix(controller): don't push the synthetic suspend filter to kernel links
+* feat(docker): wire Docker-to-Docker links through a kernel bridge
+* feat(image-manager): Add support for nested image folders and appliance compatibility checks
+* refactor: discriminate appliance settings by template_type
+* fix: bump asyncssh - at least 2.24.1 for CVE-2026-62949 fixes
+* fix: require Python >= 3.10 in remote-install.sh when installing v3
+* fix(images): Fix symlinked image imports and add regression tests
+* fix(images): Fix symlink support in image inventory
+
+
## 3.1.0a6 27/09/2026
* Bundle web-ui v3.1.0a6
diff --git a/ai-requirements.txt b/ai-requirements.txt
index 135af4c09..6125ed124 100644
--- a/ai-requirements.txt
+++ b/ai-requirements.txt
@@ -18,33 +18,34 @@ 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
# Token Counting
-tiktoken>=0.8.0
+tiktoken>=0.14.0
# LangSmith SDK
-langsmith>=0.7.7
+langsmith>=0.14.7
# Network Automation
-netmiko>=4.7.0
+paramiko>=5.0.0 # install version >= 5.0.0 to address CVE-2026-44405
+netmiko>=4.8.0
nornir>=3.5.0
nornir-netmiko>=1.0.1
-nornir-utils>=0.2.0
-nornir-salt>=0.23.0
+nornir-utils>=0.3.0
+nornir-salt>=0.23.4
# Environment & Configuration
python-dotenv>=1.2.1
# Authentication
-PyJWT>=2.10.1
+PyJWT>=2.15.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)
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 2f2d2c10b..03642922f 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -6,5 +6,5 @@ httpx_ws==0.7.2 # upgrading leads to failures in tests
pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing)
pytest-cov==7.1.0
ruff==0.16.9
-mypy==2.3.1
+mypy==2.4.0
rust-just==1.58.0 # provides the `just` command runner, see justfile
diff --git a/docs/README.md b/docs/README.md
index ea1581292..9257914ec 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -36,6 +36,7 @@ docs/
├── openapi.json # OpenAPI specification
├── features/ # Feature documentation
│ ├── compute-controller-setup.md # Controller + Compute architecture & configuration
+│ ├── packet-filters.md # Link impairment filters: 13 kernel types + relay
│ ├── statistics-api.md # Aggregated statistics API for monitoring
│ ├── vnc-websocket-console.md # Browser-based VNC console via WebSocket
│ └── web-wireshark-business-process.md # Web Wireshark (Docker + xpra packet capture)
@@ -59,6 +60,16 @@ docs/
## Features
+### Image inventory synchronization
+Image Manager's **Sync Images** action reconciles the catalog with the configured
+image directories. Automatic synchronization uses filesystem events and periodic
+scans (`auto_discover_images`, `image_sync_interval`, default 900 seconds).
+New and changed images are checksummed; missing records retain their IDs and
+template associations and become available again when restored at the same path.
+The `/v3/images/sync` API supports dry-run and forced checksum verification.
+Database migration `d9e8a2b7c401` is required when upgrading an existing catalog.
+Atomic image publication requires filesystem hard-link support.
+
### Controller + Compute Setup (`features/compute-controller-setup.md`)
Architecture and minimum configuration for setting up GNS3 Controller with remote Compute nodes. Covers compute node config, controller registration, and multi-compute deployment.
@@ -80,6 +91,9 @@ Real-time traffic insight via per-link BPF markers and project-level inherited d
### Marker Tag Replay (`features/marker-tag-replay.md`)
Aggregate playback across links keyed by `tag`: once every marker under a tag is paused, their pcaps merge into one timestamp-ordered timeline; frames are decoded on demand via tshark into an isomorphic JSON protocol tree. The cross-link delta of the same packet measures the intermediate node's forwarding latency.
+### Packet Filters (`features/packet-filters.md`)
+Link impairment filters on both datapaths: the 13 kernel types (one netem qdisc, cls_bpf match-drop, the eBPF stateful classifier) with the per-frame evaluation order, plus the 5 relay userspace types; reconcile semantics, capability gating through `available_filters`, and the window/quota drop models.
+
### Docker exec Console (Vendor NOS) (`features/docker-exec-console.md`)
Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PID 1: runs the vendor CLI via the Docker exec API, plus `GNS3_SKIP_INIT`/`GNS3_INTERFACE_NAMES` boot knobs and SKIP_INIT volume persistence.
diff --git a/docs/bugs/networkmanager-tap-release.md b/docs/bugs/networkmanager-tap-release.md
new file mode 100644
index 000000000..e5c149b17
--- /dev/null
+++ b/docs/bugs/networkmanager-tap-release.md
@@ -0,0 +1,191 @@
+
+
+> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
+
+# NetworkManager Releases Enslaved TAP Anchors from Their Bridge (Silent Link Death)
+
+## Bug Report
+
+**Date**: 2026-10-05 (root-caused; the same signature first hit on 2026-10-01 during the Ethernet-switch kernel-datapath development)
+**Severity**: High on affected hosts (one link dies silently — API and UI stay green; ~50 % per re-enslavement while NetworkManager has not yet assumed the TAP; environment-dependent and intermittent)
+**Status**: **Root-caused; host-side fix verified.** The NetworkManager declaration (`unmanaged-devices`) takes the reproducing loop from 19-20/40 to 0/40 and lets the e2e scenario pass with all test-side workarounds disabled. Test-side best-effort hardening is shipped (`harness.unmanage_from_networkmanager`); a product-side guard remains an open decision.
+**Component**: Host environment — NetworkManager × uBridge-created TAP anchors. No GNS3 code path performs the detaching operation: gns3-server and uBridge log nothing and their bookkeeping stays intact.
+
+## Symptoms
+
+On a kernel-datapath link, after a **re-enslavement of the TAP anchor** — a link delete + recreate, a port re-join after a peer restart — the link may die: ping 0 %, routing protocols never form — while every API/UI surface reports health (`kernel_datapath: true`, no error, no log). The node, the emulated router and the anchor device itself keep running; only the bridge membership is gone.
+
+Self-heal exists but nothing forces it: any subsequent NIO update re-checks `/sys/class/net//brif/` and re-attaches (`EthernetSwitch.update_nio`'s "not joined" branch, and the equivalent attach path per node type), which is why the state heals on the next filter change, suspend toggle or peer restart. The *first* enslavement of a freshly created TAP is stable in every observation; the hazard is re-enslavement before NetworkManager has assumed the device (a window of minutes), at ~50 % per attempt.
+
+## Forensic signatures (what the corpse looks like)
+
+- `/sys/class/net//brif/` missing, `/sys/class/net//brport/` gone — the port was released, not the device destroyed: **ifindex unchanged**, `ip -d link show` reports no master.
+- Flags drop `IFF_PROMISC|IFF_ALLMULTI` (0x300, set by the kernel bridge on enslavement): as seen with a 50 Hz brif sampler, `fl 0x1303 → 0x1003` while administrative UP is kept.
+- The release lands **58-87 ms after the join** — after the attach API has already returned success, so `wait_until`-style checks see the join and the next traffic sample sees the blackhole.
+- No `brctl delif` (or any netlink write) from any GNS3 process; server-side registrations (`_kernel_ports`, NIO state) are untouched; uBridge logs stay clean.
+- `nmcli device status` shows the anchor as managed by NetworkManager: `disconnected` before it assumes the device, `connected (externally)` after.
+
+## Root Cause (NetworkManager side, source-level)
+
+On hosts where NetworkManager manages the anchors — it claims uBridge-created tun/tap devices by default (`nmcli device` then shows them managed) — NM's device state machine resolves the fresh enslavement in its own favour while the device is in its pre-assumption window:
+
+1. An **external enslavement** triggers an assumption recheck — NetworkManager commit `a6ceb382e9` ("device: connect slave assumption recheck on external enslavement") exists precisely for `ip link set master ` performed by someone else.
+2. The recheck activates (assumes) the device, and activation enforces that **a connection which does not declare a master must not keep its kernel master**: the slave is force-released, reason `NM_DEVICE_STATE_REASON_CONNECTION_ASSUMED` — NetworkManager commit `ec12912908` ("device: enforce the absence of a master during activation"). This is the detaching operation; it runs several asynchronous state-machine steps after the enslavement, which is the 60-80 ms latency.
+3. Once the device is assumed (`connected (externally)`), later external enslavements take the opposite path: **external changes must not touch the device** — NetworkManager commit `3127fb0d17` ("device: don't let external changes cause a release of the slave", `configure = FALSE`, "let the device be"). The model is updated; the kernel is left alone. This is why assumed taps were stable across 14 re-enslavements in the field, and why the hazard window ends at assumption.
+
+Both processes' worlds are mutually exclusive here: NM requires "no master during activation", the datapath requires "enslaved to the switch/per-link bridge" — there is no configuration in which both hold. The sanctioned resolution is a jurisdiction declaration (mark the devices unmanaged), not a race.
+
+Docker veth host ends are not NM-managed (and the veth naming/type heuristics historically keep NM away — see History), so only the TAP anchor families are affected: `gd` (Dynamips), `gq` (QEMU), `gi` (IOU), `gx` (IOL runner containers), and switch-absorbed anchors.
+
+## Reproduction (minimal, gns3-server not involved)
+
+A self-contained reproducer — one uBridge, one TAP, one kernel bridge, an enslavement loop — needs none of the server. It drives uBridge's control socket through the repository's own client, so no build step and no extra dependencies beyond the venv:
+
+```python
+#!/usr/bin/env python3
+"""Minimal reproducer: NetworkManager releases an enslaved TAP from its bridge.
+
+Run from the gns3-server repo root on an affected host (Linux + NetworkManager
+managing the TAPs; uBridge installed with cap_net_admin):
+
+ venv/bin/python nm_tap_release_repro.py # 40 re-enslavements
+ nmcli device set gdlab-nm managed no # the A/B flip (same tap!)
+ venv/bin/python nm_tap_release_repro.py # -> 0 releases
+
+The tap and bridge are kept between runs (same device = same NM state).
+Clean up afterwards:
+
+ venv/bin/python nm_tap_release_repro.py --cleanup
+"""
+import asyncio
+import os
+import shutil
+import subprocess
+import sys
+import time
+
+sys.path.insert(0, os.getcwd()) # run from the repo root for gns3server imports
+from gns3server.compute.ubridge.ubridge_hypervisor import UBridgeHypervisor
+
+TAP = "gdlab-nm"
+BRIDGE = "gns3lab-nm"
+SOCK = "/tmp/nm_tap_release_repro.sock"
+ITERATIONS = 40
+SAMPLE_MS = 200 # membership sampled at ~1 kHz for this long after each addif
+
+
+def in_bridge():
+ return os.path.exists(f"/sys/class/net/{BRIDGE}/brif/{TAP}")
+
+
+async def main():
+ ubridge = shutil.which("ubridge")
+ if not ubridge:
+ sys.exit("ubridge not found in PATH")
+ if os.path.exists(SOCK):
+ os.unlink(SOCK)
+ proc = subprocess.Popen(
+ [ubridge, "-U", SOCK], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd="/tmp"
+ )
+ try:
+ h = UBridgeHypervisor(socket_path=SOCK)
+ await h.connect()
+
+ async def send(cmd):
+ try:
+ await h.send(cmd)
+ except Exception:
+ pass # ENOENT during setup/teardown is expected
+
+ if "--cleanup" in sys.argv:
+ await send(f'tap delete "{TAP}"')
+ await send(f'brctl delete "{BRIDGE}"')
+ return
+ await send(f'tap create "{TAP}"') # no-op if the tap already exists
+ await send(f'brctl create "{BRIDGE}"')
+ await send(f'link set "{BRIDGE}" up')
+ await send(f'brctl vlanfiltering "{BRIDGE}" on')
+
+ releases, latencies = 0, []
+ for _ in range(ITERATIONS):
+ await send(f'link set "{TAP}" up')
+ await send(f'brctl addif "{BRIDGE}" "{TAP}"')
+ t0 = time.time()
+ for _ in range(SAMPLE_MS):
+ await asyncio.sleep(0.001)
+ if not in_bridge():
+ releases += 1
+ latencies.append(round((time.time() - t0) * 1000, 1))
+ break
+ await send(f'brctl delif "{BRIDGE}" "{TAP}"')
+ await send(f'link set "{TAP}" down')
+ await asyncio.sleep(0.005)
+
+ print(f"releases: {releases}/{ITERATIONS} re-enslavements", flush=True)
+ if latencies:
+ print(f"release latency after join (ms): {latencies[:10]}")
+ print(f"expect ~19-20/40 while NetworkManager manages {TAP}, "
+ f"0/40 after `nmcli device set {TAP} managed no`")
+ finally:
+ proc.kill()
+
+
+asyncio.run(main())
+```
+
+How the numbers are produced:
+
+- 40 iterations; each is one **re-enslavement**: `link set up`, `brctl addif`, then membership sampled at ~1 kHz for 200 ms, then `brctl delif` + `link set down`.
+- A **release** is counted when `/sys/class/net//brif/` disappears inside that 200 ms window; the script prints the release latency after the join (the NM release lands at 58-87 ms).
+- The tap is kept between runs, so the A/B flip is single-variable on the *same* device; a fresh device would reset NM's assumption state (the first enslavement of a fresh tap is always stable).
+- The numbers below were measured on a host **without** the declaration from *Fix*. On a host that already carries it the tap is unmanaged from birth and the loop prints `0/40` by design; to re-observe the baseline there, temporarily remove the conf.d file (`sudo rm /etc/NetworkManager/conf.d/99-gns3-datapath.conf && sudo systemctl reload NetworkManager`) or run on an un-declared host. A runtime `nmcli device set managed yes` is *not* enough — the configuration match wins (the device stays `unmanaged` and the loop still prints 0/40; measured).
+
+Measured on an affected host (same code, five baseline runs, then the A/B between runs):
+
+| Condition | Releases / 40 re-enslavements |
+|---|---|
+| NM-managed (default) | 19, 20, 19, 20, 20 |
+| `nmcli device set managed no`, same tap | **0** |
+| re-managed (`nmcli device set managed yes`), same tap | 20 |
+
+e2e-level reproduction: `tests/e2e/test_ethernet_switch_kernel_datapath.py::test_ethernet_switch_kernel_fast_path` fails at its link delete/recreate step (`wait_ping` after the anchor re-join) roughly half of fresh runs on an NM host — it failed twice consecutively on 2026-10-05 during the 3.1 rebase validation before the fix.
+
+## Fix
+
+Three layers, of which the first is the verified root fix:
+
+1. **Host declaration** — `/etc/NetworkManager/conf.d/99-gns3-datapath.conf`:
+
+ ```ini
+ [keyfile]
+ unmanaged-devices=interface-name:gd*;interface-name:gq*;interface-name:gi*;interface-name:gx*;interface-name:gv*;interface-name:gns3*
+ ```
+
+ then `systemctl reload NetworkManager`. Devices matching are unmanaged from birth (also future ones and after re-creations); hosts without NM treat the file as inert. Verified on the affected host: minimal loop **0/40**, and the e2e scenario passes with the test-side workaround disabled (pure host config); re-verify with the reproducer below — same tap, expect 0/40 after the reload. This belongs in installers/documentation, not in the server (no root; and the server must not reconfigure the host's NM).
+
+2. **Test-side best effort** — `harness.unmanage_from_networkmanager(*taps)` runs `nmcli device set managed no` where nmcli exists (no-op otherwise); kernel-datapath scenarios that re-enslave anchors call it after their nodes have booted. This keeps the e2e deterministic on un-declared hosts.
+
+3. **Product-side guard** — open decision: a bounded post-attach re-check (~250 ms) that re-attaches once and warns (naming NetworkManager), a warn-only variant, or nothing (documentation only). Until decided, an undeclared host relies on the existing heal-on-next-update path.
+
+## Scope
+
+- Affected: Linux hosts where NetworkManager manages the TAPs — desktop-native `gns3server` deployments (the most common GNS3 form factor for Linux users). Every TAP anchor family, the switch fast path and per-link kernel links alike.
+- Not affected: the GNS3 VM (its Ubuntu-server stack is netplan/systemd-networkd, no NM — verified against the gns3-vm repository), hosts without NM, Docker veth anchors, relay links, cross-compute links (relay by default).
+- Blast radius of one occurrence: exactly one link, silently dead; no incorrect data, but it can mislead an experiment's conclusions (e.g. "this protocol never converges here") and it mimics the project's own "API green, data plane dead" bug class — during the rebase validation it was triaged as a regression until the timing signature (join, then +60 ms release with no command) identified it as environmental.
+
+## History
+
+- **2026-10-01**: the same signature first hit during the Ethernet-switch development, described then as "an anchor occasionally losing its bridge membership right after a `ports_mapping` update"; reproduced with QEMU, IOU and Dynamips peers (all TAPs) and never with Docker veths; the then-unknown cause was worked around by making VLAN reconfiguration in-place (no re-enslavement) and the failure mode was assumed gone "by construction". See `docs/features/ethernet-switch-kernel-datapath.md`.
+- **2016**: the same NM-vs-GNS3 jurisdiction class hit Docker veths — NM configured them like plain NICs and a container DHCP server hijacked the host's default route (issue #440, closed). Upstream worked around it by renaming `gns3-veth…` to `veth-gns3-…`, the commit message reading "the name create different behavior with network manager" (`59c1e125d`). NM's management heuristics even depend on interface names — the rename moved the veths into NM's ignored namespace.
+- **2026-10-05**: root-caused with the minimal harness and the single-variable A/B; host fix applied and verified; test-side hardening shipped; full write-up (this document).
+
+## Related
+
+- NetworkManager commits: [`a6ceb382e9`](https://codetest.hyprland.org/fdo-mirrors/NetworkManager/commit/a6ceb382e90a9ebc278e380a3b204ad8c2c543c4), [`ec12912908`](http://git.baserock.org/cgit/delta/NetworkManager.git/commit/src/devices/nm-device.c?id=ec12912908d739459ff3913917e9937500345c2a), [`3127fb0d17`](https://codetest.hyprland.org/fdo-mirrors/NetworkManager/commit/3127fb0d17bff0b250218c7bf82b4335b5290825).
+- GNS3 issue [#440](https://github.com/GNS3/gns3-server/issues/440) and commit `59c1e125d` (veth rename).
+- `docs/design/ubridge-l2-anchor-spec.md` — "NetworkManager releases managed TAPs from bridges" subsection (short form).
+- `docs/features/ethernet-switch-kernel-datapath.md` — the corrected account of the 2026-10-01 incident.
+- `tests/e2e/harness.py` — `unmanage_from_networkmanager`.
diff --git a/docs/dependencies/qemu.md b/docs/dependencies/qemu.md
new file mode 100644
index 000000000..001399bb4
--- /dev/null
+++ b/docs/dependencies/qemu.md
@@ -0,0 +1,28 @@
+# QEMU support
+
+GNS3 server requires QEMU 6.2 or newer. This is the minimum QEMU version that the server actively supports on Linux.
+
+For compatibility checks, the project evaluates the Linux distributions that are commonly used in server deployments and that still have supported releases: CentOS Stream, Debian, Fedora, openSUSE, RHEL, SLES, and Ubuntu LTS. Other distributions are assumed to ship similar package versions and are treated by the same rule: if the distro ships QEMU 6.2 or newer, it is considered compatible; if it ships an older version, it is not supported by GNS3 server.
+
+This document reflects the currently supported release set as of October 2026, based on the active releases listed by the major distro end-of-life trackers and package versions shipped by those releases.
+
+## Minimum supported version
+
+- Minimum supported QEMU version: 6.2
+- QEMU 6.2 was first shipped in Ubuntu 22.04 LTS and is the baseline used to determine whether a Linux distribution is suitable for GNS3 server.
+- Distributions shipping QEMU 6.2 or later are generally acceptable.
+- Older package versions are considered unsupported by policy.
+
+## Currently supported Linux releases and shipped QEMU versions
+
+The following supported releases are the current reference points used by the project. They are based on the active release lists published by the distributions' end-of-life trackers and the QEMU versions typically shipped by those releases.
+
+| Distribution | Current supported OS release(s) | QEMU version shipped | Source |
+| --- | --- | --- | --- |
+| Ubuntu LTS | 24.04 LTS, 22.04 LTS | 8.2 for 24.04, 6.2 for 22.04 | https://endoflife.date/ubuntu |
+| Debian | 13 (trixie), 12 (bookworm) | 9.x for 13, 7.2 for 12 | https://endoflife.date/debian |
+| Fedora | 42, 41, 40 | 9.x | https://endoflife.date/fedora |
+| openSUSE | Leap 15.6, Tumbleweed | 8.x to 9.x | https://endoflife.date/opensuse |
+| CentOS Stream | 10, 9 | 9.x / 8.x | https://endoflife.date/centos |
+| RHEL | 10, 9.5, 9.4 | 9.x / 8.x | https://endoflife.date/redhat-enterprise-linux |
+| SLES | 15 SP7, 15 SP6 | 8.x | https://endoflife.date/sles |
diff --git a/docs/design/ubridge-iol-tap-anchor-spec.md b/docs/design/ubridge-iol-tap-anchor-spec.md
new file mode 100644
index 000000000..172a656b9
--- /dev/null
+++ b/docs/design/ubridge-iol-tap-anchor-spec.md
@@ -0,0 +1,268 @@
+
+
+> Frozen requirements spec for the **uBridge** project. Status: **delivered
+> and verified from the server side** — implemented on the
+> `feature/iol-tap-anchor` branch of the ubridge fork (both commands, the §B
+> hardening, and one fix beyond the spec: `iol_bridge delete` now releases
+> every port NIO even when the bridge was not running, so a stopped node's
+> anchor fds never leak). The server side landed on
+> `feat/iou-kernel-datapath` and passed a live e2e (64/64 kernel, 62/62
+> relay; see `docs/features/iou-kernel-datapath.md`).
+>
+> Notes from the implementation: the two new commands answer `208` for a
+> missing bridge where the older `add_nio_udp` answered `214` (no client
+> branches on the code), and a write to an admin-DOWN anchor logs one
+> `perror` line per dropped frame.
+>
+> Scope guard: no wire format change, no change to existing commands or
+> replies. This adds one NIO type to the existing `iol_bridge` module (two
+> commands) plus one error-tolerance fix in the two listener threads. The
+> `tap`, `brctl`, `link`, `tc`, `marker` and `capture` modules are reused
+> as-is.
+
+# IOL bridge TAP anchor ports (`iol_bridge add_nio_tap`)
+
+## 0. Background & scope
+
+### What IOU's physical layer is
+
+An IOU instance has no host netdev. Its only wire is the Unix-datagram
+fabric in `/tmp/netio/`: frames carry an 8-byte IOL header (dst id,
+src id, dst port, src port, message type, channel), and the NETMAP file
+maps every `bay/unit` of the instance to a second, fake instance — which
+today is uBridge itself (`iol_bridge create `,
+binding `/tmp/netio/`). Each port of that fake
+instance terminates on a **destination NIO**, and the only destination
+NIO type is UDP (`iol_bridge add_nio_udp`): one listener thread per port
+relays fabric → NIO, and one bridge listener relays fabric-side reads →
+NIO sends.
+
+### The problem
+
+With UDP as the only termination, every IOU link is a uBridge userspace
+relay leg:
+
+- an IOU port cannot join a per-link kernel bridge, so IOU ↔ Docker/QEMU
+ traffic always crosses through a UDP relay even when both ends sit on
+ the same compute;
+- kernel-class impairments (`tc netem`, the clsact eBPF classifiers) and
+ the `*_kernel` capture/marker commands cannot anchor on an IOU port —
+ only the userspace packet filters apply, whose delay implementation is
+ the known blocking-nanosleep defect that inflates RTT under load;
+- suspend of a single link has no clean mapping.
+
+### What this change is — and what it cannot be
+
+Give the port a second destination NIO type: a **persistent TAP,
+created by gns3-server with the existing `tap create` command and opened
+(held) by uBridge**. uBridge's iol_bridge then plays, on that TAP, the
+same role the QEMU process plays on its TAP: it is the device's
+userspace endpoint. Everything the anchor model already delivers —
+`brctl addif` enslavement, `tc` on the anchor, `capture start_kernel`,
+`marker add_kernel`, `link set up/down` for carrier/suspend — is keyed
+on the interface name and does not care who holds the fd.
+
+One userspace hop on the IOU leg is irreducible: the Unix fabric is
+IOU's only physical layer, so frames always pass
+fabric ↔ listener thread ↔ TAP fd. What kernelization buys is that the
+**link segment itself** is the kernel: anchor → per-link bridge → peer
+anchor, with kernel filters, markers and capture on the anchors, and
+suspend mapped to anchor admin-down. That is the accepted design, not a
+deficiency of this spec.
+
+### Goal
+
+`iol_bridge` ports can terminate on a pre-existing persistent TAP
+anchor, with the same lifecycle semantics as `add_nio_udp` (attach
+before or after `iol_bridge start`, replace a previous NIO, delete and
+re-add), and the relay survives — without exiting — the anchor being
+administratively DOWN, which is a steady state of this design (port
+anchored but no link attached; link suspended).
+
+Explicitly **not** in scope:
+
+- creating or deleting the TAP device itself — the server owns the
+ anchor lifecycle through the existing `tap` module (`tap create`,
+ `tap delete`; the device is created hardened per the L2-only spec);
+- serial bays — a TAP carries Ethernet frames; serial links stay on
+ `add_nio_udp`;
+- an MTU command (`link set mtu `) — does not exist today and is
+ not required by default-MTU IOU traffic; noted as future work if
+ jumbo frames ever matter;
+- any change to the NETMAP, the IOL header encoding, or the L1
+ keepalive protocol.
+
+## A. New commands
+
+Belong to the existing `iol_bridge` module, next to their UDP
+counterparts. `bay`/`unit` and the IOL header handling are identical to
+`add_nio_udp`.
+
+### `iol_bridge add_nio_tap ` (6 args)
+
+| Arg | Description |
+|-----|-------------|
+| `` | An existing IOL bridge (`208` if not) |
+| `` | The real instance's application id, encoded into the IOL header as with `add_nio_udp`; the existing "`iol_id` == bridge id" refusal in `create_iol_port_entry` applies unchanged |
+| ``, `` | Port coordinates, same encoding and MAX_PORTS check as `add_nio_udp` |
+| `` | A **pre-existing** network device (`208` if not, see below); ≤ IFNAMSIZ−1 chars (`204` otherwise) |
+
+Semantics:
+
+1. **Require an existing device.** Resolve `` with
+ `if_nametoindex` first and refuse with `208` when absent. This is the
+ same reasoning as the `tap` module's `tap_require_existing`: without
+ the check, `TUNSETIFF` silently *creates a transient* TAP that dies
+ with the fd — an anchor that vanishes on detach is worse than a
+ clear error.
+2. Open the device with the existing `create_nio_tap()` (by-name
+ `TUNSETIFF`, `IFF_TAP | IFF_NO_PI`, default carrier). No owner or
+ persistence ioctl — the device is persistent and owned already.
+3. Install it through the existing `create_iol_port_entry()`, which is
+ generic over `nio_t *`: it frees whatever NIO the port held before
+ (listener stop guarded on `tid`, delay lines torn down under
+ `iol_delay_lock`, capture/filters freed), pre-computes the IOL
+ header, and starts the port listener if the bridge is running. A
+ port may therefore swap `add_nio_udp` ↔ `add_nio_tap` repeatedly —
+ that is the datapath-switch path and must keep working.
+4. If the fd cannot be opened (`create_nio_tap` fails), reply `206` and
+ free the nio as the UDP handler does.
+
+No `carrier` argument: administrative state is the server's lever, via
+the existing `link set up|down`.
+
+### `iol_bridge delete_nio_tap ` (3 args)
+
+Same body as `cmd_delete_nio_udp`, including its thread-safety guards:
+stop the port listener (cancel guarded on `tid != 0`, join), destroy
+both delay lines under `iol_delay_lock`, free capture, filters and the
+NIO (which closes the TAP fd). **The persistent device itself survives**
+— the server deletes it with `tap delete` when the node stops, exactly
+as it does for QEMU anchors. Deleting a port that holds no NIO is a
+no-op `100` (mirrors `delete_nio_udp`).
+
+Replies:
+
+```
+iol_bridge add_nio_tap IOL-BRIDGE-513 1 0 0 gi0badfe9e0p0
+100-NIO TAP added to IOL bridge 'IOL-BRIDGE-513'
+iol_bridge delete_nio_tap IOL-BRIDGE-513 0 0
+100-NIO TAP deleted from IOL bridge 'IOL-BRIDGE-513'
+```
+
+## B. Required hardening: the anchor can be administratively DOWN
+
+A TAP anchor is expected to sit admin-DOWN for long periods (port
+anchored, no link attached yet; or the link suspended). The listeners
+must treat that as normal:
+
+1. **`iol_bridge_listener`, NIO send path** (IOL → TAP, `nio->send` = a
+ `write` on the fd): a write to an admin-DOWN TAP fails with **EIO**.
+ The current tolerated set is `ECONNREFUSED | ENETDOWN | EINVAL`,
+ anything else is `exit(EXIT_FAILURE)` — which would take the whole
+ uBridge down the first time a suspended link carries traffic. Add
+ `EIO` to the tolerated set: drop the frame (optionally a debug log)
+ and continue.
+2. **`iol_nio_listener`, NIO receive path** (TAP → IOL, `nio_recv` = a
+ `read` on the fd): while the device is DOWN the read blocks (no
+ carrier), which is fine — but a return of `0` must not be counted or
+ forwarded (it would fabricate header-only packets for the instance).
+ Treat `recv <= 0` as continue; treat `-1` with `EIO` as continue
+ alongside the existing `ECONNREFUSED | ENETDOWN`.
+
+No other listener behavior changes: the fabric-side `sendto` already
+tolerates `ENOENT`/`ECONNREFUSED` for the window where the IOU process
+has not created its endpoint yet, and that stays.
+
+## C. Implementation notes
+
+- The command handlers are thin: existence check + `create_nio_tap` +
+ `create_iol_port_entry` (add), and the `cmd_delete_nio_udp` body
+ (delete). The three copies of that teardown block in the module are a
+ pre-existing pattern — a shared helper is welcome but not required by
+ this spec.
+- Do **not** route the TAP through `nio_tap_open`'s `/dev/net/tun`
+ path-name variant; the anchor is addressed by interface name.
+- Statistics (`iol_bridge get_stats`) work unchanged: the counters hang
+ off the generic nio and both listeners already update them.
+- Port filters, delay lines, capture (`iol_bridge add_packet_filter`,
+ `start_capture`, …) keep working on a TAP-terminated port; gns3-server
+ simply will not combine userspace filters with `tc` on the same port
+ (see the alignment section).
+
+## D. Reply contract & error codes
+
+| Code | Meaning |
+|------|---------|
+| `100` | OK — NIO added/deleted (delete of an empty port is also `100`) |
+| `203` | Bad number of parameters |
+| `204` | Invalid parameter (tap name longer than IFNAMSIZ−1) |
+| `206` | Unable to create (bridge exists but the TAP fd cannot be opened) |
+| `208` | Unknown object — bridge or **tap device** does not exist |
+
+Old builds answer `202-Unknown command` for both commands; gns3-server
+uses that to fall back to the UDP datapath, mirroring the `tap`-probe
+degradation.
+
+## E. Test requirements
+
+1. **Both-direction relay.** On a running IOL bridge with one
+ TAP-terminated port: a frame sent on the fabric for that bay/unit
+ appears on the TAP netdev (stripped of the IOL header); a frame
+ injected on the netdev reaches the fabric with the port's correct
+ 8-byte header. (A scratch Unix-datagram endpoint can play the
+ instance; no IOU binary is needed.)
+2. **No transient device.** `add_nio_tap … nosuchtap0` → `208`, and no
+ interface is created (`ip -o link` unchanged).
+3. **Unknowns.** Unknown bridge → `208`; name ≥ IFNAMSIZ → `204`; a
+ bridge-id == iol_id → the existing refusal.
+4. **Delete keeps the device.** After `delete_nio_tap`, the port has no
+ NIO (stats/list reflect that), the TAP netdev still exists, and the
+ port accepts `add_nio_udp` (and re-`add_nio_tap`) afterwards.
+5. **Swap semantics.** A port holding a UDP NIO that gets `add_nio_tap`
+ releases the UDP socket (old listener joined, port usable), and vice
+ versa — no fd or thread leak after several swaps.
+6. **DOWN-anchor resilience.** With the TAP admin-DOWN, drive traffic
+ from the fabric side for ≥ 100 frames: uBridge stays alive, frames
+ are dropped, nothing is written to the netdev. After `link set up`,
+ traffic flows again. This is the regression test for §B.1.
+7. **Idle DOWN port is quiet.** A DOWN anchored port produces no
+ header-only or zero-length fabric sends (§B.2).
+8. **Kernel-bridge interop.** Enslave the anchor into a kernel bridge
+ (`brctl create` + `addif`) while the port relays: frames cross
+ bridge ↔ fabric through the port, and a peer port on the same bridge
+ receives them. This is the actual deployment shape.
+
+## gns3-server alignment (informational — not uBridge scope)
+
+- **Anchors**: one TAP per Ethernet bay/unit, `gi{node_id[:8]}e{bay}p{unit}`,
+ created persistent (hardened by `tap create` per the L2-only spec) at
+ node start, swept for stale leftovers first, `tap delete`d at node
+ stop — the exact lifecycle QEMU anchors use. All Ethernet ports are
+ anchored at start ("tap-everywhere"); serial bays never are.
+- **Links**: kernel links enslave the anchor via the shared
+ `KernelDatapathMixin` (`brctl create`/`addif`, `tc`, markers,
+ `capture start_kernel`, suspend = anchor admin-down). Relay links keep
+ `add_nio_udp` and never touch the anchor. A port switching datapaths
+ is just an NIO swap (§A.3).
+- **Filters are never double-applied**: on a kernel link only the anchor
+ `tc` path is used; the port's userspace filters carry relay links
+ only.
+- **Capability gating**: the eligibility check needs to know the command
+ exists before creating a link. Probe by attempting the operation on a
+ scratch bridge + scratch TAP (create, `add_nio_tap`, delete, clean
+ up), cached by binary identity like `probe_tap_support`, reported
+ through `/capabilities`; an old build answering `202` keeps IOU on
+ the relay datapath.
+- **L1 keepalives**: unchanged — an anchored-but-unconnected port holds
+ no NIO from the server's perspective, so the responder keeps ignoring
+ it and IOU sees the port down, which matches the anchor being DOWN.
+
+## Roadmap note
+
+`link set mtu ` does not exist today. Default-MTU IOU traffic
+does not need it; if jumbo IOU frames ever matter it becomes a small,
+separate requirement on the `link` module.
diff --git a/docs/design/ubridge-kernel-impairment-spec.md b/docs/design/ubridge-kernel-impairment-spec.md
new file mode 100644
index 000000000..94446b8e6
--- /dev/null
+++ b/docs/design/ubridge-kernel-impairment-spec.md
@@ -0,0 +1,373 @@
+
+
+> Frozen requirements spec for the **uBridge** project. Delivery status:
+> **all parts delivered and integrated.** A (netem keyword extensions,
+> uBridge `feature/tc-netem-ext`), C + D + E (cBPF match-drop, full-restore
+> idempotent `tc reset`, `tc capabilities` — uBridge `feature/tc-bpf-drop`)
+> and **B (eBPF stateful classifier — uBridge `feature/tc-precision`
+> 59e2b38, including the non-root setcap load fix)** are consumed by
+> gns3-server on the branch stack ending at `feat/docker-kernel-window-drop`
+> (B: `frequency_drop` → `tc nth_drop`, the kernel-only `quota` type →
+> `tc quota_drop`, and the kernel-only `window_drop` type → `tc window_drop`
+> against uBridge `feature/tc-window`). The originally shipped netem surface
+> (delay/jitter/loss/dup/corrupt) exists in uBridge 1.2.3+ and is verified
+> against gns3-server branch `feat/docker-kernel-filters`.
+>
+> **Deviation found during integration (part A/D):** the delivered
+> `tc netem set` uses NLM_F_REPLACE, but the *kernel's* netem change merges
+> optional attributes — rate, correlation, reorder, corrupt, gemodel,
+> distribution and seed keep their previous value when the new message
+> omits them, so "netem set atomically replaces" (D's reconcile
+> assumption) does not hold for parameter *removals*. gns3-server works
+> around it by sending `tc reset` before every `netem set` (the bpf_drop
+> filters are re-added right after in the same flow, so clsact churn is
+> invisible). If uBridge later emits explicit zero/absent clears for every
+> optional attribute, the server-side reset-before-set can be dropped; the
+> two behaviours are compatible either way.
+>
+> **Deviation found during B integration, resolved uBridge-side
+> (2026-09-29):** `window_drop` as first delivered advanced the window
+> start by one length on expiry with no gap, making the windows
+> back-to-back — after the first `start_ms` the "outside the window
+> packets pass" behaviour described in B.2 was unreachable (inside ≡
+> always), so gns3-server did not expose it. uBridge now takes
+> `tc window_drop [
+> []]`: 4-arg = single window (pass before *and after*),
+> 5-arg = recurring outage with `period ≥ outage`, 6-arg adds a uniform
+> ±jitter re-drawn per cycle (0 = exactly the fixed schedule) for
+> randomized flap. The program advances whole cycles in a handful of
+> straight-line steps — **no loop at all**, because the verifier's
+> non-root path (the one production ubridge runs under: `CAP_BPF`, never
+> uid 0) cannot keep a loop counter's bound and rejects the *whole*
+> program with `E2BIG` past its jump-sequence budget, which took all four
+> modes down on the gns3-server host while the same binary verified fine
+> as root; uBridge's `doc/tc.md` has the trace. The steps are resumable by
+> each further packet, so the
+> B.2 semantics are now reachable; no caller ever depended on the old
+> back-to-back form (it was never exposed). `flow_drop` is delivered and
+> functional but not yet exposed as a GNS3 filter type (its mask/target
+> parameter shape needs a UX decision). Additionally, the delivered B.1
+> program had to fold the flow-hash L4 port reads to constant offsets
+> (IHL==20 only) because the verifier prohibits variable packet-pointer
+> arithmetic for non-root — even with CAP_BPF (Spectre-mitigation gating
+> by uid); documented in uBridge's doc/tc.md.
+
+# uBridge kernel impairment: tc netem extensions + eBPF classifiers
+
+## 0. Background & scope
+
+On the kernel datapath (Docker veth pairs enslaved into per-link Linux
+bridges) frames never pass through uBridge's userspace relay, so link
+impairment must live in the kernel: a **netem qdisc** on each veth host end
+(serving delay / jitter / loss / dup / corrupt — *shipped*) plus **tc
+classifiers** on the clsact egress hook for everything netem cannot express.
+
+This spec freezes:
+
+| Part | Content | New capability needed |
+|---|---|---|
+| A | netem keyword extensions: `rate`, `reorder`, loss models, jitter distributions, correlation, `seed`, `limit` | none (netlink only) |
+| B | eBPF stateful classifier: `frequency_drop` (nth-packet), byte quota, time window, flow select | **CAP_BPF** |
+| C | classic-BPF match-drop: the GNS3 `bpf` filter | none (netlink only) |
+| D | lifecycle, composition & ordering contract, `tc reset` semantics fix | — |
+| E | error strings, `tc capabilities` probe | — |
+| F | test requirements | — |
+
+Non-goals (explicitly out of scope for now): packet mangling (`pedit`,
+`csum`), mirroring (`mirred`), HTB hierarchies, RST/connection-kill
+injection, ingress-side (XDP) impairment.
+
+All commands operate on **egress of the veth host end** (traffic entering
+the container), consistent with the existing netem placement.
+
+---
+
+## A. `tc netem set` keyword extensions *(P6a)*
+
+Extend the shipped grammar — existing keywords and their encoding are
+frozen and unchanged:
+
+```
+tc netem set
+ [delay ] [jitter ] [distribution uniform|normal|pareto|paretonormal] [seed ]
+ [loss [correl ]]
+ [loss gemodel
[ [<1-h>]]] # mutually exclusive with plain loss
+ [dup [correl ]] [corrupt ]
+ [reorder [correl ] [gap ]] # requires delay
+ [rate ] # e.g. 10mbit, 512kbit
+ [limit ]
+```
+
+### A.1 Parameter validation
+
+| Keyword | Range / format | Errors |
+|---|---|---|
+| `rate` | `` = integer + unit `bit\|kbit\|mbit\|gbit\|bps\|kbps\|mbps` (decimal), max 100gbit | 204 `invalid rate ''` |
+| `reorder` | pct 0–100, correl 0–100, gap 1–1000 | 204; 204 `reorder requires delay` if no `delay` present (mirror the kernel's EINVAL instead of passing it through — the server keys on the string) |
+| `loss gemodel` | p 0–100, r 0–100, h as `1-h` where h is 0–100 (default r=0, 1-h=0) | 204 |
+| `distribution` | one of the four names | 204 `unknown distribution ''` |
+| `seed` | u32 decimal | 204 |
+| `limit` | 1–1000000 packets (replaces the current fixed 1000 default) | 204 |
+| `correl` suffix on loss/dup | 0–100, only directly after its keyword | 204 |
+
+Max token count for the command table rises accordingly (recommend 32).
+
+`loss gemodel` semantics (mirrors the kernel, verified against a veth link
+with per-direction qdisc counters): `p` is the good→bad state transition
+probability (PGAP), `r` the bad→good transition (PGBP), and `1-h` the loss
+probability **inside the bad state** (the good state loses nothing). The
+steady-state mean loss is `p/(p+r) * (1-h)` per direction — e.g.
+`loss gemodel 10 30 100` drops 25 % of the offered packets in each
+direction, `loss gemodel 5 45 100` drops 10 %.
+
+### A.2 Netlink encoding notes
+
+* Correlation: loss/dup/reorder use their existing nested structs with the
+ `correlation` field set (`tc_netem_loss`? no — see `struct tc_netem_qopt`
+ + `TCA_NETEM_CORR` carrying `tc_netem_corr` {delay, loss, dup}). Fill the
+ loss correlation there; dup likewise.
+* gemodel: `TCA_NETEM_LOSS` nested attr, kind `TCA_NETEM_LOSS_GEMODEL`
+ (`struct tc_netem_gemodel` {p, r, h}), probabilities in the same % × 2³²
+ encoding as `qopt.loss`. When gemodel is set, `qopt.loss` stays 0.
+* reorder: `TCA_NETEM_REORDER` (`struct tc_netem_reorder`
+ {probability, correlation}).
+* rate: prefer `TCA_NETEM_RATE64` (u64 bits/s) and also fill legacy
+ `TCA_NETEM_RATE` (`tc_netem_rate`) with the truncated value for older
+ kernels; `slot_overhead = 0`.
+* distribution: `TCA_NETEM_JITTER64` + `TCA_NETEM_DIST` must carry the
+ distribution table (s16 deltas). **Embed the three standard tables**
+ (normal / pareto / paretonormal — same values iproute2 ships as
+ `*.dist`, generated by netem's `maketable`) as const arrays in uBridge;
+ select by name. Without DIST the kernel silently uses uniform jitter.
+* seed: `TCA_NETEM_SEED` (u64 allowed) — makes random draws reproducible
+ for tests. When omitted keep the current behaviour (kernel picks).
+
+### A.3 Reply contract (unchanged shape)
+
+```
+100-netem set on
+204-invalid value ''
+204-reorder requires delay
+207-Could not set netem on :
+```
+
+---
+
+## B. eBPF stateful classifier *(frequency_drop + P6b modes)*
+
+**One** SCHED_CLS eBPF program, loaded once per interface, attached to the
+clsact egress hook. All modes are configured through one ARRAY map entry —
+no program reload on parameter changes.
+
+### B.1 Program
+
+* Source `src/tc_impair.bpf.c` committed for reference; the build commits
+ the compiled object (`tc_impair.bpf.o`, freestanding: no CO-RE, no
+ BTF-typed ptr, plain `struct __sk_buff` + direct packet access).
+ No runtime clang/libbpf dependency.
+* Maps:
+ * `CFG` — `BPF_MAP_TYPE_ARRAY`, 1 entry:
+ ```
+ struct cfg {
+ __u32 nth; /* 0 = off, else drop every Nth packet */
+ __u64 quota_bytes; /* 0 = off */
+ __u32 quota_pct; /* random drop % after quota reached */
+ __u64 win_start_ns; /* recurring window start (monotonic) */
+ __u64 win_len_ns; /* 0 = off */
+ __u32 win_pct; /* random drop % inside the window */
+ __u32 flow_mask; /* bitmask: 1=src 2=dst 4=sport 8=dport 16=proto */
+ __u32 flow_target; /* required hash remainder, 0 = off */
+ };
+ ```
+ * `CNT` — `BPF_MAP_TYPE_ARRAY`, 1 entry: `{__u64 packets; __u64 bytes; __u64 nth_state;}`
+ Counters are updated with `__sync_fetch_and_add` on the map/global value
+ so the every-Nth count is exact across CPUs (requires kernel ≥ 5.1 —
+ acceptable; probe at load, see E).
+* Evaluation order is fixed: **nth → quota → window → flow**; the first
+ rule that decides a drop returns `TC_ACT_SHOT`, otherwise `TC_ACT_OK`.
+ Random percentages use the same 2³²-probability comparison as netem
+ (`prandom` seeded via map so tests are reproducible).
+* Verifier-friendly: no loops, bounded accesses (check `data + eth+h` before
+ reading fields; when flow_mask touches ports, verify L4 header length).
+
+### B.2 Command surface
+
+```
+tc nth_drop
+tc quota_drop | off
+tc window_drop [ []] | off
+tc flow_drop | off
+```
+
+* First enable on an interface: create clsact (EEXIST tolerated), load the
+ program, attach one filter at **prio 1** (see D). Subsequent commands only
+ update `CFG`/reset the relevant `CNT` fields.
+* `off` for every mode → the filter is removed (and the prog fd closed).
+ Each `off` resets that mode's counters.
+* `flow_drop` mask is the decimal bitmask from B.1 (server composes it);
+ hash = Jenkins/equal-fold over the selected header fields modulo
+ `flow_target` == remainder 0 → drop. Server documents: per-flow drop only
+ (a filter cannot delay; delay stays netem's job).
+* Window semantics: `win_start_ns`/`win_len_ns` define a window on the
+ monotonic clock — inside the window drop with `win_pct`; outside pass.
+ The program computes the first start (command time + `start_ms`) and then
+ advances whole cycles, catching up in a bounded loop when it fell behind
+ (single writer — the CFG update — so this is safe). Omitting `period`
+ makes it a **single** window: after `start + outage` nothing drops. With
+ `period ≥ outage` the outage recurs; `jitter` re-draws each cycle's
+ outage and period uniformly in nominal ± jitter (0 = fixed schedule), on
+ the integer-ms grid so the multiply-shift draw cannot overflow.
+
+### B.3 Capability requirement
+
+`BPF_PROG_LOAD(SCHED_CLS)` needs **CAP_BPF** (or CAP_SYS_ADMIN) on kernels
+≥ 5.8. Installation must therefore set:
+
+```
+setcap cap_bpf,cap_net_admin,cap_net_raw=ep
+```
+
+On EPERM: reply `210-uBridge lacks CAP_BPF (setcap cap_bpf,cap_net_admin,cap_net_raw=ep) and the kernel requires it for stateful filters`
+— the gns3-server side falls back to the relay datapath on this string (E).
+
+---
+
+## C. classic-BPF match drop (the GNS3 `bpf` filter)
+
+No eBPF, no CAP_BPF: `cls_bpf` accepts **classic bytecode** via
+`TCA_BPF_BYTECODE` (`sock_fprog`), and the kernel internally migrates it to
+eBPF. uBridge already links libpcap.
+
+```
+tc bpf_drop add "" # prio: 10..99, server-assigned
+tc bpf_drop flush
+```
+
+* `add`: `pcap_compile(DLT_EN10MB, snaplen 65535)` the expression; on
+ failure reply
+ `209-Cannot compile filter '': ` — gns3-server keys on
+ this exact prefix (same shape as the relay's error so one regex serves
+ both datapaths). Netlink: RTM_NEWTFILTER on clsact egress, protocol
+ ETH_P_ALL, `TCA_KIND "bpf"`, `TCA_BPF_BYTECODE`, action nested
+ `TCA_BPF_ACT` → `gact` with `action = TC_ACT_SHOT` (pcap match ≠ 0 →
+ filter matches → drop; non-match falls through, no action → pass).
+* Multiple expressions (GNS3 sends one line per filter): server adds one
+ filter per line at successive prios — any match drops (OR).
+* `flush`: delete every bpf_drop filter uBridge added on this interface
+ (track prios in-process), tolerate ENOENT per filter. Does NOT touch the
+ eBPF prio-1 filter or netem.
+* Update-by-replace: server always `flush` + re-`add` (mirrors `netem set`).
+
+---
+
+## D. Lifecycle, composition & ordering (frozen contract)
+
+* **clsact coexists with the netem root qdisc** — never replace the root.
+* Execution order on egress: classifier filters by ascending prio —
+ eBPF impair filter at **prio 1**, `bpf_drop` at **prio 10..99** — then the
+ root netem qdisc. Dropped packets therefore never reach netem **nor the
+ AF_PACKET tap points** (capture/markers will not observe cls_bpf-dropped
+ frames; documented server-side).
+* **`tc reset ` becomes the full restore** (behaviour change + fix):
+ 1. remove all bpf_drop filters and the eBPF filter (clsact egress side we
+ own), 2. delete clsact, 3. delete the root qdisc. On an untouched
+ interface it must now reply **100-OK** (idempotent — currently returns
+ 207-ENOENT; gns3-server tolerates both, but the new contract is OK).
+* All state is kernel state tied to the interface: deleting the veth
+ (container stop) clears everything; no ubridge-side bookkeeping survives
+ a restart (a re-attached link rebuilds from the NIO, as today).
+
+---
+
+## E. Errors & capability probe
+
+Error strings are contract (gns3-server matches them):
+
+| Reply | Meaning |
+|---|---|
+| `100-netem set on ` / `100-qdisc reset on ` | success |
+| `203-Bad number of parameters (%d with min/max=%d/%d)` | argc mismatch |
+| `204-invalid value ''` | value validation |
+| `204-reorder requires delay` | keyword dependency |
+| `207-Could not (set netem\|reset qdisc) on : ` | netlink/kernel failure |
+| `209-Cannot compile filter '': ` | bpf_drop pcap_compile failure |
+| `210-uBridge lacks CAP_BPF ...` | stateful filters unavailable |
+
+New introspection command (also serves the gns3-server degradation TODO —
+old uBridge without these commands simply stays on the relay datapath):
+
+```
+tc capabilities
+100-netem=delay,jitter,loss,dup,corrupt,rate,reorder,gemodel,dist,seed,limit;ebpf=1;cbpf=1;ebpf_modes=nth,quota,window,flow
+```
+
+`ebpf=0` when the load probe fails at startup (kernel < 5.1 or no CAP_BPF);
+`cbpf=0` when RTM_NEWTFILTER/cls_bpf is unavailable. gns3-server hides the
+corresponding filter types per capability.
+
+`ebpf_modes` (added on uBridge `feature/tc-window`, commit 40c36ae) is a
+build fact, orthogonal to the runtime probes: the same binary flips `ebpf`
+between users and kernels while the mode set changes only with the binary.
+A mode is usable iff `ebpf=1` **and** its token is listed — gating
+`window_drop` on `ebpf=1` alone silently accepts the 4-arg form on a
+pre-correction build (`feature/tc-precision`: back-to-back windows, no
+gap). Builds that do not emit the field at all predate it and get legacy
+treatment; an incompatible mode revision renames its token
+(`window` → `window2`) rather than versioning in place.
+
+---
+
+## F. Test requirements
+
+* **Unit**: per keyword → expected netlink attrs (assert message bytes);
+ every 204 table row; `bpf_drop` compile-fail path; capabilities string.
+* **Functional** (root, veth pair + netns + nsenter ping):
+ delay+reorder observable (mdev), gemodel loss within ±5% of
+ `p/(p+r) * (1-h)` measured **per direction** — a round-trip ping figure
+ reads `1-(1-L)²` instead, and with `1-h=100` the ARP cache must be
+ pre-warmed or the neighbour-resolution blackhole during a long burst
+ inflates the observed loss,
+ rate within ±10% (measured byte throughput), nth_drop **exact** pattern
+ (ICMP seq survives 1,2,…,N-1, Nth dropped), quota/window/flow with
+ crafted senders, `tc reset` idempotent on a clean interface, P5 surface
+ regression (delay/jitter/loss/dup/corrupt, REPLACE semantics).
+* Determinism: netem `seed` + the CFG-seeded prandom make runs repeatable;
+ CI asserts identical drop patterns for identical seeds.
+
+---
+
+## gns3-server alignment (informational — not uBridge scope)
+
+* **Done** (`feat/docker-kernel-netem-ext`): the P6a types map 1:1 onto new
+ GNS3 filter entries (rate, reorder, gemodel, duplicate, seed, limit, plus
+ the delay `distribution` and loss/dup `correlation` parameters), translated
+ in `DockerVM._ubridge_apply_netem`; extension keywords are gated on the
+ `tc capabilities` netem token list (old uBridge builds are never probed
+ for the original surface).
+* **Done** (`feat/docker-kernel-bpf-drop`): `bpf` runs as cBPF match-drop.
+* **Done** (`feat/docker-kernel-ebpf-drops`): the eBPF classifier (part B)
+ is consumed — `frequency_drop` → `tc nth_drop` (-1 → every 1st), the new
+ kernel-only `quota` type → `tc quota_drop`, both keyed on `ebpf=1` from
+ the per-process `tc capabilities` probe. `KERNEL_UNSUPPORTED_FILTERS` is
+ gone entirely: kernel/relay eligibility is purely topological now.
+* **Done** (`feat/docker-kernel-window-drop`): `window_drop` is exposed
+ with the corrected semantics (uBridge `feature/tc-window`) as the
+ kernel-only `window_drop` filter type
+ `[start, outage, pct, period?, jitter?]` → `tc window_drop`; the server
+ mirrors the grammar's `period ≥ outage` rule and treats `start = 0` as
+ active (an immediate outage).
+ Not yet exposed: `flow_drop` (parameter-shape UX decision).
+* **Done** (capability reporting): the `ebpf_modes` contract is consumed
+ end to end — the compute applies it per mode (window requires its token;
+ pre-field builds keep their shipped modes), the compute
+ `GET /capabilities` payload reports `ubridge_tc` through a standalone
+ spawn-as-own-user probe cached by binary identity, and the controller's
+ `available_filters` hides kernel-datapath filter types an endpoint
+ compute reports it cannot run (computes without a report keep the full
+ list; apply-time 409 stays the guard).
+* Every veth end owns one qdisc + its filters: per-direction impairment is
+ an architectural freebie to expose later (API `direction` field), aligned
+ with marker `dir` semantics.
diff --git a/docs/design/ubridge-l2-anchor-spec.md b/docs/design/ubridge-l2-anchor-spec.md
new file mode 100644
index 000000000..2cc566053
--- /dev/null
+++ b/docs/design/ubridge-l2-anchor-spec.md
@@ -0,0 +1,253 @@
+
+
+> Frozen requirements spec for the **uBridge** project. Status: **delivered**
+> (uBridge `e2e3155` — `link l2only` plus the creators that apply it;
+> `fb72758` — the fifth creator: the TAP `bridge add_nio_tap` creates for a
+> free name) and verified from the server side: anchors and per-link bridges
+> report `addrgenmode none`, `ip -4/-6 addr` are empty, and an idle anchor is
+> silent once the one-shot membership burst of §0 has settled.
+>
+> **Residual, accepted (measured after delivery):** enslaving a port still
+> makes the *bridge device* announce its multicast memberships — one IGMPv3
+> plus one or two MLDv2 reports in the first second — which floods into the
+> emulated segment. It is outside this command's reach by design (no addresses
+> are involved, and IPv4 has no per-device IGMP off switch); see §0.
+>
+> Scope guard: no wire format, no command grammar change to existing
+> commands — this adds one command and one internal step in four creators.
+
+# L2-only host anchor devices (`link l2only`)
+
+## 0. Background & scope
+
+### What an anchor is
+
+The host-side device that carries a node's adapter in the root namespace:
+the veth host end of a Docker adapter (`gv…`, created by `docker create_veth`),
+the persistent TAP of a QEMU adapter (`gq…`, created by `tap create`), and the
+per-link kernel bridge those anchors are enslaved to (`brctl create`). Links
+attach *to* these devices (kernel bridge port or uBridge relay endpoint); the
+devices themselves are the only thing uBridge creates on the host's behalf.
+
+### The problem (measured)
+
+A host-side anchor that is UP gets an IPv6 link-local address from the kernel
+automatically — no user-space actor involved — and with it the kernel emits its
+own traffic on that device:
+
+- a freshly created persistent TAP, brought UP: **6 frames in 2 s** of MLD
+ reports (`33:33:00:00:00:16`, `33:33:00:00:00:fb`) and DAD neighbor
+ solicitations (`33:33:ff:…`, ICMPv6 next-header 0/17/58), all sourced from the
+ device's own link-local address;
+- production: the Docker veth host end `gv6ee8d537e1p0` carries
+ `fe80::5c30:e8ff:fe00:5d8d/64` (`disable_ipv6=0`), and the per-link bridge
+ `gns30dbb78c5718` carries `fe80::f0d9:94ff:feac:e42b/64`.
+
+Consequences inside an emulated topology:
+
+- on a **kernel-datapath** link the bridge floods those frames into the emulated
+ segment; on a **relay** link uBridge relays them to the peer compute;
+- they land in captures and marker pcaps, so "idle link" is not quiet;
+- the host **answers** neighbor solicitations for its own link-local address, so
+ an emulated IPv6 router can form a real adjacency with the host — a phantom
+ neighbour inside the emulated network.
+
+IPv4 needs no equivalent knob: nothing self-provisions an IPv4 address, and the
+invariant we keep is *no addresses at all* (measured: `ip -4 addr` is empty on
+every anchor and per-link bridge on the deployment above, with NetworkManager
+reporting them `unmanaged`). IPv6 is the only protocol that lights itself up.
+
+### The residual this command does not remove (measured after delivery)
+
+With `l2only` in place (anchors and bridges report `addrgenmode none`, no
+addresses), **enslaving a port still makes the bridge device speak once** —
+the kernel announces the multicast memberships the enslavement brings to the
+bridge:
+
+```
++0.00 s 90 B IPv6 MLDv2 report dst 33:33:00:00:00:16 (ICMPv6 143)
++0.09 s 54 B IPv4 IGMPv3 report dst 01:00:5e:00:00:16 0.0.0.0 → 224.0.0.22
++0.73 s 90 B IPv6 MLDv2 report dst 33:33:00:00:00:16
+```
+
+(Scratch tap pair + per-link bridge on the test host, unprivileged; source MAC
+= the bridge's. A 10 s window contains exactly those three frames, then silence.
+The same burst appears through the server: one 90 B IPv6 frame inside a 2 s
+window right after a link is created.)
+
+It is outside `l2only`'s reach by design: no addresses are involved (the report
+sources are `::` and `0.0.0.0`), and IPv4 has no per-device "no IGMP" knob to
+set. **Decision: accepted and documented, not suppressed** — it is standard L2
+control traffic, one-shot per link creation or port change, and it stops. The
+acceptance test in §E.2 therefore measures after a settle window. Suppressing
+it (e.g. `disable_ipv6` on the bridge before enslaving, for the MLD half) would
+be a new, separate requirement and has not been asked for.
+
+### NetworkManager releases managed TAPs from bridges (measured)
+
+On hosts where NetworkManager manages the data-plane TAPs — it claims uBridge-created tun/tap devices on many desktops (`nmcli device` shows them as `disconnected` before NM's state machine has assumed them, `connected (externally)` after) — NM can **release a tap from its bridge** some 60-70 ms after enslavement, while the port is in that not-yet-assumed state. Measured with a minimal harness (one uBridge, one fresh tap, one bridge, an `addif`/`delif` loop, no server): ~50 % of re-enslavements lose the port, the freed device stays administratively **up** with the bridge's promiscuity/allmulti cleared, and no GNS3 process sends anything — gns3-server and uBridge log nothing, and the server's attachment bookkeeping stays intact. The *first* enslavement of a newly created tap was stable in every run; the hazard is a **re-enslavement** (link delete + recreate, a port re-join after a peer restart) before NM assumes the device — on the same tap, `nmcli device set managed no` flips the failure rate from 20/40 to 0/40, and re-managing it restores 20/40.
+
+Consequence for an affected link: the port silently leaves the bridge (frames drop) while the API stays green — the next NIO update re-heals it, because the switch's `_kernel_update` re-checks `brif` membership, but nothing forces one. Docker's veth host ends are not NM-managed and never showed the failure; every TAP anchor family is exposed (Dynamips `gd`, QEMU `gq`, IOU `gi`, IOL `gx`, and switch-absorbed anchors). Host-side remedy: `nmcli device set managed no` (runtime, per device), or NM's `unmanaged-devices` configuration keyed on the anchor name prefixes; the e2e harness takes the anchors it re-enslaves out of NM's hands (`harness.unmanage_from_networkmanager`). Full root-cause record with the minimal reproducer, the NetworkManager source-level mechanism and the verified fix: `docs/bugs/networkmanager-tap-release.md`.
+
+### Goal
+
+Every host-side device uBridge creates for the data plane is **pure L2**: no
+IPv4 address, no IPv6 address, and no IPv6 stack activity on that device.
+
+Explicitly **not** in scope:
+
+- the guest/container side (the peer veth end inside a container netns keeps its
+ normal IPv6 behaviour);
+- global sysctls and any change outside the named interface;
+- `brctl addip`-style L3 use of a bridge (cloud / Ethernet-switch paths) — those
+ devices are meant to have addresses and are not data-plane anchors.
+
+## A. New command: `link l2only [on|off]`
+
+Belongs to the `link` module (per-interface operations: `link set`, `link veth`,
+`link addr`, `link delete`).
+
+| Arg | Description |
+|-----|-------------|
+| `` | An **existing** device. A missing name must fail (`208/ENODEV`), never create a transient device — mirror the `tap` module's `tap_require_existing` reasoning. |
+| `on` / `off` | Default `on`. `on` = suppress IPv6 address generation and stack activity on the device; `off` = restore the kernel default. |
+
+Semantics of `on`: after the call the kernel has assigned **no** IPv6
+link-local address to the device, and the device's `addrgenmode` reports `none`
+(`IFLA_INET6_ADDR_GEN_MODE = IN6_ADDR_GEN_MODE_NONE`), so no DAD, MLD or RS is
+generated from it. If an address already exists it is removed as part of the
+call (an anchor that was created and brought up before this command must end up
+clean, not just "no new addresses from now on").
+
+Replies:
+
+```
+link l2only gq1234abcd e0
+100-L2-only set on gq1234abcd e0
+link l2only gq1234abcd e0 on
+100-L2-only set on gq1234abcd e0 # idempotent, same reply
+link l2only gq1234abcd e0 off
+100-L2-only cleared on gq1234abcd e0
+```
+
+The success reply is emitted only after a read-back (`RTM_GETLINK` +
+`IFLA_AF_SPEC` → `IFLA_INET6_ADDR_GEN_MODE`) confirms the device state, so a
+caller may treat `100` as verified rather than requested.
+
+## B. Where it must be applied
+
+The creators apply it themselves — gns3-server never issues `link l2only`
+directly, and a caller cannot forget it:
+
+| Creator | Device hardened | Notes |
+|---|---|---|
+| `tap create ` | the persistent TAP | before the success reply; the device already exists (TUNSETPERSIST) |
+| `docker create_veth ` | the **host** end only | the guest end moves into a container netns and is deliberately untouched |
+| `link veth ` | both ends | both ends are host-side |
+| `brctl create ` | the bridge device itself | the fabric the anchors are enslaved to; the bridge's own link-local floods to every port |
+| `bridge add_nio_tap ` | the TAP it **creates** (name free) | by-name `TUNSETIFF` creates a transient device when the name is free — cloud's bridge interfaces, and the relay swap's create-if-missing path. Hardened at creation; an attach to an existing device is left untouched (the caller may name a user-owned TAP that has addresses). |
+
+Rule for future creators: any command that creates a host-side device for the
+data plane hardens it, and the acceptance checks in §E apply to it.
+
+An **attach** is not a creation: `bridge add_nio_tap` (and every other open
+of a TAP that already exists) must leave that device's addresses alone. The
+transient TAP `bridge add_nio_tap` creates for a free name is a creation and
+is hardened like the rest — best-effort, before the NIO is handed back.
+
+## C. Implementation notes (netlink, not `/proc`)
+
+- Use `RTM_SETLINK` with `IFLA_AF_SPEC{ AF_INET6, IFLA_INET6_ADDR_GEN_MODE =
+ IN6_ADDR_GEN_MODE_NONE }` — this is what `ip link set dev X addrgenmode none`
+ does. Removing an already-assigned link-local is `RTM_DELADDR` on that
+ address (or letting the kernel drop it once generation is off; verify the end
+ state either way).
+- Optionally also set the nested `IFLA_INET6_CONF` attributes
+ (`DEVCONF_DISABLE_IPV6 = 1`, `DEVCONF_ACCEPT_RA = 0`) when the running kernel
+ accepts writes for them. The addr-gen-mode attribute alone already removes the
+ address, and without an address there is no DAD/MLD/RS.
+- Do **not** write `/proc/sys/net/ipv6/conf//disable_ipv6`. The sysctl files
+ are root-owned mode 0644, so a setcap'd, non-root uBridge can be refused by
+ the DAC check despite holding `CAP_NET_ADMIN`. The netlink route has no such
+ problem.
+- Failure handling: `EOPNOTSUPP` / `EINVAL` from an old kernel without the
+ attribute must be **non-fatal** (log, continue) so device creation never fails
+ because of this hardening; `ENODEV` and any other error are real and reported.
+
+## D. Reply contract & error codes
+
+| Code | Meaning |
+|------|---------|
+| `100` | OK — device verified L2-only (or verified restored for `off`) |
+| `203` | Bad number of parameters |
+| `204` | Invalid parameter (`on`/`off` expected) |
+| `208` | Object not found (`ENODEV`): no such device |
+| `206` | Unable to set (netlink error other than EOPNOTSUPP/EINVAL) |
+
+## E. Test requirements
+
+1. **No addresses.** After each creator: `ip -6 addr show dev ` is empty
+ (in particular no `fe80::`) and `ip -4 addr show dev ` is empty; the
+ same for the bridge created by `brctl create`.
+2. **Idle silence, after the settle window.** Let the last port enslavement
+ settle (≥ 2 s — that burst is expected, see §0), then with the device UP and
+ in its normal role (TAP with an open fd / veth with its peer up / bridge with
+ two attached ports) nothing appears on it for 5 s — measured from the peer
+ end, or via `capture start_kernel ` while idle with the pcap
+ asserted empty. Baseline before this work: 6 frames / 2 s, continuous.
+3. **Idempotency & revert.** `on` twice → `100` both times, state unchanged;
+ `off` restores the kernel default (link-local returns after the device is
+ cycled down/up).
+4. **No transient device.** `link l2only nosuchif0` → `208`, and no interface is
+ created (check `ip -o link`).
+5. **Far side untouched.** Inside the container / VM the normal IPv6 link-local
+ still exists — nothing in this change reaches the peer.
+6. **Non-fatal on old kernels.** Simulated by testing on a kernel without the
+ attribute: the creators still succeed, and §E.1 is then expected to fail with
+ the device left in the default state.
+
+## gns3-server alignment (informational — not uBridge scope)
+
+- **Call sites**: the creators harden the devices; the server issues no
+ `link l2only` itself, and no NIO/JSON schema, filter or MCP tool
+ description changes. This is host-side hygiene only.
+- **The fifth creator was found by these servers' e2e assertions**: on a
+ build predating `fb72758`, the Ethernet switch's relay port TAPs
+ (`gns3{id}-N`, created implicitly by `bridge add_nio_tap`) carried a live
+ `fe80::` — the §E.1 assertion failed on exactly that device, live. The
+ close is uBridge-side (§B table row 5): the transient TAP is hardened at
+ creation, attaches stay untouched. The server side needed no change for
+ it — the assertion alone caught it, and it now guards the fixed build.
+- **e2e assertions** (landed in `tests/e2e/harness.py`, asserted by the
+ kernel-datapath suites alongside the existing capture/marker/filter checks):
+ - anchors and per-link bridges have no IPv4/IPv6 addresses, addrgenmode
+ `none` (`assert_pure_l2`, §E.1) — on the kernel scenarios' anchors and
+ bridges, and on relay-mode anchors/switch TAPs alike (the hardening is a
+ creation-time property, not a datapath one). The switch relay-TAP
+ assertion is what exercises the fifth creator end-to-end (the same
+ uBridge creation path cloud's transient TAPs take);
+ - an idle link stays silent for 5 s after the settle window
+ (`assert_idle_silence`, §E.2) — measured with the guests silenced (the
+ IOS consoles' interfaces shut, the Docker guests' links down, all four
+ cascade routers shut) over the anchors, the per-link bridge and the
+ switch bridges, in every kernel scenario: Dynamips, Docker, QEMU, IOU,
+ the IOL container, the switch fast path and the switch cascade. That
+ covers each creation
+ path — `tap create`, `docker create_veth` (plus the server-side
+ harden-after-create on the cascade's race-loser end), `bridge
+ add_nio_tap` and the `brctl` switch bridges (whose multicast snooping
+ is off precisely so the bridge role can reach silence);
+ - a silent-failure guard found while validating this spec: after attach, every
+ bridge port reports `brport/state == 3` (forwarding) (`assert_forwarding`).
+ A bridge device left DOWN keeps its ports `DISABLED` (`state == 0`) and
+ forwards nothing — with no error anywhere, so the assertion is the only
+ signal.
+- **Rollout**: with a uBridge that lacks the command the anchors keep today's
+ behaviour (noise present). The e2e assertions skip when `link l2only` answers
+ `202-Unknown command` (`harness.l2only_supported()` probes a throwaway
+ uBridge once), mirroring the existing tc-capability degradation. A build
+ that has the command but predates `fb72758` fails the switch relay-TAP
+ assertion instead of skipping: the gap is exactly what it exists to catch.
diff --git a/docs/design/ubridge-link-local-frame-forwarding-spec.md b/docs/design/ubridge-link-local-frame-forwarding-spec.md
new file mode 100644
index 000000000..427863e4c
--- /dev/null
+++ b/docs/design/ubridge-link-local-frame-forwarding-spec.md
@@ -0,0 +1,266 @@
+# uBridge bridge ports must forward link-local frames (per-port `group_fwd_mask`)
+
+> **Status: landed and verified.** uBridge branch `feature/bridge-tap-l2only`,
+> commits `0284500` (feat), `03a18f1` (test suite), `cf4e494` (docs) — the
+> implicit `addif` hook of §A.1 plus the `setportgroupfwd` escape hatch of
+> §A.3. gns3-server carries **zero product-code changes**: the behavior
+> reaches every `brctl addif` call site as specified. Verified by the uBridge
+> suite (`tests/brctl/test_linklocal.py`, 28/28) and live through a real
+> server by `tests/e2e/test_docker_link_local_frames.py`. The three
+> `test_vlan` kernel-state failures seen alongside are pre-existing on the
+> parent commit (verified by an fb72758 control run). The version bump (R8)
+> is still open, pending review.
+
+## 0. Background & scope
+
+### The datapath this serves
+
+GNS3's kernel datapath wires a link by enslaving each endpoint's host-side
+anchor (veth host end, TAP) into a per-link Linux bridge. The same shape is
+used by the Ethernet switch node (its bridge absorbs peers' anchors), by
+switch-to-switch cascades (a veth pair joining two bridges) and by the Cloud
+node (pnet bridges). Functionally these bridges stand in for **a cable** — a
+transparent L2 segment — and that is the contract users expect from them.
+
+### The problem: a Linux bridge is not a transparent cable
+
+`br_handle_frame()` in `net/bridge/br_input.c` treats the IEEE 802.1D
+reserved link-local range `01:80:c2:00:00:00`–`01:80:c2:00:00:0f` specially
+and, by default, does **not** forward it. Measured on a stock mainline kernel
+(7.2.6, no patches) with two ports in one bridge:
+
+| Destination MAC | Protocol | Default bridge | Notes |
+|---|---|---|---|
+| `ff:ff:ff:ff:ff:ff`, `01:00:5e:*`, `33:33:*` | broadcast, IPv4/IPv6 multicast | forwarded | unaffected |
+| `01:80:c2:00:00:00` | STP / RSTP BPDU | **forwarded** | special-cased: forwarded while the bridge runs no STP |
+| `01:80:c2:00:00:01` | 802.3x PAUSE / 802.1Qbb PFC | not forwarded | kernel drops unconditionally — see §0.3 |
+| `01:80:c2:00:00:02` | LACP / slow protocols | not forwarded | |
+| `01:80:c2:00:00:03` | 802.1X EAPOL | not forwarded | |
+| `01:80:c2:00:00:0e` | LLDP (DCBX) | not forwarded | |
+| `01:80:c2:00:00:10` and up | — | forwarded | outside the reserved nibble range |
+
+Consequence: on a kernel-datapath link, two emulated devices that run LACP,
+LLDP/DCBX or 802.1X never see each other, while the same lab over the uBridge
+UDP relay (a dumb store-and-forward, fully transparent) works. The two
+datapaths disagree, and the failure is silent.
+
+### Why the bridge-level knob is not enough
+
+`IFLA_BR_GROUP_FWD_MASK` (the bridge-level attribute, already reachable via
+uBridge's `brctl setgroupfwd`) is restricted by `BR_GROUPFWD_RESTRICTED`
+(`BR_GROUPFWD_STP | BR_GROUPFWD_MACPAUSE | BR_GROUPFWD_LACP`): writes
+containing bits 0/1/2 fail with `EINVAL`. Measured: bridge-level accepts at
+most `0xfff8`, which forwards LLDP/EAPOL but **not LACP**.
+
+The per-port attribute `IFLA_BRPORT_GROUP_FWD_MASK` (commit
+`5af48b59f35cf712793badabe1a574a0d0ce3bd3`, "net: bridge: add per-port
+group_fwd_mask with less restrictions", net-next 2017, Linux 4.15) exists
+precisely for this: its commit message names "transparent forwarding of
+most link-local frames (e.g. STP, LACP) through tunnels (vxlan, qinq)" and
+restricts only MAC PAUSE. Measured: port-level accepts `0xfffd` and then
+forwards STP, LACP, EAPOL, LLDP and every other reserved address.
+
+The mask is evaluated on the **ingress** port (setting it on the egress port
+only has no effect), so every port of a bridge needs it — which is exactly
+what a per-addif hook gives us.
+
+### What this change is — and what it cannot be
+
+PAUSE / PFC is **not** in scope and cannot be delivered by any mask: the
+`case 0x01:` branch in `br_handle_frame()` drops unconditionally
+(`kfree_skb_reason(..., SKB_DROP_REASON_MAC_IEEE_MAC_CONTROL)`) without
+consulting `fwd_mask`, and the port-level setter rejects bit 1 with `EINVAL`.
+Forwarding PAUSE would require patching that branch. Emulation of PFC
+semantics is out of reach anyway (no hardware pause generation/response, no
+per-priority queues, no headroom/watchdog), so the GNS3 documentation will
+state PFC as unsupported on kernel links rather than pretending otherwise.
+
+### Goal
+
+A kernel-datapath link (and every other uBridge bridge) carries link-local
+frames exactly like the cable it emulates: STP, LACP, EAPOL, LLDP and the
+remaining reserved addresses flow; PAUSE/PFC is documented as impossible.
+
+---
+
+## A. The requested change
+
+### A.1 One step in `br_enslave_if()` (`hypervisor_brctl.c:251`)
+
+After the port is enslaved (step 1) — and using the existing u16 port-attribute
+helper — set the link-local forwarding mask:
+
+```c
+/* Step 3 – link-local transparency. A Linux bridge stands in for a cable
+ * here: LACP/LLDP/EAPOL/STP must cross it. The kernel restricts the
+ * *bridge-level* mask to bits 3..15 (BR_GROUPFWD_RESTRICTED), so LACP is
+ * only reachable per port. 0xfffd = every reserved address except MAC
+ * PAUSE (bit 1), which the kernel refuses and hard-drops anyway.
+ * Best-effort: kernels before 4.15 have no such port attribute, and the
+ * cable simply keeps today's behaviour there. */
+if (br_set_port_attr_u16(bridge, port, IFLA_BRPORT_GROUP_FWD_MASK,
+ LINK_LOCAL_FWD_MASK) < 0)
+ fprintf(stderr, "ubridge: %s: link-local forwarding mask not set\n", port);
+```
+
+with
+
+```c
+/* bit 0 STP (harmless: already forwarded while STP is off), bit 2 LACP,
+ * bit 3 802.1X EAPOL, bit 14 LLDP; bit 1 MAC PAUSE must stay clear or the
+ * kernel rejects the whole value with EINVAL. */
+#define LINK_LOCAL_FWD_MASK 0xfffdu
+```
+
+`br_set_port_attr_u16()` (`hypervisor_brctl.c:811`) already does everything
+needed: it checks membership via `br_check_master()`, builds the
+`IFLA_PROTINFO` nested attribute on `AF_BRIDGE`/`RTM_SETLINK`, and — critically
+— encodes the value as **2 bytes**, which is what the kernel's port policy
+requires (`NLA_U16`); a 1-byte payload is rejected with `-ERANGE` (the same
+trap documented in that helper's comment for `IFLA_BRPORT_PRIORITY`).
+
+### A.2 Why in `br_enslave_if()` rather than in any single caller
+
+* **Every** enslavement in uBridge funnels through this helper — per-link
+ bridges, the Ethernet switch's own ports, peers absorbed into a switch
+ bridge, cascades, pnet/Cloud bridges. One hook covers all of them, and
+ covers future callers too.
+* gns3-server **cannot** do it itself: the packaged service runs unprivileged
+ (`init/gns3.service.systemd`: `User=gns3`) and `CAP_NET_ADMIN` is carried by
+ the uBridge binary (`base_manager.py:_has_privileged_access()`). A
+ server-side `ip link set … type bridge_slave …` is not possible in the
+ supported deployment.
+* Doing it at addif time also means ports that are re-attached after a node
+ restart (the server re-pushes NIOs) are re-covered without extra logic.
+
+### A.3 Alternative interface, if the project prefers explicit policy
+
+If uBridge would rather keep this policy out of `addif`, the equivalent
+explicit command is:
+
+```
+brctl setportgroupfwd (3 args)
+```
+
+implemented as a thin wrapper over `br_set_port_attr_u16(..., (unsigned
+short)mask)` and registered next to `setportprio` in the command table
+(`hypervisor_brctl.c:2049`). In that case gns3-server will call it at its
+five `brctl addif` sites and gate it on a probe of the running build. The
+implicit form is preferred by gns3-server because it needs no server change
+and cannot be forgotten at a new call site.
+
+---
+
+## B. Requirements (normative)
+
+* **R1** After a successful `brctl addif `, the port carries
+ `group_fwd_mask = 0xfffd` (`LINK_LOCAL_FWD_MASK`).
+* **R2** The value must **exclude bit 1 (MAC PAUSE)**. `0xfffe`/`0xffff` are
+ rejected by the kernel with `EINVAL` and would silently lose the whole
+ mask — do not use them.
+* **R3** The attribute payload must be encoded as u16 (`br_set_port_attr_u16`).
+ A u8 payload fails `validate_nla` with `-ERANGE` on kernels that enforce the
+ policy strictly.
+* **R4** **Best-effort**: a failed mask write must never fail the `addif`
+ (kernels < 4.15 have no per-port attribute; unusual deployments may lack
+ `CAP_NET_ADMIN`). Reply to `addif` is unchanged; the failure is logged to
+ stderr.
+* **R5** Ordering: set the mask after the port is enslaved (both
+ `br_check_master()` and the kernel's `IFLA_PROTINFO` handling require
+ membership). The step-2 "bring the port up" may run before or after; both
+ work.
+* **R6** Idempotent: re-`addif` of an already-enslaved port must succeed and
+ keep the mask set (gns3-server re-attaches links on node start).
+* **R7** Applies uniformly to every bridge/port this helper touches — no
+ per-bridge or per-caller switch (a real switch terminates link-local frames,
+ but GNS3's Ethernet switch node is used as a transparent segment, so
+ forwarding is the wanted behaviour there too).
+* **R8** Version: bump the reported version so gns3-server can raise its
+ minimum (the server already gates on `ubridge version X.Y.Z`,
+ `compute/ubridge/hypervisor.py:_check_ubridge_version`, currently ≥ 1.2.3).
+* **R9** Document the PAUSE/PFC exclusion in uBridge's help/docs, so the gap
+ is not rediscovered as a bug.
+
+---
+
+## C. Implementation notes
+
+* `IFLA_BRPORT_GROUP_FWD_MASK` is defined in `include/uapi/linux/if_link.h`
+ (Linux ≥ 4.15); guard the build on its presence if uBridge must compile
+ against older headers — behavior on such kernels is "no mask", which R4
+ already tolerates.
+* The kernel's `br_set_group_fwd_mask()` (port variant) validates against
+ `BR_GROUPFWD_MACPAUSE`; there is also a follow-up fix
+ (`16c42db48522`, "bridge: check for attempt to forward STP PDU's with STP
+ enabled") that moves STP validation into the setter — bit 0 is harmless for
+ our case because we never enable STP on these bridges.
+* No interaction with existing commands: `brctl setgroupfwd` (bridge-level)
+ stays as is and is unused by gns3-server; `hairpin`/`isolated`/`setportstate`
+ continue to use their own attributes through the same helper family.
+
+---
+
+## D. Reply contract & error behavior
+
+`addif` replies (`HSC_INFO_OK` / `HSC_ERR_*`) are unchanged. The mask write
+does not produce its own reply and does not alter the addif verdict; on
+failure it writes one line to stderr (§A.1). No new error codes.
+
+---
+
+## E. Test requirements
+
+The uBridge test suite already drives bridge/veth setups; the following cases
+pin the contract (all are cheap AF_PACKET probes — send a frame with a given
+destination MAC into port A, observe whether it arrives on port B, using
+`PACKET_OUTGOING` filtering so a port's own transmissions do not count):
+
+* **T1 transparency**: with two veths in one bridge created via `brctl`:
+ `01:00:5e:00:00:01` forwarded, `01:80:c2:00:00:0e` (LLDP) forwarded,
+ `01:80:c2:00:00:02` (LACP) forwarded, `01:80:c2:00:00:00` (STP) forwarded,
+ `01:80:c2:00:00:01` (PAUSE) **not** forwarded.
+* **T2 ingress semantics**: mask on the egress port only → LACP not forwarded
+ (documents why every port is set; regression guard against "set it once").
+* **T3 best-effort**: force the mask write to fail (fault-injection hook or an
+ invalid attribute id) and assert `addif` still replies `HSC_INFO_OK`.
+* **T4 idempotency**: `addif` twice → both succeed, mask still `0xfffd`.
+* **T5 value boundary** (optional, documents R2): a direct write of `0xfffe`
+ through the port setter fails with `EINVAL`.
+
+---
+
+## gns3-server alignment (informational — not uBridge scope)
+
+* **No server change is required** for the behavior: all five server-side
+ enslavements (`compute/kernel_datapath.py:165`,
+ `compute/builtin/nodes/ethernet_switch.py:399,518`,
+ `compute/builtin/nodes/cloud.py:377`, and anything added later) go through
+ `brctl addif`.
+* If R8's version bump lands, gns3-server can raise
+ `_check_ubridge_version`'s minimum so that a compute that cannot deliver
+ the behavior is reported instead of silently degrading.
+* GNS3 documentation will state the resulting matrix for kernel links:
+ LLDP/DCBX ✅, LACP ✅, 802.1X ✅, STP ✅, **PAUSE/PFC ❌** (kernel-level,
+ not fixable by mask).
+* The planned VXLAN datapath inherits the same story: the vxlan device is a
+ bridge port, so the same per-port mask applies; PAUSE/PFC stays out of
+ reach there too. That must be decided before VXLAN ships, because today's
+ cross-compute relay links *do* carry PFC and would silently regress.
+
+## Roadmap note
+
+If PFC emulation ever becomes a hard requirement, the only paths are a
+patched kernel (`case 0x01`), a tc/eBPF ingress redirect on the anchor, or a
+userspace AF_PACKET pass-through in uBridge (packet sockets see these frames
+before the bridge drops them). None of these is part of this spec.
+
+## References
+
+* Kernel: `net/bridge/br_input.c` (`br_handle_frame`, the link-local switch),
+ `net/bridge/br_private.h` (`BR_GROUPFWD_*`, `BR_GROUPFWD_RESTRICTED`).
+* Per-port mask: commit
+ [`5af48b59f35c`](https://github.com/Freescale/linux-fslc/commit/5af48b59f35cf712793badabe1a574a0d0ce3bd3)
+ ("net: bridge: add per-port group_fwd_mask with less restrictions");
+ [patchwork entry](https://patchwork.ozlabs.org/project/netdev/patch/1506517964-17479-1-git-send-email-nikolay@cumulusnetworks.com/).
+* Measurements: stock mainline 7.2.6-1-default, two veth ports in one Linux
+ bridge, AF_PACKET probes (2026-10).
diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md
new file mode 100644
index 000000000..fe324b446
--- /dev/null
+++ b/docs/features/docker-kernel-datapath.md
@@ -0,0 +1,330 @@
+
+
+> 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.
+
+# Docker Kernel Datapath (veth + per-link Linux bridge)
+
+## Overview
+
+Docker node links historically flowed through a uBridge userspace relay: each adapter was a TAP whose file descriptor uBridge pumped between the container and UDP tunnels. This implementation replaces that model with **kernel-native forwarding**: every adapter is born as a **veth pair**, and each link is a **dedicated Linux kernel bridge** enslaving the two endpoints' veth host ends. Frames never leave the kernel.
+
+| | Old (relay) | New (kernel datapath) |
+|---|---|---|
+| Adapter interface | TAP moved into the container netns | veth pair (guest end in container, host end in root ns) |
+| Link | uBridge bridge, TAP fd ↔ UDP, userspace copy | per-link kernel bridge `gns3{link_id[:11]}`, zero-copy |
+| Relay latency | ~0.3 ms RTT | ~0.05 ms RTT (measured) |
+| Link attach at runtime | yes (relay attaches the pre-existing TAP) | yes (brctl addif / add_nio_ethernet — interface untouched) |
+| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt / duplicate + the netem extensions rate, reorder, gemodel, seed, limit, jitter distributions, loss correlation); bpf as cls_bpf match-drop classifiers; frequency_drop, quota and window_drop as the eBPF stateful classifier — every filter type has a kernel equivalent |
+| Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end |
+
+The work landed in nine stages on branch stack `feat/docker-kernel-datapath` → `feat/docker-kernel-capture` → `feat/docker-kernel-markers` → `feat/docker-veth-everywhere` → `feat/docker-kernel-filters` → `feat/docker-kernel-bpf-drop` → `feat/docker-kernel-netem-ext` → `feat/docker-kernel-ebpf-drops` → `feat/docker-kernel-window-drop`:
+
+1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based suspend, crash-safe reconciliation.
+2. **Capture** — `capture start_kernel` (AF_PACKET) on the veth host end.
+3. **Markers** — `marker add_kernel` (AF_PACKET + BPF) with the same signals, pcaps and fine-grained REST operations as the relay `mark` filter.
+4. **veth-everywhere** — the TAP path deleted; every adapter is a veth and the datapath is a *runtime* decision (kernel or relay) that never touches a running container's interfaces.
+5. **Filters** — impairment filters (delay, packet_loss, corrupt) become one tc netem qdisc per veth host end, pushed through uBridge's netlink `tc` module. This also removes the relay delay filter's nanosleep bottleneck (upstream #2827) from the kernel path.
+6. **bpf match-drop** — the `bpf` filter (any line matching drops) runs as cls_bpf classifiers on the veth host end's clsact egress (`tc bpf_drop`: uBridge pcap-compiles the line to classic BPF, the kernel migrates it to eBPF internally — no CAP_BPF needed). Requires a uBridge reporting `cbpf=1` via `tc capabilities`; older builds get a clear upgrade error.
+7. **netem extensions** — the netem-extension surface delivered in uBridge `feature/tc-netem-ext` is exposed as GNS3 filter types: `rate`, `reorder`, `gemodel`, `duplicate`, `seed`, `limit`, plus the delay `distribution` and loss/dup `correlation` parameters. Kernel-datapath links only (the relay has no equivalent); capability-gated per uBridge process. Reconcile became reset-before-set: the kernel's netem replace merges optional attributes, so removing a parameter needs the explicit reset.
+8. **eBPF stateful drops** — `frequency_drop` becomes the eBPF classifier's exact every-Nth mode (`tc nth_drop`, uBridge `feature/tc-precision`; -1 = drop everything → every 1st) and the new kernel-only `quota` type the byte-cap mode (`tc quota_drop`). Needs a uBridge reporting `ebpf=1` (setcap cap_bpf,cap_net_admin,cap_net_raw=ep — verified working for a non-root server process, the production credential shape). With this, no filter type forces the relay anymore: kernel/relay is a purely topological choice.
+9. **window_drop flaps** — the eBPF classifier's time-window mode (`tc window_drop`, uBridge `feature/tc-window`): a single outage that passes traffic before and after, a recurring flap with `period ≥ outage`, and an optional per-cycle jitter that randomizes each beat's outage and period. The originally delivered back-to-back window implementation (which made "outside the window packets pass" unreachable) was fixed uBridge-side; this stage exposes the corrected semantics as the kernel-only `window_drop` filter type.
+
+## Adapter interface types
+
+Which interface a Docker node's adapters use is decided by the node class (`Docker._select_node_class`, from `console_type` + `GNS3_*` environment markers):
+
+| Node class | Selected by | Adapter interface | Kernel links |
+|---|---|---|---|
+| `DockerVM` | default | veth pair | yes |
+| `VendorDockerVM` (non-unix-socket) | `console_type=docker_exec` (XRd, SR Linux, …) | veth pair (same path via `super()`) | yes |
+| `IOLDockerVM` | `GNS3_IOL_RUNNER=1` | AF_UNIX datagram socket pairs (`sNN`/`cNN`) — the container netns is unused; kernel links ride persistent `gx` TAP anchors through the port bridge's swappable TAP leg (see `iol-docker-kernel-datapath.md`) | yes (gated on `ubridge_bridge_tap` + `ubridge_tap`) |
+| `VendorDockerVM` (unix-socket) | `GNS3_UNIX_SOCKET_NIO=1` | same socket contract, generic capability for vendor NOS images | rejected |
+
+The other `GNS3_*` markers a vendor image sets (`GNS3_SKIP_INIT`, `GNS3_INTERFACE_NAMES`, …) are behavior knobs of the selected class, not class selectors — an environment carrying only `GNS3_SKIP_INIT` stays on `DockerVM`. Unix-socket containers are detected both in the controller (`_is_unix_socket_docker`, link eligibility) and on the compute side (NIO rejection) — a missed detection surfaces as a clearer-late error, except on the Ethernet-switch fast path, where an absent anchor is deferred forever by design (the join must not fail while the peer is stopped), leaving a silently dead cable.
+
+## Architecture
+
+Kernel link between two containers on the same compute. Interface names follow two deterministic rules — `gv|gc{node_id[:8]}e{adapter}p{port}` for the veth pair (`gv` = GNS3 veth host end, `gc` = its guest end, moved into the container and renamed `eth{N}`) and `gns3{link_id[:11]}` for the bridge; the concrete names in the diagrams use node ids `3f2a1b9c` / `7c9d4e2f` and link id prefix `5d3c2b1a4e6`. Terms the diagrams use:
+
+| Term | Meaning |
+|---|---|
+| uBridge | the per-node helper process gns3-server spawns with elevated capabilities; its hypervisor command surface executes all host-side wiring (veths, bridges, carrier, tc, taps) — on a kernel link it builds and observes the path but never carries frames |
+| anchor | the host-side interface a link attaches to — for Docker, the `gv` veth host end |
+| per-link bridge | the Linux bridge `gns3{link_id[:11]}` that stands in for the cable; the two anchors are its only ports |
+| veth pair | a kernel virtual Ethernet pair — two interfaces created together and wired so a frame into one end comes out the other; here one end is the container's `eth0`, the other the `gv` host end |
+| bridge port | an interface enslaved to a Linux bridge (`brctl addif`) — it no longer sends or receives on its own: frames arriving on it go to the bridge's forwarding logic, and the bridge transmits through it |
+| veth crossing | a frame passing through the veth pair between a container netns and the root namespace — pure kernel, no userspace |
+| FDB | the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded |
+| `01:80:c2` / `group_fwd_mask` | the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's `group_fwd_mask` — `0xfffd` passes everything except PAUSE/PFC |
+| tc / qdisc | traffic-control objects the kernel inserts into the host end's egress path — stages frames pass through in flight, not external packet consumers; uBridge only installs them over netlink |
+| `clsact` | the tc classifier carrier on the host end; its egress chain is where the drop classifiers run |
+| eBPF classifier | the stateful drop classifier at clsact egress prio 1 — modes every-Nth, quota, time window and flow (GNS3 exposes the first three as filter types) |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| `netem` | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| `AF_PACKET tap` | uBridge's packet socket bound to the host end; the kernel clones every frame the interface sends or receives to it (the tcpdump mechanism) — read-only copies, the original stays on the kernel path |
+| carrier | the host end's admin state (`link set up/down`); suspend is carrier down on both ends = 100 % loss |
+| `nio_bridge` | the NIO record the controller posts to each endpoint node: bridge name, filters, markers, suspend flag |
+
+```mermaid
+flowchart LR
+ subgraph NetA["container A netns"]
+ ethA["eth0"]
+ end
+ subgraph RootNS["root namespace"]
+ gvA["gv3f2a1b9ce0p0 — anchor"]
+ BR["gns35d3c2b1a4e6 — per-link bridge"]
+ gvB["gv7c9d4e2fe0p0 — anchor"]
+ UB1["uBridge A"]
+ UB2["uBridge B"]
+ end
+ subgraph NetB["container B netns"]
+ ethB["eth0"]
+ end
+ ethA <-->|"veth pair"| gvA
+ gvA <-->|"bridge port"| BR
+ BR <-->|"bridge port"| gvB
+ gvB <-->|"veth pair"| ethB
+ gvA -. "frame copies" .-> UB1
+ UB1 -. "qdisc install" .-> gvA
+ gvB -. "frame copies" .-> UB2
+ UB2 -. "qdisc install" .-> gvB
+```
+
+Everything in the root namespace is built the same way: gns3-server creates the veth pairs at container start, the bridge and its memberships at link creation, and every later change (carrier, capture, markers, filters) by sending hypervisor commands to its per-node uBridge process — the privileged agent that turns them into netlink/ioctl operations (the production credential shape is a non-root server with a capabilities-equipped ubridge). That build flow is the Link creation sequence below; this diagram is the steady state that remains, where the only uBridge relationships left are the two dashed kinds.
+
+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 — 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
+ subgraph KA["container A netns"]
+ EA["eth0"]
+ end
+ subgraph KERN["kernel — root ns"]
+ GVA["gv host end"]
+ UDP["UDP sockets (loopback)"]
+ GVB["gv host end"]
+ end
+ subgraph UBA["uBridge A — userspace"]
+ NEA["AF_PACKET nio"]
+ ENA["relay + userspace filters"]
+ NUA["UDP nio"]
+ end
+ subgraph UBB["uBridge B — userspace"]
+ NUB["UDP nio"]
+ ENB["relay + userspace filters"]
+ NEB["AF_PACKET nio"]
+ end
+ subgraph KB["container B netns"]
+ EB["eth0"]
+ end
+ EA -->|"veth pair"| GVA
+ GVA -->|"copy 1: kernel→user"| NEA
+ NEA --> ENA --> NUA
+ NUA -->|"copy 2: user→kernel"| UDP
+ UDP -->|"copy 3: kernel→user"| NUB
+ NUB --> ENB --> NEB
+ NEB -->|"copy 4: user→kernel"| GVB
+ GVB -->|"veth pair"| EB
+```
+
+Drawn one-way; the reverse direction is the mirror image. A relayed frame crosses the kernel/user boundary **four times each way** (eight per round trip — the measured ≈0.3 ms RTT against ≈0.05 ms on the kernel datapath, whose crossings are zero), and the relay's filters run in userspace on the frames between the copies.
+
+Both datapaths anchor on the *same* veth host end; switching between them (link delete + recreate) never touches the container-side interface.
+
+Frame path for one A → B frame. The impairment chain fires on the egress of the *destination* end's veth, in order clsact → netem (a classifier drop means netem never sees the frame); each direction is impaired exactly once and B → A is symmetric on gvA — hence `delay 100` ≈200 ms RTT, `packet_loss 30` ≈51 % round trip:
+
+```mermaid
+flowchart LR
+ subgraph ANS["container A netns"]
+ S1["eth0 A sends"]
+ end
+ subgraph ROOT["root namespace"]
+ subgraph GVA["gvA — ingress (its egress chain serves B→A)"]
+ S3["ingress"]
+ end
+ subgraph BRP["per-link bridge"]
+ G1{"reserved 01:80:c2?"}
+ D["dropped"]
+ S4["FDB lookup / flood"]
+ end
+ subgraph GVB["gvB — egress chain (clsact, then netem)"]
+ S5["eBPF prio 1"]
+ S6["bpf_drop prio 10-99"]
+ S7["netem qdisc"]
+ end
+ T["capture / markers"]
+ end
+ subgraph BNS["container B netns"]
+ S9["eth0 B receives"]
+ end
+ S1 -->|"veth pair"| S3
+ S3 --> G1
+ G1 -->|"PAUSE 0x01"| D
+ G1 -->|"0xfffd passes"| S4
+ S4 --> S5
+ S5 --> S6 --> S7
+ S7 -->|"veth pair"| S9
+ S3 -. "AF_PACKET tap" .-> T
+ S7 -. "AF_PACKET tap" .-> T
+```
+
+What each stage drops: the reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Packet filters below). Whichever end hosts capture / markers, its AF_PACKET tap — the dashed sideband above — clones the frame at that anchor: a tap on gvA sees the frame on arrival, a tap on gvB only if the clsact chain let it through.
+
+All dozen-plus GNS3 filter types ride these three kernel objects — nine of them (delay, packet_loss, corrupt, duplicate, rate, reorder, gemodel, seed, limit) are parameters of the single netem qdisc, `bpf` expressions are the cls_bpf filters, and frequency_drop / quota / window_drop map to three of the one eBPF program's four modes (evaluated nth → quota → window → flow; the flow-hash mode has no GNS3 filter type). The complete type-to-object mapping and the per-frame evaluation order are documented in `packet-filters.md`.
+
+## Datapath selection
+
+`UDPLink._kernel_datapath_eligible` decides in the controller at NIO prepare time; the compute side only reacts to the NIO type:
+
+* both endpoints on the same compute, both able to anchor — Docker anchors via its veth (any class except unix-socket containers), and QEMU / IOU / Dynamips / Ethernet-switch peers anchor via their own mechanisms, so mixed links take the kernel path too
+ * cross-compute pairs take the kernel path as a **VXLAN tunnel** when `Server.enable_vxlan_cross_compute` is enabled and both computes can create the device — see [vxlan-cross-compute](vxlan-cross-compute.md); the default there is the relay, because the tunnel caps the usable MTU at the underlay minus 50 bytes
+* `Server.enable_kernel_datapath` enabled (default)
+
+Filters never disqualify the kernel path anymore — every type has a kernel equivalent (netem / cls_bpf / eBPF classifier).
+
+There is **no stopped-node requirement**. Since every adapter is a veth, links attach to running containers: `brctl addif` (kernel) or `bridge add_nio_ethernet` (relay) are runtime-safe operations on the host end. On project (re)open every link re-runs `_prepare`, so relay links whose endpoints became eligible are upgraded to the kernel datapath automatically.
+
+Which datapath a link ended up on is visible in its REST payload (and the `link_list` / `link_get` MCP tools) as the read-only boolean `kernel_datapath`, recomputed from the NIO wiring — a relay link upgraded on reopen flips the field. It is not persisted and never sent on create/update.
+
+## Link creation sequence
+
+Both Docker nodes sit on the same compute (a kernel-link precondition) and each drives its own uBridge process. They attach concurrently and compute the same bridge name independently (a pure function of the link id), so either may win the `brctl create` race — the loser verifies the bridge exists (`brctl show`) and continues:
+
+```mermaid
+sequenceDiagram
+ participant API as REST API
+ participant C as Controller
+ participant CA as Docker node A
+ participant CB as Docker node B
+ participant UA as uBridge A
+ participant UB as uBridge B
+
+ API->>C: POST /links
+ C->>C: eligibility check
+ C->>C: bridge = gns3 + link_id[:11]
+ C--)CA: nio_bridge
+ C--)CB: nio_bridge (same bridge)
+ par Docker node A
+ CA->>UA: brctl create
+ CA->>UA: link set bridge up
+ CA->>UA: brctl addif gvA
+ CA->>UA: capture / markers if any
+ CA->>UA: tc netem + bpf + eBPF
+ CA->>UA: link set gvA up
+ and Docker node B
+ CB->>UB: same on gvB
+ end
+ Note over UA,UB: steady state — kernel forwards, uBridge taps
+```
+
+Every arrow to uBridge is one `_ubridge_send` command line on the node's local hypervisor console; uBridge executes it (netlink/ioctl) and replies OK or an error synchronously before the next command is sent — those replies are omitted for brevity. uBridge never initiates anything: it is a pure executor, and the only data ever flowing back (capture/marker frame copies) rides separate AF_PACKET sockets, not this console.
+
+The veth pairs already exist at attach time — every adapter is born as a veth at container start (Adapter lifecycle below), which is what makes the attach a runtime-safe operation on a running container.
+
+## Adapter lifecycle
+
+* **Birth (container start)** — `_create_veth`: stale sweep of any leftover pair (crash residue), `docker create_veth`, host end admin-down, MAC from the adapter base, `docker move_to_ns` of the guest end (renamed `eth{N}`, or the `GNS3_INTERFACE_NAMES` mapping). Names are deterministic: `gv|gc{node_id[:8]}e{adapter}p{port}` (≤ 15 chars). Unconnected adapters are born too (carrier off) so the interface is visible inside the container.
+* **Life (running)** — the veth is never created/deleted/moved again. Link create/delete/switch, suspend, capture and markers only change *what is attached to the host end*.
+* **Death (container stop)** — `_remove_kernel_veths` deletes the host ends explicitly: unlike a TAP (which died with the container netns), a veth host end outlives the container. Deleting either end destroys the pair. The node's per-link kernel bridges go with them — deleting a node or closing a project never runs the link-teardown path (no per-link delete is issued), so a bridge whose ports just disappeared would stay behind as an empty orphan. Both endpoints do this and the peer's still-enslaved port makes the delete fail with EBUSY (suppressed, last one wins), the same contract as link deletion; a node restart rebuilds the bridge from the NIO.
+* **Host L3 noise (known, hardening specified).** An anchor is a host-side netdev like any other, so the kernel gives it an IPv6 link-local address and emits its own MLD/DAD from it (measured ≈6 frames / 2 s on an idle TAP; the production anchors and per-link bridges carry `fe80::…` today). On a kernel link the bridge floods those frames into the emulated segment — and the host answers neighbor solicitations for its own address, which looks like a phantom IPv6 neighbor to the emulated nodes. The fix is to make anchors pure L2 at creation time on the uBridge side: `docs/design/ubridge-l2-anchor-spec.md`.
+
+## Link operations
+
+| Operation | Kernel-datapath action |
+|---|---|
+| Create / attach | `brctl create` (EEXIST tolerated via `brctl show` verify) + `link set up` + `brctl addif` both ends (concurrent, race-tolerant) + capture / markers if any + the tc chain (netem, bpf, eBPF) + carrier up |
+| Delete / detach | marker teardown → `tc reset` (netem teardown — the veth survives the link) → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The veth survives — unlike a relay bridge, whose death dropped its filters, an orphaned AF_PACKET marker would keep sniffing, hence the explicit teardown |
+| Reset (`POST /links/{id}/reset`) | delete + create (re-evaluates eligibility) |
+| Suspend (`PUT /links/{id}` `{"suspend": true}`) | veth host end admin-state down both ends — 100 % loss, no synthetic filter needed; resume restores (netem qdisc survives the carrier flap) |
+
+## Capture
+
+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
+
+Markers ride the NIO like on the relay datapath, routed by `capture_node_id`; the anchor is the veth host end instead of a relay bridge (`_ubridge_apply_markers(anchor, nio)` is datapath-agnostic; DockerVM overrides the add/delete/enable primitives to translate the commands):
+
+```
+marker add_kernel "" [tag ] [link ] [dir ] [linktype ] [pcap ""]
+marker delete_kernel # idempotent
+marker enable_kernel # off = installed but silent
+```
+
+* Per-deployment convention: **only the capture node's end** runs `add_kernel` (symmetric with capture) — signals form clean send/return pairs (`PACKET_OUTGOING` on the host end = container receiving = `rx`).
+* Same BPF compilation, MARK UDP signals, pcap files and fine-grained REST toggle/rebuild/delete endpoints as the relay `mark` filter.
+* Node restart reinstalls markers from the NIO; link deletion tears them down.
+
+## Packet filters
+
+Impairment filters are documented once for every kernel-datapath node type in `packet-filters.md` — the 13 filter types ride three kernel objects on the anchor (one netem qdisc, cls_bpf match-drop classifiers, the eBPF stateful classifier; the frame-path diagram above shows where the chain sits), and the type-to-object mapping, per-frame evaluation order, reconcile behaviour, capability gating and window semantics all live there. `KernelDatapathMixin` keys everything on the anchor interface name, which is why this veth datapath and the TAP datapaths (QEMU, IOU, Dynamips) share the implementation unchanged.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at `brctl addif` time (Linux 4.15+, best-effort on older kernels). The bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2, so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in `br_handle_frame()` drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by `tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest matrix); the contract and kernel references live in `docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+## uBridge command surface
+
+No uBridge changes were needed beyond the marker module's `*_kernel` commands (the AF_PACKET capture module was pre-existing):
+
+```
+docker create_veth / delete_veth / move_to_ns / set_mac_addr
+brctl create / delete / addif / delif / show
+link set up|down
+capture start_kernel / stop_kernel
+marker add_kernel / delete_kernel / enable_kernel
+tc netem set [delay ] [jitter ] [loss <%> [correl <%>] | loss gemodel
<1-h>]
+ [dup <%> [correl <%>]] [corrupt <%>] [reorder <%> [correl <%>] [gap ]]
+ [rate ] [limit ] [distribution uniform|normal|pareto|paretonormal] [seed ]
+tc bpf_drop add "" / tc bpf_drop flush
+tc nth_drop # eBPF exact every-Nth (frequency_drop)
+tc quota_drop | off # eBPF byte cap (quota)
+tc window_drop [ []] | off # eBPF time window (window_drop)
+tc reset # full restore: eBPF filter -> bpf_drops -> clsact -> root qdisc, idempotent
+tc capabilities # "netem=;ebpf=0|1;cbpf=0|1[;ebpf_modes=]", probed once per process
+bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture
+```
+
+## Configuration
+
+```ini
+[Server]
+# Wire eligible links through kernel interfaces instead of the uBridge UDP relay
+enable_kernel_datapath = True
+```
+
+This is now the global datapath switch — `_kernel_datapath_eligible` consults it first, and every node type that can anchor a kernel link has landed:
+
+* **Docker** — veth host ends as anchors (this document)
+* **QEMU** — persistent TAP anchors, see `docs/features/qemu-kernel-datapath.md`
+* **IOU** — TAP-terminated fabric ports (`iol_bridge add_nio_tap`), see `docs/features/iou-kernel-datapath.md`
+* **Dynamips** — uBridge-created TAPs the hypervisor opens, see `docs/features/dynamips-kernel-datapath.md`
+* **IOL runner containers** — persistent `gx` TAPs through the bridge module's swappable TAP leg, see `docs/features/iol-docker-kernel-datapath.md`
+* **Ethernet Switch** — its own uBridge `brctl` bridge that absorbs the peer's anchor as a fast path (switch-to-switch cascades over a veth pair), see `docs/features/ethernet-switch-kernel-datapath.md`
+
+A link rides the kernel when both endpoints sit on one compute, both can anchor (per-type uBridge capabilities), the switch is on and the ports are Ethernet; everything else falls back to the relay. The shared compute machinery behind all of these lives in `gns3server/compute/kernel_datapath.py`.
+
+## Verification
+
+End-to-end on a five-container FRR topology (mixed runtime-drawn and reloaded links): 8/8 links on the kernel datapath, ping RTT ≈ 0.05 ms, FDB learning, suspend = 100 % loss / resume restores, project reopen upgrades relay links, runtime link creation on running containers, marker pcaps exact (tx/rx pairs), capture freeze on stop, server restart reconciliation.
+
+Filters (two-container kernel link, 27/27 checks): baseline 0.06 ms → `delay 100` = 200.2 ms RTT (2× per direction) → `delay 50` = 100.2 ms (atomic replace) → clear = baseline (qdisc detached); `packet_loss 30` = 48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop` = 409 (at that stage — superseded by the eBPF stage below); suspend with delay active = 100 % loss, resume keeps the qdisc (200.1 ms); node restart restores it (200.1 ms); link delete detaches it (`/usr/sbin/tc qdisc show` — no netem left); re-created link has no residual impairment.
+
+bpf match-drop (same setup, 22/22 checks): `bpf "icmp"` = 100 % loss on both ends' clsact; size-discriminating expression (`greater 150`) = small pings pass / 300-byte pings dropped (real byte-level BPF match); two-line OR; coexistence with netem (delay 50 → small pings 100.2 ms while big dropped); clear removes clsact entirely; suspend/resume keeps the drops; link delete + recreate leaves no residual filters; `frequency_drop` still 409 and hidden (at that stage — superseded by the eBPF stage below); `available_filters` shows `bpf`. Unit tests: `tests/compute/docker/test_docker_kernel_datapath.py`, `tests/controller/test_kernel_datapath_link.py`.
+
+netem extensions (same setup, 24/24 checks): `rate 512kbit` on both veths, 1400-byte pings measure 45.5 ms RTT = baseline + 2× the 22.2 ms serialization (exact); `delay 100 20 paretonormal` applies delay+jitter and clears the previous rate (leak detector for the kernel's merge-on-replace); `reorder 25 gap 5` visible in the qdisc dump and traffic passes; `gemodel 100/0/30` = 50 % bursty round-trip loss with the previous reorder cleared; `duplicate 50` link alive; `packet_loss 30 correl 50` shows `loss 30% 50%`; `seed 42` + `limit 5000` both visible in the dump, RTT exactly 2×50 ms; suspend/resume and node restart keep the filters; a docker↔ethernet-switch relay link rejects `rate` with 409 and its `available_filters` hides the kernel-only types while the kernel link shows them all (frequency_drop included since the eBPF stage); `reorder` without `delay` and `gemodel`+`packet_loss` are 409s; teardown leaves no netem on either veth. Also verified live: a relay attach (`bridge add_nio_ethernet`) on the admin-down veth host end fails in libpcap — the relay path now brings the interface up before attaching.
+
+eBPF stateful drops (same setup, 13/13 checks): `frequency_drop 3` installs the tc_impair classifier on both veths (clsact egress pref 1, visible in `tc filter show`); round-trip loss 57 % against the per-direction theory 1 − (2/3)² = 55.6 % (each direction drops every 3rd independently — the relay's single shared counter would give ≈ 1/3); `frequency_drop -1` = exactly 100 % loss; `quota 3000B/100 %` = pings pass until the byte cap then hard cutoff; coexistence with `delay 50` (RTT 100.3 ms with every-4th drops); clearing filters leaves no residual bpf filter; suspend = 100 % while down with drops persisting after resume; node restart restores every-Nth on the fresh veth; `available_filters` shows frequency_drop + quota on kernel links and hides quota on relay links; a two-leg relay path through an Ethernet switch still serves frequency_drop from the userspace filter (56 % round-trip for N=3). The e2e also exposed and fixed a pre-existing restart bug: the relay bridge-name registry was not cleared on stop, so a node restart with a relay link skipped `bridge create` and failed. Unit tests: `tests/utils/test_packet_filter_validation.py` (P6a class), `tests/compute/docker/test_docker_kernel_datapath.py`, `tests/controller/test_kernel_datapath_link.py`.
+
+window_drop (same setup, 15/15 checks, against uBridge `feature/tc-window`): a single outage `[1000, 1500, 100]` delivers **per-seq evidence** of the B.2 semantics — one ping per 200 ms arrives as `[0,1,2,3,4, 13…19]`: passing before the window, dropped inside it, passing again after; a recurring flap `[300, 1000, 100, 2000]` measures 52 % round-trip loss (the 1000/2000 duty cycle); `period == outage` is a permanent outage (100 %); `period < outage` is a 409 before uBridge sees it; a jittered flap `[0, 1000, 100, 2000, 400]` keeps the ~50 % duty cycle (62 % sampled over 4 cycles — the per-cycle re-draw widens the variance by design); a 30 % chance on a permanent window measures 53 % round trip against the per-direction theory 1 − 0.7² = 51 % (the same arithmetic as `packet_loss 30`); `start = 0` is stored as an *active* filter; the window coexists with netem (`delay 50` + a permanent window = 100 % inside the outage); clearing removes the classifier; a node restart restores the schedule on the fresh veth; `available_filters` shows window_drop on kernel links and a relay link both hides it and 409s it; teardown leaves no filter or qdisc behind. The P6b e2e was re-run as a regression on the same uBridge build (13/13 — nth/quota unaffected by the de-looping below).
+
+In-repo live coverage: `tests/e2e/test_docker_kernel_datapath.py` (pytest marker `e2e`) runs two real Alpine containers on an isolated instance — the per-link bridge enslaving exactly the two veth host ends, real ICMP, the L2-anchor spec §E assertions (no L3 identity, forwarding ports, idle silence with the guests' links down), the filter matrix one type at a time (netem core and extensions, cls_bpf match-drop, the eBPF classifier, each asserted through real traffic), suspend, capture, link delete/re-create, container stop/start, a relay negative control — whose own filters (delay/frequency_drop/bpf) really shape the relay wire, whose capture and markers ride the same relay engine (`bridge start_capture`, the bridge `mark` filter) — and the relay→kernel upgrade on project reopen (the datapath choice is flipped across a server restart). The container image is digest-pinned and pulled through the server's own pull route on a cache miss, so collaborators test the same bytes.
+
+The uBridge-side window work took two fixes that are worth recording, both found by *this* integration rather than by unit tests: the delivered catch-up loop tripped the verifier's 8192 **jump-sequence** budget — on the non-root path the verifier drops the loop counter's bound (`R4=scalar(smax=umax32=0xfffff086)`), treats the loop as unbounded and unrolls it until the pending-state limit, so the program was rejected wholesale (`E2BIG`, `The sequence of 8193 jumps is too complex`) and `tc capabilities` reported `ebpf=0` — taking `frequency_drop` and `quota` down with it, since the classifier loads as one program; lowering the trip cap did not help (128 trips failed identically), removing the loop did (572ca0c: 16-step unroll + outer guard, zero back edges). A latent Makefile header-dependency gap (53e34bb) had been shipping the *old* truncated program object — the kernel answered `jump out of range from insn 9 to 421` with `processed 0 insns`. Both matter to anyone rebuilding uBridge: `ebpf=1` from a direct probe is the only trustworthy signal.
diff --git a/docs/features/dynamips-kernel-datapath.md b/docs/features/dynamips-kernel-datapath.md
new file mode 100644
index 000000000..72c2e6412
--- /dev/null
+++ b/docs/features/dynamips-kernel-datapath.md
@@ -0,0 +1,241 @@
+
+
+# Dynamips kernel datapath
+
+Dynamips routers emulate every port inside the Dynamips process; on the relay datapath a link reaches a router through a UDP tunnel (a Dynamips `nio create_udp`, a loopback port pair, the node's uBridge relay, and the link's UDP NIOs). The kernel datapath replaces that segment with the same anchor model Docker, QEMU and IOU use: every **Ethernet slot/port** owns a persistent TAP (`gd{node_id[:8]}e{slot}p{port}`), created by the server through uBridge's tap module, and links attach to the anchor — a per-link Linux bridge enslaves it, tc impairments run on it, capture and markers anchor on it. The Dynamips **hypervisor** opens the TAP with `nio create_tap` (QEMU's move: an external process holds the fd), unprivileged because the server handed the device's ownership to its own user.
+
+## Architecture
+
+The diagram shows the e2e shape — two c7200 routers on one link (slot 1 port 0 each, a PA-2FE-TX — slot 0 is the c7200's fixed I/O slot), the topology `tests/e2e/test_dynamips_kernel_datapath.py` drives. A Dynamips↔Docker/QEMU/IOU link is the same shape with the peer's own anchor on the other side; a routed transit is two such links with IOS forwarding between them. Interface names follow the deterministic rules `gd{node_id[:8]}e{slot}p{port}` for the anchor TAPs and `gns3{link_id[:11]}` for the bridge; the concrete names in the diagrams use node ids `7c1d4e2f` / `9e8b6a3f` and link id prefix `5d3c2b1a4e6` (`gdA`/`gdB` shorthand in the sequence below). Terms the diagrams use:
+
+| Term | Meaning |
+|---|---|
+| hypervisor | the Dynamips process emulating the router; it holds every anchor TAP's fd |
+| anchor | the host-side interface a link attaches to — for Dynamips the `gd` persistent TAP, for Docker the `gv` veth host end |
+| persistent TAP | the TAP created per Ethernet slot/port at node start (`tap create` + `tap set_owner`, ownership handed to the server user so the unprivileged hypervisor can open it); born DOWN, it outlives `vm stop` and hypervisor crashes |
+| per-link bridge | the Linux bridge `gns3{link_id[:11]}` that stands in for the cable; the two anchors are its only ports |
+| tap fd | a TAP's file descriptor: writes inject frames into the kernel as if the device received them, reads return frames the kernel transmits through the device (bridge-forwarded, toward the router) — the hypervisor opens it with `nio create_tap` and holds it for the port's lifetime |
+| FDB | the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded |
+| `01:80:c2` / `group_fwd_mask` | the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's `group_fwd_mask` — `0xfffd` passes everything except PAUSE/PFC |
+| `clsact` | the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run |
+| eBPF classifier | the stateful drop modes (every-Nth, quota, time window) at clsact egress prio 1 |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| netem | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| `AF_PACKET tap` | uBridge's packet socket on the anchor — how capture and markers observe frames off the forwarding path |
+| carrier | the anchor's admin state (`link set up/down`); suspend is carrier down — traffic dead both ways |
+| `nio_bridge` | the NIO record the controller posts to each endpoint node: bridge name, filters, markers, suspend flag |
+| uBridge | the per-node helper process — on a kernel link it created the anchor, enslaves it and taps it, but never carries frames |
+
+```mermaid
+flowchart LR
+ subgraph H1["Dynamips hypervisor R1"]
+ ios1["IOS router 1"]
+ end
+ subgraph RootNS["root namespace"]
+ gdA["gd7c1d4e2fe1p0"]
+ BR["gns35d3c2b1a4e6"]
+ gdB["gd9e8b6a3fe1p0"]
+ ub1["uBridge R1"]
+ ub2["uBridge R2"]
+ end
+ subgraph H2["Dynamips hypervisor R2"]
+ ios2["IOS router 2"]
+ end
+ ios1 <-->|"TAP fd"| gdA
+ gdA <-->|"bridge port"| BR
+ BR <-->|"bridge port"| gdB
+ gdB <-->|"TAP fd"| ios2
+ gdA -.->|"AF_PACKET tap"| ub1
+ ub1 -.->|"tc qdisc"| gdA
+ gdB -.->|"AF_PACKET tap"| ub2
+ ub2 -.->|"tc qdisc"| gdB
+```
+
+The only userspace crossings left are the Dynamips processes themselves — the routers *are* the emulation, and the hypervisor reads and writes the TAP directly (QEMU's external-fd-holder shape, no extra relay hop on the node leg unlike IOU's fabric). What the kernel datapath buys is the link segment: anchor → per-link bridge → peer anchor crosses entirely in the kernel, impairments run as kernel tc instead of userspace filters, and suspend maps to anchor admin-down. Each side's uBridge does its own tap-and-qdisc work on its node's anchor — but neither dashed edge is a steady fixture: the qdisc exists only while the link carries filters (deletion `tc reset`s it), the tap only while capture or markers run, and by convention only on the capture node's end, so an unfiltered, uncaptured link shows neither. The anchors themselves were created through uBridge at node start (Anchor lifecycle below).
+
+The legacy relay is untouched: a port without a kernel link keeps its Dynamips UDP NIO and the node's uBridge relay tunnel exactly as before, and a kernel link binds the same port to its anchor instead — per port, no node-wide switch. The same R1 ↔ R2 link on the relay — one direction drawn, the reverse is the mirror image; this is the exact shape the negative control keeps when `enable_kernel_datapath = false` (Verified below). Each relay bridge is named `DYNAMIPS-{lport}-{rport}` after the loopback port pair its NIO allocates at link time:
+
+```mermaid
+flowchart LR
+ subgraph H1["Dynamips hypervisor R1"]
+ ios1["IOS router 1"]
+ nio1["UDP NIO"]
+ end
+ subgraph ub1["uBridge R1"]
+ rl1["relay bridge"]
+ end
+ subgraph KERN["kernel"]
+ lp1["loopback UDP"]
+ tun["UDP tunnel"]
+ lp2["loopback UDP"]
+ end
+ subgraph ub2["uBridge R2"]
+ rl2["relay bridge"]
+ end
+ subgraph H2["Dynamips hypervisor R2"]
+ nio2["UDP NIO"]
+ ios2["IOS router 2"]
+ end
+ ios1 --> nio1
+ nio1 -->|"copy 1: user→kernel"| lp1
+ lp1 -->|"copy 2: kernel→user"| rl1
+ rl1 -->|"copy 3: user→kernel"| tun
+ tun -->|"copy 4: kernel→user"| rl2
+ rl2 -->|"copy 5: user→kernel"| lp2
+ lp2 -->|"copy 6: kernel→user"| nio2
+ nio2 --> ios2
+```
+
+Six kernel/user crossings each way, against the kernel datapath's two — and those two are the TAP-fd legs that are the emulation itself, so the relay's four extra copies are pure overhead. Userspace filters run inside the relay bridges, between copies 2-3 and 4-5. On a same-compute link the UDP tunnel rides loopback as drawn; cross-compute it rides the real network with the copy count unchanged.
+
+Frame path for one R1 → R2 frame. The impairment chain fires on the egress of the *destination* end's anchor, in order clsact → netem (a classifier drop means netem never sees the frame); each direction is impaired exactly once and R2 → R1 is symmetric on gdA:
+
+```mermaid
+flowchart LR
+ subgraph H1["Dynamips hypervisor R1"]
+ S1["IOS port sends"]
+ end
+ subgraph ROOT["root namespace"]
+ subgraph GA["gd7c1d4e2fe1p0 — ingress (egress chain serves R2→R1)"]
+ S3["ingress"]
+ end
+ subgraph BRP["per-link bridge"]
+ G1{"reserved 01:80:c2?"}
+ D["dropped"]
+ S4["FDB lookup / flood"]
+ end
+ subgraph GB["gd9e8b6a3fe1p0 — egress chain (clsact, then netem)"]
+ S5["eBPF prio 1"]
+ S6["bpf_drop prio 10-99"]
+ S7["netem qdisc"]
+ end
+ T["capture / markers"]
+ end
+ subgraph H2["Dynamips hypervisor R2"]
+ S9["IOS port receives"]
+ end
+ S1 -->|"tap fd, copy 1: user→kernel"| S3
+ S3 --> G1
+ G1 -->|"PAUSE 0x01"| D
+ G1 -->|"0xfffd passes"| S4
+ S4 --> S5
+ S5 --> S6 --> S7
+ S7 -->|"tap fd, copy 2: kernel→user"| S9
+ S3 -. "AF_PACKET tap" .-> T
+ S7 -. "AF_PACKET tap" .-> T
+```
+
+What each stage drops: the reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Capture, markers, filters below). The R2 → R1 reply is symmetric — its impairment points are `gd9e8b6a3fe1p0`'s egress (entering R2) and `gd7c1d4e2fe1p0`'s egress (entering R1) — so every direction is impaired exactly once, and `delay 100` adds one-way 100 ms per direction, ≈200 ms round trip (the e2e pins the lower bound: RTT ≥ 150 ms with 100% success). Copies 1-2 are the whole kernel/user bill — both are the TAP-fd legs of the emulation itself; the same frame on the relay datapath crosses six times (diagram above). The dashed sideband is uBridge's observation: capture and markers run as AF_PACKET taps on whichever end hosts them, cloning this frame at that anchor's stage — gdA's rx or gdB's tx — and a clsact-dropped frame never reaches the tap.
+
+## Datapath selection
+
+`UDPLink._kernel_datapath_eligible` extends the common rules with:
+
+* the compute must report **`ubridge_tap`** — the anchors are uBridge-created TAPs the hypervisor merely opens, the same capability QEMU gates on; there is no Dynamips-specific one;
+* **both ports must be Ethernet**. The controller's port matrix decides per port (the port's `link_type`); serial, ATM and POS ports keep the relay whatever the capabilities. Anchor creation on the compute side uses its own registry of Ethernet models — `ETHERNET_ADAPTERS` / `ETHERNET_WICS` in `compute/dynamips/adapters/adapter.py`, WIC-1ENET included at its Dynamips port number `16 * (wic_slot + 1)` — a name-keyed list kept in step with the controller's matrix by convention, not derivation.
+
+## Link creation sequence
+
+Both routers sit on the same compute (a kernel-link precondition) and each drives its own uBridge process; the hypervisor is one process per router. The sequence shows the running-router case — a router that is stopped stores the NIO and defers every step here to its next start (deferred wiring, below). The two node handlers attach concurrently and compute the same bridge name independently (a pure function of the link id), so either may win the `brctl create` race — the loser verifies the bridge exists (`brctl show`) and continues:
+
+```mermaid
+sequenceDiagram
+ participant API as REST API
+ participant C as Controller
+ participant R1 as Router R1
+ participant HY1 as Hypervisor R1
+ participant UB1 as uBridge R1
+ participant R2 as Router R2
+ participant HY2 as Hypervisor R2
+ participant UB2 as uBridge R2
+
+ API->>C: POST /links
+ C->>C: eligibility check
+ C->>C: bridge = gns3 + link_id[:11]
+ C--)R1: nio_bridge
+ C--)R2: nio_bridge (same bridge)
+ par Router R1
+ R1->>HY1: nio create_tap
+ R1->>HY1: vm slot_add_nio_binding
+ R1->>HY1: vm slot_enable_nio
+ R1->>UB1: brctl create
+ R1->>UB1: link set gns35d3c2b1a4e6 up
+ R1->>UB1: brctl addif gns35d3c2b1a4e6 gdA
+ R1->>UB1: capture start_kernel gdA
+ R1->>UB1: marker add_kernel gdA
+ R1->>UB1: tc netem set gdA
+ R1->>UB1: link set gdA up
+ and Router R2
+ R2->>HY2: nio create_tap
+ R2->>UB2: same on gdB
+ end
+ Note over HY1,UB2: steady state — kernel forwards between the anchors, the hypervisors hold the tap fds
+```
+
+`nio create_tap`, `vm slot_add_nio_binding` and `vm slot_enable_nio` are the Dynamips-specific steps: the hypervisor opens the anchor and binds it to the slot/port before any bridge work, so a half-wired port never appears on the link. Capture, markers and `tc netem set` run only when the NIO carries them — a marker's real form is `marker add_kernel ""`, `bpf` expressions arrive as `tc bpf_drop add`, the stateful modes as `tc nth/quota/window_drop` — and `brctl addif` is itself what opens the port's `group_fwd_mask` to `0xfffd` — the server sends no separate command for it (uBridge does have one, `brctl setportgroupfwd`, last-writer-wins against addif's default, which re-applies on every re-attach). The anchor's carrier stays down while the link is suspended. A link created while the router is **stopped** is stored and wired by the next start — the NIO simply waits in the slot adapter (deferred wiring, Anchor lifecycle below).
+
+## Anchor lifecycle
+
+* **Birth (node start)** — `_prepare_tap_datapath` probes the tap module (one create/delete cycle) and creates one persistent TAP per Ethernet slot/port, ownership handed to the server user, born DOWN. A stale leftover from a previous run is swept first; an anchor that already exists (restart) is kept — an anchor is never recreated under a live link. Serial/ATM/POS ports simply have no anchor.
+* **Life** — a kernel link opens the anchor in the hypervisor (`nio create_tap`), binds it to the slot/port and enslaves it into the per-link bridge; removing the link unbinds the port, deletes the hypervisor's TAP NIO (releasing the fd) and tears the anchor out of the bridge. A link bound while the router is **stopped** is stored and wired by the next start (deferred wiring — the NIO simply waits in the slot adapter).
+* **Death (node close)** — the fd holder releases before the devices are deleted, the order every node type uses: here the Dynamips hypervisor (not uBridge) holds the anchor fds, so its TAP NIOs go first (`nio delete` closes the fd), then `tap delete` per anchor, then the per-link kernel bridges, then uBridge itself.
+
+**A stop is not a close.** `stop` only halts the emulated router: the hypervisor process — and with it the TAP fds, the port bindings and the per-link bridges — survives `vm stop` (the same reason a relay link's tunnel always survived a stop). Links therefore keep working through a stop/start with no re-wiring at all; the restart's attach pass skips every port that still has its TAP NIO. If the hypervisor died mid-flight (crash or kill), the persistent TAPs remain, but the node cannot simply be started again — `start()` speaks to the dead hypervisor and nothing respawns it; recovery is re-creating the node (a project reopen does it), where the stale anchors are swept and rebuilt and the controller re-posts the link NIOs.
+
+## Link operations
+
+| Operation | Kernel-datapath action |
+|---|---|
+| Create / attach | `nio create_tap` (hypervisor holds the fd) + `vm slot_add_nio_binding` + `brctl create` + `brctl addif` + carrier up + markers + `tc netem set` |
+| Delete / detach | marker teardown → `tc reset` → carrier off → `brctl delif`/`brctl delete` → unbind + `nio delete` (release the fd) |
+| Update (filters/markers/suspend changed) | reconcile on the anchor only — no re-binding, no re-enslaving |
+| Suspend | anchor admin-down (traffic dead both ways) + resume restores |
+| Adapter hot-add (OIR) | anchors for the new adapter's Ethernet ports created at once, so a kernel link can attach immediately |
+
+## Capture, markers, filters
+
+Capture on a kernel link is `capture start_kernel `; markers ride the base reconcile with the anchor as the location (`marker add_kernel` — the shared KernelDatapathMixin machinery, no Dynamips-specific code); filters are one tc netem qdisc per anchor plus cls_bpf/eBPF drops. A relay port keeps its Dynamips NIO and the node's uBridge relay bridge (`DYNAMIPS-*`) with userspace filters, exactly as before.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at `brctl addif` time (Linux 4.15+, best-effort on older kernels). The bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2, so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in `br_handle_frame()` drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by `tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest matrix); the contract and kernel references live in `docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+## Relay fallback
+
+A uBridge without the tap module keeps the node relay-only: `_prepare_tap_datapath` warns, no anchors are created, the warning says the node cannot carry kernel links, and every link keeps its `nio create_udp` tunnel. The controller never offers such a node a kernel link (capability unreported).
+
+## Verified
+
+Unit level (`tests/compute/dynamips/test_dynamips_kernel_datapath.py`): anchor topology (Ethernet models and WIC-1ENET port numbering only), the lifecycle (create/sweep/skip-existing), deferred wiring of NIOs bound while stopped, attach/update/remove command shapes on both datapaths, the idempotent re-attach across a restart, the stop order (hypervisor NIO delete before tap delete before brctl delete), capture and marker shapes, hot-added adapters, and the manager's `nio_bridge` construction. Plus the controller-eligibility cases (mixable with docker/qemu/iou, tap capability required, serial excluded) in `tests/controller/test_kernel_datapath_link.py`.
+
+End-to-end (`tests/e2e/test_dynamips_kernel_datapath.py`, pytest marker `e2e`) on a live server with **two real c7200 routers on a real IOS image**, driven through the REST API and the IOS consoles with real ICMP:
+
+* a link created while both routers are stopped selects the kernel datapath and is wired by node start (deferred wiring);
+* the per-link kernel bridge on the host has exactly the two anchor TAPs enslaved; `show ip int brief` is up/up and ping R1→R2 succeeds;
+* `delay 100` ⇒ netem qdisc visible on both anchors and the ping RTT grows by ~200 ms (one-way 100 per direction, each anchor's egress chain impairing the direction entering its own router); clearing resets the qdiscs and the RTT;
+* suspend ⇒ anchor administratively down, ping 0%; resume restores both;
+* capture writes a pcap full of ICMP-over-Ethernet records;
+* a **serial** link between the same two routers stays on the relay (per-port exclusion) and pings over it — the relay regression net;
+* link delete/re-create: the bridge goes and comes back, the anchors survive, traffic resumes;
+* router stop/start keeps the wiring intact (bridge membership unchanged — the no-duplicate-NIO half is the unit-tested idempotent re-attach) and traffic resumes after the reboot;
+* deleting the project removes every anchor and bridge.
+
+`test_dynamips_relay_control` is the negative control: the same topology on an isolated relay-configured instance (`enable_kernel_datapath = false`) has no per-link bridge and nothing enslaved, and still pings — so the kernel objects above can only come from the kernel datapath.
+
+## Configuration
+
+```ini
+[Server]
+# Wire eligible links (same compute, both endpoints Ethernet and able to
+# anchor) through kernel interfaces instead of the uBridge UDP relay.
+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`) 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
new file mode 100644
index 000000000..3a844bacc
--- /dev/null
+++ b/docs/features/ethernet-switch-kernel-datapath.md
@@ -0,0 +1,220 @@
+
+
+# Ethernet switch kernel datapath
+
+The builtin Ethernet switch has been a real Linux kernel bridge since its
+brctl migration: one bridge per switch node, every port a persistent TAP
+enslaved to it (see `ethernet-switch-ubridge-brctl-migration` design notes
+in `gns3server/compute/builtin/nodes/ethernet_switch.py`). What remained on
+the userspace path was the *cable*: each port's TAP doubled as a uBridge
+relay endpoint, and a link to another node crossed
+`peer anchor/UDP tunnel → uBridge relay ↔ port TAP → bridge` — two extra
+copies of every frame.
+
+The kernel datapath removes the cable's userspace leg: the switch absorbs
+the **peer's anchor directly into its own bridge**. The peer's veth host
+end / TAP becomes the switch port; frames cross
+`peer anchor → switch bridge → other switch ports` entirely in the kernel.
+
+```
+ relay (before): peer ──UDP── ubridge[nio_udp↔nio_tap] ──tap── kernel bridge
+ kernel (now): peer anchor ──────────────────────────► kernel bridge
+ (the peer's own interface IS the switch port)
+```
+
+## Datapath selection
+
+A link with an `ethernet_switch` endpoint rides the fast path when the
+**peer** can anchor (same compute; Docker always — including IOL runner
+containers, per their own capability gate — QEMU/IOU/Dynamips per
+their capabilities) — the switch side needs no anchoring capability of its
+own, its bridge and brctl exist by construction. Exclusions, all staying
+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 (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
+`gns3{id}-N` devices) are created *implicitly* by uBridge's
+`bridge add_nio_tap` — a name that is free is minted as a transient TAP,
+and uBridge hardens it L2-only at creation (the fifth creator of
+`docs/design/ubridge-l2-anchor-spec.md` §B, uBridge `fb72758`). The server
+issues nothing extra: without that hardening the device would take a
+kernel-default IPv6 identity whose DAD/MLD noise floods the segment, which
+is exactly what the e2e's relay-control §E.1 assertion guards against.
+
+## The wire format: one NIO names the other end's interface
+
+```
+ controller ──► switch compute: nio_anchor { anchor: "gv00010203e0p0", filters, markers }
+ controller ──► peer compute: nio_bridge { bridge: null, filters: {}, markers }
+```
+
+* `nio_anchor` (new, `NIOAnchor`) is the mirror of `nio_bridge`: "enslave
+ *this* interface into *my own* bridge". The switch joins the anchor and
+ applies the port's VLAN mode to it (`brctl addif` + the access/dot1q/
+ qinq composition), exactly as it programs its own port TAPs.
+* `nio_bridge` with `bridge: null` on the peer end means "externally
+ bridged": the peer keeps everything its node type does around the anchor
+ (Dynamips opens the TAP in the hypervisor, IOU binds the fabric to it,
+ QEMU's netdev, Docker's carrier) and applies markers/impairments, but
+ never touches bridge membership — including on detach, where deleting a
+ bridge it does not own would take the switch down.
+
+The anchor's **name** is the shared naming contract
+(`gns3server/utils/kernel_anchor.py`): `g{node_id[:8]}e{adapter}p{port}`,
+the same function the four node types create their anchors with. It is a
+pure function of (node type, node id, adapter, port) — valid whether or
+not the peer is running, which is what makes links to stopped nodes
+wireable at all.
+
+**Ownership.** The switch owns everything about the anchor's bridge state
+(membership, VLANs). The link's *link state* — capture, markers, tc
+impairments, suspend — is attached to the same interface by exactly one
+end: filters and capture go to the switch (the controller assigns them
+single-sided; both ends would hit one interface twice), suspend rides both
+(same admin state either way), markers follow the usual capture-node
+routing and work from either end.
+
+## Stopped peers: deferred join and the re-push
+
+The peer's anchor exists only while the peer runs. A link created against
+a stopped peer cannot be joined immediately, so the switch **defers**: it
+remembers the anchor and touches nothing (the link must not fail — this is
+the normal case when a project reopens while nodes are still starting).
+
+The controller completes the wiring: after any node of a link reaches the
+started state (`Node.start` → `UDPLink.node_started`), a switch link's NIO
+is re-pushed to the switch, which joins the anchor now. The same re-join
+runs when a peer restart *replaced* its interface under the same name
+(QEMU recreates its TAPs at every start): the membership is checked against
+the kernel (`/sys/class/net//brif/`), not assumed.
+
+Port setting changes (`ports_mapping` update) edit VLAN membership **in
+place**: only the ports whose mode/VLAN actually changed are touched, and
+each change deletes the previous mode's entries the new mode does not want
+and adds the new mode's (the dot1q admits-all entry is one range operation
+in both directions). A port never leaves the bridge to change its VLANs —
+no traffic gap, no FDB flush, and nothing that detaches an interface this
+switch does not own.
+
+This replaced an earlier "reset by re-enslaving" shortcut (`delif` +
+`addif`, so the kernel would hand the port a fresh default membership —
+used because a mode change must drop stale entries, e.g. dot1q's all-VIDs
+range). On the switch's own relay TAPs it merely cost a brief traffic gap;
+on **absorbed anchors** re-enslaving an interface owned by another node
+proved to be where links died: live testing showed an anchor occasionally
+losing its bridge membership right after a `ports_mapping` update (later
+updates then failed `delif` with EINVAL), reproducibly with qemu, IOU and
+Dynamips peers, never with Docker's veths. That failure was root-caused
+later: it is NetworkManager releasing NM-managed TAPs from their bridge
+tens of milliseconds after (re)enslavement — veths are not NM-managed,
+which is exactly why only TAP peers showed it (the mechanics and the
+`nmcli device set managed no` remedy are in the L2-anchor spec). The
+in-place reconcile removes this particular re-enslavement, but link delete
++ recreate and peer restarts still re-enslave the anchor, so the hazard
+remains on NM hosts wherever NM manages the taps; the kernel e2e scenarios
+take the anchors out of NM's hands
+(`harness.unmanage_from_networkmanager`) before they exercise those paths.
+The full record — forensic signatures, the minimal server-free reproducer,
+the NetworkManager source-level mechanism and the verified host fix — is in
+`docs/bugs/networkmanager-tap-release.md`.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does
+not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by
+default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while
+the UDP relay carries them fine. uBridge therefore opens every port's
+per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at
+`brctl addif` time (Linux 4.15+, best-effort on older kernels). The
+bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2,
+so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and
+LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in
+`br_handle_frame()` drops it unconditionally and no mask can enable it.
+That is a documented limit, not a regression (PFC's hardware semantics are
+out of emulation's reach anyway); a lab that needs PAUSE frames on the wire
+must use a relay link. Verified live by
+`tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest
+matrix); the contract and kernel references live in
+`docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+The switch node's own bridge ports carry the same mask: the builtin
+Ethernet switch is a transparent L2 segment in GNS3, not a protocol
+endpoint, so LACP/LLDP between two peers crosses it (a physical switch
+would terminate them itself).
+
+## Verified
+
+Unit level (all under `tests/`): the switch's kernel-port lifecycle (join
+with VLAN composition, deferred join, re-join, detach with marker/tc/
+carrier teardown, capture on the anchor, batch acceptance for project
+open); the in-place VLAN reconcile (every mode transition, untouched ports
+emit nothing, relay TAPs and absorbed anchors alike); the shared naming
+contract; the mixin's externally-bridged branch (no bridge membership work
+in attach/remove, the bridge sweep never deletes a bridge the node does not
+own); the controller's eligibility table, wire format (anchor name,
+single-sided filters, endpoint-order alignment), `kernel_datapath`
+reporting and the node-start re-push.
+
+Live end-to-end on a real server (`tests/e2e/test_ethernet_switch_kernel_datapath.py`
+plus scripted reproduction rounds over the REST API):
+
+* two qemu peers (the previously 2-out-of-2 failing scenario): deferred
+ links → start → both anchors joined; four `ports_mapping` cycles
+ (10/20 ↔ 10/10) with both anchors in the bridge throughout, no failed
+ update, kernel VLAN state correct at every step;
+* a mixed switch (2 × IOU + 2 × Dynamips on one bridge, four kernel
+ ports): four anchors stable across updates that changed all ports and
+ across updates that changed only two;
+* Docker peers with real ICMP: isolating VLANs cuts the ping with **both
+ anchors still enslaved** (the filter does the cutting, not a broken
+ link); restoring the VLAN restores the ping;
+* VPCS peers (the relay path, same switch-side mechanics as cloud): the
+ relay TAPs likewise stay enslaved across VLAN changes, with a real ping
+ proving isolation and recovery.
+
+The cascade has its own in-repo e2e
+(`tests/e2e/test_ethernet_switch_cascade.py`): two switches joined by the
+link-owned veth pair, two c7200 routers per switch, real ICMP crossing both
+bridges — the pair is born UP with exactly one end per bridge, a dot1q
+trunk cascade carries two VLANs at once, the §E.2 idle-silence window with
+all four router interfaces shut (the absorbed anchors, both cascade ends
+and both switch bridges), the symmetric access/trunk mode flip on the
+cascade ends isolates and revives a VLAN in place, a `delay` filter lands
+as netem on both ends (one per direction, RTT ≈ 2×delay), suspend
+admin-downs both ends, and a link delete/re-create retires the whole pair
+and mints a new one under the new link id — with zero host residue after
+the project goes.
+
+The host-side checks throughout are the kernel's own view: bridge
+membership under `/sys/class/net//brif/`, the `master` link
+attribute, `bridge vlan show`, and live pings where the peer allows it.
+
+## Configuration
+
+Same switch as every node type:
+
+```ini
+[Server]
+enable_kernel_datapath = True
+```
+
+## Roadmap
+
+Switch-to-switch links (a veth pair joining the two kernel bridges, with
+each side's port mode applied at its own end) have landed (see above), and
+so has the IOL runner container (`iol-docker-kernel-datapath.md` — its
+`iol_docker` anchors absorb like any peer's). Cross-compute kernel links
+need VXLAN/GENEVE encapsulation, deferred until every node type is
+kernelized.
diff --git a/docs/features/iol-docker-kernel-datapath.md b/docs/features/iol-docker-kernel-datapath.md
new file mode 100644
index 000000000..6a9310d4e
--- /dev/null
+++ b/docs/features/iol-docker-kernel-datapath.md
@@ -0,0 +1,277 @@
+
+
+> 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.
+
+# IOL runner container kernel datapath
+
+## Overview
+
+IOL runner containers (`IOLDockerVM`, selected by the `GNS3_IOL_RUNNER` environment marker — the Cisco CML `iol-runner` images such as `iol-xe/iol-xe:17-18-02`) have no container-namespace networking at all: their guest leg is the runner's per-interface AF_UNIX datagram socket pairs. A unix socket is not a kernel interface, so until now every link of such a container rode the uBridge userspace relay (unix ↔ UDP).
+
+The kernel datapath gives every Ethernet bay/unit a **persistent TAP anchor** (`gx` names, born at node start exactly like IOU's) and wires the *link segment* through the kernel. One userspace hop on the container leg is irreducible — the socket pair is the only physical layer the runner exposes — but the anchor → per-link bridge → peer anchor crossing, the kernel filters, the suspend carrier and the AF_PACKET capture/markers are all native now.
+
+The port bridge is the deployment shape ubridge's `doc/gns3server-integration.md` § *bridge — generic NIO relay with a swappable TAP leg* froze: one bridge per bay/unit, all created at start, the unix NIO in the first slot for the node's whole life, the topology leg in the second — `add_nio_udp` (relay) or `add_nio_tap` (kernel link) — swapped through stop → delete → add → start whenever a link is deleted, re-created or switched between datapaths. Suspend swaps no leg (an in-place NIO update), and a project reopen is a fresh build from the NIO, not a swap. The c-socket binding never re-binds across a swap: frames the container sends while the bridge is stopped queue on the socket and relay after `start` (regression-tested in ubridge's bridge suite).
+
+## Architecture
+
+Kernel link between two IOL runner containers on the same compute. Interface names follow the shared deterministic rules — `gx{node_id[:8]}e{bay}p{unit}` for the persistent TAP anchors (one per Ethernet bay/unit) and `gns3{link_id[:11]}` for the bridge; the concrete names in the diagrams use node ids `1a2b3c4d` / `99aa88bb`, Ethernet0/0 on both, and link id prefix `9a8b7c6d5e4`. Terms the diagrams use:
+
+| Term | Meaning |
+|---|---|
+| netiomux | the runner's network agent: one AF_UNIX datagram socket pair per interface, carrying raw Ethernet frames |
+| `sNN.sock` / `cNN.sock` | the interface's socket pair (`NN = bay×4 + unit`): frames sent to `s` are injected into the guest, the guest's egress arrives on `c` |
+| wiring dir | the host-side directory bind-mounted at the container's `/tmp` (`$XDG_RUNTIME_DIR/gns3/unixio/`) — the same socket files on both sides, short enough for the 107-byte `sun_path` cap |
+| uBridge | the per-node helper process gns3-server spawns; its hypervisor command surface executes all host-side wiring (anchors, bridges, carrier, tc, taps) — and, unique to this node type, it also carries frames: the socket guest leg has no kernel form |
+| port bridge | the per-bay/unit uBridge bridge (`bridge{bay}` on unit 0, `bridge{bay}_{unit}` on units 1-3 — the historical `_bridge_name` naming): `nio_unix` in its first slot for the node's whole life, the topology leg — `nio_tap` (kernel link) or `nio_udp` (relay) — in the second |
+| anchor | the persistent TAP `gx…` a link attaches to: born at node start, DOWN, its fd held by the port bridge while a kernel link binds the port |
+| per-link bridge | the Linux bridge `gns3{link_id[:11]}` that stands in for the cable; the two anchors are its only ports |
+| bridge port | an interface enslaved to a Linux bridge (`brctl addif`) — it no longer sends or receives on its own: frames arriving on it go to the bridge's forwarding logic, and the bridge transmits through it |
+| FDB | the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded |
+| `01:80:c2` / `group_fwd_mask` | the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's `group_fwd_mask` — `0xfffd` passes everything except PAUSE/PFC |
+| tc / qdisc | traffic-control objects the kernel inserts into the anchor's egress path — stages frames pass through in flight, not external packet consumers; uBridge only installs them over netlink |
+| `clsact` | the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run |
+| eBPF classifier | the stateful drop modes (every-Nth, quota, time window) at clsact egress prio 1 |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| netem | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| `AF_PACKET tap` | uBridge's packet socket bound to the anchor; the kernel clones every frame the interface sends or receives to it (the tcpdump mechanism) — read-only copies for capture and markers, the original stays on the kernel path |
+| carrier | the anchor's admin state (`link set up/down`); suspend is carrier down on both anchors = 100 % loss |
+| `nio_bridge` | the NIO record the controller sends each compute: bridge name, filters, markers, suspend flag — the bridge name is None when an Ethernet-switch peer absorbs the anchor into its own kernel bridge (then no per-link bridge exists on this side) |
+
+```mermaid
+flowchart TB
+ subgraph NetA["container A netns (iol-runner)"]
+ IOLA["IOL process"]
+ MUXA["netiomux"]
+ IOLA <-->|"Eth0/0"| MUXA
+ end
+ subgraph RootNS["root namespace"]
+ subgraph UBA["uBridge A process"]
+ PBA["port bridge A"]
+ end
+ gxA["gx1a2b3c4de0p0 — anchor"]
+ subgraph LBR["per-link Linux bridge"]
+ BR["gns39a8b7c6d5e4"]
+ end
+ gxB["gx99aa88bbe0p0 — anchor"]
+ subgraph UBB["uBridge B process"]
+ PBB["port bridge B"]
+ end
+ end
+ subgraph NetB["container B netns (iol-runner)"]
+ MUXB["netiomux"]
+ IOLB["IOL process"]
+ MUXB <-->|"Eth0/0"| IOLB
+ end
+ MUXA <-->|"nio_unix"| PBA
+ PBA <-->|"nio_tap"| gxA
+ gxA <-->|"bridge port"| BR
+ BR <-->|"bridge port"| gxB
+ gxB <-->|"nio_tap"| PBB
+ PBB <-->|"nio_unix"| MUXB
+ PBA -. "qdisc install" .-> gxA
+ gxA -. "frame copies" .-> PBA
+ PBB -. "qdisc install" .-> gxB
+ gxB -. "frame copies" .-> PBB
+```
+
+Everything in the root namespace is built the same way: gns3-server creates the anchor TAPs at node start, the per-link bridge and its memberships at link creation, and every later change (carrier, capture, markers, filters) by sending hypervisor commands to the node's uBridge process. That build flow is the Link operations sequence below; this diagram is the steady state that remains.
+
+The solid chain is the frame path, and it passes through the port bridges — the one userspace hop per container leg the socket pair forces, and the only one on the whole path; unlike the Docker veth datapath, where uBridge sits entirely off the forwarding path, here it is on it. The dashed edges are control and observation: `qdisc install` is the port bridge configuring the kernel over netlink (the kernel then runs those qdiscs in-flight on the anchor's egress — stages of the transmit path, not packet consumers), and `frame copies` is the AF_PACKET tap — read-only clones for capture and markers. Everything sideband is keyed on the anchor's interface name, which is why a TAP anchor and a veth host end are interchangeable in the shared mixin (`gns3server/compute/kernel_datapath.py`).
+
+Relay link (what a missing capability, a cross-compute peer or `enable_kernel_datapath = off` falls back to — same sockets, same port bridge, only the topology leg changes):
+
+```mermaid
+flowchart LR
+ subgraph CTA["container A (iol-runner)"]
+ MUXA["netiomux A"]
+ end
+ subgraph KRN["kernel — sockets & loopback"]
+ SOCKA["c00.sock"]
+ UDP["UDP sockets (loopback)"]
+ SOCKB["s00.sock"]
+ ANCH["gx anchors — idle"]
+ end
+ subgraph UBA["uBridge A — userspace"]
+ UNA["unix nio"]
+ ENA["port bridge relay + userspace filters"]
+ NDA["udp nio"]
+ end
+ subgraph UBB["uBridge B — userspace"]
+ NDB["udp nio"]
+ ENB["port bridge relay + userspace filters"]
+ UNB["unix nio"]
+ end
+ subgraph CTB["container B (iol-runner)"]
+ MUXB["netiomux B"]
+ end
+ MUXA -->|"copy 1: user→kernel"| SOCKA
+ SOCKA -->|"copy 2: kernel→user"| UNA
+ UNA --> ENA --> NDA
+ NDA -->|"copy 3: user→kernel"| UDP
+ UDP -->|"copy 4: kernel→user"| NDB
+ NDB --> ENB --> UNB
+ UNB -->|"copy 5: user→kernel"| SOCKB
+ SOCKB -->|"copy 6: kernel→user"| MUXB
+```
+
+Drawn one-way; the reverse direction is the mirror image. The IOL relay crosses the kernel/user boundary **six times each way** — but four of them are the socket guest leg itself, paid identically on the kernel datapath (six there too): unlike Docker's four-against-zero, the crossing count is datapath-invariant here, and the choice swaps only the middle two — loopback UDP between the port bridges (filters userspace, at them) for the per-link bridge between the anchors (filters as tc, on them).
+
+Two further contrasts with Docker's relay: the IOL relay's local leg is the unix NIO itself, so the anchors never appear on the path (idle from birth — a Docker relay attaches to the *same* veth host end the kernel link enslaves); and the port bridge's first slot is shared with the kernel datapath — the unix NIO and its c-socket binding survive every leg swap, so frames the container sends while the bridge is stopped for a swap queue on the socket and relay after `start`. On this datapath suspend rides the synthetic frequency_drop filter (the kernel datapath's suspend is the anchor's admin state).
+
+Frame path for one A → B frame, with the relay diagram's copy numbering: copies 1, 2, 5 and 6 are the socket guest legs and are paid identically on both datapaths — only the middle two swap (TAP fd write/read here, loopback UDP there). The impairment chain fires on the egress of the *destination* anchor, in order clsact → netem (a classifier drop means netem never sees the frame); each direction is impaired exactly once and B → A is symmetric on gxA's egress chain — `delay 100` is therefore collected once per direction, ~200 ms on the RTT (measured ≥ 150 ms):
+
+```mermaid
+flowchart LR
+ subgraph CTA["IOL A container — userspace"]
+ S1["IOL A sends on Eth0/0"]
+ end
+ subgraph KERN["kernel"]
+ SOCKA["c00.sock"]
+ SOCKB["s00.sock"]
+ end
+ subgraph UBA["uBridge port bridge A — userspace"]
+ S2["relay"]
+ end
+ subgraph GXA["gxA — ingress (egress chain serves B→A)"]
+ S4["ingress"]
+ end
+ subgraph BRP["per-link bridge"]
+ G1{"reserved 01:80:c2?"}
+ D["dropped"]
+ S5["FDB lookup / flood"]
+ end
+ subgraph GXB["gxB — egress chain (clsact, then netem)"]
+ S6["eBPF prio 1"]
+ S7["bpf_drop prio 10-99"]
+ S8["netem qdisc"]
+ end
+ subgraph UBB["uBridge port bridge B — userspace"]
+ S9["relay"]
+ end
+ subgraph CTB["IOL B container — userspace"]
+ S11["IOL B receives on Eth0/0"]
+ end
+ tap["capture / markers"]
+ S1 -->|"copy 1: user→kernel"| SOCKA
+ SOCKA -->|"copy 2: kernel→user"| S2
+ S2 -->|"copy 3: user→kernel"| S4
+ S4 --> G1
+ G1 -->|"PAUSE 0x01"| D
+ G1 -->|"0xfffd passes"| S5
+ S5 --> S6
+ S6 --> S7 --> S8
+ S8 -->|"copy 4: kernel→user"| S9
+ S9 -->|"copy 5: user→kernel"| SOCKB
+ SOCKB -->|"copy 6: kernel→user"| S11
+ S4 -.->|"AF_PACKET tap"| tap
+ S8 -.->|"AF_PACKET tap"| tap
+```
+
+The reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Capture, markers, filters below). The AF_PACKET taps observe both anchors but never a clsact-dropped frame.
+
+## Datapath selection
+
+`UDPLink._kernel_datapath_eligible` treats an IOL runner container like any Docker node (same compute, Ethernet port) with one gate of its own in `_kernel_endpoint_ready`: the compute must report both
+
+* `ubridge_tap` — the tap module (`tap create`/`tap delete`), the same capability QEMU and Dynamips gate on, and
+* `ubridge_bridge_tap` — `bridge delete_nio_tap`, the swappable-leg command (probed with one command against a bridge that cannot exist: 202 = old build, 214 = new; no scratch objects, no capabilities, cached by binary identity like every other probe).
+
+Both strict `True`; anything unreported (old uBridge, failed probe) keeps the link on the relay, where it always works. The generic `GNS3_UNIX_SOCKET_NIO` containers stay excluded as before — that marker's semantics are image-specific, while `GNS3_IOL_RUNNER` selects a class whose anchors this server owns.
+
+## Anchor lifecycle
+
+* **Birth (node start, before any link)** — `_start_ubridge` runs `_prepare_tap_datapath` right after the hypervisor connects: probe both capabilities, then for every (bay, unit 0-3) sweep a leftover (`tap delete`, suppressed), `tap create` (hardened, starts DOWN) and `link set down` — carrier off until a link attaches. The anchors exist whether or not any link ever uses them: an anchor born with its link would leave the Ethernet switch fast path's deferred join waiting forever. No `tap set_owner`: uBridge itself holds these fds.
+* **Life** — the anchor is never recreated. Link create/delete/switch, suspend, capture and markers only change what is attached to it, and the ensure-then-add contract guards the one window where the device could have been swept (`/sys/class/net` existence check — `tap create` is strictly create-only, `IFF_TUN_EXCL`, and refuses an existing device with EBUSY).
+* **Death (node stop)** — the port bridges hold the anchor fds (`bridge add_nio_tap`), so they are deleted first (`bridge delete` stops their relay threads and frees the NIOs), then the anchors themselves (`tap delete` answers EBADFD on a device another fd still holds), then the per-link kernel bridges — all while the control channel lives, before the hypervisor stops. A restart recreates everything from the NIO.
+
+## Link operations
+
+Both endpoints attach concurrently and compute the same bridge name independently (a pure function of the link id), so either may win the `brctl create` race — the loser verifies the bridge exists (`brctl show`) and continues:
+
+```mermaid
+sequenceDiagram
+ participant API as REST API
+ participant C as Controller
+ participant CA as IOL node A
+ participant CB as IOL node B
+ participant UA as uBridge A
+ participant UB as uBridge B
+
+ API->>C: POST /links
+ C->>C: eligibility check
+ C->>C: bridge = gns3 + link_id[:11]
+ C--)CA: nio_bridge
+ C--)CB: nio_bridge (same bridge)
+ par IOL node A
+ CA->>UA: ensure port bridge
+ CA->>UA: ensure anchor
+ CA->>UA: bridge add_nio_tap
+ CA->>UA: brctl create
+ CA->>UA: link set bridge up
+ CA->>UA: brctl addif gxA
+ CA->>UA: port fwd_mask 0xfffd
+ CA->>UA: markers / capture if any
+ CA->>UA: tc netem + bpf + eBPF
+ CA->>UA: bridge start
+ CA->>UA: link set gxA up
+ and IOL node B
+ CB->>UB: same on gxB
+ end
+ Note over UA,UB: steady state — kernel forwards between the anchors, port bridges relay to the containers
+```
+
+Every arrow to uBridge is one `_ubridge_send` command line on the node's local hypervisor console; uBridge executes it (netlink/ioctl) and replies OK or an error synchronously before the next command is sent — those replies are omitted for brevity. uBridge never initiates anything: it is a pure executor, and the only data ever flowing back (capture/marker frame copies) rides separate AF_PACKET sockets, not this console.
+
+The attach above is `_attach_kernel_link`; its ensures are the frozen ensure-then-add contract: the port bridge (`bridge create` + `add_nio_unix`) comes from the node's start loop or is created here, and `tap create` runs only when `/sys/class/net` shows the anchor missing — it is strictly create-only (`IFF_TUN_EXCL`) and answers EBUSY on the device the start loop just made. `bridge add_nio_tap` is what opens and holds the anchor fd, `bridge start` starts the [unix ↔ tap] relay, and the final `link set up` refines the anchor's admin state to the NIO's suspend flag — `brctl addif` has already brought the port up (addif auto-UPs it along with its fwd_mask), so on an unsuspended link the command changes nothing, and a suspended one is what pushes the anchor back down.
+
+* **Link delete** (`_release_port_tap`): `bridge stop` (delete_nio_tap refuses while running — the relay threads hold the NIO pointers for their whole life; freeing under them is a use-after-free) → `bridge delete_nio_tap ""` (matched on the kernel-resolved name) → the mixin teardown (markers, tc reset, `brctl delif`, per-link bridge deletion). The anchor survives — the node owns it, not the link.
+* **Relay link** — unchanged: the port bridge's second leg is `add_nio_udp`, userspace filters at the port bridge. The unix NIO stays in its slot across every datapath switch.
+
+## Capture, markers, filters
+
+Inherited from `KernelDatapathMixin` unchanged — everything is keyed on the anchor interface name, which is why a TAP and a veth host end are interchangeable there: `capture start_kernel` / `marker add_kernel` (AF_PACKET), one tc netem qdisc (the full netem surface plus the extensions), `bpf` as cls_bpf match-drop, `frequency_drop`/`quota`/`window_drop` as the eBPF stateful classifier — all capability-gated per compute exactly like the Docker veth datapath (the filter semantics live in `packet-filters.md`).
+
+Suspend semantics: anchor admin-down. The port bridge's TAP writes fail EIO (tolerated by the delivered uBridge hardening) and its reads fall silent — traffic stops both ways. The runner itself is unaware of the link state (netiomux has no carrier signal), exactly as on the relay datapath where suspend rode the synthetic frequency_drop filter instead.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at `brctl addif` time (Linux 4.15+, best-effort on older kernels). The bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2, so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in `br_handle_frame()` drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by `tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest matrix); the contract and kernel references live in `docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+## Relay fallback
+
+Missing either capability, the node runs relay-only: links ride unix ↔ UDP, `kernel_datapath` stays false, and anchors are simply never created (or, with `enable_kernel_datapath` off server-wide, created but never attached — the same semantics as QEMU's and Dynamips' anchors).
+
+## Verified
+
+Live on the real server (`tests/e2e/test_iol_docker_kernel_datapath.py`, isolated instance, real `iol-xe/iol-xe:17-18-02` images, real uBridge with `bridge delete_nio_tap`):
+
+* kernel link reports `kernel_datapath`, anchors exist from node start, the per-link bridge enslaves exactly the two of them;
+* real ICMP crosses the [unix ↔ tap] port-bridge relay (100 % ping);
+* the L2-anchor spec on the anchors: FORWARDING ports always, and on a uBridge with `link l2only` §E.1's pure-L2 checks plus §E.2's silence window with the guests shut — the behavioral half of the hardening on anchors created by uBridge's bridge TAP module (`bridge add_nio_tap`);
+* `delay 100` lands as netem on both anchors, measured RTT grows ≥ 150 ms and returns < 50 ms when cleared;
+* the classifier spot check on a TAP anchor (the Docker suite runs the full matrix on veth host ends), each behind its capability gate: `bpf "icmp"` drops everything (clsact on the anchor, needs cbpf), `frequency_drop 3` as the eBPF every-nth mode (needs the nth token) — round-trip loss asserted 20-90 % around the per-direction theory 1-(2/3)² = 55.6 %, both restoring on clear;
+* suspend admin-downs the anchor and kills the traffic; resume restores;
+* AF_PACKET capture writes a real pcap of the ICMP exchange;
+* link delete/re-create swaps the port bridge's TAP leg out and back in (unix binding never re-binds) with the ping returning;
+* node stop tears the anchors down (no `gx` devices left), a restart recreates the whole wiring from the NIO;
+* project delete leaves no anchors and no bridges; the relay control on a relay-configured instance keeps the link off the kernel and still pings.
+
+Also caught and fixed live: `tap create` is strictly create-only (`IFF_TUN_EXCL`) — the ensure-then-add guard must check existence first (`/sys/class/net`), not re-create unconditionally (EBUSY on the device the start loop just made).
+
+## Configuration
+
+```
+[Server]
+enable_kernel_datapath = True # the global switch (link attachment)
+```
+
+The anchor lifecycle needs no configuration of its own — the capability probes decide per compute, and an IOL runner container without them simply never anchors.
+
+## 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 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/iou-kernel-datapath.md b/docs/features/iou-kernel-datapath.md
new file mode 100644
index 000000000..5e83507b1
--- /dev/null
+++ b/docs/features/iou-kernel-datapath.md
@@ -0,0 +1,264 @@
+
+
+# IOU kernel datapath
+
+IOU's only physical layer is the Unix-datagram IOL fabric (`/tmp/netio/`, 8-byte IOL headers, NETMAP), so it has no host netdev to enslave — a kernel link instead gives each **Ethernet bay/unit** a persistent TAP anchor (`gi{node_id[:8]}e{bay}p{unit}`), and uBridge's `iol_bridge` (the fake IOL instance the NETMAP maps to) binds the port to that TAP with `iol_bridge add_nio_tap`. Links then attach to the anchor exactly as they do for Docker (veth) and QEMU (TAP): a per-link Linux bridge enslaves it, tc impairments run on it, capture and markers anchor on it. The uBridge capability is specified in `docs/design/ubridge-iol-tap-anchor-spec.md` and delivered by the `feature/iol-tap-anchor` branch of the ubridge fork.
+
+Serial bays never anchor (a TAP carries Ethernet frames): serial links stay on the relay datapath whatever the compute's capabilities.
+
+## Architecture
+
+The diagram shows the native shape — two IOU devices linked on the kernel datapath, the scenario the e2e suite drives. A Docker or QEMU peer on the far end is the same link segment with a different anchor (a `gv` veth host end or a `gq` TAP — see `docker-kernel-datapath.md`); each IOU still terminates its own fabric in its own uBridge. Interface names follow the deterministic rules `gi{node_id[:8]}e{bay}p{unit}` for the anchors and `gns3{link_id[:11]}` for the per-link bridge (ids stripped of dashes before the slice); the concrete names in the diagrams use node ids `a1b2c3d4` / `5e6f7a8b` (IOU A/B), link id prefix `5d3c2b1a4e6`, and application ids 1 / 2 (so the fake instances are `IOL-BRIDGE-513` / `IOL-BRIDGE-514`). Terms the diagrams use:
+
+| Term | Meaning |
+|---|---|
+| IOL fabric | the `/tmp/netio/` unix-datagram mailboxes — one directory shared by every IOU node on the host, namespaced by IOL id; every frame carries an 8-byte IOL header |
+| NETMAP | the per-node redirect table pointing a port's fabric traffic at another IOL id — here uBridge's fake instance |
+| `iol_bridge` | uBridge's fake IOL instance — one per node, named `IOL-BRIDGE-{application_id + 512}` (the NETMAP redirects the node's own id to it); one listener thread per bound port, and it holds the anchor TAP fds |
+| anchor | the host-side interface a link attaches to — here the `gi` persistent TAP; a Docker peer's `gv` veth host end or a QEMU `gq` TAP is interchangeable as the other port |
+| persistent TAP | the TAP created per Ethernet bay/unit at node start (`tap create`): born DOWN, address-free, 4 units per bay (as many bays as the node has Ethernet adapters) |
+| per-link bridge | the Linux bridge `gns3{link_id[:11]}` that stands in for the cable; the two anchors are its only ports |
+| tap fd | a TAP's file descriptor: reads return frames the kernel received on the device, writes inject frames into it — on this datapath the `iol_bridge` listener holds it |
+| FDB | the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded |
+| `01:80:c2` / `group_fwd_mask` | the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's `group_fwd_mask` — `0xfffd` passes everything except PAUSE/PFC |
+| `clsact` | the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run |
+| eBPF classifier | the stateful drop modes (every-Nth, quota, time window) at clsact egress prio 1 |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| netem | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| `AF_PACKET tap` | uBridge's packet socket on the anchor — how capture and markers observe frames off the forwarding path |
+| carrier | the anchor's admin state (`link set up/down`); suspend is carrier down — a down TAP answers EIO in both fd directions (writes drop the frame; the §B hardening tolerates both) |
+
+```mermaid
+flowchart LR
+ subgraph IOSPA["IOU A process"]
+ iosA["IOS (IOL binary)"]
+ end
+ subgraph RootNS["root namespace"]
+ subgraph UBA["uBridge A"]
+ brA["IOL-BRIDGE-513"]
+ end
+ giA["gia1b2c3de0p0"]
+ BR["gns35d3c2b1a4e6"]
+ giB["gi5e6f7a8be0p0"]
+ subgraph UBB["uBridge B"]
+ brB["IOL-BRIDGE-514"]
+ end
+ end
+ subgraph IOSPB["IOU B process"]
+ iosB["IOS (IOL binary)"]
+ end
+ iosA <-->|"IOL fabric (NETMAP)"| brA
+ brA <-->|"TAP fd 0/0"| giA
+ giA <-->|"bridge port"| BR
+ BR <-->|"bridge port"| giB
+ giB <-->|"TAP fd 0/0"| brB
+ brB <-->|"IOL fabric (NETMAP)"| iosB
+ brA -.->|"AF_PACKET tap"| giA
+ brA -.->|"tc qdisc"| giA
+ brB -.->|"AF_PACKET tap"| giB
+ brB -.->|"tc qdisc"| giB
+```
+
+Unlike the Docker veth datapath, where uBridge sits off the forwarding path and only taps, here each node's uBridge is **on** it: the `iol_bridge` listener terminates the fabric and relays frames between the mailboxes and the anchor TAP fd. That one userspace hop per leg is **irreducible** — the fabric is Unix sockets, so frames always cross fabric → listener thread → TAP fd. What the kernel datapath buys is the **link segment**: anchor → per-link bridge → peer anchor crosses entirely in the kernel (including IOU ↔ Docker/QEMU links), impairments run as kernel tc instead of the userspace delay line (the known blocking-nanosleep RTT defect), and suspend maps to anchor admin-down (writes to a down TAP fail EIO and are dropped by the delivered §B hardening). Direction semantics on the anchor match QEMU's exactly: uBridge holds the fd like the QEMU process does.
+
+Relay link between the same two nodes (what an old uBridge build, a serial port or `enable_kernel_datapath` off falls back to; a cross-compute peer is the same tunnel with the far socket on the real network instead of loopback): each port rides a UDP NIO inside its `iol_bridge` and no kernel interface exists — anchors untouched, filters/capture/markers on the port's userspace list:
+
+```mermaid
+flowchart LR
+ subgraph IOPA["IOU A process — userspace"]
+ iosA["IOS (IOL binary)"]
+ end
+ subgraph KERN["kernel"]
+ mbxA["netio sockets"]
+ UDP["UDP sockets (loopback)"]
+ mbxB["netio sockets"]
+ end
+ subgraph UBA["uBridge A — userspace"]
+ ioA["IOL nio"]
+ enA["relay + userspace filters"]
+ nuA["UDP nio"]
+ end
+ subgraph UBB["uBridge B — userspace"]
+ nuB["UDP nio"]
+ enB["relay + userspace filters"]
+ ioB["IOL nio"]
+ end
+ subgraph IOPB["IOU B process — userspace"]
+ iosB["IOS (IOL binary)"]
+ end
+ iosA -->|"copy 1: user→kernel"| mbxA
+ mbxA -->|"copy 2: kernel→user"| ioA
+ ioA --> enA --> nuA
+ nuA -->|"copy 3: user→kernel"| UDP
+ UDP -->|"copy 4: kernel→user"| nuB
+ nuB --> enB --> ioB
+ ioB -->|"copy 5: user→kernel"| mbxB
+ mbxB -->|"copy 6: kernel→user"| iosB
+```
+
+Drawn one-way; the reverse direction is the mirror image. A relayed frame crosses the kernel/user boundary **six times each way** (twelve per round trip — each `sendto` copies user→kernel, each `recvfrom` kernel→user), the UDP hop additionally traversing the protocol stack twice; the relay's filters run in userspace on the frames between the copies.
+
+Frame path for one A → B frame (`giA` = `gia1b2c3de0p0`, `giB` = `gi5e6f7a8be0p0`). The controller ships the same filter dict to **both** endpoints, so the link carries **two impairment points** — each anchor's egress chain — but a one-way frame crosses exactly one of them: the destination anchor's, in order clsact → netem (a classifier drop means netem never sees the frame). The asymmetry is the trace's, not the configuration's — both anchors carry the identical chain: the frame enters the kernel at the sender's anchor **ingress** (the TAP-fd write; this datapath installs nothing on ingress), and the bridge's forward out of the far anchor is an **egress** transmission, which is where the tc chain sits. B → A is symmetric on giA. Capture and markers are the observation point: one AF_PACKET tap at whichever end captures, seeing **both** directions through its anchor:
+
+```mermaid
+flowchart LR
+ subgraph IOPA["IOU A process — userspace"]
+ S1["IOS A sends on e0/0"]
+ end
+ subgraph KERN["kernel"]
+ mbxA["netio sockets"]
+ mbxB["netio sockets"]
+ end
+ subgraph UBA["uBridge A — userspace"]
+ S2["relay"]
+ end
+ subgraph GIA["giA — ingress (its egress chain serves B→A)"]
+ S4["ingress"]
+ end
+ subgraph BRP["per-link bridge"]
+ G1{"reserved 01:80:c2?"}
+ X1["dropped"]
+ S5["FDB lookup / flood"]
+ end
+ subgraph GIB["giB — egress chain (clsact, then netem)"]
+ S6["eBPF prio 1"]
+ S7["bpf_drop prio 10-99"]
+ S8["netem qdisc"]
+ end
+ subgraph UBB["uBridge B — userspace"]
+ S9["relay"]
+ end
+ subgraph IOPB["IOU B process — userspace"]
+ S11["IOS B receives on e0/0"]
+ end
+ S1 -->|"copy 1: user→kernel"| mbxA
+ mbxA -->|"copy 2: kernel→user"| S2
+ S2 -->|"copy 3: user→kernel"| S4
+ S4 --> G1
+ G1 -->|"PAUSE 0x01"| X1
+ G1 -->|"0xfffd passes"| S5
+ S5 --> S6
+ S6 --> S7 --> S8
+ S8 -->|"copy 4: kernel→user"| S9
+ S9 -->|"copy 5: user→kernel"| mbxB
+ mbxB -->|"copy 6: kernel→user"| S11
+ tap["capture / markers"]
+ S4 -.->|"AF_PACKET tap"| tap
+ S8 -.->|"AF_PACKET tap"| tap
+```
+
+What each stage drops: the reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Capture, markers, filters below); the AF_PACKET tap — capture and markers ride it, installed at whichever end captures — observes both directions of the link through its anchor (tx/rx signal pairs) but never a clsact-dropped frame. The crossings do not disappear on this datapath: each leg still crosses the kernel/user boundary three times (`sendto`/`recvfrom` on the fabric, then the TAP write — the B leg mirrors them, six per one-way frame, the same count as the relay); what moves into the kernel is the link segment — no UDP tunnel, no protocol-stack pass — and with it the impairment chain. A fabric → anchor → bridge → anchor → fabric round trip measures ≈0.2 ms (Verified below).
+
+## Datapath selection
+
+`UDPLink._kernel_datapath_eligible` extends the Docker/QEMU rules with:
+
+* the compute must report **`ubridge_iol_tap`** (its own capability: the tap module and `iol_bridge add_nio_tap` land independently, and either can be missing on an old build). Probed by running one add/delete cycle on a scratch bridge + scratch TAP (`probe_iol_tap_support`, cached by binary identity, scratch bridge id 1050 — above the per-node id space 513..1024 the fabric locks); any failure means unknown → relay;
+* **both linked ports must be Ethernet** — an IOU serial port disqualifies the kernel path per-port, not per-node, so serial links between capable IOU nodes silently stay on the relay instead of failing at the compute.
+
+The NIO schema on the compute NIO routes accepts `nio_bridge` alongside the UDP/TAP/Ethernet NIOs (the same widening QEMU needed — without it, the controller's NIO POST is 422'd before the node ever sees it).
+
+## Link creation sequence
+
+Both endpoints sit on the **same compute** (a kernel-link prerequisite). The controller derives the bridge name once from the link id and ships one `nio_bridge` record to each endpoint — same bridge name, each side's own filters/markers and the suspend flag — the two POSTs overlapping (`asyncio.gather`, either failure rolls the peer back). The bridge has no owner: both sides race `brctl create` (EEXIST tolerated, the loser verifies via `brctl show`). When both endpoints run, the anchors already exist (born at node start — Anchor lifecycle below), so attaching only binds and enslaves:
+
+```mermaid
+sequenceDiagram
+ participant API as REST API
+ participant C as Controller
+ participant NA as Node A handler
+ participant NB as Node B handler
+ participant UA as uBridge A
+ participant UB as uBridge B
+
+ API->>C: POST /links
+ C->>C: eligibility check
+ C->>C: bridge = gns3 + link_id[:11]
+ par POST nio_bridge to node A
+ C->>NA: nio_bridge (bridge, A's filters/markers, suspend)
+ NA->>UA: iol_bridge add_nio_tap
+ NA->>UA: brctl create
+ NA->>UA: link set bridge up
+ NA->>UA: brctl addif giA
+ NA->>UA: capture / markers if any
+ NA->>UA: tc netem + bpf + eBPF
+ NA->>UA: link set giA up
+ and POST nio_bridge to node B
+ C->>NB: nio_bridge (same bridge, B's filters/markers)
+ NB->>UB: same on giB
+ end
+ Note over UA,UB: steady state — kernel forwards between the anchors, iol_bridge relays to the IOU processes
+```
+
+`iol_bridge add_nio_tap ""` is the one IOU-specific step: it binds the fabric port to the anchor and hands uBridge the TAP fd. Deletion reverses it in miniature — markers, tc reset, delif, `brctl delete`, then `iol_bridge delete_nio_tap` releases the fd while the control channel lives (Link operations below).
+
+A stopped endpoint defers its whole side of the flow — the common case, since a project is typically opened with its links already drawn. With no uBridge up, the NIO POST lands as bookkeeping only (the duplicate-port guard still applies), and the node's start replays this same attach from the recorded NIO once the anchors exist (`_networking` → `_attach_kernel_nio`), capture and filters included — which is also how a link survives its node's stop/start.
+
+## Anchor lifecycle — and why the fd holder must let go first
+
+* **Birth (node start)** — `_prepare_tap_datapath` probes the capability and creates one persistent TAP per Ethernet bay/unit (4 units per bay, swept for stale leftovers first, born DOWN). uBridge holds a device's fd only while a kernel link binds the port; until then the TAP has no holder.
+* **Life** — link churn never recreates the TAP. A kernel link adds `iol_bridge add_nio_tap ` then enslaves the anchor (`_kernel_attach`); a relay link keeps `iol_bridge add_nio_udp` and never touches the anchor. Switching a port between datapaths is just an NIO swap — the IOL port frees its previous NIO (fd or socket) on attach.
+* **Death (node stop)** — the fd holder must let go before the devices are deleted. Here the holder is uBridge itself (on QEMU it is the VM's own process), so the uBridge side retires first: `iol_bridge delete` (releases every port's TAP fd) → `tap delete` per anchor (a held TAP answers EBADFD/207) → `brctl delete` for per-link bridges still held → stop uBridge. The IOU process is stopped last, holding no anchor fd — the reverse of QEMU's process-first sequence. Removing a single kernel link does the same in miniature: `_remove_kernel_nio` (markers, tc reset, delif, brctl delete) then `iol_bridge delete_nio_tap` — releasing the fd while the control channel lives.
+
+## Link operations
+
+| Operation | Kernel-datapath action |
+|---|---|
+| Create / attach | `iol_bridge add_nio_tap` (bind port ↔ anchor, uBridge holds the fd) + `brctl create` (EEXIST tolerated) + `link set up` + `brctl addif` + capture/markers + `tc netem set` + carrier up |
+| Delete / detach | marker teardown → `tc reset ` → carrier off → `brctl delif`/`brctl delete` (last endpoint wins) → `iol_bridge delete_nio_tap` (release the fd; the device survives) |
+| Update (filters/markers changed) | reconcile on the anchor only — no re-binding, no re-enslaving |
+| Suspend | anchor admin-down (TAP-fd writes fail EIO → dropped; reads answer EIO too — the §B hardening tolerates both) + resume restores |
+
+## Capture, markers, filters
+
+Capture on a kernel link is `capture start_kernel ` (driven by the NIO type); markers ride the base reconcile with the anchor as the location — IOU's marker primitives are polymorphic (`marker add_kernel/enable_kernel/delete_kernel` on an anchor, `iol_bridge … mark` on an IOL location), which let the old IOU-specific reconcile override be deleted outright. Filters are one tc netem qdisc per anchor plus cls_bpf/eBPF drops — the port's userspace `iol_bridge add_packet_filter` filters carry relay links only and are never combined with tc on the same port.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at `brctl addif` time (Linux 4.15+, best-effort on older kernels) — delivered by the fork's `feature/bridge-tap-l2only` branch (which also carries the `iol_bridge add_nio_tap` anchor work; a build from an earlier branch point leaves ports at the kernel default). The bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2, so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in `br_handle_frame()` drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by `tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest matrix, driven on the Docker datapath — the same per-link-bridge mechanism; the IOU e2e does not repeat it); the contract and kernel references live in `docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+## Relay fallback
+
+A uBridge without `iol_bridge add_nio_tap` (old build) keeps the node on the relay datapath unchanged: every link rides `iol_bridge add_nio_udp`, the node starts fine, and the warning says it cannot carry kernel links. The controller never offers such a node a kernel link (capability unreported).
+
+## Verified
+
+Unit level: anchor lifecycle and naming, kernel/relay binding on add, update and remove, the stop order (iol_bridge delete before tap delete before brctl delete before uBridge stops), `_networking` on restart with mixed kernel/relay ports, capture and marker command shapes on both datapaths, the capability probe (cycle, old-build fallback, cleanup, caching), capability plumbing, and controller eligibility (per-compute capability, its independence from `ubridge_tap`, serial-port exclusion).
+
+An isolated-instance validation run — a development harness that is not part of this repository: two IOU nodes behind a fake ELF image, the driver playing the IOL fabric on `/tmp/netio/` and pushing frames through it — passed **64/64 checks** on the kernel datapath and **62/62** on the relay (`enable_kernel_datapath = false`). Kernel highlights:
+
+* 8 anchors per node (2 bays × 4 units), persistent, DOWN, address-free; `link_list` reports `kernel_datapath: true` and one per-link bridge holds both anchors with `brport/state = 3`;
+* fabric → anchor → bridge → anchor → fabric in both directions at ~0.2 ms; `delay 100` ⇒ netem on both anchors and a measured **100.5 ms** one-way;
+* suspend ⇒ anchor DOWN, no traffic, resume ⇒ 100.4 ms with the qdisc intact;
+* capture ⇒ a 328-byte pcap written by `capture start_kernel`;
+* link delete ⇒ bridge gone, qdisc clean, anchor alive, re-create at 0.6 ms;
+* a **serial** link between the same two capable nodes stays on the relay (`kernel_datapath: false`) — per-port exclusion, not per-node;
+* single-sided restart ⇒ stopped node's anchors removed, peer's kept, link re-attached from the NIO (0.3 ms); stop ⇒ no stray anchors, bridges or fabric sockets.
+
+The relay run exercises the same lifecycle with the IOL UDP NIOs (no tc qdiscs, userspace `delay` at 100.7 ms, suspend via the synthetic frequency_drop, `iol_bridge start_capture`), which is also the regression net for the port-coordinate capture restore this work touched.
+
+The repository's live e2e suite carries the same scenario with **real IOU routers** — the reproducible run in the tree (`tests/e2e/test_iou_kernel_datapath.py`, pytest marker `e2e`): a real L3 IOU image provides the fabric and the real IOS CLI on the server's telnet console, driving anchors-born-with-the-node, the §E.2 idle-silence window with the guests shut, real ICMP through the port bridge, netem delay, the TAP-anchor classifier spot check (bpf match-drop; the eBPF every-nth mode at 56 % measured round-trip loss, 55.6 % expected), suspend, capture, link delete/re-create, node stop/start rewiring with a delay filter restored on the fresh anchor (tc and traffic both), the serial link staying on the relay, and a relay negative control whose real `delay`/`frequency_drop` filters ride the port's `iol_bridge add_packet_filter` list — capture and markers on the same engine (`iol_bridge start_capture` / the port's `mark` filter).
+
+The uBridge side passed its own 30/30 (`tests/iol/test_tap_anchor.py` in the fork), including the DOWN-anchor resilience and kernel-bridge interop this design depends on.
+
+One pre-existing bug surfaced here and is fixed: the IOU NIO **update** route never copied the body's `suspend` onto the NIO (Docker and QEMU routes do). Harmless on the relay, where suspend is emulated by the synthetic frequency_drop filter, but fatal for a kernel link, whose suspend *is* the anchor's admin state — a "suspended" link kept forwarding.
+
+**Measuring seeded impairments on IOU**: IOL emits CDP/keepalive frames of its own, and they draw from the netem RNG stream — a `seed`-reproducible run never matches while they are on (measured: three rounds of mismatches, then bit-identical pcaps after `no cdp run`). Turn the platform chatter off before comparing seeded runs; the extra frames are visible in the capture.
+
+## Configuration
+
+```ini
+[Server]
+# Wire eligible links (same compute, both endpoints Ethernet and able to
+# anchor) through kernel interfaces instead of the uBridge UDP relay.
+enable_kernel_datapath = True
+```
+
+## Roadmap
+
+Dynamips has landed (see `dynamips-kernel-datapath.md` — the hypervisor opens uBridge-created TAPs, the same shape QEMU uses), and so has the Ethernet switch (see `ethernet-switch-kernel-datapath.md` — it absorbs the peer's anchor into its own kernel bridge). Switch-to-switch cascades (a veth pair between the two kernel bridges) have landed, and so has the IOL runner container — see `iol-docker-kernel-datapath.md` (same persistent-TAP shape, through the generic bridge module's swappable TAP leg instead of the IOL bridge). Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized.
diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md
index 51b35d3ac..f3d4114a5 100644
--- a/docs/features/marker-tag-replay.md
+++ b/docs/features/marker-tag-replay.md
@@ -41,11 +41,16 @@ graph TB
TMP["/tmp scratch copies (hardened-profile workaround)"]
SK["sharkd - (resident JSON-RPC on stdio)"]
+ subgraph Remote["Remote compute"]
+ RFS[("its markers dir")]
+ end
+
UI -->|"GET range / frames [?filter=&link=]"| GATE
GATE --> SCAN
SCAN -->|"columns / filter matches"| SESS
SESS --> TMP --> SK
SCAN -->|"hex: raw bytes"| FS
+ RFS -->|"HTTP fetch → local cache"| SCAN
UI -->|"GET frame detail (lazy)"| SESS
```
@@ -76,6 +81,8 @@ and the user's home even though the server process can read both. The pcap copy
off the event loop, and server shutdown kills every session so no scratch copy leaks
into `/tmp` across restarts.
+Capture-file locality: a marker's pcap is written by uBridge on the compute hosting the capture node, so only local markers' pcaps sit in the controller's project directory. A remote marker's pcap is fetched through the compute's project-files API on every acquire into a bounded cache (32 idle files under `/tmp`, wiped at shutdown) — a paused marker's pcap holds matched packets only, so the refetch stays cheap. The cached copy is replaced only when the bytes changed, so an unchanged refetch keeps the previous file's mtime and the resident sharkd session keyed by it survives; for remote sources the per-request `(mtime, size)` validation runs against the cache copy. A compute that cannot be reached flags its source (`count: 0` plus an `error` message in `sources`) instead of silently shrinking the timeline; the frame-detail endpoint then answers 409 with the fetch failure.
+
### Resource bounds
Three independent bounds, easily confused:
@@ -171,7 +178,7 @@ without it.
"frame_count": 29,
"sources": [
{ "node_id": "47703cad…", "link_id": "2697a7c6…", "marker": "global-ospf",
- "data_link_type": "DLT_EN10MB", "count": 4 }
+ "data_link_type": "DLT_EN10MB", "count": 4, "error": null }
],
"frames": [
{ "ts": "1788369209.406812", "len": 114,
@@ -218,7 +225,10 @@ design:
- **`sources` is the stable inventory of the tag**: every capture source is always
listed, with engine-free **total** counts, unaffected by `link` / `filter` — a
- source dropdown must not shrink when the view narrows.
+ source dropdown must not shrink when the view narrows. A source whose capture file
+ could not be fetched from its compute keeps its entry with `count: 0` and an `error`
+ message (compute unreachable) — a partial timeline must be visible as partial, never
+ mistaken for the whole capture.
- **An unknown `link_id` matches nothing**: `frame_count: 0`, `start: null`, empty
`frames` — the same shape as a zero-match display filter, deliberately not a 404.
@@ -308,12 +318,18 @@ All error bodies are `{"message": "…"}` (the app's unified format).
| 400 | Invalid display filter (sharkd's filter rejection only; message carries its original text) or filter longer than 2000 chars |
| 401 | Not authenticated |
| 404 | Tag has no markers in the project; detail source unknown; `ts`/`frame_number` do not match the file (the capture may have been rebuilt); or the pcap vanished mid-request |
-| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them) |
+| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them); detail endpoint only — the compute hosting the capture file could not be reached (message names the compute) |
| 501 | sharkd not installed / unavailable — replay is unavailable, no degraded mode |
| 502 | sharkd failed, timed out (10 s per RPC), or answered out of sync — the session is killed and re-spawned on the next request |
## Notes
+- **In-repo e2e coverage (2026-10).** `tests/e2e/test_marker_kernel_datapath.py` exercises
+ the whole contract against a paused tag on real marker pcaps: the `range` aggregate
+ (frame_count, sources inventory with the node/link/marker identity, first-frame
+ src/dst/proto from sharkd), the display filter narrowing (`icmp.type == 8`), the
+ unknown-link empty stream, the `frames` window endpoint, and the 409 gate while a
+ marker under the tag is still capturing.
- **Heterogeneous link types coexist.** Frames are never merged into a single pcap
(mergecap is deliberately not used) — each frame carries its source and is decoded
individually, so Ethernet and serial (cHDLC/PPP) markers can share one timeline.
@@ -342,8 +358,4 @@ All error bodies are `{"message": "…"}` (the app's unified format).
directory — nothing accumulates across restarts.
- **Tag type.** REST and the `marker.match` WS event both carry `tag` as `int` (the
listener normalizes); replay keys on that int value.
-- **Follow-ups.** Remote-compute support via the existing capture-file proxy pattern;
- convenience APIs (`GET …/markers/tags` to list tags, `POST …/markers/tags/{tag}/pause`
- to batch-pause — a one-call path to the replayable state); uBridge-side
- `pcap_dump_open_append` (with a linktype-header check on the existing file) so capture
- history survives node restarts instead of being truncated on every reinstall.
+- **Follow-ups.** Convenience APIs (`GET …/markers/tags` to list tags, `POST …/markers/tags/{tag}/pause` to batch-pause — a one-call path to the replayable state); uBridge-side `pcap_dump_open_append` (with a linktype-header check on the existing file) so capture history survives node restarts instead of being truncated on every reinstall.
diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md
index ab71cbf65..a27ad5a61 100644
--- a/docs/features/marker-traffic-insight.md
+++ b/docs/features/marker-traffic-insight.md
@@ -46,6 +46,14 @@ unchanged. Each compute process runs one UDP listener serving every uBridge on t
`node` and `link` fields in each signal together identify the source link (see
[Per-link attribution](#per-link-attribution)).
+On **kernel-datapath** links (see
+[docker-kernel-datapath.md](docker-kernel-datapath.md)) there is no relay
+bridge: the marker attaches directly to the veth host end via uBridge's
+AF_PACKET marker module (`marker add_kernel`), with identical BPF matching,
+signals, pcaps and REST operations. Deployment convention: only the capture
+node's end carries the marker, so each exchange produces one clean
+`tx`/`rx` signal pair.
+
## Business Process
```mermaid
@@ -336,6 +344,15 @@ direction relative to the capture node; see [Direction](#direction).
## Notes
+- **Live e2e coverage.** `tests/e2e/test_marker_kernel_datapath.py` drives two real
+ Alpine containers over a kernel-only link with markers attached to a veth anchor
+ (`marker add_kernel`): it asserts the exact `marker.match` identity on the dedicated
+ marker WebSocket (filter/tag/link_id/node_id/ts/len and the tx/rx direction set of a
+ ping), that the main project channel carries no `marker.match`, that a non-matching
+ BPF and a `tx`-directioned sibling each see exactly their intended traffic, the host
+ pcap frame count, pause/resume silence counting against a still-firing sibling,
+ marker delete removing the pcap and silencing signals, and the empty marker
+ directory at the end.
- **Marker name is immutable.** It is the identifier across the controller, the uBridge
filter, the pcap filename, and `MARK` signal routing — so rename is a delete + recreate,
not a field update. PUT ignores the body `name`; the `{name}` path parameter identifies
diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md
index 5390211a3..4963ad748 100644
--- a/docs/features/mcp-service.md
+++ b/docs/features/mcp-service.md
@@ -187,7 +187,9 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|------|-------------|
| `drawing_list` | List drawings on canvas |
| `drawing_get` | Get drawing details |
-| `drawing_create` | Create drawing (SVG label/shape/image) |
+| `drawing_create_text` | Create a text label (SVG assembled server-side, Web UI compatible) |
+| `drawing_create_rectangle` | Create a rectangle annotation |
+| `drawing_create_ellipse` | Create an ellipse/circle annotation |
| `drawing_update` | Update drawing (position, rotation, SVG) |
| `drawing_delete` | Delete a drawing |
diff --git a/docs/features/packet-filters.md b/docs/features/packet-filters.md
new file mode 100644
index 000000000..cc67ff230
--- /dev/null
+++ b/docs/features/packet-filters.md
@@ -0,0 +1,381 @@
+
+
+> 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.
+
+# Packet Filters (link impairments)
+
+## Overview
+
+Per-link impairment filters — delay, packet loss, corruption, rate limiting, deterministic drops — configured through a link's `filters` dict and applied by **both link endpoints**, so every direction of a link is impaired exactly once. A `delay 100` link therefore measures ≈200 ms RTT; `packet_loss 30` measures ≈51 % round trip (1 − 0.7²).
+
+Every kernel-datapath node type shares one implementation: `KernelDatapathMixin` keys everything on the anchor interface name, which is why a Docker adapter's veth host end and a QEMU / IOU / Dynamips adapter's TAP are interchangeable. On the kernel datapath the filters run **in the kernel** as tc objects that uBridge installs over netlink (`tc netem set`, raw netlink — no `tc` binary needed): one netem qdisc, cls_bpf match-drop classifiers and one eBPF stateful classifier per anchor. Relay links run the uBridge userspace filters on the single relay bridge instead — five types there (`delay`, `packet_loss`, `corrupt`, `frequency_drop`, `bpf`); the rest are kernel-only.
+
+## Terms
+
+| Term | Meaning |
+|---|---|
+| anchor | the host-side interface a link attaches to — a Docker adapter's veth host end, a QEMU / IOU / Dynamips adapter's persistent TAP |
+| tc / qdisc | traffic-control objects the kernel inserts into the anchor's egress path — stages frames pass through in flight, not external packet consumers; uBridge only installs them over netlink |
+| `clsact` | the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run |
+| netem | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| eBPF classifier | the stateful drop classifier at clsact egress prio 1 — modes every-Nth, quota, time window and flow (GNS3 exposes the first three as filter types) |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| relay filter | a uBridge userspace filter on the relay bridge — both directions cross the single filtered bridge |
+
+## Evaluation order (kernel datapath)
+
+All 13 types in evaluation order for one frame with every filter set — the chain runs once per direction on the destination anchor's egress, the classifier chain first and the single netem qdisc behind it. A drop at any stage ends the frame: whatever the classifiers drop never reaches netem, nor the AF_PACKET tap points — captures and markers will not observe it:
+
+```mermaid
+flowchart LR
+ subgraph CLS["clsact egress — the classifier chain"]
+ subgraph EB["eBPF stateful classifier — prio 1"]
+ M1{"frequency_drop (nth)"}
+ M2{"quota"}
+ M3{"window_drop (window)"}
+ end
+ BP{"bpf lines — prio 10-99"}
+ end
+ subgraph NET["netem root qdisc"]
+ DUP["duplicate — copy queued"]
+ LOS{"packet_loss / gemodel"}
+ COR["corrupt"]
+ TS["delay · reorder · rate"]
+ TFQ["delay queue — limit"]
+ SEED["seed"]
+ end
+ FR["frame from the link bridge"]
+ OUT["into the node"]
+ DROP["dropped"]
+
+ FR --> M1 --> M2 --> M3 --> BP
+ BP --> DUP --> LOS --> COR --> TS --> TFQ --> OUT
+ M1 -->|"every Nth"| DROP
+ M2 -->|"cap reached"| DROP
+ M3 -->|"in window"| DROP
+ BP -->|"match"| DROP
+ LOS -->|"hit"| DROP
+ TFQ -->|"full"| DROP
+```
+
+Semantic model rather than kernel source order: the netem draws fire at enqueue (a `duplicate` hit queues a copy alongside the original, `corrupt` damages and passes), `delay`/`reorder`/`rate` fold into one time_to_send that the delay queue drains when due, `limit` is that queue's capacity — the `full` edge above — and `seed` is no stage but the PRNG behind every stochastic draw (duplicate, loss/gemodel, corrupt, jitter). The eBPF program's fourth mode (flow) has no GNS3 filter type, and the attached parameters (jitter, correlation, distribution) ride their types and add no stages. On the relay datapath there is no such chain — the five relay-supported types run as uBridge userspace filters on the single relay bridge, both directions through one filter list.
+
+## Filter types
+
+On the kernel datapath the 13 types ride three kernel objects on the anchor. The `tc …` verbs in the table are uBridge's own hypervisor command names — **not the iproute2 `tc` binary**: uBridge builds every netlink message itself (RTM_NEWQDISC for the netem qdisc, RTM_NEWTFILTER for the cls_bpf and eBPF classifiers), loads the eBPF program through raw `bpf()` syscalls with the instruction array embedded in the binary, embeds the jitter-distribution tables the `tc` CLI would read from disk, and uses libpcap only as a library to compile `bpf` expressions — the same compiler the relay filter uses. The `tc` and `ip` binaries appear only in uBridge's test suite, as kernel-state oracles:
+
+| GNS3 filter | kernel implementation | Notes |
+|---|---|---|
+| `delay [ms, jitter, dist]` | netem `delay X jitter Y [distribution D]` | jitter 0 omitted; dist ∈ uniform/normal/pareto/paretonormal (kernel-only parameter) |
+| `packet_loss [%, correl]` | netem `loss P [correl C]` | per direction (see below); correlation is a kernel-only parameter — and collapses small rates, see the correlation-bias bullet |
+| `corrupt [%]` | netem `corrupt P` | |
+| `duplicate [%, correl]` | netem `dup P [correl C]` | kernel-only type |
+| `rate ["512kbit"]` | netem `rate B` | tc-style integer+unit (bit/kbit/mbit/gbit/bps/kbps/mbps, ≤100gbit); kernel-only type |
+| `reorder [%, correl, gap]` | netem `reorder P [correl C] [gap G]` | requires `delay`; kernel-only type |
+| `gemodel [p, r, 1-h]` | netem `loss gemodel P R H` | Gilbert-Elliot bursty loss: p = good→bad transition, r = bad→good transition, 1-h = loss chance in the bad state (good loses nothing) — mean one-way loss p/(p+r)×(1-h); mutually exclusive with `packet_loss`; kernel-only type |
+| `seed [u32]` | netem `seed N` | reproducible random draws; kernel-only type |
+| `limit [pkts]` | netem `limit N` | queue depth above the 1000 default (rate+delay BDP); kernel-only type |
+| `bpf` (one line per expression, OR) | `tc bpf_drop add ""` — cls_bpf on clsact egress + gact drop | needs uBridge `cbpf=1` (`tc capabilities`, probed once per uBridge process); a line that fails to compile on the compute is skipped with a warning, mirroring the relay |
+| `frequency_drop [N]` | `tc nth_drop N` — the eBPF stateful classifier's exact every-Nth mode (clsact egress prio 1) | needs uBridge `ebpf=1`; -1 = drop everything → every 1st; per-direction counters (see below) |
+| `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**: 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 (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); duplicates have no gemodel equivalent — for bursty duplication keep Chance at or above the Correlation.
+
+**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)
+
+**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**: 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"]}`.
+
+### `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). 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.
+
+### `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). 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.
+
+### `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. 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]}`.
+
+### `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. 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.
+
+### `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 — 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`.
+
+**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 %) | 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.
+
+**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.
+* **Correlation collapses small rates (kernel netem defect, quantified).** netem's correlated draw mixes `answer = (value·(2³²−ρ) + last·ρ) >> 32` — truncated, never wrapped modulo 2³² — so the stationary distribution is starved at both edges: a draw almost never lands in the bottom ρ slice of the range. The effective rate of nominal P under correl C collapses toward zero whenever P ≲ C. Reproduced with stock iproute2 `tc` on this kernel (uBridge's netlink encoding is identical — GNS3 shows the same numbers): `1% 25%` → measured 0/1000 pings (theoretical ≈0.0002 %), `5% 25%` → ≈0.2 %, `10% 25%` → ≈1.8 % one-way (3.8 % round-trip measured), `25% 25%` → ≈16.7 %; nominal 50 % stays 50 % under every correl (the distribution's symmetry point). The same edge-starvation suppresses small `duplicate`/`reorder` rates under their correl and thins the tails of correlated jitter. `gemodel` is immune — a Markov chain over plain uniform draws — and is the honest tool for bursty loss at a chosen mean rate (p/(p+r)×(1-h)). Not fixable server- or uBridge-side without deviating from stock `tc` behavior; the kernel fix would be to wrap the mixing modulo 2³².
+* **Reconcile = reset + full re-apply.** The kernel's netem replace MERGES optional attributes (rate, correlation, reorder, corrupt, gemodel, distribution: an absent attr keeps its previous value), so re-applying a filter set with a parameter *removed* would silently keep the old value. Every apply therefore starts with `tc reset` (also drops clsact and its bpf_drop filters — re-added right after in the same flow) followed by one `netem set` built from the current filters; no per-parameter diffing. An empty filter set is the reset alone (ENOENT-tolerated).
+* **Extension gating.** The netem-extension keywords (rate, reorder, gemodel, dist, seed, limit — plus the correl suffixes, which gate on the `rate` token as the extension-build marker) probe `tc capabilities` once per uBridge process and require the tokens; a plain delay/loss/corrupt/dup filter never probes — an old uBridge serves the original surface untouched.
+* **kernel-only vs relay-available.** The extension types, `quota` and `window_drop` have no uBridge relay equivalent: `available_filters` hides them on relay links, setting one on a created relay link returns 409, and a project loaded with such filters on a link that cannot be kernel-wired drops them with a warning (invalid filters are dropped the same way at load). `frequency_drop` runs on both datapaths (relay userspace filter / eBPF every-Nth).
+* **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).
+* **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.
+* **Observability caveat:** clsact drops happen before the AF_PACKET tap points — markers/captures on the same anchor do not observe cls_bpf-dropped frames (on the relay, filter-vs-mark ordering determined visibility instead). Also note `tc qdisc show` cannot print a distribution's name (the kernel stores only the sampled table).
+* **eBPF mode tokens.** A stateful mode is usable iff `tc capabilities` reports `ebpf=1` **and** lists the mode's token in `ebpf_modes` (uBridge `feature/tc-window`+). Builds predating the field keep the modes that shipped with it — everything but `window`, whose semantics were corrected exactly when the field was added: gating on `ebpf=1` alone would silently install the broken back-to-back-window behaviour on a pre-correction build. Requesting an unusable mode fails the apply with an upgrade error; the cleanup ("off") path only touches modes the build declares.
+* **Capability reporting.** The compute exposes the probe through `GET /v3/compute/capabilities` as `ubridge_tc` (netem keyword list, `ebpf`/`cbpf` flags, and the usable `ebpf_modes` — legacy fallback applied), answered by a throwaway uBridge spawned as the server's own user and cached by binary identity — the runtime `ebpf` flag flips between users and kernels for the same binary, so a root-run probe would lie for a non-root server. The controller forwards it through `GET /v3/computes` and `available_filters` hides kernel-datapath filter types an endpoint compute reports it cannot run (both ends apply their own filters, so one incapable compute is enough). Computes that report nothing — older servers, failed probe — keep the full list; apply-time validation (409) stays the guard.
+
+## Combining filters
+
+The composition model in one line: **deterministic drops are an OR-gate** (first kill wins, the rest never run), **netem draws are independent probabilities that multiply**, and **a round trip squares everything** — the only genuine coupling inside one direction is `limit` backstopping the backlog that `rate` + `delay` create.
+
+Classifier-stage composition: a frame dropped at clsact never reaches netem, so it consumes no `rate` serialization time, no delay-queue capacity (`limit`), and no netem counter — and captures/markers will not see it (the observability caveat above). The stateful modes interact only through their counters: `quota`'s byte count sees just the `frequency_drop` survivors (evaluation is ordered), so every-Nth drops slow the quota burn by the nth pass rate; the `window_drop` schedule and the nth counter are independent of each other. A frame the eBPF program lets pass continues to the `bpf` lines — the program returns `TC_ACT_UNSPEC` on a pass, not `TC_ACT_OK`, so the prio chain continues — making the two classifier stages a chain, not an exclusion.
+
+Within netem, the nine types are **one qdisc pass, not nine stages**: duplicate, loss/gemodel and corrupt are independent draws (their probabilities multiply; a duplicated copy draws its own delay; corrupt damages and passes, leaving the receiver's checksum to do the dropping), while `delay`/`reorder`/`rate` fold into a single time_to_send. The coupling to watch is `limit` × `rate` × `delay`: rate serializes while delay holds, so the backlog grows toward the bandwidth-delay product — once it passes `limit`, the queue tail-drops and the link loses packets no filter asked to lose. Raise `limit` when combining a large `rate` with a large `delay`. `seed` pins only the netem draws (duplicate, loss, corrupt, jitter) — the eBPF program's percentage draws (quota, window) ride a separate PRNG seeded at program load, outside `seed`'s reach.
+
+Round-trip composition: both endpoints run their own instance on their own anchor (each direction impaired exactly once), so per-direction survival multiplies across drop sources and then squares for a round trip — `delay 100` → ≈200 ms RTT, `packet_loss 30` → ≈51 % round-trip loss, `frequency_drop 3` → 55.6 % (the per-type arithmetic in Semantics above); `window_drop` schedules start ~simultaneously on both ends, so a round trip survives only when **both** directions are outside their windows, and `quota` burns two independent byte counters. The combinations that cannot merge — `packet_loss` + `gemodel` (one netem loss slot), `reorder` without `delay`, `distribution` without jitter, `period < outage` — are rejected at validation (409), never silently merged.
+
+A fully-loaded example — `delay 100 jitter 10`, `packet_loss 10`, `corrupt 5`, `duplicate 10`, `rate 512kbit`, `reorder 25 gap 5`, `limit 5000`, `seed 42`, `bpf "icmp"`, `frequency_drop 3`, `quota 3000000 100`, `window_drop [1000, 1500, 100]` — sends one A→B frame through this gauntlet at B's anchor: it dies exactly on every 3rd send (nth), else it counts toward a 3 MB budget after which it is hard-cut (quota), else it drops when it lands inside the 1–2.5 s outage (window), else it drops when the expression matches (bpf); a survivor enters netem, where it may be duplicated (the copy draws its own delay), lost (10 %), or corrupted (5 %, passes damaged), and otherwise waits 100 ± 10 ms with 25 % reorder while 512 kbit/s serializes it inside a 5000-packet queue — every draw reproducible under `seed 42`. The B→A direction runs the same gauntlet independently on A's anchor.
+
+## REST surface
+
+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
+
+The per-node-type frame paths — where an anchor's egress chain sits in the whole journey from one node to another — are drawn in the datapath docs: `docker-kernel-datapath.md` (veth), `qemu-kernel-datapath.md` and `dynamips-kernel-datapath.md` (TAP), `iou-kernel-datapath.md`, `ethernet-switch-kernel-datapath.md` and `iol-docker-kernel-datapath.md`. The frozen uBridge contract behind the kernel objects (netlink encoding, `tc capabilities`, the full-restore `tc reset`) is `../design/ubridge-kernel-impairment-spec.md`.
diff --git a/docs/features/qemu-kernel-datapath.md b/docs/features/qemu-kernel-datapath.md
new file mode 100644
index 000000000..e1f459fd9
--- /dev/null
+++ b/docs/features/qemu-kernel-datapath.md
@@ -0,0 +1,286 @@
+
+
+> 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.
+
+# QEMU kernel datapath
+
+QEMU adapters anchor on a **persistent TAP**, and links between two QEMU nodes — or a QEMU node and a Docker container — on the same compute are wired in the kernel (per-link Linux bridge) instead of the uBridge UDP relay. The compute side is the same machinery Docker already uses: `KernelDatapathMixin` (`gns3server/compute/kernel_datapath.py`) takes an *anchor interface* and does not care whether it is a veth host end or a TAP.
+
+## Architecture
+
+Kernel link between two QEMU nodes on the same compute. Interface names follow the shared contract of `gns3server/utils/kernel_anchor.py`: `gq{node_id[:8]}e{adapter}p{port}` for the persistent TAP (one per QEMU adapter, port always 0) and `gns3{link_id[:11]}` for the bridge; the concrete names in the diagrams use node ids `3f2a1b9c` / `7c9d4e2f` and link id prefix `5d3c2b1a4e6`. A mixed Docker↔QEMU link is the same bridge with the Docker end's `gv` veth host end as its other anchor — the shared mixin is anchor-agnostic; the pure-veth picture is `docker-kernel-datapath.md`. Terms the diagrams use:
+
+| Term | Meaning |
+|---|---|
+| uBridge | the per-node helper process gns3-server spawns with elevated capabilities; its hypervisor command surface executes all host-side wiring (TAPs, bridges, carrier, tc, taps) — on a kernel link it builds and observes the path but never carries frames |
+| anchor | the host-side interface a link attaches to — here the `gq` persistent TAP |
+| persistent TAP | the TAP uBridge creates per QEMU adapter at node start (`tap create`, `tap set_owner`); it outlives restarts and crashes, and QEMU opens it by name as its `-netdev tap` |
+| per-link bridge | the Linux bridge `gns3{link_id[:11]}` that stands in for the cable; the two anchors are its only ports |
+| bridge port | an interface enslaved to a Linux bridge (`brctl addif`) — it no longer sends or receives on its own: frames arriving on it go to the bridge's forwarding logic, and the bridge transmits through it |
+| tap fd | a TAP's file descriptor: reads return frames the kernel received on the device, writes inject frames into it — on this datapath QEMU itself holds it |
+| FDB | the bridge's forwarding database (learned MAC → port); unknown, broadcast and multicast destinations are flooded |
+| `01:80:c2` / `group_fwd_mask` | the IEEE 802.1D reserved multicast range (STP, LACP, LLDP, 802.1X, PAUSE); the bridge forwards it only per the port's `group_fwd_mask` — `0xfffd` passes everything except PAUSE/PFC |
+| tc / qdisc | traffic-control objects the kernel inserts into the anchor's egress path — stages frames pass through in flight, not external packet consumers; uBridge only installs them over netlink |
+| `clsact` | the tc classifier carrier on the anchor; its egress chain is where the drop classifiers run |
+| eBPF classifier | the stateful drop modes (every-Nth, quota, time window) at clsact egress prio 1 |
+| `bpf_drop` | match-drop BPF expressions at clsact egress prio 10–99 |
+| netem | the impairment qdisc behind clsact: delay, loss, rate, corrupt, duplicate, … |
+| `AF_PACKET tap` | uBridge's packet socket bound to the anchor; the kernel clones every frame the interface sends or receives to it (the tcpdump mechanism) — read-only copies for capture and markers, the original stays on the kernel path |
+| carrier | the anchor's admin state (`link set up/down`); suspend is carrier down plus QMP `set_link off` so the guest notices |
+| QMP | the QEMU machine protocol — how the server drives a running QEMU (link state on suspend/resume) without restarting it |
+| `nio_bridge` | the NIO record the controller posts to each endpoint node: bridge name, filters, markers, suspend flag |
+
+```mermaid
+flowchart LR
+ subgraph GA["guest A (in the VM)"]
+ nA["NIC 0"]
+ end
+ subgraph RootNS["root namespace"]
+ gqA["gq3f2a1b9ce0p0 — anchor"]
+ BR["gns35d3c2b1a4e6 — per-link bridge"]
+ gqB["gq7c9d4e2fe0p0 — anchor"]
+ UBA["uBridge A"]
+ UBB["uBridge B"]
+ end
+ subgraph QPA["QEMU A process"]
+ ndA["netdev gns3-0 (tap fd)"]
+ end
+ subgraph QPB["QEMU B process"]
+ ndB["netdev gns3-0 (tap fd)"]
+ end
+ subgraph GB["guest B (in the VM)"]
+ nB["NIC 0"]
+ end
+ nA <-->|"virtio / e1000"| ndA
+ ndA <-->|"tap fd"| gqA
+ gqA <-->|"bridge port"| BR
+ BR <-->|"bridge port"| gqB
+ gqB <-->|"tap fd"| ndB
+ ndB <-->|"virtio / e1000"| nB
+ gqA -. "frame copies" .-> UBA
+ UBA -. "qdisc install" .-> gqA
+ gqB -. "frame copies" .-> UBB
+ UBB -. "qdisc install" .-> gqB
+```
+
+Everything in the root namespace is built the same way: gns3-server creates the persistent TAPs at node start (before QEMU is launched), the per-link bridge and its memberships at link creation, and every later change (carrier, capture, markers, filters) by sending hypervisor commands to the node's uBridge process — the privileged agent that turns them into netlink/ioctl operations. That build flow is the Link creation sequence below; this diagram is the steady state that remains, where the only uBridge relationships left are the two dashed kinds.
+
+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 anchor's egress (stages of the transmit path, not packet consumers). The `frame copies` edge is the AF_PACKET tap, there only while a capture or markers is active (a kernel link leaves no uBridge endpoint on the anchor otherwise): the kernel clones every frame the TAP sends or receives to uBridge's packet socket (the tcpdump mechanism) — read-only copies for capture and markers; the originals never leave the kernel. The only userspace on the path is the two QEMU processes pumping their own tap fds — the irreducible VM leg, one fd crossing at each end per direction.
+
+Relay link between the same two QEMU nodes on the same single compute — what every link becomes when `enable_kernel_datapath` is off (mixed node types and cross-compute wiring fall back too; there the UDP hop rides the real network instead of loopback, the copy count unchanged):
+
+```mermaid
+flowchart LR
+ subgraph GA["guest A (in the VM)"]
+ EA["NIC sends"]
+ end
+ subgraph QA["QEMU A — userspace"]
+ NDA["netdev tap fd"]
+ end
+ subgraph KERN["kernel — root ns"]
+ TA["gq3f2a1b9ce0p0 — anchor"]
+ UDP["UDP sockets (loopback)"]
+ TB["gq7c9d4e2fe0p0 — anchor"]
+ end
+ subgraph UBA["uBridge A — userspace"]
+ NEA["AF_PACKET nio"]
+ ENA["relay + userspace filters"]
+ NUA["UDP nio"]
+ end
+ subgraph UBB["uBridge B — userspace"]
+ NUB["UDP nio"]
+ ENB["relay + userspace filters"]
+ NEB["AF_PACKET nio"]
+ end
+ subgraph QB["QEMU B — userspace"]
+ NDB["netdev tap fd"]
+ end
+ subgraph GB["guest B (in the VM)"]
+ EB["NIC receives"]
+ end
+ EA -->|"virtio"| NDA
+ NDA -->|"copy 1: user→kernel"| TA
+ TA -->|"copy 2: kernel→user"| NEA
+ NEA --> ENA --> NUA
+ NUA -->|"copy 3: user→kernel"| UDP
+ UDP -->|"copy 4: kernel→user"| NUB
+ NUB --> ENB --> NEB
+ NEB -->|"copy 5: user→kernel"| TB
+ TB -->|"copy 6: kernel→user"| NDB
+ NDB -->|"virtio IRQ"| EB
+```
+
+Drawn one-way; the reverse direction is the mirror image. A relayed frame crosses the kernel/user boundary **six times each way** (twelve per round trip): the two halves of the irreducible VM leg — QEMU's tap-fd write at the sending end, its read at the receiving end — plus the four the relay adds around them (AF_PACKET read, UDP send, UDP receive, AF_PACKET inject). The kernel datapath's only crossings are the VM leg's two; the relay's filters run in userspace, on the frames between the copies.
+
+The relay attaches the TAP with `bridge add_nio_ethernet` (AF_PACKET, which does not take the device over) — **never** `bridge add_nio_tap`, which would claim the fd QEMU itself holds. And unlike the IOL runner container, whose anchors idle on the relay, the anchor here is the netdev itself: it stays up and carries as the relay's AF_PACKET endpoint — QEMU has no other leg (the legacy socket-netdev fallback below is the one shape without the TAP).
+
+Frame path for one A → B frame. The impairment chain fires on the egress of the *destination* end's TAP, in order clsact → netem (a classifier drop means netem never sees the frame); each direction is impaired exactly once and B → A is symmetric on gqA — hence `delay 100` ≈200 ms round trip (measured 100.3 ms one-way). Each one-way trip crosses the kernel/user boundary exactly twice — the sending QEMU's tap-fd write and the receiving QEMU's tap-fd read, the irreducible VM leg:
+
+```mermaid
+flowchart LR
+ subgraph ANS["guest A (in the VM)"]
+ S1["NIC sends"]
+ end
+ subgraph QPA["QEMU A — userspace"]
+ S2["tap fd to kernel"]
+ end
+ subgraph ROOT["root namespace"]
+ subgraph GQA["gqA — ingress (its egress chain serves B→A)"]
+ S3["ingress"]
+ end
+ subgraph BRP["per-link bridge"]
+ G1{"reserved 01:80:c2?"}
+ D["dropped"]
+ S4["FDB lookup / flood"]
+ end
+ subgraph GQB["gqB — egress chain (clsact, then netem)"]
+ S5["eBPF prio 1"]
+ S6["bpf_drop prio 10-99"]
+ S7["netem qdisc"]
+ end
+ T["capture / markers"]
+ end
+ subgraph QPB["QEMU B — userspace"]
+ S8["tap fd to QEMU"]
+ end
+ subgraph BNS["guest B (in the VM)"]
+ S9["NIC receives"]
+ end
+ S1 -->|"virtio"| S2
+ S2 -->|"tap fd, copy 1: user→kernel"| S3
+ S3 --> G1
+ G1 -->|"PAUSE 0x01"| D
+ G1 -->|"0xfffd passes"| S4
+ S4 --> S5
+ S5 --> S6 --> S7
+ S7 -->|"tap fd, copy 2: kernel→user"| S8
+ S8 -->|"virtio IRQ"| S9
+ S3 -. "AF_PACKET tap" .-> T
+ S7 -. "AF_PACKET tap" .-> T
+```
+
+What each stage drops: the reserved-range gate passes LACP, LLDP, 802.1X and STP per the port mask but never PAUSE/PFC (Link-local frames below); the clsact/netem stages drop or impair per the installed filters (Capture, markers, filters below). The AF_PACKET taps observe both anchors but never a clsact-dropped frame. Copies 1–2 are the whole path's only kernel/user crossings — both halves of the emulation's own TAP-fd leg (QEMU A's write, QEMU B's read); every stage between them runs in the kernel.
+
+## Why a TAP works like a veth host end
+
+| Event | Docker anchor (veth host end) | QEMU anchor (TAP) |
+|---|---|---|
+| Frames the node sends | arrive on the veth as `PACKET_HOST` | written to the tap fd, arrive as RX (`PACKET_HOST`) |
+| Frames the peer sends | injected by uBridge, seen as `PACKET_OUTGOING` | injected by uBridge, seen as `PACKET_OUTGOING` |
+| `tc` egress qdisc impairs | traffic entering the container | traffic entering the VM |
+| Capture/marker anchor | the interface | the same interface |
+
+So every filter, capture and marker primitive carries over verbatim, and `delay 100` on both anchors still measures ≈200 ms round trip while each direction is impaired exactly once.
+
+## Datapath selection
+
+`UDPLink._kernel_datapath_eligible` needs both endpoints on one compute and both able to anchor (an Ethernet-switch endpoint is the exception — it absorbs the peer's anchor into its own bridge, so the switch side needs no anchoring capability of its own; `docs/features/ethernet-switch-kernel-datapath.md`):
+
+* Docker can (adapters are born as veth pairs), except the IOL runner container, whose unix-socket guest leg anchors on a TAP under its own capability pair (`docs/features/iol-docker-kernel-datapath.md`);
+* QEMU can when that compute's uBridge reports the `tap` module — asked per compute through `/capabilities` (`ubridge_tap`, probed by creating and deleting one throwaway TAP, cached by binary identity), because an old uBridge leaves QEMU on the legacy socket-netdev datapath and cannot anchor a kernel link. An unknown/failed probe keeps the link on the relay.
+
+Because the `-netdev` type is fixed when QEMU is launched, this is all-or-nothing per VM: on the TAP datapath *every* link can attach to (or detach from) a running VM, including switching a link between the two datapaths (delete + re-create, or a project reopen).
+
+## Link creation sequence
+
+Both endpoints attach concurrently and compute the same bridge name independently (a pure function of the link id), so either may win the `brctl create` race — the loser verifies the bridge exists (`brctl show`) and continues. The topology above's link, at attach time:
+
+```mermaid
+sequenceDiagram
+ participant API as REST API
+ participant C as Controller
+ participant CA as QEMU node A
+ participant CB as QEMU node B
+ participant UA as uBridge A
+ participant UB as uBridge B
+
+ API->>C: POST /links
+ C->>C: eligibility check
+ C->>C: bridge = gns3 + link_id[:11]
+ C--)CA: nio_bridge
+ C--)CB: nio_bridge (same bridge)
+ par QEMU node A
+ CA->>UA: brctl create
+ CA->>UA: link set bridge up
+ CA->>UA: brctl addif gq3f2a1b9ce0p0
+ CA->>UA: capture / markers if any
+ CA->>UA: tc netem + bpf + eBPF
+ CA->>UA: link set gq3f2a1b9ce0p0 up
+ and QEMU node B
+ CB->>UB: same on gq7c9d4e2fe0p0
+ end
+ Note over UA,UB: steady state — kernel forwards, uBridge taps
+```
+
+Every arrow to uBridge is one `_ubridge_send` command line on the node's local hypervisor console; uBridge executes it (netlink/ioctl) and replies OK or an error synchronously before the next command is sent — those replies are omitted for brevity. The per-port `group_fwd_mask` 0xfffd is deliberately not an arrow: uBridge applies it inside `brctl addif` itself (Link-local frames below). uBridge never initiates anything: it is a pure executor, and the only data ever flowing back (capture/marker frame copies) rides separate AF_PACKET sockets, not this console.
+
+The TAP already exists at attach time — created at node start, before QEMU launched (Adapter lifecycle below) — which is what makes the attach runtime-safe on a running VM: anchors, bridge memberships and qdiscs all change around the `-netdev tap` QEMU already holds, and the only QEMU traffic is carrier replication — QMP `set_link` on attach, detach and suspend/resume, gated by the node's `replicate_network_connection_state` option (default on), so the guest's carrier follows the link.
+
+A mixed Docker↔QEMU link runs the identical sequence per end — each node's uBridge receives the same commands on its own anchor, the Docker end's `gv` veth host end in place of the `gq` TAP; only the anchor name differs (the mixin is anchor-agnostic, which is the point of the shared `gns3server/compute/kernel_datapath.py`).
+
+## Adapter lifecycle
+
+* **Birth (node start, before QEMU is launched)** — `_prepare_tap_datapath` probes the tap module, then `_create_taps` sweeps any leftover (a persistent TAP outlives a crash) and creates one TAP per adapter: `tap create gq{node_id[:8]}e{adapter}p{port}` → `tap set_owner ` (so the unprivileged QEMU process can open it) → `link set down` (carrier off until a link attaches). QEMU then opens it as `-netdev tap,id=gns3-{adapter},ifname=,script=no,downscript=no`.
+* **Life (running)** — the TAP is never created or destroyed again. Link create/delete/switch, suspend, capture and markers only change *what is attached to it*.
+* **Death (node stop)** — the QEMU process is stopped **first**, then `_remove_taps` un-persists the TAPs (`tap delete`) and deletes the per-link kernel bridges the node still holds. The order matters: uBridge's `tap delete` refuses a device another process holds open ("Device or resource busy") and that best-effort delete is suppressed, so deleting the TAPs while QEMU still ran leaked every persistent TAP (process side first, then the devices — the same lesson as IOU's stop order, where uBridge itself holds the anchors and is made to release them before `tap delete`). Stopping a node never runs the link-teardown path — links survive stops by design, and project close is the same (it stops nodes without deleting links) — so those bridges would otherwise stay behind as empty orphans; a restart rebuilds them from the NIO. Deleting a node from the project is not that case: the controller deletes its links first, so the teardown does run.
+
+## Link operations
+
+| Operation | Kernel-datapath action |
+|---|---|
+| Create / attach | `brctl create` (EEXIST tolerated via `brctl show`) + `link set up` + `brctl addif `, then capture and markers and filters on the anchor (`tc netem set`, bpf/eBPF drops), carrier up last |
+| Delete / detach | marker teardown → `tc reset ` → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The TAP survives — the adapter keeps it for the next link |
+| Reset (`POST /links/{id}/reset`) | delete + create (re-evaluates eligibility) |
+| Suspend (`PUT /links/{id}` `{"suspend": true}`) | tap admin-down (writing to a down TAP fd fails with EIO, so the link is genuinely dead) + QMP `set_link gns3-N off` so the guest notices; resume restores both, and the netem qdisc survives the flap |
+
+## Capture, markers, filters
+
+Identical to Docker's kernel links, on the TAP: `capture start_kernel ` (driven by the NIO type, not by a link flag), `marker add_kernel …` (only the capture node's end), and one `tc netem set` per anchor so each direction is impaired once. `frequency_drop`/`quota`/`window_drop` run as the eBPF classifier on the anchor's clsact egress.
+
+## Link-local frames (LACP, LLDP, 802.1X, STP)
+
+The bridge stands in for a cable, but the kernel's `br_handle_frame()` does not forward the IEEE 802.1D reserved range (`01:80:c2:00:00:00`-`0f`) by default — LACP, LLDP/DCBX and 802.1X would never cross a kernel link while the UDP relay carries them fine. uBridge therefore opens every port's per-port `group_fwd_mask` (`IFLA_BRPORT_GROUP_FWD_MASK`) to `0xfffd` at `brctl addif` time (Linux 4.15+, best-effort on older kernels). The bridge-level knob cannot do this: `BR_GROUPFWD_RESTRICTED` rejects bits 0-2, so LACP is reachable per port only.
+
+Result on kernel links: ordinary multicast, STP/RSTP, LACP, 802.1X and LLDP/DCBX all cross; **802.3x PAUSE / PFC does not** — `case 0x01` in `br_handle_frame()` drops it unconditionally and no mask can enable it. That is a documented limit, not a regression (PFC's hardware semantics are out of emulation's reach anyway); a lab that needs PAUSE frames on the wire must use a relay link. Verified live by `tests/e2e/test_docker_link_local_frames.py` (per-MAC guest-to-guest matrix); the contract and kernel references live in `docs/design/ubridge-link-local-frame-forwarding-spec.md`.
+
+## Legacy fallback
+
+A uBridge build without the `tap` module (or without `CAP_NET_ADMIN`) keeps the previous datapath untouched: local UDP tunnels into uBridge, QEMU launched with `-netdev socket`, and the node logs a warning that it cannot carry kernel links. Such a node is never offered a kernel link, so nothing fails.
+
+## Verified
+
+End-to-end through a real server (isolated instance, two QEMU nodes with a stub binary, the test playing the guest by holding the TAP fds and pushing frames): **32/32 checks** on the kernel datapath and **29/29** on the relay variant (`enable_kernel_datapath = false`). Kernel run highlights:
+
+* anchors created persistent and owned by the server user, QEMU's netdev is the TAP, no local UDP tunnel left; the link object reports `kernel_datapath: true` (the `POST /links` response) and one per-link bridge holds both taps;
+* frames cross in both directions (~0.1 ms); `delay 100` ⇒ netem on both anchors and a measured **100.3 ms** one-way;
+* suspend ⇒ anchor DOWN, no traffic, resume ⇒ 100.3 ms again (filter kept);
+* capture ⇒ a 252-byte pcap written by `capture start_kernel`;
+* link delete ⇒ bridge gone, qdisc clean, anchor alive; re-create ⇒ 0.1 ms with no residual impairment; node stop ⇒ no taps, no bridges left.
+
+Relay run the same script with the flag off: same lifecycle, filter measured at 109.8 ms (applied by uBridge's bridge — no netem on the anchor, as expected), no kernel bridge holding the tap.
+
+The repo suite `tests/e2e/test_qemu_kernel_datapath.py` covers the same ground with a *real* guest: two IOSv routers (a real qcow2 image, linked clone, the real IOS CLI on the telnet console) — the full kernel lifecycle (anchors at start, per-link bridge membership, pure-L2 anchors, the §E.2 idle-silence window with the guests shut, real ICMP, netem delay, suspend, capture, link delete/re-create, node stop/start rewiring, zero residue on project delete) plus the relay negative control, where the anchor is up and carrying as the relay's AF_PACKET endpoint (QEMU has no other leg — the TAP is the netdev on the relay too). It needs an L3 IOSv image in the images directory's `QEMU` subfolder; a raw node must also name `hda_disk_interface` (the default `none` attaches no frontend device and the VM comes up diskless — the IOSv appliance boots virtio).
+
+## Configuration
+
+```ini
+[Server]
+# Wire eligible links (both endpoints on one compute, both able to anchor)
+# through kernel interfaces instead of the uBridge UDP relay.
+enable_kernel_datapath = True
+```
+
+## Known caveat
+
+Anchors are host-side netdevs, so the kernel gives them an IPv6 link-local address and emits MLD/DAD from them; those frames flood into the emulated segment. The hardening requirement (anchors must be pure L2, no L3 identity) is specified for uBridge in `docs/design/ubridge-l2-anchor-spec.md` and is **delivered**: anchors and per-link bridges come up with `addrgenmode none`, no addresses, and an idle anchor is silent (asserted in the e2e after a 2.5 s settle).
+
+One residual is accepted and documented there: enslaving a port makes the *kernel* announce the bridge's multicast memberships once — an IGMPv3 plus one or two MLDv2 reports in the first second — which floods into the segment like any L2 control frame. It stops; it is outside the L2-only command's reach (no addresses involved, IPv4 has no per-device IGMP switch).
+
+## Roadmap
+
+The rest of the node family has landed on the same mixin: **IOU** — see `docs/features/iou-kernel-datapath.md` (Ethernet bays anchor on persistent TAPs bound to the IOL fabric, `iol_bridge add_nio_tap` per `docs/design/ubridge-iol-tap-anchor-spec.md`; serial links stay relay, and one userspace hop on the IOU leg is irreducible — the fabric is Unix sockets); **Dynamips** — see `docs/features/dynamips-kernel-datapath.md` (the hypervisor opens uBridge-created TAPs with `nio create_tap`: the same external-fd-holder shape QEMU uses; serial/ATM/POS ports stay relay); **the Ethernet switch** — see `docs/features/ethernet-switch-kernel-datapath.md` (the switch absorbs a peer's anchor into its own kernel bridge; switch-to-switch cascades too); **the IOL runner container** — see `docs/features/iol-docker-kernel-datapath.md` (persistent TAP anchors through the generic bridge module's swappable TAP leg; the unix-socket guest leg keeps one userspace hop, like IOU's fabric).
+
+Still on the relay: the cloud paths (their anchors are ubridge-owned by design); and cross-compute kernel links need VXLAN/GENEVE encapsulation, which is a separate project (it would serve Docker links the same way).
diff --git a/docs/features/vpcs-kernel-datapath.md b/docs/features/vpcs-kernel-datapath.md
new file mode 100644
index 000000000..6339b53c1
--- /dev/null
+++ b/docs/features/vpcs-kernel-datapath.md
@@ -0,0 +1,65 @@
+
+
+> 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`). 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
+ 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/docs/features/vxlan-cross-compute.md b/docs/features/vxlan-cross-compute.md
new file mode 100644
index 000000000..8d21a86ff
--- /dev/null
+++ b/docs/features/vxlan-cross-compute.md
@@ -0,0 +1,121 @@
+# Cross-compute VXLAN links
+
+## Overview
+
+A link whose endpoints live on two different computes normally flows through the uBridge UDP relay: each end's anchor is attached to a userspace relay bridge and the frames cross the network as plain UDP payloads pumped by uBridge. This feature adds a second, **kernel-native** cross-compute datapath: each end creates a **VXLAN device** (uBridge's `vxlan` module, rtnetlink) and enslaves it into the same per-link kernel bridge the same-host datapath uses, so the kernel encapsulates and decapsulates and no userspace process is on the data path.
+
+It is **opt-in and off by default** (`Server.enable_vxlan_cross_compute`). The reason is MTU: a tunnel can only carry what the underlay carries minus the outer headers, so its sweet spot is a fabric that moves jumbo frames. The relay stays the default cross-compute datapath and the fallback whenever a tunnel cannot be wired — it always works, at the price of a userspace hop.
+
+## Terms
+
+| Term | Meaning |
+|---|---|
+| Anchor | The host-side interface an adapter owns (a veth host end, a persistent TAP); links attach *to* it, never create it |
+| Relay | The default cross-compute datapath: uBridge userspace bridges pumping UDP tunnels between the computes |
+| Tunnel device | The per-end VXLAN device (`gvl…`) carrying one link's frames to the peer compute |
+| VNI | The VXLAN network identifier — the kernel's demux key for received frames; both ends of a link must use the same one |
+| dstport | The UDP port every tunnel of a deployment listens on; the kernel serves one socket per port and demuxes its traffic by VNI |
+| Lower device | The underlay interface the route to the peer resolves to; the kernel derives the tunnel's MTU from it |
+
+## Architecture
+
+```mermaid
+flowchart LR
+ subgraph GA["guest A"]
+ EA["eth0"]
+ end
+ subgraph CA["compute A"]
+ AA["anchor"] --> BA["bridge"]
+ BA --> VA["tunnel device"]
+ end
+ subgraph U["underlay"]
+ UD["UDP to the peer, demuxed by VNI"]
+ end
+ subgraph CB["compute B"]
+ VB["tunnel device"] --> BB["bridge"]
+ BB --> AB["anchor"]
+ end
+ subgraph GB["guest B"]
+ EB["eth0"]
+ end
+ EA --> AA
+ VA --> UD
+ UD --> VB
+ AB --> EB
+```
+
+Everything on the anchor side is shared with the same-host kernel datapath: filters (the full tc surface including the netem extensions the relay cannot run), markers, capture and suspend all run on the anchor, so they behave identically on a tunneled link. The controller picks the VNI and the device names and hands the peer's address to each end; the kernel does the rest. The device names are a pure function of the link id (bridge, `gvl` device) and the endpoint's node id (device), so each machine can recognise its own leftovers without asking anyone.
+
+## Datapath selection
+
+The controller's `UDPLink._kernel_datapath_eligible` decides at NIO prepare time, on every link (re)creation:
+
+| Condition | Outcome |
+|---|---|
+| Both endpoints on one compute, both able to anchor | Kernel link (per-link bridge, unchanged) |
+| Cross-compute, `enable_vxlan_cross_compute` on, both computes report `ubridge_vxlan`, both ends anchorable, addresses on a common subnet and distinct | **VXLAN tunnel** |
+| Same-host pair (both computes resolve to one address) | Relay — the kernel keeps one socket per dstport, so a tunnel pair collides with itself on one host |
+| Ethernet switch on either end (cross-compute), serial / non-Ethernet ports, unix-socket vendor containers | Relay |
+| Capability missing, flag off, or the link was already downgraded | Relay |
+
+On project (re)open every link re-runs the same decision, so flipping the flag upgrades or downgrades links the same way the same-host datapath is chosen.
+
+## Link creation
+
+Each end's compute owns its own machine: it resolves the lower device from the peer address locally, creates the device and enslaves it. The resolution is per tunnel, not a host setting: a connected UDP socket makes the kernel run the route lookup for the peer (nothing is sent), the chosen source address is matched back to the interface that holds it, and that interface becomes the `dev=` — carrying both the derived MTU and the outer source address along the actual routed path, policy routing included. Two peers reachable through different interfaces therefore ride different lower devices with independently derived MTUs; a peer with no route, or a source address no interface owns, is a wiring error and the link degrades to the relay. The two sides never talk to each other; the controller's VNI is what makes them a pair.
+
+```mermaid
+sequenceDiagram
+ participant C as Controller
+ participant UA as Compute A uBridge
+ participant UB as Compute B uBridge
+ C->>UA: NIO: bridge, VNI, dstport, peer address
+ C->>UB: NIO: bridge, VNI, dstport, peer address
+ par compute A
+ UA->>UA: vxlan show, delete a leftover
+ UA->>UA: vxlan create with the local lower device
+ UA->>UA: brctl addif bridge anchor, tunnel
+ and compute B
+ UB->>UB: vxlan show, delete a leftover
+ UB->>UB: vxlan create with the local lower device
+ UB->>UB: brctl addif bridge anchor, tunnel
+ end
+ Note over C: a refused create retries with a fresh VNI, then degrades the link to the relay
+```
+
+The `vxlan show` step is the idempotency contract: a device found under the link's deterministic name belongs to this link (a crashed run's leftover, the previous instance of a node restart) and is replaced, never refused. A create that fails with the name free can only be the VNI — the kernel refuses one already registered on the shared socket — so the controller bumps the derivation salt and retries, and after the retries run out it marks the link downgraded and rebuilds it on the relay. The same shape covers project open: a refused entry is reported per link by the batch route, retried on fresh VNIs up to the same candidate count an interactive creation gets, then degraded, so a VNI collision can never make a project unopenable. And a link wired while its nodes were stopped meets its collision only at node start — the controller's `Node.start` recognises the refused device (its name names the link), stops the half-started node, re-wires the link on a fresh VNI or, on a second refusal, degrades it to the relay, and retries: no path can fail permanently over a VNI.
+
+## Interfaces
+
+| Interface | Where | Notes |
+|---|---|---|
+| `nio_bridge.vxlan` (name, vni, remote, dstport) | Compute API, NIO create/update body | Present only on cross-compute kernel links; ignored by update (the device persists) |
+| `ubridge_vxlan` | `GET /capabilities` and `GET /computes/{id}` | Probed with a scratch create/delete on a scratch port; `null` (unknown) keeps links on the relay |
+| `kernel_datapath` | Link REST payload | True for tunneled links, like any kernel link |
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `Server.enable_vxlan_cross_compute` | `false` | Wire eligible cross-compute links as VXLAN tunnels |
+| `Server.vxlan_dstport` | `4789` | The UDP port all tunnels of the deployment listen on (one kernel socket per port; the IANA-assigned VXLAN port — change it when other VXLAN software on the computes already owns it, since a shared socket merges demux tables) |
+
+## Known caveats
+
+* **MTU is the whole story.** The kernel derives the tunnel device's MTU from the lower device (`underlay − 50` for an IPv4 underlay, `− 70` for IPv6; the 50 is 20 outer IPv4 + 8 UDP + 8 VXLAN + 14 inner Ethernet header — a VXLAN packet carries the guest's complete inner frame, per `vxlan_headroom()` in `include/net/vxlan.h`) and the per-link bridge, which carries no MTU of its own, reports the minimum of its ports — i.e. the derived value. A frame past it is dropped at the bridge egress **silently** (no ICMP, no log), which is why the datapath is opt-in: on a 1500-byte fabric a tunnel caps guests at 1450 while the relay carries 1500 with no tax. Guests set their own interface MTU, exactly like a VM or container raising eth0 on the same-host datapath.
+* **Asymmetric underlays give one-way jumbo.** The derivation is per machine: a 9000-MTU compute paired with a 1500-MTU one forwards large frames in one direction only. Keep the fabric's MTU uniform between computes.
+* **The MTU is derived at device creation and never tracks the underlay afterwards** — the vxlan driver registers no `NETDEV_CHANGEMTU` handler. Re-creation re-derives — and re-resolves the lower device, so a route change is picked up with it — and the wiring path deletes and re-creates the device (`_ensure_vxlan_device`) on every node start and link (re)creation, so the operational fix is: change the underlay MTU on **both** computes, then stop/start the nodes (or re-create the link, or reopen the project). A manual `ip link set mtu` on a live deployment also sticks: the bridge, unlike the tunnel, follows its ports' MTU at runtime (`NETDEV_CHANGEMTU → br_mtu_auto_adjust`), up and down, as long as the bridge MTU was never set by hand — ours never is.
+* **VNI collisions self-heal, not never happen.** The VNI is drawn from the upper VNI space (1M–16.7M) but two links of one deployment can still collide on a host. Every path where the kernel's refusal can surface carries its own heal: link creation retries a fresh VNI then degrades to the relay, project open re-dispatches refused entries the same way, and node start (a link wired while its nodes were stopped) stops the half-started node, re-wires the link on a fresh VNI or degrades it on a second refusal, and retries the start. A reopen re-rolls every link's VNI, so an unlucky link is not permanently unlucky.
+* **Security is the relay's, unchanged.** VXLAN carries no authentication; the VNI and the shared port are the only demux, and anyone who can reach the port can inject into any VNI. Treat the compute fabric as trusted, exactly as for the relay's UDP tunnels.
+* **Same-host compute pairs stay on the relay**, as do switch links, serial ports and unix-socket vendor containers — the tunnel is a per-link-bridge shape, and those cases are not (or would collide with themselves on one host).
+
+## Verification
+
+* Unit: `tests/controller/test_kernel_datapath_link.py` (selection matrix, tunnel spec derivation, VNI retry, downgrade), `tests/utils/test_vxlan.py`, `tests/compute/docker/test_docker_kernel_datapath.py` (device lifecycle, leftover replacement, teardown ordering), `tests/api/routes/compute/test_capabilities.py`.
+* Live: no automated scenario — a second network stack on one machine means a network namespace, which needs root and host-specific routing and proved too environment-coupled for the suite. Validate on a real two-compute deployment (two machines, or a namespace-isolated second server): both ends agree on the VNI and dstport, each per-link bridge enslaves exactly `[anchor, tunnel]`, the derived MTU crosses while one past it is dropped at the bridge egress, traffic and filters behave as on the same-host datapath, and link delete plus project close/reopen leave no devices behind.
+
+## Roadmap
+
+* PMTU probing between computes to pick the datapath (and pin the MTU) per pair instead of a deployment-wide flag.
+* Ethernet switch links tunneled through the switch's own bridge (the absorbed-anchor shape) instead of falling back to the relay.
+* VNI-space bookkeeping to make collisions impossible by construction rather than self-healing.
diff --git a/gns3server/__init__.py b/gns3server/__init__.py
index 66dd93d3f..b338903ce 100644
--- a/gns3server/__init__.py
+++ b/gns3server/__init__.py
@@ -14,4 +14,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-from .version import __version__
+from .version import __version__ as __version__
diff --git a/gns3server/agent/__init__.py b/gns3server/agent/__init__.py
index dee2aaed1..f27893a61 100644
--- a/gns3server/agent/__init__.py
+++ b/gns3server/agent/__init__.py
@@ -34,8 +34,7 @@ AI_COPILOT_AVAILABLE = False
# Try to import AI Copilot components
try:
- from .gns3_copilot.project_agent_manager import get_project_agent_manager
- from .gns3_copilot.project_agent_manager import ProjectAgentManager
+ from .gns3_copilot.project_agent_manager import ProjectAgentManager, get_project_agent_manager
AI_COPILOT_AVAILABLE = True
@@ -57,7 +56,7 @@ except ImportError as e:
AI_COPILOT_AVAILABLE = False
# Provide stub functions that raise helpful errors
- async def get_project_agent_manager():
+ async def get_project_agent_manager() -> "ProjectAgentManager":
"""
Get the global ProjectAgentManager singleton instance.
@@ -68,7 +67,7 @@ except ImportError as e:
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
- class ProjectAgentManager:
+ class ProjectAgentManager: # type: ignore[no-redef]
"""
Stub class for ProjectAgentManager when AI dependencies are not installed.
"""
@@ -103,6 +102,6 @@ except ImportError:
__all__ = [
"AI_COPILOT_AVAILABLE",
"MCP_AVAILABLE",
- "get_project_agent_manager",
"ProjectAgentManager",
+ "get_project_agent_manager",
]
diff --git a/gns3server/agent/gns3_copilot/agent/context_manager.py b/gns3server/agent/gns3_copilot/agent/context_manager.py
index 79c526dbf..48258729b 100644
--- a/gns3server/agent/gns3_copilot/agent/context_manager.py
+++ b/gns3server/agent/gns3_copilot/agent/context_manager.py
@@ -46,8 +46,7 @@ import logging
import os
import warnings
from pathlib import Path
-from typing import Any
-from typing import Callable
+from typing import Any, Callable
# Configure tiktoken cache directory (must be set before importing tiktoken)
_cache_dir = Path(__file__).parent.parent / "cache" / "tiktoken"
@@ -55,9 +54,7 @@ _cache_dir.mkdir(parents=True, exist_ok=True)
os.environ["TIKTOKEN_CACHE_DIR"] = str(_cache_dir)
import tiktoken
-from langchain_core.messages import BaseMessage
-from langchain_core.messages import SystemMessage
-from langchain_core.messages import trim_messages
+from langchain_core.messages import BaseMessage, SystemMessage, trim_messages
logger = logging.getLogger(__name__)
@@ -140,7 +137,7 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
for tool in tools:
try:
# Build tool schema in OpenAI format
- tool_schema = {
+ tool_schema: dict[str, Any] = {
"type": "function",
"function": {
"name": tool.name,
@@ -391,10 +388,10 @@ def create_pre_model_hook(
max_tokens_for_trim = max_input_tokens - tool_tokens
# Validate budget and provide actionable warnings
- if system_tokens + tool_tokens > max_input_tokens: # noqa: E501
+ if system_tokens + tool_tokens > max_input_tokens:
logger.error(
"System prompt (%d tokens) + tools (%d tokens) EXCEED input "
- "budget (%d tokens). This will likely cause LLM call failures. " # noqa: E501
+ "budget (%d tokens). This will likely cause LLM call failures. "
"Recommendations: 1) Reduce system prompt length, 2) Reduce "
"number of tools, 3) Use a model with larger context window, "
"or 4) Switch to 'conservative' strategy.",
@@ -524,7 +521,7 @@ def _inject_topology_into_system(
if filtered_count > 0:
logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count)
- return [SystemMessage(content=formatted_prompt)] + non_system_messages
+ return [SystemMessage(content=formatted_prompt), *non_system_messages]
# ============================================================================
@@ -556,7 +553,7 @@ def prepare_context_messages(
else:
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
- return [SystemMessage(content=formatted_prompt)] + state_messages
+ return [SystemMessage(content=formatted_prompt), *state_messages]
# ============================================================================
diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py
index a9616eb8b..822c54b20 100644
--- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py
+++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py
@@ -50,17 +50,12 @@ import json
import logging
import operator
from datetime import datetime
-from typing import Annotated
-from typing import Literal
+from typing import Annotated, Literal
# Third-party imports
-from langchain.messages import AnyMessage
-from langchain.messages import SystemMessage
-from langchain.messages import ToolMessage
+from langchain.messages import AnyMessage, SystemMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
-from langgraph.graph import END
-from langgraph.graph import START
-from langgraph.graph import StateGraph
+from langgraph.graph import END, START, StateGraph
from langgraph.managed.is_last_step import RemainingSteps
from typing_extensions import TypedDict
@@ -70,8 +65,6 @@ from gns3server.agent.gns3_copilot.agent.context_manager import (
)
from gns3server.agent.gns3_copilot.agent.model_factory import (
create_base_model_with_tools,
-)
-from gns3server.agent.gns3_copilot.agent.model_factory import (
create_title_model,
)
from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool
@@ -79,27 +72,26 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
get_current_llm_config,
)
from gns3server.agent.gns3_copilot.prompts import load_system_prompt
+from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool, InjectionSkillsTool, PacketAnalysisSkillsTool
from gns3server.agent.gns3_copilot.skills.registry import get_prompt
from gns3server.agent.gns3_copilot.tools_v2 import (
ExecuteMultipleDeviceCommands,
-)
-from gns3server.agent.gns3_copilot.tools_v2 import (
ExecuteMultipleDeviceConfigCommands,
+ GNS3CreateEllipseDrawingTool,
+ GNS3CreateNodeTool,
+ GNS3CreateRectangleDrawingTool,
+ GNS3CreateTextDrawingTool,
+ GNS3LinkTool,
+ GNS3PacketFilterTool,
+ GNS3StartNodeTool,
+ GNS3StopNodeTool,
+ GNS3SuspendNodeTool,
+ GNS3TemplateTool,
+ GNS3UpdateNodeNameTool,
+ GNS3WaitTool,
+ PacketAnalysisTool,
)
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3PacketFilterTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
-from gns3server.agent.gns3_copilot.tools_v2 import GNS3WaitTool
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
-from gns3server.agent.gns3_copilot.tools_v2 import PacketAnalysisTool
-from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool
-from gns3server.agent.gns3_copilot.skills import InjectionSkillsTool
-from gns3server.agent.gns3_copilot.skills import PacketAnalysisSkillsTool
# Set up logger for GNS3-Copilot
logger = logging.getLogger(__name__)
@@ -114,6 +106,9 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
+ GNS3CreateTextDrawingTool(), # Add text labels to the canvas
+ GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
+ GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3UpdateNodeNameTool(), # Update node name
@@ -129,6 +124,9 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
+ GNS3CreateTextDrawingTool(), # Add text labels to the canvas
+ GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
+ GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3StopNodeTool(), # Stop GNS3 nodes
@@ -527,7 +525,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
)
except Exception as e:
logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
- observation = f"Error: {str(e)}"
+ observation = f"Error: {e!s}"
# Serialize observation to JSON string if it's not already a string
# This ensures ToolMessage.content is always JSON format, not Python
diff --git a/gns3server/agent/gns3_copilot/agent/model_factory.py b/gns3server/agent/gns3_copilot/agent/model_factory.py
index cfdc26854..5700b38bc 100644
--- a/gns3server/agent/gns3_copilot/agent/model_factory.py
+++ b/gns3server/agent/gns3_copilot/agent/model_factory.py
@@ -32,8 +32,7 @@ Configuration is passed directly from the database.
"""
import logging
-from typing import Any
-from typing import Optional
+from typing import Any, Optional
from langchain.chat_models import init_chat_model
@@ -110,7 +109,7 @@ def create_base_model(
try:
# Prepare parameters for init_chat_model
- init_params = {
+ init_params: dict[str, Any] = {
"model": config_vars["model_name"],
"model_provider": config_vars["model_provider"],
"api_key": config_vars["api_key"],
@@ -177,7 +176,7 @@ def create_title_model(
try:
# Prepare parameters for init_chat_model
- init_params = {
+ init_params: dict[str, Any] = {
"model": config_vars["model_name"],
"model_provider": config_vars["model_provider"],
"api_key": config_vars["api_key"],
diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py
index bf916b043..5c58cd4e1 100644
--- a/gns3server/agent/gns3_copilot/agent_service.py
+++ b/gns3server/agent/gns3_copilot/agent_service.py
@@ -36,11 +36,7 @@ import json
import logging
import os
from datetime import datetime
-from typing import Any
-from typing import AsyncGenerator
-from typing import Dict
-from typing import List
-from typing import Optional
+from typing import Any, AsyncGenerator, Dict, List, Optional
from uuid import uuid4
import aiosqlite
@@ -53,8 +49,6 @@ from gns3server.agent.gns3_copilot.chat_sessions_repository import (
)
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
set_current_jwt_token,
-)
-from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
set_current_llm_config,
)
from gns3server.agent.gns3_copilot.utils.error_handler import format_error_message
@@ -256,17 +250,16 @@ class AgentService:
)
# Ensure checkpointer is initialized
- if not self._checkpointer_conn:
- await self._get_checkpointer()
+ checkpointer = await self._get_checkpointer()
# Get or create chat session
- repo = ChatSessionsRepository(self._checkpointer_conn)
+ repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.get_session_by_thread(session_id)
is_new_session = session is None
if is_new_session:
# Create new session
- copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
+ copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
session = await repo.create_session(
thread_id=session_id,
user_id=user_id or "",
@@ -290,7 +283,7 @@ class AgentService:
# Build config - only thread-safe identifiers
# Determine recursion_limit based on copilot_mode
- copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
+ copilot_mode = (llm_config or {}).get("copilot_mode", "teaching_assistant").lower()
if copilot_mode == "troubleshooting_injection":
recursion_limit = 100 # Need more recursion depth for fault injection workflow
log.debug("Using extended recursion_limit for troubleshooting_injection mode: 100")
@@ -453,10 +446,10 @@ class AgentService:
yield chunk
else:
# Use stateless converter for other events
- chunk = self._convert_event_to_chunk(event, session_id)
- if chunk:
- log.debug("Yielding chunk: type=%s", chunk.get("type"))
- yield chunk
+ converted = self._convert_event_to_chunk(event, session_id)
+ if converted:
+ log.debug("Yielding chunk: type=%s", converted.get("type"))
+ yield converted
# Check if stream was aborted and yield tool_end events for aborted tools
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
@@ -624,10 +617,9 @@ class AgentService:
Returns:
List of session dictionaries
"""
- if not self._checkpointer_conn:
- await self._get_checkpointer()
+ checkpointer = await self._get_checkpointer()
- repo = ChatSessionsRepository(self._checkpointer_conn)
+ repo = ChatSessionsRepository(checkpointer.conn)
sessions = await repo.list_sessions(user_id=user_id, copilot_mode=copilot_mode, limit=limit)
return [s.to_dict() for s in sessions]
@@ -641,10 +633,9 @@ class AgentService:
Returns:
True if deleted, False if not found
"""
- if not self._checkpointer_conn:
- await self._get_checkpointer()
+ checkpointer = await self._get_checkpointer()
- repo = ChatSessionsRepository(self._checkpointer_conn)
+ repo = ChatSessionsRepository(checkpointer.conn)
return await repo.delete_session(session_id)
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
@@ -658,10 +649,9 @@ class AgentService:
Returns:
Updated session dictionary or None
"""
- if not self._checkpointer_conn:
- await self._get_checkpointer()
+ checkpointer = await self._get_checkpointer()
- repo = ChatSessionsRepository(self._checkpointer_conn)
+ repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.update_session(thread_id=session_id, title=new_title)
return session.to_dict() if session else None
@@ -676,10 +666,9 @@ class AgentService:
Returns:
Updated session dictionary or None
"""
- if not self._checkpointer_conn:
- await self._get_checkpointer()
+ checkpointer = await self._get_checkpointer()
- repo = ChatSessionsRepository(self._checkpointer_conn)
+ repo = ChatSessionsRepository(checkpointer.conn)
session = await repo.pin_session(thread_id=session_id, pinned=pinned)
return session.to_dict() if session else None
@@ -696,7 +685,7 @@ class AgentService:
"Checkpointer connection closed for: %s",
self.project_path,
)
- except asyncio.TimeoutError:
+ except TimeoutError:
log.warning(
"Checkpointer connection close timeout for: %s (forcing cleanup)",
self.project_path,
diff --git a/gns3server/agent/gns3_copilot/chat_sessions_repository.py b/gns3server/agent/gns3_copilot/chat_sessions_repository.py
index 4d18d8852..d263f35cf 100644
--- a/gns3server/agent/gns3_copilot/chat_sessions_repository.py
+++ b/gns3server/agent/gns3_copilot/chat_sessions_repository.py
@@ -33,10 +33,7 @@ checkpoint database.
import json
import logging
from datetime import datetime
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
+from typing import Any, Dict, List, Optional
import aiosqlite
@@ -125,7 +122,7 @@ class ChatSessionsRepository:
project_id: str,
title: str = "New Conversation",
copilot_mode: Optional[str] = None,
- ) -> ChatSession:
+ ) -> Optional[ChatSession]:
"""
Create a new chat session.
@@ -158,6 +155,8 @@ class ChatSessionsRepository:
session_id = cursor.lastrowid
log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode)
+ if session_id is None:
+ return None
return await self.get_session_by_id(session_id)
async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
@@ -214,7 +213,7 @@ class ChatSessionsRepository:
List of ChatSession
"""
query = "SELECT * FROM chat_sessions"
- params = []
+ params: List[Any] = []
conditions = []
if user_id:
@@ -268,7 +267,7 @@ class ChatSessionsRepository:
Updated ChatSession or None
"""
updates = []
- params = []
+ params: List[Any] = []
now = datetime.utcnow().isoformat()
diff --git a/gns3server/agent/gns3_copilot/gns3_client/__init__.py b/gns3server/agent/gns3_copilot/gns3_client/__init__.py
index 100ed500c..326c0f886 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/__init__.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/__init__.py
@@ -47,14 +47,18 @@ The connector is adapted from the upstream gns3fy project
from .api_handlers import build_gns3_ctx
from .connector import Gns3Connector
-from .connector_factory import get_gns3_connector
-from .connector_factory import get_gns3_connector_with_llm_config
-from .connector_factory import get_gns3_server_host
-from .connector_factory import get_llm_config
-from .context_helpers import get_current_jwt_token
-from .context_helpers import get_current_llm_config
-from .context_helpers import set_current_jwt_token
-from .context_helpers import set_current_llm_config
+from .connector_factory import (
+ get_gns3_connector,
+ get_gns3_connector_with_llm_config,
+ get_gns3_server_host,
+ get_llm_config,
+)
+from .context_helpers import (
+ get_current_jwt_token,
+ get_current_llm_config,
+ set_current_jwt_token,
+ set_current_llm_config,
+)
from .gns3_project_info import GNS3ProjectInfoTool
from .gns3_topology_reader import GNS3TopologyTool
@@ -71,16 +75,16 @@ __description__ = "AI-powered network automation assistant for GNS3"
__url__ = "https://github.com/yueguobin/gns3-copilot"
__all__ = [
+ "GNS3ProjectInfoTool",
+ "GNS3TopologyTool",
"Gns3Connector",
"build_gns3_ctx",
- "GNS3TopologyTool",
- "GNS3ProjectInfoTool",
+ "get_current_jwt_token",
+ "get_current_llm_config",
"get_gns3_connector",
"get_gns3_connector_with_llm_config",
"get_gns3_server_host",
"get_llm_config",
"set_current_jwt_token",
- "get_current_jwt_token",
"set_current_llm_config",
- "get_current_llm_config",
]
diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
index b0934c201..44e03688b 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
@@ -47,16 +47,15 @@ Copilot-side callers build the context with :func:`build_gns3_ctx`, which
pulls the request-scoped user JWT from the context variables.
"""
-from typing import Any
-from concurrent.futures import ThreadPoolExecutor
-
import hashlib
import logging
-
-from gns3server.services import access_ticket_service
-from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
+from gns3server.services import access_ticket_service
+from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
+from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
log = logging.getLogger(__name__)
@@ -107,6 +106,10 @@ VALID_LINK_FIELDS = {
"link_id",
"project_id",
"link_type",
+ # Runtime datapath fact (kernel bridge vs uBridge relay): the MCP tool
+ # descriptions have always advertised it, but it was missing here, so
+ # link_list could not return it at all.
+ "kernel_datapath",
"nodes",
"suspend",
"link_style",
@@ -162,14 +165,14 @@ def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict
}
-def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
+def _filter_node_response(node: dict, fields: list[str] | None = None) -> dict:
"""Filter node response to only include requested fields."""
if not fields:
fields = ["node_id", "name", "node_type", "status", "console"]
return {k: node[k] for k in fields if k in node}
-def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
+def _filter_link_response(link: dict, fields: list[str] | None = None) -> dict:
"""Filter link response to only include requested fields."""
if not fields:
fields = LINK_DEFAULT_FIELDS
@@ -278,7 +281,7 @@ def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
return list(pool.map(_act, node_ids))
-def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -296,7 +299,7 @@ def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"message": f"Node {node_id} started", "node_id": node_id}
-def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -314,7 +317,7 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
return {"message": f"Node {node_id} stopped", "node_id": node_id}
-def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -332,7 +335,7 @@ def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
return {"message": f"Node {node_id} suspended", "node_id": node_id}
-def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
@@ -401,7 +404,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return _filter_node_response(resp, fields)
-def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -694,8 +697,8 @@ def available_filters_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
"""
List the packet filter types available for a link (GNS3 API v3 only).
- Returns a list of filter descriptors (frequency_drop, packet_loss,
- delay, corrupt, bpf) with their parameters.
+ Returns a list of filter descriptors with their parameters — the
+ authoritative, per-link answer (topology and compute-capability gated).
"""
project_id = params.get("project_id")
link_id = params.get("link_id")
@@ -706,7 +709,7 @@ def available_filters_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return conn.http_call("get", url).json()
-def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -762,7 +765,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return _filter_link_response(resp, fields)
-def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -809,7 +812,7 @@ def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
# ── Link capture / reset handlers ──────────────────────────────────────
-def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
@@ -1074,3 +1077,95 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
+
+
+# ── Drawing handlers ──────────────────────────────────────────────────────
+# SVG assembly and validation live in gns3server.utils.drawings (Web UI
+# compatible format); these handlers only carry params and do the REST call.
+
+
+def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ """Create a canvas text label; the SVG is assembled from validated params."""
+
+ project_id = params.get("project_id")
+ if not project_id:
+ return {"error": "project_id is required"}
+ try:
+ svg, width, height, line_height = build_text_svg(
+ params.get("text"),
+ font_size=params.get("font_size", 13),
+ color=params.get("color", "#000000"),
+ bold=params.get("bold", False),
+ font_family=params.get("font_family", "monospace"),
+ )
+ except ValueError as e:
+ return {"error": str(e)}
+ conn = _get_connector(gns3_ctx)
+ data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 1)}
+ result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
+ return {
+ "message": "Text drawing created",
+ "drawing": result,
+ "width": width,
+ "height": height,
+ "line_height": line_height,
+ }
+
+
+def create_rectangle_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ """Create a canvas rectangle; the SVG is assembled from validated params."""
+
+ project_id = params.get("project_id")
+ if not project_id:
+ return {"error": "project_id is required"}
+ width = params.get("width")
+ height = params.get("height")
+ if width is None or height is None:
+ return {"error": "width and height are required"}
+ try:
+ svg, width, height = build_rectangle_svg(
+ width,
+ height,
+ fill=params.get("fill", "#FFFFFF"),
+ fill_opacity=params.get("fill_opacity", 1.0),
+ stroke=params.get("stroke"),
+ stroke_width=params.get("stroke_width", 1),
+ dashed=params.get("dashed", False),
+ dasharray=params.get("dasharray"),
+ rx=params.get("rx", 0),
+ )
+ except ValueError as e:
+ return {"error": str(e)}
+ conn = _get_connector(gns3_ctx)
+ data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
+ result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
+ return {"message": "Rectangle drawing created", "drawing": result, "width": width, "height": height}
+
+
+def create_ellipse_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ """Create a canvas ellipse from a bounding box; the SVG is assembled from validated params."""
+
+ project_id = params.get("project_id")
+ if not project_id:
+ return {"error": "project_id is required"}
+ width = params.get("width")
+ height = params.get("height")
+ if width is None or height is None:
+ return {"error": "width and height are required"}
+ try:
+ svg, width, height = build_ellipse_svg(
+ width,
+ height,
+ fill=params.get("fill", "#FFFFFF"),
+ fill_opacity=params.get("fill_opacity", 1.0),
+ stroke=params.get("stroke"),
+ stroke_width=params.get("stroke_width", 1),
+ dashed=params.get("dashed", False),
+ dasharray=params.get("dasharray"),
+ )
+ except ValueError as e:
+ return {"error": str(e)}
+ conn = _get_connector(gns3_ctx)
+ data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
+ result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
+ return {"message": "Ellipse drawing created", "drawing": result, "width": width, "height": height}
diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector.py b/gns3server/agent/gns3_copilot/gns3_client/connector.py
index 40ce1c5e5..21f7fc58d 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/connector.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/connector.py
@@ -175,7 +175,7 @@ class Gns3Connector:
else:
raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
except Exception as e:
- raise HTTPError(f"v3 API authentication error: {str(e)}") from e
+ raise HTTPError(f"v3 API authentication error: {e!s}") from e
def _is_token_expired(self) -> bool:
"""
@@ -273,7 +273,7 @@ class Gns3Connector:
except Exception:
# If JSON parsing fails, return error with original text
return HTTPError(
- f"Original Error: {str(e)}. GNS3 response text: {response.text}",
+ f"Original Error: {e!s}. GNS3 response text: {response.text}",
response=response,
)
return e
diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py
index c57dc8fee..0bf95f845 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py
@@ -49,14 +49,13 @@ import logging
from typing import Optional
from uuid import UUID
-from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
- get_current_jwt_token,
-)
-
# Local imports
from gns3server.agent.gns3_copilot.gns3_client.connector import (
Gns3Connector,
)
+from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
+ get_current_jwt_token,
+)
logger = logging.getLogger(__name__)
@@ -347,7 +346,7 @@ def get_gns3_server_host() -> str:
host = get_gns3_server_host()
print(f"GNS3 server host: {host}")
"""
- url = _detect_url_for_api()
+ url = _detect_url_for_api() or DEFAULT_GNS3_URL
# Extract host from URL
# URL format: protocol://host:port
diff --git a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py
index 5070dcb9a..b65cf88cd 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py
@@ -124,8 +124,8 @@ def get_current_llm_config() -> Optional[dict]:
__all__ = [
- "set_current_jwt_token",
"get_current_jwt_token",
- "set_current_llm_config",
"get_current_llm_config",
+ "set_current_jwt_token",
+ "set_current_llm_config",
]
diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py
index 9b699e51f..3ed793580 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py
@@ -143,7 +143,7 @@ class GNS3ProjectInfoTool(BaseTool):
except Exception as e:
logger.error("Error retrieving GNS3 project info: %s", str(e))
- return {"error": f"Failed to retrieve project info: {str(e)}"}
+ return {"error": f"Failed to retrieve project info: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py
index ce5449079..057f4e879 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py
@@ -67,7 +67,7 @@ class GNS3TopologyTool(BaseTool):
Output: Dictionary with:
- `project_id`, `name`, `status`: Project metadata
- `nodes`: Dict of node details (node_id, name, ports, console_port,
- type, etc.)
+ console_host, type, etc.)
- `links`: List of link connections
Use this to understand network structure before making changes.
@@ -145,7 +145,7 @@ class GNS3TopologyTool(BaseTool):
except Exception as e:
logger.error("Error retrieving GNS3 topology: %s", str(e))
- return {"error": f"Failed to retrieve topology: {str(e)}"}
+ return {"error": f"Failed to retrieve topology: {e!s}"}
def _clean_nodes_ports(self, data: dict) -> dict:
"""
diff --git a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
index b1f958767..dd4851cff 100644
--- a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
+++ b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
@@ -43,17 +43,20 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
Build an inventory-style dict keyed by node name.
Shape (per node name):
- {server, name, node_id, console_port, console_type, type, ports,
- status, x, y, tags, netmiko_device_type, default_username,
- default_password}
+ {server, name, node_id, console_port, console_host, console_type,
+ type, ports, status, x, y, tags, netmiko_device_type,
+ default_username, default_password}
"""
inventory: dict[str, Any] = {}
for n in nodes:
- inventory[n.get("name")] = {
+ inventory[n.get("name") or ""] = {
"server": server_host,
"name": n.get("name"),
"node_id": n.get("node_id"),
"console_port": n.get("console"),
+ # Where the console port is bound: the compute's own host for
+ # remote computes, not the controller running this code.
+ "console_host": n.get("console_host"),
"console_type": n.get("console_type"),
"type": n.get("node_type"),
"ports": n.get("ports"),
@@ -68,7 +71,7 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
return inventory
-def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
+def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Build a human/LLM-friendly link list resolving node and port names.
@@ -76,7 +79,7 @@ def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]
Links whose endpoints cannot be resolved are skipped, mirroring the
original Project.links_summary behavior.
"""
- summary: list[dict[str, str]] = []
+ summary: list[dict[str, Any]] = []
for link in links:
if not link.get("nodes"):
continue
diff --git a/gns3server/agent/gns3_copilot/project_agent_manager.py b/gns3server/agent/gns3_copilot/project_agent_manager.py
index b9ade018a..7448a55b9 100644
--- a/gns3server/agent/gns3_copilot/project_agent_manager.py
+++ b/gns3server/agent/gns3_copilot/project_agent_manager.py
@@ -33,8 +33,7 @@ database.
import asyncio
import logging
-from typing import Dict
-from typing import Optional
+from typing import Dict, Optional
from gns3server.agent.gns3_copilot.agent_service import AgentService
diff --git a/gns3server/agent/gns3_copilot/skills/__init__.py b/gns3server/agent/gns3_copilot/skills/__init__.py
index c23c9d8b8..40dbd4476 100644
--- a/gns3server/agent/gns3_copilot/skills/__init__.py
+++ b/gns3server/agent/gns3_copilot/skills/__init__.py
@@ -36,37 +36,38 @@ Directory Structure:
- loader.py # SkillsLoader - YAML/Markdown file loading
"""
+from .loader import SkillsLoader
+from .manager import SkillsManager
from .registry import (
- SKILLS_REGISTRY,
INJECTION_SKILLS_REGISTRY,
- get_skill,
- get_injection_skill,
+ SKILLS_REGISTRY,
DeviceSkillsTool,
InjectionSkillsTool,
PacketAnalysisSkillsTool,
- set_skills_manager,
+ get_injection_skill,
+ get_skill,
get_skills_manager,
- reload_injection_skills,
- reload_forbidden_commands,
- reload_skills_repository,
get_skills_repository_info,
+ reload_forbidden_commands,
+ reload_injection_skills,
+ reload_skills_repository,
+ set_skills_manager,
)
-from .manager import SkillsManager
-from .loader import SkillsLoader
__all__ = [
- "SKILLS_REGISTRY",
"INJECTION_SKILLS_REGISTRY",
- "get_skill",
- "get_injection_skill",
+ "SKILLS_REGISTRY",
"DeviceSkillsTool",
"InjectionSkillsTool",
"PacketAnalysisSkillsTool",
- "SkillsManager",
"SkillsLoader",
- "set_skills_manager",
+ "SkillsManager",
+ "get_injection_skill",
+ "get_skill",
"get_skills_manager",
+ "get_skills_repository_info",
+ "reload_forbidden_commands",
"reload_injection_skills",
"reload_skills_repository",
- "get_skills_repository_info",
+ "set_skills_manager",
]
diff --git a/gns3server/agent/gns3_copilot/skills/loader.py b/gns3server/agent/gns3_copilot/skills/loader.py
index 655d76263..bfe8fa8b1 100644
--- a/gns3server/agent/gns3_copilot/skills/loader.py
+++ b/gns3server/agent/gns3_copilot/skills/loader.py
@@ -31,7 +31,6 @@ Supports loading injection skills and device/feature skills.
"""
import logging
-import os
from pathlib import Path
from typing import Any, Dict
@@ -248,7 +247,7 @@ class SkillsLoader:
continue
# Use device_type from YAML content as the key
# Fallback to filename stem if device_type not present
- skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None
+ skill_key = skill_data.get("device_type")
if not skill_key:
skill_key = yaml_file.stem
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
@@ -278,7 +277,7 @@ class SkillsLoader:
return ""
try:
- with open(prompt_file, "r", encoding="utf-8") as f:
+ with open(prompt_file, encoding="utf-8") as f:
content = f.read()
logger.debug(f"Loaded prompt: {prompt_name} from {prompt_file}")
return content
@@ -302,7 +301,7 @@ class SkillsLoader:
try:
commands = []
- with open(config_file, "r", encoding="utf-8") as f:
+ with open(config_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
@@ -364,7 +363,7 @@ class SkillsLoader:
Returns:
Parsed YAML content as dictionary, or empty dict if file is empty
"""
- with open(file_path, "r", encoding="utf-8") as f:
+ with open(file_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
logger.warning(f"YAML file {file_path} is empty or has invalid format")
diff --git a/gns3server/agent/gns3_copilot/skills/manager.py b/gns3server/agent/gns3_copilot/skills/manager.py
index 473fb2b09..ef62562ce 100644
--- a/gns3server/agent/gns3_copilot/skills/manager.py
+++ b/gns3server/agent/gns3_copilot/skills/manager.py
@@ -33,14 +33,17 @@ including Git operations and hot reload of skills.
import logging
import os
from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Any, Dict, Optional
try:
import git
+
+ GIT_AVAILABLE = True
except ImportError:
- git = None
+ GIT_AVAILABLE = False
from gns3server.config import Config
+
from .loader import SkillsLoader
logger = logging.getLogger(__name__)
@@ -66,7 +69,7 @@ class SkillsManager:
- Version tracking
"""
- def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
+ def __init__(self, repo_url: Optional[str] = None, branch: str = "main", auto_update: bool = False):
"""
Initialize the skills manager.
@@ -87,10 +90,10 @@ class SkillsManager:
self.branch = branch
self.auto_update = auto_update
self.loader = SkillsLoader(str(local_path))
- self._repo: Optional["git.Repo"] = None
+ self._repo: Optional[git.Repo] = None
self._prompt_count = 0
- if git is None:
+ if not GIT_AVAILABLE:
logger.warning("GitPython is not installed. Skills management features will be limited.")
def initialize(self) -> bool:
@@ -134,7 +137,7 @@ class SkillsManager:
- Up to date → nothing
- Network error → use existing files, log warning
"""
- if git is None or self._repo is None:
+ if not GIT_AVAILABLE or self._repo is None:
return
# Check for uncommitted changes
@@ -173,7 +176,7 @@ class SkillsManager:
Returns:
True if successful, False otherwise
"""
- if git is None:
+ if not GIT_AVAILABLE:
logger.error("GitPython is not installed. Cannot clone repository.")
return False
@@ -347,7 +350,7 @@ class SkillsManager:
Returns:
Commit hash as string, or empty string if not available
"""
- if git is None or self._repo is None:
+ if not GIT_AVAILABLE or self._repo is None:
try:
self._repo = git.Repo(self.local_path)
except Exception:
@@ -409,7 +412,7 @@ class SkillsManager:
Returns:
True if successful, False otherwise
"""
- if git is None:
+ if not GIT_AVAILABLE:
logger.error("GitPython is not installed. Cannot rollback.")
return False
@@ -436,7 +439,7 @@ class SkillsManager:
Returns:
List of commit information dictionaries
"""
- if git is None:
+ if not GIT_AVAILABLE:
return []
try:
diff --git a/gns3server/agent/gns3_copilot/skills/registry.py b/gns3server/agent/gns3_copilot/skills/registry.py
index d9e2b5083..782215dd0 100644
--- a/gns3server/agent/gns3_copilot/skills/registry.py
+++ b/gns3server/agent/gns3_copilot/skills/registry.py
@@ -183,8 +183,8 @@ def reload_skills_repository() -> dict[str, Any]:
protocols_ok = manager.reload_packet_analysis_protocols()
# Reload forbidden commands (local import to avoid circular dependency)
- from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload_fc
from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands
+ from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload_fc
_reload_fc()
forbidden_commands = get_forbidden_commands()
@@ -239,7 +239,7 @@ def reload_injection_skills() -> dict[str, Any]:
logger.error(f"Error during skills reload: {e}")
return {
"success": False,
- "message": f"Error: {str(e)}",
+ "message": f"Error: {e!s}",
"skill_count": len(INJECTION_SKILLS_REGISTRY),
"version": "",
}
@@ -278,7 +278,7 @@ def reload_prompts() -> dict[str, Any]:
manager = get_skills_manager()
return {
"success": False,
- "message": f"Error: {str(e)}",
+ "message": f"Error: {e!s}",
"prompt_count": manager.get_prompt_count() if manager else 0,
"version": "",
}
@@ -300,8 +300,8 @@ def reload_forbidden_commands() -> dict[str, Any]:
}
"""
try:
- from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload
from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands
+ from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload
_reload()
commands = get_forbidden_commands()
@@ -314,7 +314,7 @@ def reload_forbidden_commands() -> dict[str, Any]:
}
except Exception as e:
logger.error(f"Error during forbidden commands reload: {e}")
- return {"success": False, "message": f"Error: {str(e)}", "command_count": 0, "version": ""}
+ return {"success": False, "message": f"Error: {e!s}", "command_count": 0, "version": ""}
def get_prompt(prompt_name: str) -> str:
diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py
index 18f122727..078105ada 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/__init__.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py
@@ -40,6 +40,7 @@ Main modules:
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
- gns3_create_node: GNS3 node creation tool
- gns3_create_link: GNS3 link creation tool
+- gns3_create_drawing: GNS3 canvas drawing tools (text, rectangle, ellipse)
- gns3_start_node: GNS3 node startup tool (immediate return, no boot wait)
- gns3_wait: Wait timer tool (pair with node start to let devices boot)
- gns3_get_node_temp: GNS3 template retrieval tool
@@ -54,12 +55,16 @@ Author: Yue Guobin (岳国宾)
# Import main tool classes
from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands
from .display_tools_nornir import ExecuteMultipleDeviceCommands
+from .gns3_create_drawing import (
+ GNS3CreateEllipseDrawingTool,
+ GNS3CreateRectangleDrawingTool,
+ GNS3CreateTextDrawingTool,
+)
from .gns3_create_link import GNS3LinkTool
from .gns3_create_node import GNS3CreateNodeTool
from .gns3_get_node_temp import GNS3TemplateTool
from .gns3_packet_filter import GNS3PacketFilterTool
-from .gns3_start_node import GNS3StartNodeQuickTool
-from .gns3_start_node import GNS3StartNodeTool
+from .gns3_start_node import GNS3StartNodeQuickTool, GNS3StartNodeTool
from .gns3_stop_node import GNS3StopNodeTool
from .gns3_suspend_node import GNS3SuspendNodeTool
from .gns3_update_node_name import GNS3UpdateNodeNameTool
@@ -80,17 +85,20 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
# Export main tool classes
__all__ = [
- "ExecuteMultipleDeviceConfigCommands",
"ExecuteMultipleDeviceCommands",
+ "ExecuteMultipleDeviceConfigCommands",
+ "GNS3CreateEllipseDrawingTool",
"GNS3CreateNodeTool",
+ "GNS3CreateRectangleDrawingTool",
+ "GNS3CreateTextDrawingTool",
"GNS3LinkTool",
"GNS3PacketFilterTool",
- "GNS3StartNodeTool",
"GNS3StartNodeQuickTool",
+ "GNS3StartNodeTool",
"GNS3StopNodeTool",
"GNS3SuspendNodeTool",
- "GNS3UpdateNodeNameTool",
"GNS3TemplateTool",
+ "GNS3UpdateNodeNameTool",
"GNS3WaitTool",
"PacketAnalysisTool",
]
diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py
index db215c6bf..5d2506a65 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py
@@ -43,21 +43,21 @@ from langchain_core.callbacks import CallbackManagerForToolRun
from netmiko.exceptions import ReadTimeout
from nornir import InitNornir
from nornir.core import Nornir
-from nornir.core.task import AggregatedResult
-from nornir.core.task import Result
-from nornir.core.task import Task
+from nornir.core.task import AggregatedResult, Result, Task
from nornir_netmiko.tasks import netmiko_send_config
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
-from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
-from gns3server.agent.gns3_copilot.utils.command_filter import (
- filter_forbidden_commands,
-)
# Import custom Netmiko device types for GNS3 emulation
# This registers gns3_huawei_telnet_ce and other custom device types
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
-from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
+from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
+from gns3server.agent.gns3_copilot.utils.command_filter import (
+ filter_forbidden_commands,
+)
+from gns3server.agent.gns3_copilot.utils.device_configs import (
+ merge_duplicate_device_configs,
+)
# Explicitly register custom device types to ensure they are available
# This is a safety measure in case the auto-registration on import doesn't work
@@ -78,7 +78,7 @@ try:
# so we use importlib to get the actual module
import importlib
- sd = importlib.import_module("netmiko.ssh_dispatcher")
+ sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -196,9 +196,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
- if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
+ if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
+ # Merge same-device entries before anything keys by device_name
+ try:
+ device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="config_commands")
+ except ValueError as e:
+ logger.error("Invalid device_configs input: %s", e)
+ return [{"status": "failed", "error": str(e)}]
+
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
device_configs_list
@@ -255,7 +262,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error("Error executing configurations on all devices: %s", e)
- return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
+ return [{"status": "failed", "error": f"Execution error: {e!s}"}]
logger.info(
"Multiple device configuration execution completed. Results: %s",
@@ -285,7 +292,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"Configuration failed (ReadTimeout): {str(e)}",
+ result=f"Configuration failed (ReadTimeout): {e!s}",
failed=True,
)
@@ -305,7 +312,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"Configuration failed (Unhandled Exception): {str(e)}",
+ result=f"Configuration failed (Unhandled Exception): {e!s}",
failed=True,
)
diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py
index 8c4a5281d..bbb92b0c5 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py
@@ -43,21 +43,21 @@ from langchain_core.callbacks import CallbackManagerForToolRun
from netmiko.exceptions import ReadTimeout
from nornir import InitNornir
from nornir.core import Nornir
-from nornir.core.task import AggregatedResult
-from nornir.core.task import Result
-from nornir.core.task import Task
+from nornir.core.task import AggregatedResult, Result, Task
from nornir_netmiko.tasks import netmiko_multiline
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
-from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
-from gns3server.agent.gns3_copilot.utils.command_filter import (
- filter_forbidden_commands,
-)
# Import custom Netmiko device types for GNS3 emulation
# This registers gns3_huawei_telnet_ce and other custom device types
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
-from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
+from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
+from gns3server.agent.gns3_copilot.utils.command_filter import (
+ filter_forbidden_commands,
+)
+from gns3server.agent.gns3_copilot.utils.device_configs import (
+ merge_duplicate_device_configs,
+)
# Explicitly register custom device types to ensure they are available
# This is a safety measure in case the auto-registration on import doesn't work
@@ -78,7 +78,7 @@ try:
# so we use importlib to get the actual module
import importlib
- sd = importlib.import_module("netmiko.ssh_dispatcher")
+ sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -199,9 +199,16 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
- if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
+ if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
+ # Merge same-device entries before anything keys by device_name
+ try:
+ device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
+ except ValueError as e:
+ logger.error("Invalid device_configs input: %s", e)
+ return [{"status": "failed", "error": str(e)}]
+
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
device_configs_list
@@ -253,7 +260,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error("Error executing display on all devices: %s", e)
- return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
+ return [{"status": "failed", "error": f"Execution error: {e!s}"}]
logger.debug(
"Multiple device display execution completed. Results: %s",
@@ -288,7 +295,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"diagnostic command failed (ReadTimeout): {str(e)}",
+ result=f"diagnostic command failed (ReadTimeout): {e!s}",
failed=True,
)
@@ -313,7 +320,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"diagnostic command failed (Unhandled): {str(e)}",
+ result=f"diagnostic command failed (Unhandled): {e!s}",
failed=True,
)
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py
new file mode 100644
index 000000000..7f3f688fe
--- /dev/null
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py
@@ -0,0 +1,162 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
+#
+# This file is part of GNS3-Copilot project.
+#
+# GNS3-Copilot 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.
+#
+# GNS3-Copilot 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 GNS3-Copilot. If not, see .
+#
+# Copyright (C) 2025 Yue Guobin (岳国宾)
+# Author: Yue Guobin (岳国宾)
+#
+# Project Home: https://github.com/yueguobin/gns3-copilot
+#
+
+"""
+GNS3 canvas drawing tools for text, rectangle and ellipse annotations.
+
+Thin LangChain wrappers over the shared REST handlers in
+``gns3_client.api_handlers``; the Web UI compatible SVG is assembled and
+validated by ``gns3server.utils.drawings`` (single implementation shared
+with the MCP drawing tools).
+"""
+
+import json
+import logging
+from typing import Any
+
+from langchain.tools import BaseTool
+from langchain_core.callbacks import CallbackManagerForToolRun
+
+from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
+ build_gns3_ctx,
+ create_ellipse_drawing_handler,
+ create_rectangle_drawing_handler,
+ create_text_drawing_handler,
+)
+
+# Configure logging
+logger = logging.getLogger(__name__)
+
+
+class _GNS3CreateDrawingToolBase(BaseTool):
+ """Shared plumbing: parse JSON input, build ctx, dispatch to a handler."""
+
+ def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ raise NotImplementedError
+
+ def _run(
+ self,
+ tool_input: str,
+ run_manager: CallbackManagerForToolRun | None = None,
+ ) -> dict[str, Any]:
+ try:
+ input_data = json.loads(tool_input)
+
+ gns3_ctx = build_gns3_ctx()
+ if gns3_ctx is None:
+ logger.error("Failed to create GNS3 connector")
+ return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
+
+ return self._dispatch(input_data, gns3_ctx)
+ except json.JSONDecodeError as e:
+ logger.error("Invalid JSON input: %s", e)
+ return {"error": f"Invalid JSON input: {e}"}
+ except Exception as e:
+ logger.exception("Drawing creation failed")
+ return {"error": str(e)}
+
+
+class GNS3CreateTextDrawingTool(_GNS3CreateDrawingToolBase):
+ """
+ A LangChain tool to add a text label to the GNS3 project canvas.
+
+ **Input**:
+ A JSON object with project_id, x, y (top-left corner on the canvas)
+ and text; optional font_size (1-200, default 13), color (#RRGGBB,
+ default #000000), bold (default false), font_family (default
+ monospace) and z (default 1, keeps text above links).
+ Newlines in text start additional lines; XML characters are escaped
+ automatically; the computed width/height are returned.
+
+ **Output**:
+ A dictionary with the created drawing, its drawing_id and the
+ computed width/height/line_height, or an "error" key on failure.
+ """
+
+ name: str = "create_gns3_text_drawing"
+ description: str = """
+ Adds a text label to the GNS3 project canvas.
+ Input: JSON with project_id, x, y (top-left corner), text and optional
+ font_size, color, bold, font_family, z.
+ Returns: the created drawing with its computed width and height.
+ """
+
+ def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ return create_text_drawing_handler(input_data, gns3_ctx)
+
+
+class GNS3CreateRectangleDrawingTool(_GNS3CreateDrawingToolBase):
+ """
+ A LangChain tool to add a rectangle to the GNS3 project canvas.
+
+ **Input**:
+ A JSON object with project_id, x, y (top-left corner), width and
+ height (1-2000); optional fill (#RRGGBB or "none", default #FFFFFF),
+ fill_opacity (0-1, default 1.0), stroke (#RRGGBB, no border by
+ default), stroke_width (1-20), dashed (bool), dasharray (like "4,2",
+ requires stroke) and rx (corner radius, default 0). z defaults to 0
+ so shapes stay behind nodes and text.
+
+ **Output**:
+ A dictionary with the created drawing, or an "error" key on failure.
+ """
+
+ name: str = "create_gns3_rectangle_drawing"
+ description: str = """
+ Adds a rectangle to the GNS3 project canvas.
+ Input: JSON with project_id, x, y, width, height and optional fill,
+ fill_opacity, stroke, stroke_width, dashed, dasharray, rx, z.
+ Returns: the created drawing with its width and height.
+ """
+
+ def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ return create_rectangle_drawing_handler(input_data, gns3_ctx)
+
+
+class GNS3CreateEllipseDrawingTool(_GNS3CreateDrawingToolBase):
+ """
+ A LangChain tool to add an ellipse (or circle) to the GNS3 canvas.
+
+ **Input**:
+ A JSON object with project_id, x, y (bounding box top-left corner),
+ width and height (2-2000, equal values give a circle); the same
+ optional style fields as the rectangle tool. cx/cy are derived from
+ the bounding box; for odd width/height the drawing is 1 px smaller
+ than requested and the actual size is returned.
+
+ **Output**:
+ A dictionary with the created drawing, or an "error" key on failure.
+ """
+
+ name: str = "create_gns3_ellipse_drawing"
+ description: str = """
+ Adds an ellipse or circle to the GNS3 project canvas.
+ Input: JSON with project_id, x, y, width, height (bounding box) and
+ optional fill, fill_opacity, stroke, stroke_width, dashed, dasharray, z.
+ Returns: the created drawing with its actual width and height.
+ """
+
+ def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
+ return create_ellipse_drawing_handler(input_data, gns3_ctx)
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py
index 8f926586b..c70c559ba 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py
@@ -222,6 +222,8 @@ class GNS3LinkTool(BaseTool):
},
gns3_ctx,
)
+ if isinstance(link_resp, list):
+ raise RuntimeError("Unexpected batch response when creating a single link")
if "error" in link_resp:
raise RuntimeError(link_resp["error"])
@@ -236,7 +238,7 @@ class GNS3LinkTool(BaseTool):
created_links.append(link_info)
except Exception as e:
- error_msg = f"Failed to create link {i}: {str(e)}"
+ error_msg = f"Failed to create link {i}: {e!s}"
logger.error(error_msg)
created_links.append({"error": error_msg})
@@ -255,7 +257,7 @@ class GNS3LinkTool(BaseTool):
return [{"error": f"Invalid JSON input: {e}"}]
except Exception as e:
logger.error("Failed to process link creation: %s", e)
- return [{"error": f"Failed to process link creation: {str(e)}"}]
+ return [{"error": f"Failed to process link creation: {e!s}"}]
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py
index a511de21f..60b2b0801 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py
@@ -230,6 +230,8 @@ class GNS3CreateNodeTool(BaseTool):
},
gns3_ctx,
)
+ if isinstance(created, list):
+ raise RuntimeError("Unexpected batch response when creating a single node")
if "error" in created:
raise RuntimeError(created["error"])
node_info = {
@@ -242,7 +244,7 @@ class GNS3CreateNodeTool(BaseTool):
except Exception as e:
error_info = {
- "error": f"Node {i + 1} creation failed: {str(e)}",
+ "error": f"Node {i + 1} creation failed: {e!s}",
"status": "failed",
}
results.append(error_info)
@@ -278,7 +280,7 @@ class GNS3CreateNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process node creation request: %s", e)
- return {"error": f"Failed to process node creation request: {str(e)}"}
+ return {"error": f"Failed to process node creation request: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py
index af685ea4b..594c2c2cf 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py
@@ -168,7 +168,7 @@ class GNS3TemplateTool(BaseTool):
except Exception as e:
logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e)
- return {"error": f"Failed to retrieve templates: {str(e)}"}
+ return {"error": f"Failed to retrieve templates: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py
index d94a0fc1d..b643a26f0 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py
@@ -122,10 +122,27 @@ class GNS3PacketFilterTool(BaseTool):
Common filter types for fault injection:
- "frequency_drop": Drop every Nth packet (parameter: frequency, -1 to 32767)
- - "packet_loss": Packet loss percentage (parameter: chance, 0-100)
- - "delay": Delay in ms with optional jitter (parameters: latency 0-32767, jitter 0-32767)
+ - "packet_loss": Packet loss percentage (parameters: chance 0-100, optional correlation)
+ - "delay": Delay in ms with optional jitter and distribution (parameters: latency 0-32767,
+ jitter 0-32767, distribution uniform|normal|pareto|paretonormal)
- "corrupt": Packet corruption percentage (parameter: chance, 0-100)
- "bpf": Berkeley Packet Filter (parameter: filter expression text)
+ - "rate": Bandwidth cap, tc-style string like "512kbit" (kernel links only)
+ - "reorder": Packet reordering (parameters: percentage, correlation, gap; needs delay;
+ kernel links only)
+ - "gemodel": Gilbert-Elliot bursty loss (parameters: p good-to-bad transition, r bad-to-good
+ transition, 1-h loss in the bad state; mean loss = p/(p+r)*(1-h); kernel links only)
+ - "duplicate": Packet duplication percentage (kernel links only)
+ - "seed": Reproducible netem random draws (kernel links only)
+ - "limit": Impairment queue depth in packets (kernel links only)
+ - "quota": Byte cap — after the quota, drop with a chance (parameters: bytes, chance;
+ kernel links only)
+ - "window_drop": Time-window outage (parameters: start_ms, outage_ms, chance, optional
+ period_ms, jitter_ms; start is relative to when the filter is applied — any filter
+ update restarts the schedule; kernel links only)
+
+ Filter availability depends on the link (kernel-only types need kernel-datapath links
+ and compute support): always check with action "get_available" before setting.
Input is a JSON object with:
- project_id (str): GNS3 project UUID
@@ -239,7 +256,7 @@ class GNS3PacketFilterTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process packet filter request: %s", e)
- return {"error": f"Failed to process packet filter request: {str(e)}"}
+ return {"error": f"Failed to process packet filter request: {e!s}"}
def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
"""Get available filter types for the link."""
@@ -260,7 +277,7 @@ class GNS3PacketFilterTool(BaseTool):
return {
"action": "get_available",
"link_id": link_id,
- "error": f"Failed to get available filters: {str(e)}",
+ "error": f"Failed to get available filters: {e!s}",
"status": "failed",
}
@@ -286,7 +303,7 @@ class GNS3PacketFilterTool(BaseTool):
# Check if output contains "Invalid" indicating syntax error
if "Invalid" in result.stdout or "Invalid" in result.stderr:
- error_lines = []
+ error_lines: list[str] = []
if "Invalid" in result.stderr:
error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
if "Invalid" in result.stdout:
@@ -318,7 +335,7 @@ class GNS3PacketFilterTool(BaseTool):
except Exception as e:
logger.error("Unexpected error during BPF validation: %s", e)
- return {"valid": False, "error": f"BPF validation error: {str(e)}"}
+ return {"valid": False, "error": f"BPF validation error: {e!s}"}
def _set_filters(
self,
@@ -384,7 +401,7 @@ class GNS3PacketFilterTool(BaseTool):
return {
"action": "set",
"link_id": link_id,
- "error": f"Failed to set filters: {str(e)}",
+ "error": f"Failed to set filters: {e!s}",
"status": "failed",
}
@@ -407,7 +424,7 @@ class GNS3PacketFilterTool(BaseTool):
return {
"action": "get",
"link_id": link_id,
- "error": f"Failed to get filters: {str(e)}",
+ "error": f"Failed to get filters: {e!s}",
"status": "failed",
}
@@ -449,7 +466,7 @@ class GNS3PacketFilterTool(BaseTool):
return {
"action": "clear",
"link_id": link_id,
- "error": f"Failed to clear filters: {str(e)}",
+ "error": f"Failed to clear filters: {e!s}",
"status": "failed",
}
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py
index 8e98c7480..2fb17f8f8 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py
@@ -136,7 +136,11 @@ class GNS3StartNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
- start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
+ start_errors = (
+ {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
+ if isinstance(start_results, list)
+ else {}
+ )
# Get immediate status (likely 'starting' or 'stopped') — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -216,7 +220,7 @@ class GNS3StartNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to start nodes: %s", e)
- return {"error": f"Failed to start nodes: {str(e)}"}
+ return {"error": f"Failed to start nodes: {e!s}"}
# Backward-compat alias: the waiting variant was removed; both names now
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py
index 3c0d8ef2c..b4ef8cfd5 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py
@@ -124,7 +124,11 @@ class GNS3StopNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
- stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
+ stop_errors = (
+ {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
+ if isinstance(stop_results, list)
+ else {}
+ )
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -194,7 +198,7 @@ class GNS3StopNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to stop nodes: %s", e)
- return {"error": f"Failed to stop nodes: {str(e)}"}
+ return {"error": f"Failed to stop nodes: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py
index 74f9ba883..25dda50fd 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py
@@ -130,7 +130,11 @@ class GNS3SuspendNodeTool(BaseTool):
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
- suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
+ suspend_errors = (
+ {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
+ if isinstance(suspend_results, list)
+ else {}
+ )
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@@ -205,7 +209,7 @@ class GNS3SuspendNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to suspend nodes: %s", e)
- return {"error": f"Failed to suspend nodes: {str(e)}"}
+ return {"error": f"Failed to suspend nodes: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py
index 7756b33a2..3121bc598 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py
@@ -241,7 +241,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to update node names: %s", e)
- return {"error": f"Failed to update node names: {str(e)}"}
+ return {"error": f"Failed to update node names: {e!s}"}
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py
index 15a9ed24e..ed2f12802 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py
@@ -104,4 +104,4 @@ class GNS3WaitTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Wait tool failed: %s", e)
- return {"error": f"Wait tool failed: {str(e)}"}
+ return {"error": f"Wait tool failed: {e!s}"}
diff --git a/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py
index b9c35d141..a1123403c 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py
@@ -143,7 +143,8 @@ class PacketAnalysisTool(BaseTool):
stderr=subprocess.DEVNULL,
text=True,
)
- tshark.stdout.close()
+ if tshark.stdout is not None:
+ tshark.stdout.close()
stdout, _ = grep.communicate(timeout=30)
tshark.wait(timeout=5)
except Exception as e:
@@ -324,7 +325,7 @@ class PacketAnalysisTool(BaseTool):
except Exception as e:
logger.error(f"PacketAnalysisTool error: {e}", exc_info=True)
- return f'{{"error": "Analysis failed: {str(e)}"}}'
+ return f'{{"error": "Analysis failed: {e!s}"}}'
finally:
# Clean up temp file
@@ -443,7 +444,7 @@ class PacketAnalysisTool(BaseTool):
# Build command: tshark -r
import shlex
- cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
+ cmd = ["tshark", "-r", pcap_file, *shlex.split(tshark_args)]
logger.info(f"Running tshark: {' '.join(cmd)}")
@@ -496,7 +497,7 @@ class PacketAnalysisTool(BaseTool):
return '{"error": "tshark not installed. Please install tshark: apt install tshark"}'
except Exception as e:
logger.error(f"tshark execution error: {e}", exc_info=True)
- return f'{{"error": "tshark failed: {str(e)}"}}'
+ return f'{{"error": "tshark failed: {e!s}"}}'
if __name__ == "__main__":
diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py
index 3a8789cda..fb953936f 100644
--- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py
+++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py
@@ -43,18 +43,18 @@ from langchain_core.callbacks import CallbackManagerForToolRun
from netmiko.exceptions import ReadTimeout
from nornir import InitNornir
from nornir.core import Nornir
-from nornir.core.task import AggregatedResult
-from nornir.core.task import Result
-from nornir.core.task import Task
+from nornir.core.task import AggregatedResult, Result, Task
from nornir_netmiko.tasks import netmiko_multiline
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
-from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
# Import custom Netmiko device types for GNS3 emulation
# This registers gns3_vpcs_telnet and other custom device types
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
-from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
+from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
+from gns3server.agent.gns3_copilot.utils.device_configs import (
+ merge_duplicate_device_configs,
+)
# Explicitly register VPCS device type to ensure it is available
try:
@@ -68,7 +68,7 @@ try:
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
import importlib
- sd = importlib.import_module("netmiko.ssh_dispatcher")
+ sd: Any = importlib.import_module("netmiko.ssh_dispatcher")
# Recalculate platforms lists to include custom device types
sd.platforms = list(sd.CLASS_MAPPER.keys())
@@ -179,9 +179,16 @@ class VPCSCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
- if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
+ if len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
+ # Merge same-device entries before anything keys by device_name
+ try:
+ device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
+ except ValueError as e:
+ logger.error("Invalid device_configs input: %s", e)
+ return [{"status": "failed", "error": str(e)}]
+
# Create a mapping of device names to their commands
device_configs_map = self._configs_map(device_configs_list)
@@ -227,7 +234,7 @@ class VPCSCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error("Error executing commands on all VPCS devices: %s", e)
- return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
+ return [{"status": "failed", "error": f"Execution error: {e!s}"}]
logger.debug(
"VPCS command execution completed. Results: %s",
@@ -262,7 +269,7 @@ class VPCSCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"Command failed (ReadTimeout): {str(e)}",
+ result=f"Command failed (ReadTimeout): {e!s}",
failed=True,
)
@@ -289,7 +296,7 @@ class VPCSCommands(BaseTool):
)
return Result(
host=task.host,
- result=f"Command failed: {str(retry_e)}",
+ result=f"Command failed: {retry_e!s}",
failed=True,
)
@@ -386,7 +393,7 @@ class VPCSCommands(BaseTool):
def _prepare_device_hosts_data(
self,
device_configs_list: list[dict[str, Any]],
- project_id: str,
+ project_id: str | None,
jwt_token: str | None = None,
url: str | None = None,
) -> dict[str, dict[str, Any]]:
@@ -415,7 +422,7 @@ class VPCSCommands(BaseTool):
device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url)
# Build Nornir inventory hosts data
- hosts_data = {}
+ hosts_data: dict[str, Any] = {}
for device_name in device_names:
if device_name not in device_ports:
logger.error("Device '%s' not found in topology", device_name)
@@ -444,7 +451,7 @@ class VPCSCommands(BaseTool):
continue
# VPCS devices use gns3_vpcs_telnet device type
- hosts_data[device_name] = {
+ host_entry = {
"port": port,
"platform": "vpcs",
"groups": ["vpcs_devices"], # All VPCS devices share one group
@@ -458,6 +465,12 @@ class VPCSCommands(BaseTool):
}
},
}
+ # Remote computes bind console ports on their own host; carry the
+ # per-node hostname from the topology so it overrides the group's
+ # controller-wide default.
+ if "hostname" in device_ports[device_name]:
+ host_entry["hostname"] = device_ports[device_name]["hostname"]
+ hosts_data[device_name] = host_entry
return hosts_data
@@ -588,7 +601,6 @@ class VPCSCommands(BaseTool):
if __name__ == "__main__":
# Example usage
- import sys
command_groups = json.dumps(
{
diff --git a/gns3server/agent/gns3_copilot/utils/__init__.py b/gns3server/agent/gns3_copilot/utils/__init__.py
index ed79a457c..59cfca0a7 100644
--- a/gns3server/agent/gns3_copilot/utils/__init__.py
+++ b/gns3server/agent/gns3_copilot/utils/__init__.py
@@ -39,9 +39,7 @@ Author: Yue Guobin (岳国宾)
# Import main utility functions
from .error_handler import format_error_message
from .get_gns3_device_port import get_device_ports_from_topology
-from .parse_tool_content import format_tool_response
-from .parse_tool_content import normalize_tool_response
-from .parse_tool_content import parse_tool_content
+from .parse_tool_content import format_tool_response, normalize_tool_response, parse_tool_content
# Dynamic version management
try:
@@ -58,8 +56,8 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
# Export main utility functions
__all__ = [
"format_error_message",
- "get_device_ports_from_topology",
- "parse_tool_content",
"format_tool_response",
+ "get_device_ports_from_topology",
"normalize_tool_response",
+ "parse_tool_content",
]
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py
index 1aea7adf8..30eb84bc4 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py
@@ -61,17 +61,17 @@ logger = logging.getLogger(__name__)
# Import all custom drivers (auto-registers them with Netmiko)
try:
- from . import huawei_ce # noqa: F401
+ from . import huawei_ce
except Exception as e:
logger.warning(f"Failed to import Huawei CE driver: {e}", exc_info=True)
try:
- from .ruijie_telnet import RuijieTelnetEnhanced # noqa: F401
+ from .ruijie_telnet import RuijieTelnetEnhanced as RuijieTelnetEnhanced
except Exception as e:
logger.warning(f"Failed to import Ruijie driver: {e}", exc_info=True)
try:
- from . import vpcs_telnet # noqa: F401
+ from . import vpcs_telnet
except Exception as e:
logger.warning(f"Failed to import VPCS driver: {e}", exc_info=True)
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py
index 3e951f10a..de517249f 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py
@@ -31,8 +31,8 @@ Features:
import os
import sys
-from typing import Dict, List, Tuple
from datetime import datetime
+from typing import Dict, List, Tuple
def get_netmiko_version() -> str:
@@ -169,7 +169,7 @@ def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
Returns:
Dictionary mapping brand names to device type lists
"""
- brands = {}
+ brands: dict[str, list[str]] = {}
for device_type in device_types:
brand = extract_brand_name(device_type)
@@ -338,7 +338,7 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Markdown document saved to: {output_path}")
- except IOError as e:
+ except OSError as e:
print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr)
sys.exit(1)
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py
index c6b6db344..4e231ae62 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py
@@ -18,10 +18,10 @@ This script tests:
Run with: python test_huawei_ce.py
"""
+import os
import sys
import unittest
from unittest.mock import Mock, patch
-import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +157,7 @@ class TestHuaweiTelnetCEIntegration(unittest.TestCase):
def test_mock_telnet_connection(self):
"""Test telnet_login logic with mocked connection."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.huawei_ce import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.huawei_ce import (
GNS3HuaweiTelnetCE,
)
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py
index dfef81823..3ff869bf6 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py
@@ -18,10 +18,10 @@ This script tests:
Run with: python test_ruijie_telnet.py
"""
+import os
import sys
import unittest
from unittest.mock import Mock, patch
-import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
@@ -178,7 +178,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
def test_send_config_set_calls_preprocess(self):
"""Test that send_config_set calls preprocessing."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import (
RuijieTelnetEnhanced,
)
@@ -205,7 +205,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
def test_multiple_interactive_patterns(self):
"""Test that multiple interactive commands are detected."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import (
RuijieTelnetEnhanced,
)
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py
index 4659465cd..d0c66f821 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py
@@ -20,10 +20,10 @@ This script tests:
Run with: python test_vpcs_telnet.py
"""
+import os
import sys
import unittest
-from unittest.mock import Mock, patch, MagicMock
-import os
+from unittest.mock import MagicMock, Mock, patch
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
@@ -116,7 +116,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Set up test fixtures."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
VPCSTelnet,
)
@@ -165,7 +165,7 @@ class TestVPCSTelnetMethods(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Set up test fixtures."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
VPCSTelnet,
)
@@ -208,7 +208,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Set up test fixtures."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
VPCSTelnet,
)
@@ -273,7 +273,7 @@ class TestVPCSTelnetTelnetLogin(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Set up test fixtures."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
VPCSTelnet,
)
@@ -353,7 +353,7 @@ class TestVPCSTelnetAnsiStripping(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Set up test fixtures."""
- from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
+ from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
VPCSTelnet,
)
@@ -426,7 +426,6 @@ IP/MASK : 192.168.1.20/24"""
def test_send_command_calls_strip_ansi(self):
"""Test that send_command calls _strip_ansi_codes."""
- from unittest.mock import patch
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py
index 0711f9c5d..863f52cdb 100644
--- a/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py
+++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py
@@ -47,7 +47,6 @@ import time
from netmiko.base_connection import BaseConnection
-
logger = logging.getLogger(__name__)
# ANSI escape code pattern for stripping terminal formatting codes
diff --git a/gns3server/agent/gns3_copilot/utils/device_configs.py b/gns3server/agent/gns3_copilot/utils/device_configs.py
new file mode 100644
index 000000000..f5ff4575f
--- /dev/null
+++ b/gns3server/agent/gns3_copilot/utils/device_configs.py
@@ -0,0 +1,78 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
+#
+# This file is part of GNS3-Copilot project.
+#
+# GNS3-Copilot 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.
+#
+# GNS3-Copilot 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 GNS3-Copilot. If not, see .
+#
+# Copyright (C) 2025 Yue Guobin (岳国宾)
+# Author: Yue Guobin (岳国宾)
+#
+# Project Home: https://github.com/yueguobin/gns3-copilot
+#
+"""
+Device config entry normalization shared by the tools_v2 executors.
+
+Callers (LLM agents via MCP, or gns3-copilot directly) may send the same
+device several times in one batch. Every downstream stage keys by
+device_name, so duplicates must merge before execution, not after.
+"""
+
+from typing import Any
+
+
+def merge_duplicate_device_configs(
+ device_configs: list[Any],
+ commands_field: str,
+) -> list[dict[str, Any]]:
+ """
+ Merge entries that share a device_name, concatenating their command lists.
+
+ Without this merge the per-device command map keeps only the last
+ entry's commands while result processing iterates every original entry —
+ duplicate devices would silently drop all but the last command batch and
+ every row would report that same output.
+
+ Args:
+ device_configs: Device config entries (parsed tool input, validated
+ here); each holds device_name and a command list under
+ commands_field.
+ commands_field: Field carrying the commands ("commands" or
+ "config_commands" depending on the tool).
+
+ Returns:
+ One entry per device_name, in first-appearance order. The first
+ entry's extra fields are preserved; later duplicates only contribute
+ their commands.
+
+ Raises:
+ ValueError: An entry is not an object or has no usable device_name.
+ Such an entry cannot be addressed; dropping it silently would
+ let a malformed batch execute partially while every returned
+ row reports success.
+ """
+ merged: dict[str, dict[str, Any]] = {}
+ for index, config in enumerate(device_configs):
+ if not isinstance(config, dict) or not config.get("device_name"):
+ raise ValueError(f"device_configs[{index}] must be an object with a non-empty 'device_name' field")
+ name = config["device_name"]
+ commands = list(config.get(commands_field, []))
+ if name in merged:
+ merged[name][commands_field].extend(commands)
+ else:
+ entry = dict(config)
+ entry[commands_field] = commands
+ merged[name] = entry
+ return list(merged.values())
diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py
index 6c75f5b12..840cffe00 100644
--- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py
+++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py
@@ -68,7 +68,12 @@ def get_device_ports_from_topology(
}
}
}
- Devices that don't exist or missing console_port will not be included
+ Devices that don't exist or missing console_port will not be included.
+ Nodes whose console is bound on a remote compute also carry a
+ host-level "hostname" (copied from the topology's console_host) so
+ netmiko dials the compute hosting the console port instead of the
+ controller; for everything else the field is omitted and the shared
+ group hostname (the controller host) applies.
"""
# Log received parameters
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
@@ -167,6 +172,15 @@ def get_device_ports_from_topology(
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
}
+ # Per-node console hostname. Remote computes bind console ports
+ # on their own host, not on the controller running this code, so
+ # the group's controller-wide hostname cannot reach them. A
+ # host-level hostname (which wins over the group in nornir) fixes
+ # that; unset or wildcard values keep the group inheritance.
+ console_host = node_info.get("console_host")
+ if console_host and console_host not in ("0.0.0.0", "::"):
+ host_entry["hostname"] = console_host
+
# Per-node default credentials (seeded from the template appliance
# metadata) override the group's empty fallback. Only inject when
# set, so credential-less devices keep inheriting the group values.
diff --git a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py
index a28278744..6e20dc72d 100644
--- a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py
+++ b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py
@@ -31,8 +31,9 @@ Supports ellipse and rectangle shapes for two-node annotations.
"""
import math
-from typing import Any
-from typing import Literal
+from typing import Any, Literal
+
+from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
# Default parameters
DEFAULT_DEVICE_WIDTH = 50
@@ -145,10 +146,16 @@ def calculate_two_node_shape(
center_y = (node1_center_y + node2_center_y) / 2
color_scheme = _get_color_scheme(area_name)
- text_svg = generate_text_svg(area_name, color_scheme)
- text_svg_width = len(area_name) * 8 + 20
- text_svg_height = DEFAULT_FONT_SIZE + 16
+ # Shared builder: Web UI compatible SVG plus the computed label size
+ # (the old len * 8 + 20 estimate ignored the Web UI's pt-based rendering).
+ text_svg, text_svg_width, text_svg_height, _ = build_text_svg(
+ area_name,
+ font_size=DEFAULT_FONT_SIZE,
+ color=color_scheme["stroke"],
+ bold=True,
+ font_family="TypeWriter",
+ )
if shape_type == "ellipse":
rx = distance / 2
@@ -237,8 +244,9 @@ def generate_ellipse_svg(
svg_width: int,
svg_height: int,
) -> str:
- """Generate SVG for ellipse."""
- return f"""""" # noqa: E501
+ """Generate SVG for ellipse (delegates to the shared Web UI compatible builder)."""
+ svg, _, _ = build_ellipse_svg(rx * 2, ry * 2, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"])
+ return svg
def generate_rectangle_svg(
@@ -246,16 +254,21 @@ def generate_rectangle_svg(
height: int,
color_scheme: dict[str, Any],
) -> str:
- """Generate SVG for rectangle."""
- return f"""""" # noqa: E501
+ """Generate SVG for rectangle (delegates to the shared Web UI compatible builder)."""
+ svg, _, _ = build_rectangle_svg(width, height, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"])
+ return svg
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
- """Generate SVG for text label."""
- text_width = len(text) * 8 + 20
- text_height = DEFAULT_FONT_SIZE + 16
-
- return f"""""" # noqa: E501
+ """Generate SVG for text label (delegates to the shared Web UI compatible builder)."""
+ svg, _, _, _ = build_text_svg(
+ text,
+ font_size=DEFAULT_FONT_SIZE,
+ color=color_scheme["stroke"],
+ bold=True,
+ font_family="TypeWriter",
+ )
+ return svg
def _hsv_to_hex(h: int, s: int, v: int) -> str:
diff --git a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py
index f0118c9d2..57e6658ae 100644
--- a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py
+++ b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py
@@ -43,9 +43,7 @@ Usage:
"""
import logging
-from typing import Any
-from typing import Dict
-from typing import Optional
+from typing import Any, Dict, Optional
from uuid import UUID
from fastapi import FastAPI
diff --git a/gns3server/agent/gns3_copilot/utils/message_converters.py b/gns3server/agent/gns3_copilot/utils/message_converters.py
index 8bf18f668..eb0d9a825 100644
--- a/gns3server/agent/gns3_copilot/utils/message_converters.py
+++ b/gns3server/agent/gns3_copilot/utils/message_converters.py
@@ -31,13 +31,9 @@ Converts between LangChain messages and OpenAI-compatible format.
import json
import uuid
-from typing import Any
-from typing import Dict
+from typing import Any, Dict
-from langchain_core.messages import AIMessage
-from langchain_core.messages import HumanMessage
-from langchain_core.messages import SystemMessage
-from langchain_core.messages import ToolMessage
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolCall, ToolMessage
def _ensure_string(content: Any) -> str:
@@ -86,7 +82,7 @@ def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
tool_calls = []
for tc in lc_message.tool_calls:
# Convert to dict if it's an object
- tc_dict = tc if isinstance(tc, dict) else tc.model_dump()
+ tc_dict = tc
tool_calls.append(
{
"id": tc_dict.get("id", str(uuid.uuid4())),
@@ -139,7 +135,7 @@ def convert_openai_to_langchain(msg: Dict[str, Any]):
# Restore tool calls if present
if "tool_calls" in msg and msg["tool_calls"]:
- tool_calls = []
+ tool_calls: list[ToolCall] = []
for tc in msg["tool_calls"]:
tool_calls.append(
{
diff --git a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py
index 4ea17912d..736d792c3 100644
--- a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py
+++ b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py
@@ -214,9 +214,7 @@ def parse_tool_content(
return content
# Handle unsupported types
- error_msg = ( # type: ignore[unreachable]
- f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
- )
+ error_msg = f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
logger.error(error_msg)
if strict_mode:
@@ -498,7 +496,7 @@ def _test_parse_tool_content() -> None:
for i, (input_data, expected) in enumerate(test_cases):
result = parse_tool_content(input_data)
status = "✓" if result == expected else "✗"
- print(f"Test {i + 1}: {status} Input: {repr(input_data)} -> {result}")
+ print(f"Test {i + 1}: {status} Input: {input_data!r} -> {result}")
print("\nTesting format_tool_response function:")
format_tests = [
@@ -520,7 +518,7 @@ def _test_parse_tool_content() -> None:
valid = "✓"
except Exception:
valid = "✗"
- print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
+ print(f"Format Test {i + 1}: {valid} Input: {input_data!r} -> {result}")
if __name__ == "__main__":
diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py
index 9b9026253..2a39bdb5f 100644
--- a/gns3server/agent/mcp/__init__.py
+++ b/gns3server/agent/mcp/__init__.py
@@ -26,54 +26,67 @@ Implements the standard MCP protocol over SSE transport using FastMCP:
Tools are registered via @mcp.tool() decorators.
"""
+import asyncio
import contextvars
import json
-import asyncio
import logging
import socket
-from uuid import UUID
-import bcrypt
-from typing import Any, Annotated
+from typing import Annotated, Any
from urllib.parse import parse_qs
+from uuid import UUID
+import bcrypt
from fastapi import APIRouter
from fastapi.responses import Response
-
-from pydantic import Field
-
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
-
-from sqlalchemy import select
+from pydantic import Field
from sqlalchemy.ext.asyncio import AsyncSession
+from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
+ available_filters_handler,
+ create_ellipse_drawing_handler,
+ create_link_handler,
+ create_node_handler,
+ create_rectangle_drawing_handler,
+ create_text_drawing_handler,
+ delete_link_handler,
+ delete_node_file_handler,
+ delete_node_handler,
+ download_capture_file_handler,
+ duplicate_node_handler,
+ get_link_handler,
+ get_links_handler,
+ get_node_console_info_handler,
+ get_node_file_handler,
+ get_node_handler,
+ get_node_links_handler,
+ get_nodes_handler,
+ isolate_node_handler,
+ link_marker_handler,
+ list_node_files_handler,
+ marker_definition_handler,
+ reset_link_handler,
+ start_all_nodes_handler,
+ start_capture_handler,
+ start_node_handler,
+ stop_all_nodes_handler,
+ stop_capture_handler,
+ stop_node_handler,
+ suspend_all_nodes_handler,
+ suspend_node_handler,
+ unisolate_node_handler,
+ update_link_handler,
+ update_node_handler,
+ write_node_file_handler,
+)
from gns3server.config import Config
-from gns3server.services.authentication import AuthService
-import gns3server.db.models as models
-from gns3server.services import auth_service
-from gns3server.utils.request_utils import extract_client_info
+from gns3server.db import models as models
from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository
-from .projects import (
- list_projects_handler,
- get_project_handler,
- create_project_handler,
- delete_project_handler,
- open_project_handler,
- close_project_handler,
- get_project_stats_handler,
- update_project_handler,
- duplicate_project_handler,
- get_project_readme_handler,
- update_project_readme_handler,
- lock_project_handler,
- unlock_project_handler,
- get_locked_project_handler,
-)
-from .server import (
- get_version_handler,
- get_statistics_handler,
-)
+from gns3server.services import auth_service
+from gns3server.services.authentication import AuthService as AuthService
+from gns3server.utils.request_utils import extract_client_info
# Symbol tools are disabled for now: they require a vision-capable model to
# be genuinely useful (the tools shuttle SVG content, which a text-only LLM
@@ -84,80 +97,66 @@ from .server import (
# upload_symbol_handler, delete_symbol_handler,
# )
from .appliances import (
- get_appliances_handler,
get_appliance_handler,
+ get_appliances_handler,
install_appliance_handler,
)
-from .images import (
- get_images_handler,
- get_image_handler,
- delete_image_handler,
- prune_images_handler,
- install_images_handler,
+from .computes import (
+ get_compute_handler,
+ get_compute_images_handler,
+ list_computes_handler,
)
from .device_config import (
device_config_send_handler,
device_show_run_handler,
vpcs_config_set_handler,
)
-from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
- get_nodes_handler,
- get_node_handler,
- start_node_handler,
- stop_node_handler,
- suspend_node_handler,
- create_node_handler,
- delete_node_handler,
- update_node_handler,
- get_node_console_info_handler,
- list_node_files_handler,
- get_node_file_handler,
- write_node_file_handler,
- delete_node_file_handler,
- start_all_nodes_handler,
- stop_all_nodes_handler,
- suspend_all_nodes_handler,
- duplicate_node_handler,
- isolate_node_handler,
- unisolate_node_handler,
- get_node_links_handler,
- get_links_handler,
- get_link_handler,
- available_filters_handler,
- create_link_handler,
- delete_link_handler,
- update_link_handler,
- reset_link_handler,
- start_capture_handler,
- stop_capture_handler,
- download_capture_file_handler,
- link_marker_handler,
- marker_definition_handler,
+from .drawings import (
+ delete_drawing_handler,
+ get_drawing_handler,
+ get_drawings_handler,
+ update_drawing_handler,
)
-from .templates import (
- list_templates_handler,
- get_template_handler,
- create_template_handler,
- update_template_handler,
- delete_template_handler,
+from .images import (
+ delete_image_handler,
+ get_image_handler,
+ get_images_handler,
+ install_images_handler,
+ prune_images_handler,
)
-from .computes import (
- list_computes_handler,
- get_compute_handler,
- get_compute_images_handler,
+from .locators import add_did_you_mean, resolve_locators
+from .projects import (
+ close_project_handler,
+ create_project_handler,
+ delete_project_handler,
+ duplicate_project_handler,
+ get_locked_project_handler,
+ get_project_handler,
+ get_project_readme_handler,
+ get_project_stats_handler,
+ list_projects_handler,
+ lock_project_handler,
+ open_project_handler,
+ unlock_project_handler,
+ update_project_handler,
+ update_project_readme_handler,
+)
+from .server import (
+ get_statistics_handler,
+ get_version_handler,
)
from .snapshots import (
- get_snapshots_handler,
create_snapshot_handler,
delete_snapshot_handler,
+ get_snapshots_handler,
restore_snapshot_handler,
)
-from .drawings import (
- get_drawings_handler,
- create_drawing_handler,
- get_drawing_handler,
- update_drawing_handler,
- delete_drawing_handler,
+from .templates import (
+ create_template_handler,
+ delete_template_handler,
+ get_template_handler,
+ list_templates_handler,
+ update_template_handler,
)
log = logging.getLogger(__name__)
@@ -216,7 +215,7 @@ async def wait_for_mcp_ready() -> bool:
await asyncio.wait_for(_mcp_ready_event.wait(), timeout=5.0)
log.debug("MCP server is now ready, proceeding with connection")
return True
- except asyncio.TimeoutError:
+ except TimeoutError:
log.warning("MCP server ready check timed out after 5 seconds - GNS3 server initialization may have issues")
return False
@@ -343,7 +342,19 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
"jwt_username": _jwt_username_var.get(),
"jwt_token_version": _jwt_token_version_var.get(),
}
- result = handler(params, ctx)
+
+ def run(h, p):
+ return h(p, ctx)
+
+ # Name-shaped project_id/node_id values become UUIDs before the handler
+ # runs; a name that matches nothing fails fast with the known names.
+ resolved_params, resolution_error = resolve_locators(params, run)
+ if resolved_params is None:
+ result = resolution_error
+ else:
+ result = handler(resolved_params, ctx)
+ # backstop for UUID transcription slips: "Did you mean ...?"
+ result = add_did_you_mean(result, resolved_params, run)
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
@@ -355,7 +366,7 @@ async def project_list() -> list[dict[str, Any]]:
@mcp.tool()
async def project_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Get detailed information about a specific project."""
return await asyncio.to_thread(_run_handler_sync, get_project_handler, {"project_id": project_id})
@@ -372,7 +383,7 @@ async def project_create(
@mcp.tool()
async def project_delete(
- project_id: Annotated[str, Field(description="UUID of the project to delete")],
+ project_id: Annotated[str, Field(description="Project name or UUID to delete")],
) -> list[dict[str, Any]]:
"""Delete a GNS3 project permanently."""
return await asyncio.to_thread(_run_handler_sync, delete_project_handler, {"project_id": project_id})
@@ -380,7 +391,7 @@ async def project_delete(
@mcp.tool()
async def project_open(
- project_id: Annotated[str, Field(description="UUID of the project to open")],
+ project_id: Annotated[str, Field(description="Project name or UUID to open")],
) -> list[dict[str, Any]]:
"""Open a closed GNS3 project."""
return await asyncio.to_thread(_run_handler_sync, open_project_handler, {"project_id": project_id})
@@ -388,7 +399,7 @@ async def project_open(
@mcp.tool()
async def project_close(
- project_id: Annotated[str, Field(description="UUID of the project to close")],
+ project_id: Annotated[str, Field(description="Project name or UUID to close")],
) -> list[dict[str, Any]]:
"""Close an open GNS3 project."""
return await asyncio.to_thread(_run_handler_sync, close_project_handler, {"project_id": project_id})
@@ -396,7 +407,7 @@ async def project_close(
@mcp.tool()
async def project_stats(
- project_id: Annotated[str, Field(description="UUID of the project to get statistics for")],
+ project_id: Annotated[str, Field(description="Project name or UUID to get statistics for")],
) -> list[dict[str, Any]]:
"""Get statistics (nodes, links, snapshots, drawings) for a project."""
return await asyncio.to_thread(_run_handler_sync, get_project_stats_handler, {"project_id": project_id})
@@ -404,23 +415,25 @@ async def project_stats(
@mcp.tool()
async def project_update(
- project_id: Annotated[str, Field(description="UUID of the project to update")],
- name: Annotated[str, Field(description="New project name")] = None,
- auto_close: Annotated[bool, Field(description="Close project when last client leaves")] = None,
- auto_open: Annotated[bool, Field(description="Project opens when GNS3 starts")] = None,
- auto_start: Annotated[bool, Field(description="Project starts when opened")] = None,
- scene_width: Annotated[int, Field(description="Width of the drawing area")] = None,
- scene_height: Annotated[int, Field(description="Height of the drawing area")] = None,
- zoom: Annotated[int, Field(description="Zoom of the drawing area")] = None,
- show_layers: Annotated[bool, Field(description="Show layers on the drawing area")] = None,
- snap_to_grid: Annotated[bool, Field(description="Snap to grid on the drawing area")] = None,
- show_grid: Annotated[bool, Field(description="Show the grid on the drawing area")] = None,
- grid_size: Annotated[int, Field(description="Grid size for the drawing area for nodes")] = None,
- drawing_grid_size: Annotated[int, Field(description="Grid size for the drawing area for drawings")] = None,
- show_interface_labels: Annotated[bool, Field(description="Show interface labels on the drawing area")] = None,
+ project_id: Annotated[str, Field(description="Project name or UUID to update")],
+ name: Annotated[str | None, Field(description="New project name")] = None,
+ auto_close: Annotated[bool | None, Field(description="Close project when last client leaves")] = None,
+ auto_open: Annotated[bool | None, Field(description="Project opens when GNS3 starts")] = None,
+ auto_start: Annotated[bool | None, Field(description="Project starts when opened")] = None,
+ scene_width: Annotated[int | None, Field(description="Width of the drawing area")] = None,
+ scene_height: Annotated[int | None, Field(description="Height of the drawing area")] = None,
+ zoom: Annotated[int | None, Field(description="Zoom of the drawing area")] = None,
+ show_layers: Annotated[bool | None, Field(description="Show layers on the drawing area")] = None,
+ snap_to_grid: Annotated[bool | None, Field(description="Snap to grid on the drawing area")] = None,
+ show_grid: Annotated[bool | None, Field(description="Show the grid on the drawing area")] = None,
+ grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for nodes")] = None,
+ drawing_grid_size: Annotated[int | None, Field(description="Grid size for the drawing area for drawings")] = None,
+ show_interface_labels: Annotated[
+ bool | None, Field(description="Show interface labels on the drawing area")
+ ] = None,
) -> list[dict[str, Any]]:
"""Update a project's properties (name, auto_close, auto_open, etc.)."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
local_vars = {
"name": name,
"auto_close": auto_close,
@@ -444,12 +457,12 @@ async def project_update(
@mcp.tool()
async def project_duplicate(
- project_id: Annotated[str, Field(description="UUID of the project to duplicate")],
+ project_id: Annotated[str, Field(description="Project name or UUID to duplicate")],
name: Annotated[str, Field(description="New project name")],
reset_mac_addresses: Annotated[bool, Field(description="Reset MAC addresses for this project")] = False,
) -> list[dict[str, Any]]:
"""Duplicate a project."""
- params = {"project_id": project_id, "name": name}
+ params: dict[str, Any] = {"project_id": project_id, "name": name}
if reset_mac_addresses:
params["reset_mac_addresses"] = reset_mac_addresses
return await asyncio.to_thread(_run_handler_sync, duplicate_project_handler, params)
@@ -457,7 +470,7 @@ async def project_duplicate(
@mcp.tool()
async def project_readme_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Get the content of a project's README.md file — the project documentation (Markdown format)."""
return await asyncio.to_thread(_run_handler_sync, get_project_readme_handler, {"project_id": project_id})
@@ -465,7 +478,7 @@ async def project_readme_get(
@mcp.tool()
async def project_readme_update(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
content: Annotated[str, Field(description="Content to write to README.md (Markdown format)")],
) -> list[dict[str, Any]]:
"""Update or create a project's README.md file — the project documentation (Markdown format)."""
@@ -479,7 +492,7 @@ async def project_readme_update(
@mcp.tool()
async def node_list(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
fields: Annotated[
list[str] | None,
Field(
@@ -493,8 +506,8 @@ async def node_list(
@mcp.tool()
async def node_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
fields: Annotated[
list[str] | None,
Field(
@@ -516,14 +529,14 @@ async def node_get(
@mcp.tool()
async def node_start(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str | None, Field(description="Node name or UUID (single mode)")] = None,
node_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — start multiple nodes in parallel')
] = None,
) -> list[dict[str, Any]]:
"""Start one or more nodes. Provide node_id for single, or node_ids for batch."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -533,14 +546,14 @@ async def node_start(
@mcp.tool()
async def node_stop(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str | None, Field(description="Node name or UUID (single mode)")] = None,
node_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — stop multiple nodes in parallel')
] = None,
) -> list[dict[str, Any]]:
"""Stop one or more nodes. Provide node_id for single, or node_ids for batch."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -550,14 +563,14 @@ async def node_stop(
@mcp.tool()
async def node_suspend(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str | None, Field(description="Node name or UUID (single mode)")] = None,
node_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — suspend multiple nodes in parallel')
] = None,
) -> list[dict[str, Any]]:
"""Suspend one or more nodes. Provide node_id for single, or node_ids for batch."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -567,7 +580,7 @@ async def node_suspend(
@mcp.tool()
async def node_create(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
template_id: Annotated[
str | None, Field(description="Template UUID (required for single mode; used as default in batch mode)")
] = None,
@@ -628,14 +641,14 @@ async def node_create(
@mcp.tool()
async def node_delete(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str | None, Field(description="Node name or UUID (single mode)")] = None,
node_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — delete multiple nodes in parallel')
] = None,
) -> list[dict[str, Any]]:
"""Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
@@ -645,8 +658,8 @@ async def node_delete(
@mcp.tool()
async def node_update(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node to update")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID to update")],
**kwargs: Any,
) -> list[dict[str, Any]]:
"""Update a node's properties (name, position, etc.)."""
@@ -656,8 +669,8 @@ async def node_update(
@mcp.tool()
async def node_console(
- project_id: Annotated[str, Field(description="UUID of the project containing the node")],
- node_id: Annotated[str, Field(description="UUID of the node to get console info for")],
+ project_id: Annotated[str, Field(description="Project name or UUID containing the node")],
+ node_id: Annotated[str, Field(description="Node name or UUID to get console info for")],
) -> list[dict[str, Any]]:
"""Get WebSocket console connection info for a node.
@@ -695,11 +708,11 @@ async def node_console(
@mcp.tool()
async def link_list(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
fields: Annotated[
list[str] | None,
Field(
- description='Optional: return only these fields. e.g. ["link_id","nodes"]. Available: link_id, project_id, link_type, nodes, suspend, filters, capturing, capture_file_name, link_style'
+ description='Optional: return only these fields. e.g. ["link_id","nodes"]. Available: link_id, project_id, link_type, kernel_datapath, nodes, suspend, filters, capturing, capture_file_name, link_style'
),
] = None,
) -> list[dict[str, Any]]:
@@ -709,7 +722,7 @@ async def link_list(
@mcp.tool()
async def link_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
"""Get detailed information about a specific link."""
@@ -718,7 +731,7 @@ async def link_get(
@mcp.tool()
async def link_create(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
nodes: Annotated[
list | None,
Field(description="Single mode: [{node_id, adapter_number, port_number}] or compact [id, ad, pt, id, ad, pt]"),
@@ -735,7 +748,7 @@ async def link_create(
list[str] | None,
Field(
description="Response fields to include (default: [link_id, link_type, nodes]). "
- "Available: link_id, project_id, link_type, nodes, suspend, "
+ "Available: link_id, project_id, link_type, kernel_datapath, nodes, suspend, "
"link_style, filters, show_filters_icon, capturing, "
"capture_file_name, capture_file_path, capture_compute_id, wireshark"
),
@@ -756,7 +769,7 @@ async def link_create(
"fields": fields,
},
)
- params = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
+ params: dict[str, Any] = {"project_id": project_id, "nodes": nodes, "link_type": link_type, "fields": fields}
if filters:
params["filters"] = filters
return await asyncio.to_thread(_run_handler_sync, create_link_handler, params)
@@ -764,14 +777,14 @@ async def link_create(
@mcp.tool()
async def link_delete(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — delete multiple links in parallel')
] = None,
) -> list[dict[str, Any]]:
"""Delete one or more links from a project."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -781,7 +794,7 @@ async def link_delete(
@mcp.tool()
async def link_update(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str, Field(description="UUID of the link to update")],
**kwargs: Any,
) -> list[dict[str, Any]]:
@@ -789,24 +802,61 @@ async def link_update(
Supported kwargs:
- suspend: boolean - Suspend or resume the link
- - filters: dict - Packet filters (must use array format):
- * frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767)
- * packet_loss: [rate] - Packet loss percentage (rate: 0 to 100)
- * delay: [ms, jitter] - Latency and jitter in milliseconds
- * corrupt: [rate] - Packet corruption percentage (rate: 0 to 100)
- * bpf: [expression] - Berkeley Packet Filter expression
+ - filters: dict - Packet filters (values MUST be positional arrays). Types
+ marked (K) run on kernel-datapath links only (both endpoints on one
+ compute with an anchor of their own) and are rejected with 409 on relay
+ links; which types a given link accepts is authoritatively answered by
+ link_available_filters:
+ * frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767; -1 = drop
+ everything). Exact on kernel links (eBPF counter)
+ * packet_loss: [chance, correl?] - Loss percentage (0-100); correl (K)
+ makes consecutive losses dependent (bursty). CAVEAT: the kernel netem
+ correl suppresses small rates — a chance below ~the correl value drops
+ almost nothing (1% + correl 25% ≈ zero loss); use gemodel for bursty
+ loss at a known mean rate
+ * delay: [ms, jitter?, distribution?] - Latency 1-32767 ms, jitter 0-32767
+ ms; distribution (K) one of uniform|normal|pareto|paretonormal, needs
+ jitter > 0
+ * corrupt: [chance] - Corruption percentage (0-100)
+ * bpf: [expression] - Berkeley Packet Filter expression, one per line,
+ any match drops
+ * rate (K): ["512kbit"] - Bandwidth cap, tc-style int+unit (bit/kbit/
+ mbit/gbit/bps/kbps/mbps, max 100gbit)
+ * reorder (K): [pct, correl?, gap?] - Requires delay to also be set
+ * gemodel (K): [p, r, 1-h] - Gilbert-Elliot bursty loss; p = chance
+ of moving from the good to the bad state, r = chance of moving back,
+ 1-h = loss chance in the bad state (the good state loses nothing);
+ steady-state mean loss = p/(p+r)*(1-h) per direction; mutually
+ exclusive with packet_loss
+ * duplicate (K): [pct, correl?] - Duplication percentage
+ * seed (K): [u32] - Makes netem random draws reproducible
+ * limit (K): [pkts] - Impairment queue depth (default 1000; raise for
+ low rate + long delay)
+ * quota (K): [bytes, pct] - After the byte quota is consumed, each
+ further packet drops with the chance (100 = hard cutoff)
+ * window_drop (K): [start_ms, outage_ms, pct, period_ms?, jitter_ms?] -
+ Drop with chance pct inside [start, start+outage): 3 args = single
+ outage (traffic passes before and after), 4 args = recurring flap
+ (period >= outage), 5 args = per-cycle timing randomized within +/-
+ jitter. start is measured from the moment this filter is applied and
+ ANY filter update (including re-sending the same values) or node
+ restart restarts the schedule — a one-shot window that already
+ passed will not fire again until re-applied, so arm the filter
+ immediately before generating traffic (or use a period); start 0 =
+ outage begins immediately (active, not disabled)
Example filters:
{"filters": {"frequency_drop": [10]}}
{"filters": {"delay": [100, 10]}}
- {"filters": {"packet_loss": [5]}}
+ {"filters": {"window_drop": [0, 2000, 100]}}
{"filters": {"delay": [50, 5], "packet_loss": [2]}}
To clear all filters: {"filters": {}}
Filters are applied **bidirectionally** — a packet crossing the link twice
(e.g. ping round-trip) is filtered in both directions independently.
- For example, packet_loss: [50] gives ~75% observed loss (1 - 0.5²), not 50%.
+ For example, packet_loss: [50] gives ~75% observed loss (1 - 0.5²), not 50%;
+ window_drop pct likewise applies per direction.
ARP frames also pass through filters; at high loss/corrupt rates, pre-set
static ARP entries to avoid false "Destination Host Unreachable" errors.
"""
@@ -816,11 +866,14 @@ async def link_update(
@mcp.tool()
async def link_available_filters(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
- """List the packet filter types available for a link (frequency_drop, packet_loss, delay, corrupt, bpf)
- with their parameters. Use before setting filters with link_update."""
+ """List the packet filter types available for a link with their parameters
+ and ranges — the authoritative answer for what link_update accepts on THIS
+ link (topology and compute-capability gated: kernel-only types appear only
+ on kernel-datapath links whose compute reports support). Call this before
+ setting filters; a type absent here will be rejected with 409."""
return await asyncio.to_thread(
_run_handler_sync,
available_filters_handler,
@@ -974,8 +1027,8 @@ async def compute_images(
@mcp.tool()
async def node_file_list(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
path: Annotated[str, Field(description="Subdirectory path within node directory (optional)")] = "",
recursive: Annotated[bool, Field(description="Recursively list all files (optional, default: false)")] = False,
) -> list[dict[str, Any]]:
@@ -998,8 +1051,8 @@ async def node_file_list(
@mcp.tool()
async def node_file_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
file_path: Annotated[str, Field(description="Path to the file within the node directory")],
offset: Annotated[int, Field(description="Line offset to start reading from (optional, default: 0)")] = 0,
limit: Annotated[int, Field(description="Maximum number of lines to return (optional, default: 200)")] = 200,
@@ -1028,8 +1081,8 @@ async def node_file_get(
@mcp.tool()
async def node_file_write(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
file_path: Annotated[str, Field(description="Path to the file within the node directory")],
content: Annotated[str, Field(description="Content to write to the file")],
) -> list[dict[str, Any]]:
@@ -1048,8 +1101,8 @@ async def node_file_write(
@mcp.tool()
async def node_file_delete(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
file_path: Annotated[str, Field(description="Path to the file within the node directory")],
) -> list[dict[str, Any]]:
"""Delete a file from a node directory. Cannot be undone."""
@@ -1069,7 +1122,7 @@ async def node_file_delete(
@mcp.tool()
async def node_start_all(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Start all nodes in a project."""
return await asyncio.to_thread(
@@ -1083,7 +1136,7 @@ async def node_start_all(
@mcp.tool()
async def node_stop_all(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Stop all nodes in a project."""
return await asyncio.to_thread(
@@ -1097,7 +1150,7 @@ async def node_stop_all(
@mcp.tool()
async def node_suspend_all(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Suspend all nodes in a project."""
return await asyncio.to_thread(
@@ -1111,8 +1164,8 @@ async def node_suspend_all(
@mcp.tool()
async def node_duplicate(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node to duplicate")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID to duplicate")],
x: Annotated[int, Field(description="X coordinate for the new node")] = 0,
y: Annotated[int, Field(description="Y coordinate for the new node")] = 0,
z: Annotated[int, Field(description="Z layer for the new node")] = 0,
@@ -1133,8 +1186,8 @@ async def node_duplicate(
@mcp.tool()
async def node_isolate(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node to isolate")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID to isolate")],
) -> list[dict[str, Any]]:
"""Isolate a node by suspending all its attached links (network isolation)."""
return await asyncio.to_thread(
@@ -1149,8 +1202,8 @@ async def node_isolate(
@mcp.tool()
async def node_unisolate(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node to unisolate")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID to unisolate")],
) -> list[dict[str, Any]]:
"""Un-isolate a node by resuming all its suspended links."""
return await asyncio.to_thread(
@@ -1165,8 +1218,8 @@ async def node_unisolate(
@mcp.tool()
async def node_links(
- project_id: Annotated[str, Field(description="UUID of the project")],
- node_id: Annotated[str, Field(description="UUID of the node")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ node_id: Annotated[str, Field(description="Node name or UUID")],
) -> list[dict[str, Any]]:
"""List all links connected to a specific node."""
return await asyncio.to_thread(
@@ -1184,7 +1237,7 @@ async def node_links(
@mcp.tool()
async def link_reset(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — reset multiple links in parallel')
@@ -1201,7 +1254,7 @@ async def link_reset(
while keeping the filter configuration intact. Filters are preserved but
their internal application state resets.
"""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1211,7 +1264,7 @@ async def link_reset(
@mcp.tool()
async def link_capture_start(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB",
capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None,
@@ -1222,7 +1275,7 @@ async def link_capture_start(
] = None,
) -> list[dict[str, Any]]:
"""Start packet capture on one or more links."""
- params = {
+ params: dict[str, Any] = {
"project_id": project_id,
"data_link_type": data_link_type,
"capture_file_name": capture_file_name,
@@ -1237,7 +1290,7 @@ async def link_capture_start(
@mcp.tool()
async def link_capture_stop(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[
list[str] | None,
@@ -1245,7 +1298,7 @@ async def link_capture_stop(
] = None,
) -> list[dict[str, Any]]:
"""Stop packet capture on one or more links."""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1255,7 +1308,7 @@ async def link_capture_stop(
@mcp.tool()
async def link_capture_download(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[
list[str] | None, Field(description='Batch mode: ["uuid1","uuid2"] — get download URLs for multiple captures')
@@ -1273,7 +1326,7 @@ async def link_capture_download(
- The ticket expires after 10 minutes: call this tool again to get a
fresh one; do not reuse an old URL.
"""
- params = {"project_id": project_id}
+ params: dict[str, Any] = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
@@ -1286,7 +1339,7 @@ async def link_capture_download(
@mcp.tool()
async def link_marker(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
link_id: Annotated[str, Field(description="UUID of the link")],
action: Annotated[str, Field(description="Action: create, update, or delete")],
bpf: Annotated[
@@ -1354,7 +1407,7 @@ async def link_marker(
@mcp.tool()
async def marker_definition(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
action: Annotated[str, Field(description="Action: create, update, delete, or list")],
bpf: Annotated[
str | None, Field(description="BPF expression, e.g. 'arp', 'ospf', 'tcp port 22' (required for create)")
@@ -1405,7 +1458,7 @@ async def marker_definition(
@mcp.tool()
async def snapshot_list(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""List all snapshots of a project."""
return await asyncio.to_thread(
@@ -1419,7 +1472,7 @@ async def snapshot_list(
@mcp.tool()
async def snapshot_create(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
name: Annotated[str, Field(description="Name for the new snapshot")],
) -> list[dict[str, Any]]:
"""Create a new snapshot of a project.
@@ -1440,7 +1493,7 @@ async def snapshot_create(
@mcp.tool()
async def snapshot_delete(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
snapshot_id: Annotated[str, Field(description="UUID of the snapshot to delete")],
) -> list[dict[str, Any]]:
"""Delete a snapshot from a project. Cannot be undone."""
@@ -1456,7 +1509,7 @@ async def snapshot_delete(
@mcp.tool()
async def snapshot_restore(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
snapshot_id: Annotated[str, Field(description="UUID of the snapshot to restore")],
) -> list[dict[str, Any]]:
"""Restore a project to a previous snapshot state. The project may be closed and reopened."""
@@ -1475,7 +1528,7 @@ async def snapshot_restore(
@mcp.tool()
async def drawing_list(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""List all drawings (labels, shapes, images) on a project canvas."""
return await asyncio.to_thread(
@@ -1488,48 +1541,149 @@ async def drawing_list(
@mcp.tool()
-async def drawing_create(
- project_id: Annotated[str, Field(description="UUID of the project")],
- svg: Annotated[str, Field(description="SVG content for the drawing")],
- x: Annotated[int, Field(description="X coordinate (default: 0)")] = 0,
- y: Annotated[int, Field(description="Y coordinate (default: 0)")] = 0,
- z: Annotated[int, Field(description="Z layer (default: 0)")] = 0,
- locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False,
- rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0,
+async def drawing_create_text(
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ x: Annotated[int, Field(description="X coordinate of the text top-left corner on the canvas")],
+ y: Annotated[int, Field(description="Y coordinate of the text top-left corner on the canvas")],
+ text: Annotated[str, Field(description="Label text; newlines (\\n) start additional lines")],
+ font_size: Annotated[int, Field(description="Font size, 1-200 (default: 13)")] = 13,
+ color: Annotated[str, Field(description="Text color, hex like #RRGGBB (default: #000000)")] = "#000000",
+ bold: Annotated[bool, Field(description="Bold text (default: false)")] = False,
+ font_family: Annotated[str, Field(description="Font family name (default: monospace)")] = "monospace",
+ z: Annotated[int, Field(description="Z layer; 1 keeps text above links (default: 1)")] = 1,
) -> list[dict[str, Any]]:
- """Create a new drawing (label, shape, or image) on a project canvas.
+ """Add a text label to a project canvas.
- GNS3 SVG rendering notes:
- - MUST have a solid fill color (e.g. fill=\"#FF0000\") to render.
- fill=\"none\" or fill=\"transparent\" will be invisible in the GUI.
- - works correctly with or without fill.
- - and work normally.
+ The SVG is assembled server-side in the Web UI compatible format
+ (validation, escaping and sizing included).
- SVG examples:
- Text label:
- Rectangle:
- Ellipse:
- Line:
- Dashed line:
+ - XML characters (& < >) are escaped automatically.
+ - Multi-line: embed newlines (\\n) in text; each line renders below the previous one.
+ - The drawing width/height are computed from the text and returned, so labels
+ can be laid out without guessing the selection box size.
+ - Constraints: color must be hex like #RRGGBB; font_family accepts plain names
+ (letters, digits, spaces, . ' -); text capped at 500 characters.
+
+ Example: bold red label at (100, 80):
+ drawing_create_text(project_id="demo", x=100, y=80, text="R1 management", color="#CC0000", bold=True)
"""
return await asyncio.to_thread(
_run_handler_sync,
- create_drawing_handler,
+ create_text_drawing_handler,
{
"project_id": project_id,
- "svg": svg,
+ "text": text,
"x": x,
"y": y,
"z": z,
- "locked": locked,
- "rotation": rotation,
+ "font_size": font_size,
+ "color": color,
+ "bold": bold,
+ "font_family": font_family,
+ },
+ )
+
+
+@mcp.tool()
+async def drawing_create_rectangle(
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ x: Annotated[int, Field(description="X coordinate of the rectangle top-left corner on the canvas")],
+ y: Annotated[int, Field(description="Y coordinate of the rectangle top-left corner on the canvas")],
+ width: Annotated[int, Field(description="Rectangle width in px, 1-2000")],
+ height: Annotated[int, Field(description="Rectangle height in px, 1-2000")],
+ fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
+ fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
+ stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
+ stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
+ dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
+ dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
+ rx: Annotated[int, Field(description="Corner radius, 0 to min(width, height)/2 (default: 0)")] = 0,
+ z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
+) -> list[dict[str, Any]]:
+ """Add a rectangle to a project canvas.
+
+ The SVG is assembled server-side in the Web UI compatible format
+ (validation included).
+
+ - fill "none" renders an outline-only box (stroke recommended then).
+ - Border attributes are only emitted when stroke is given.
+ - dasharray must be plain "on,off" numbers; the Qt-remapped patterns
+ ("25, 25", "5, 25", ...) are rejected because the Web UI rewrites them.
+
+ Example: semi-transparent outline box at (200, 150):
+ drawing_create_rectangle(project_id="demo", x=200, y=150, width=400, height=200,
+ fill="none", stroke="#333333", dashed=True)
+ """
+ return await asyncio.to_thread(
+ _run_handler_sync,
+ create_rectangle_drawing_handler,
+ {
+ "project_id": project_id,
+ "x": x,
+ "y": y,
+ "z": z,
+ "width": width,
+ "height": height,
+ "fill": fill,
+ "fill_opacity": fill_opacity,
+ "stroke": stroke,
+ "stroke_width": stroke_width,
+ "dashed": dashed,
+ "dasharray": dasharray,
+ "rx": rx,
+ },
+ )
+
+
+@mcp.tool()
+async def drawing_create_ellipse(
+ project_id: Annotated[str, Field(description="Project name or UUID")],
+ x: Annotated[int, Field(description="X coordinate of the bounding box top-left corner on the canvas")],
+ y: Annotated[int, Field(description="Y coordinate of the bounding box top-left corner on the canvas")],
+ width: Annotated[int, Field(description="Bounding box width in px, 2-2000; equal to height gives a circle")],
+ height: Annotated[int, Field(description="Bounding box height in px, 2-2000")],
+ fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
+ fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
+ stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
+ stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
+ dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
+ dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
+ z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
+) -> list[dict[str, Any]]:
+ """Add an ellipse (or circle) to a project canvas.
+
+ The SVG is assembled server-side in the Web UI compatible format. cx/cy are
+ derived from the bounding box (the Web UI treats them as offsets inside the
+ drawing, not canvas coordinates); for odd width/height the drawing is 1 px
+ smaller than requested and the actual size is returned.
+
+ Example: circle of diameter 160 at (300, 300):
+ drawing_create_ellipse(project_id="demo", x=300, y=300, width=160, height=160,
+ fill="#5AA9DD", fill_opacity=0.8)
+ """
+ return await asyncio.to_thread(
+ _run_handler_sync,
+ create_ellipse_drawing_handler,
+ {
+ "project_id": project_id,
+ "x": x,
+ "y": y,
+ "z": z,
+ "width": width,
+ "height": height,
+ "fill": fill,
+ "fill_opacity": fill_opacity,
+ "stroke": stroke,
+ "stroke_width": stroke_width,
+ "dashed": dashed,
+ "dasharray": dasharray,
},
)
@mcp.tool()
async def drawing_get(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
drawing_id: Annotated[str, Field(description="UUID of the drawing")],
) -> list[dict[str, Any]]:
"""Get detailed information about a specific drawing."""
@@ -1545,7 +1699,7 @@ async def drawing_get(
@mcp.tool()
async def drawing_update(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
drawing_id: Annotated[str, Field(description="UUID of the drawing")],
svg: Annotated[str | None, Field(description="New SVG content")] = None,
locked: Annotated[bool | None, Field(description="Lock or unlock the drawing")] = None,
@@ -1555,7 +1709,7 @@ async def drawing_update(
rotation: Annotated[int | None, Field(description="Rotation angle in degrees, -359 to 359")] = None,
) -> list[dict[str, Any]]:
"""Update a drawing's properties (svg, position, lock state, rotation, etc.)."""
- params = {"project_id": project_id, "drawing_id": drawing_id}
+ params: dict[str, Any] = {"project_id": project_id, "drawing_id": drawing_id}
local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z, "rotation": rotation}
for key, val in local_vars.items():
if val is not None:
@@ -1565,7 +1719,7 @@ async def drawing_update(
@mcp.tool()
async def drawing_delete(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
drawing_id: Annotated[str, Field(description="UUID of the drawing to delete")],
) -> list[dict[str, Any]]:
"""Delete a drawing from a project canvas. Cannot be undone."""
@@ -1584,7 +1738,7 @@ async def drawing_delete(
@mcp.tool()
async def project_lock(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Lock all drawings and nodes in a project to prevent accidental changes."""
return await asyncio.to_thread(
@@ -1598,7 +1752,7 @@ async def project_lock(
@mcp.tool()
async def project_unlock(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Unlock a project to allow editing of drawings and nodes."""
return await asyncio.to_thread(
@@ -1612,7 +1766,7 @@ async def project_unlock(
@mcp.tool()
async def project_locked(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
) -> list[dict[str, Any]]:
"""Check whether a project is locked (preventing edits to drawings and nodes)."""
return await asyncio.to_thread(
@@ -1840,7 +1994,7 @@ async def image_install() -> list[dict[str, Any]]:
@mcp.tool()
async def device_config_send(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
device_configs: Annotated[
list,
Field(
@@ -1877,7 +2031,7 @@ async def device_config_send(
@mcp.tool()
async def device_show_run(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
device_configs: Annotated[
list,
Field(
@@ -1919,7 +2073,7 @@ async def device_show_run(
@mcp.tool()
async def vpcs_config_set(
- project_id: Annotated[str, Field(description="UUID of the project")],
+ project_id: Annotated[str, Field(description="Project name or UUID")],
device_configs: Annotated[
list,
Field(
diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py
index 45a9b472a..35084ea6a 100644
--- a/gns3server/agent/mcp/appliances.py
+++ b/gns3server/agent/mcp/appliances.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 appliance management.
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
@@ -108,7 +107,7 @@ def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if version:
request_params["version"] = version
response = conn.http_call("post", url, params=request_params)
- result = {"message": f"Appliance {appliance_id} installed"}
+ result: dict[str, Any] = {"message": f"Appliance {appliance_id} installed"}
if response.content:
# the install endpoint returns the created template (201); tolerate an
# empty body in case an older server still replies with 204
diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py
index 1f744066c..722ed15ce 100644
--- a/gns3server/agent/mcp/computes.py
+++ b/gns3server/agent/mcp/computes.py
@@ -19,8 +19,8 @@
MCP tool handlers for GNS3 compute management.
"""
-from typing import Any
import logging
+from typing import Any
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/device_config.py b/gns3server/agent/mcp/device_config.py
index 278295071..be7f04356 100644
--- a/gns3server/agent/mcp/device_config.py
+++ b/gns3server/agent/mcp/device_config.py
@@ -33,7 +33,8 @@ import json
import logging
from typing import Any
-from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError
+from jinja2 import Template as JinjaTemplate
+from jinja2 import TemplateError as JinjaError
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py
index a3ca146f6..7c459fe6a 100644
--- a/gns3server/agent/mcp/drawings.py
+++ b/gns3server/agent/mcp/drawings.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 drawing management.
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
@@ -52,24 +51,6 @@ def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
return {"drawings": drawings, "count": len(drawings)}
-def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
- project_id = params.get("project_id")
- svg = params.get("svg")
- if not project_id or not svg:
- return {"error": "project_id and svg are required"}
- conn = _get_connector(gns3_ctx)
- data = {
- "svg": svg,
- "x": params.get("x", 0),
- "y": params.get("y", 0),
- "z": params.get("z", 0),
- "locked": params.get("locked", False),
- "rotation": params.get("rotation", 0),
- }
- result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
- return {"message": "Drawing created", "drawing": result}
-
-
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
drawing_id = params.get("drawing_id")
diff --git a/gns3server/agent/mcp/images.py b/gns3server/agent/mcp/images.py
index d2b7ed958..7d20bc2fa 100644
--- a/gns3server/agent/mcp/images.py
+++ b/gns3server/agent/mcp/images.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 image management.
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/locators.py b/gns3server/agent/mcp/locators.py
new file mode 100644
index 000000000..727e0b208
--- /dev/null
+++ b/gns3server/agent/mcp/locators.py
@@ -0,0 +1,226 @@
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+# Author: Yue Guobin
+#
+# 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 .
+
+"""
+Name locators for MCP tools.
+
+LLM agents transcribe UUIDs unreliably (a 2-character slip invents an id
+that never existed and is invisible to the eye), so MCP tool parameters
+accept names wherever the server guarantees uniqueness: project names are
+unique among live projects (the controller rejects duplicates) and node
+names are unique within a project (auto-suffixed on collision). Links,
+drawings, appliances and snapshots have no names and stay UUID-only.
+
+Resolution happens in the single dispatch choke point (_run_handler_sync):
+UUID-shaped values pass through untouched, names are resolved with one
+list call, and a name that matches nothing fails fast with the known
+names instead of a confusing downstream 404. As a backstop for residual
+UUID typos, error replies carrying " doesn't exist" are matched
+against the ids visible in scope; a single candidate within edit
+distance 2 earns a "Did you mean ..." hint.
+"""
+
+import re
+from typing import Any, Callable
+
+from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler, get_nodes_handler
+
+from .projects import list_projects_handler
+
+_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
+_ID_IN_TEXT_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
+
+# how many names to list when a locator matches nothing
+_MAX_HINT_NAMES = 15
+
+
+def looks_like_uuid(value: Any) -> bool:
+ """
+ Whether *value* is a UUID-shaped string (anything else is treated as a name).
+ """
+
+ return isinstance(value, str) and bool(_UUID_RE.match(value))
+
+
+def resolve_locators(
+ params: dict[str, Any], run: Callable[[Callable, dict], Any]
+) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
+ """
+ Rewrite name-shaped ``project_id``/``node_id`` values into UUIDs.
+
+ :param params: tool params (top-level keys only; nested structures are
+ left alone — link_create's node tuples require ids)
+ :param run: callback running (handler, params) through the same context
+ :returns: (resolved params, None) on success, (None, error dict) on failure
+ """
+
+ params = dict(params)
+
+ project_ref = params.get("project_id")
+ if project_ref is not None and not looks_like_uuid(project_ref):
+ result = run(list_projects_handler, {})
+ projects = _as_items(result, "projects")
+ if projects is None:
+ return None, {"error": f"Could not resolve project '{project_ref}': {_as_error(result)}"}
+ match = _match_by_name(projects, project_ref)
+ if match is None:
+ return None, {"error": f"Project '{project_ref}' not found. Known projects: {_known_names(projects)}"}
+ params["project_id"] = match["project_id"]
+
+ node_ref = params.get("node_id")
+ if node_ref is not None and not looks_like_uuid(node_ref) and params.get("project_id"):
+ result = run(get_nodes_handler, {"project_id": params["project_id"]})
+ nodes = _as_items(result, "nodes")
+ if nodes is None:
+ return None, {"error": f"Could not resolve node '{node_ref}': {_as_error(result)}"}
+ match = _match_by_name(nodes, node_ref)
+ if match is None:
+ return None, {"error": f"Node '{node_ref}' not found. Known nodes: {_known_names(nodes)}"}
+ params["node_id"] = match["node_id"]
+
+ return params, None
+
+
+def add_did_you_mean(result: Any, params: dict[str, Any], run: Callable[[Callable, dict], Any]) -> Any:
+ """
+ Append a "Did you mean ...?" hint to an error reply that names a
+ non-existent UUID, when exactly one in-scope id is within edit
+ distance 2 of it (the signature of a transcription slip).
+ """
+
+ if not isinstance(result, dict):
+ return result
+ error = result.get("error")
+ if not isinstance(error, str) or ("doesn't exist" not in error and "not found" not in error.lower()):
+ return result
+ bad_ids = set(_ID_IN_TEXT_RE.findall(error))
+ if not bad_ids:
+ return result
+
+ candidates = _collect_candidates(params, run)
+ if not candidates:
+ return result
+
+ hints = []
+ for bad in bad_ids:
+ close = [
+ (cid, label)
+ for cid, label in candidates
+ if bad != cid and _edit_distance_at_most(bad.lower(), cid.lower(), 2)
+ ]
+ if len(close) == 1:
+ cid, label = close[0]
+ hints.append(f"Did you mean {label} ({cid})?")
+ if not hints:
+ return result
+ result = dict(result)
+ result["error"] = error.rstrip() + " " + " ".join(hints)
+ return result
+
+
+def _as_items(result: Any, key: str) -> list | None:
+ """
+ Normalize a handler reply into a list of dicts (None = failure).
+ """
+
+ if isinstance(result, list):
+ return result
+ if isinstance(result, dict):
+ items = result.get(key)
+ if isinstance(items, list):
+ return items
+ return None
+
+
+def _as_error(result: Any) -> Any:
+ return result.get("error", result) if isinstance(result, dict) else result
+
+
+def _match_by_name(items: list, name: str) -> dict | None:
+ """
+ The unique item whose name equals *name* (exact first, then a unique
+ case-insensitive match); None when absent or ambiguous.
+ """
+
+ exact = [item for item in items if item.get("name") == name]
+ if len(exact) == 1:
+ return exact[0]
+ if not exact:
+ folded = [item for item in items if isinstance(item.get("name"), str) and item["name"].lower() == name.lower()]
+ if len(folded) == 1:
+ return folded[0]
+ return None
+
+
+def _known_names(items: list) -> str:
+ names = sorted(str(item.get("name", "?")) for item in items)
+ if len(names) > _MAX_HINT_NAMES:
+ names = [*names[:_MAX_HINT_NAMES], f"... ({len(names) - _MAX_HINT_NAMES} more)"]
+ return ", ".join(names)
+
+
+def _collect_candidates(params: dict[str, Any], run: Callable[[Callable, dict], Any]) -> list[tuple[str, str]]:
+ """
+ (id, label) pairs in scope for a hint: every project, plus the nodes
+ and links of the project the call targeted. Best-effort — any failure
+ just yields what was gathered so far.
+ """
+
+ candidates: list[tuple[str, str]] = []
+ try:
+ projects = _as_items(run(list_projects_handler, {}), "projects") or []
+ candidates.extend((p["project_id"], f"project '{p.get('name', '?')}'") for p in projects if p.get("project_id"))
+ except Exception: # best-effort hint enrichment
+ pass
+ project_id = params.get("project_id")
+ if looks_like_uuid(project_id):
+ try:
+ nodes = _as_items(run(get_nodes_handler, {"project_id": project_id}), "nodes") or []
+ candidates.extend((n["node_id"], f"node '{n.get('name', '?')}'") for n in nodes if n.get("node_id"))
+ except Exception: # best-effort hint enrichment
+ pass
+ try:
+ links = _as_items(run(get_links_handler, {"project_id": project_id}), "links") or []
+ candidates.extend((link["link_id"], "link") for link in links if link.get("link_id"))
+ except Exception: # best-effort hint enrichment
+ pass
+ return candidates
+
+
+def _edit_distance_at_most(a: str, b: str, cap: int) -> bool:
+ """
+ Levenshtein distance with an early bail above *cap* (ids are short;
+ the cap keeps the DP band trivial).
+ """
+
+ if abs(len(a) - len(b)) > cap:
+ return False
+ if a == b:
+ return True
+ previous = list(range(len(b) + 1))
+ for i, char_a in enumerate(a, 1):
+ current = [i]
+ best = i
+ for j, char_b in enumerate(b, 1):
+ substitution = previous[j - 1] + (char_a != char_b)
+ value = min(previous[j] + 1, current[j - 1] + 1, substitution)
+ current.append(value)
+ best = min(best, value)
+ if best > cap:
+ return False
+ previous = current
+ return previous[-1] <= cap
diff --git a/gns3server/agent/mcp/projects.py b/gns3server/agent/mcp/projects.py
index ff6b5125c..57b9c01d3 100644
--- a/gns3server/agent/mcp/projects.py
+++ b/gns3server/agent/mcp/projects.py
@@ -22,9 +22,8 @@ Tool handlers receive (params, gns3_ctx) and call GNS3's REST API
via Gns3Connector (from gns3_copilot.gns3_client.connector).
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/server.py b/gns3server/agent/mcp/server.py
index fe5c21dc3..0585a8566 100644
--- a/gns3server/agent/mcp/server.py
+++ b/gns3server/agent/mcp/server.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 server information.
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/snapshots.py b/gns3server/agent/mcp/snapshots.py
index 336c7a3e0..9f741a91e 100644
--- a/gns3server/agent/mcp/snapshots.py
+++ b/gns3server/agent/mcp/snapshots.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 snapshot management.
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
diff --git a/gns3server/agent/mcp/symbols.py b/gns3server/agent/mcp/symbols.py
index d363e042c..30f8f7d71 100644
--- a/gns3server/agent/mcp/symbols.py
+++ b/gns3server/agent/mcp/symbols.py
@@ -19,9 +19,8 @@
MCP tool handlers for GNS3 symbol management.
"""
-from typing import Any
-
import logging
+from typing import Any
from gns3server.services import access_ticket_service
diff --git a/gns3server/agent/mcp/templates.py b/gns3server/agent/mcp/templates.py
index a80f08ff9..2a6130c78 100644
--- a/gns3server/agent/mcp/templates.py
+++ b/gns3server/agent/mcp/templates.py
@@ -22,9 +22,8 @@ Handlers receive (params, gns3_ctx) and call GNS3's REST API
via Gns3Connector (from gns3_copilot.gns3_client.connector).
"""
-from typing import Any
-
import logging
+from typing import Any
log = logging.getLogger(__name__)
@@ -173,7 +172,7 @@ def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
template_id = matches[0]["template_id"]
conn.http_call("delete", f"{conn.base_url}/templates/{template_id}")
- return {"message": f"Template deleted"}
+ return {"message": "Template deleted"}
# ── Tool definitions ───────────────────────────────────────────────────────
diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py
index d27873fa5..0ee7fa339 100644
--- a/gns3server/agent/web_wireshark/docker_client.py
+++ b/gns3server/agent/web_wireshark/docker_client.py
@@ -21,8 +21,9 @@ Web Wireshark - Docker HTTP API client
This module handles Docker API operations via aiohttp Unix socket.
"""
-import asyncio
import logging
+from asyncio import timeout as async_timeout
+from typing import Any
import aiohttp
@@ -132,7 +133,7 @@ class DockerHTTPClient:
url = f"http://docker/v{self._api_version}/{endpoint}"
try:
- async with asyncio.timeout(self.REQUEST_TIMEOUT):
+ async with async_timeout(self.REQUEST_TIMEOUT):
async with session.request(method, url, **kwargs) as response:
if response.status >= 300:
error_text = await response.text()
@@ -140,7 +141,7 @@ class DockerHTTPClient:
if response.status == 204: # No Content
return None
return await response.json()
- except asyncio.TimeoutError:
+ except TimeoutError:
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for {endpoint}")
except aiohttp.ClientError as e:
raise RuntimeError(f"Docker connection error: {e}") from e
@@ -157,9 +158,9 @@ class DockerHTTPClient:
return await self._request(method, endpoint, check_connection=False, **kwargs)
raise
- async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
+ async def create_network(self, name: str, driver: str = "bridge", subnet: str | None = None):
"""Create Docker network."""
- data = {"Name": name, "Driver": driver}
+ data: dict[str, Any] = {"Name": name, "Driver": driver}
if subnet:
data["IPAM"] = {"Config": [{"Subnet": subnet}]}
await self._request("POST", "networks/create", json=data)
@@ -175,7 +176,7 @@ class DockerHTTPClient:
async def create_container(self, name: str, image: str, **kwargs):
"""Create container."""
- data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
+ data: dict[str, Any] = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
# Handle network config
if "network" in kwargs:
@@ -223,7 +224,7 @@ class DockerHTTPClient:
return None
raise
- async def stop_container(self, container_id: str, timeout: int = 0):
+ async def stop_container(self, container_id: str, timeout: int = 0): # noqa: ASYNC109 - operation-specific timeout is intentional
"""Stop container immediately (force kill, no graceful shutdown)."""
await self._request("POST", f"containers/{container_id}/stop", params={"t": timeout})
@@ -248,13 +249,13 @@ class DockerHTTPClient:
url = f"http://docker/v{self._api_version}/containers/{container_name}/top?ps_args=aux"
try:
- async with asyncio.timeout(self.REQUEST_TIMEOUT):
+ async with async_timeout(self.REQUEST_TIMEOUT):
async with session.get(url) as response:
if response.status >= 300:
error_text = await response.text()
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
result = await response.json()
- except asyncio.TimeoutError:
+ except TimeoutError:
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for containers/{container_name}/top")
except aiohttp.ClientError as e:
raise RuntimeError(f"Docker connection error: {e}") from e
diff --git a/gns3server/agent/web_wireshark/manage_wireshark.py b/gns3server/agent/web_wireshark/manage_wireshark.py
index 559eaadd9..82ea6274e 100755
--- a/gns3server/agent/web_wireshark/manage_wireshark.py
+++ b/gns3server/agent/web_wireshark/manage_wireshark.py
@@ -67,13 +67,12 @@ For more information on each command, use:
python manage_wireshark.py --help
"""
-import sys
-import json
import argparse
-import logging
import asyncio
+import json
+import logging
import os
-from typing import Optional
+import sys
from gns3server.utils.uuid_validator import validate_uuid
@@ -81,10 +80,7 @@ from gns3server.utils.uuid_validator import validate_uuid
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
-try:
- from gns3server.agent.web_wireshark.manager import WebWiresharkManager
-except ImportError:
- from manager import WebWiresharkManager
+from gns3server.agent.web_wireshark.manager import WebWiresharkManager
logger = logging.getLogger(__name__)
diff --git a/gns3server/agent/web_wireshark/manager.py b/gns3server/agent/web_wireshark/manager.py
index e17cf65c0..ec638ee4a 100644
--- a/gns3server/agent/web_wireshark/manager.py
+++ b/gns3server/agent/web_wireshark/manager.py
@@ -30,6 +30,7 @@ from urllib.parse import urlparse
from gns3server.config import Config
from gns3server.utils.port_allocator import link_id_to_display, link_id_to_port
+
from .docker_client import DockerHTTPClient
logger = logging.getLogger(__name__)
@@ -74,7 +75,7 @@ class WebWiresharkManager:
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT)
return proc.returncode == 0 and b"ping" in stdout
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.warning(f"Container {container_id[:12]} health check timeout")
return False
except FileNotFoundError:
@@ -84,7 +85,7 @@ class WebWiresharkManager:
logger.warning(f"Container {container_id[:12]} health check failed: {e}")
return False
- async def _exec_in_container(self, container_id: str, command: str, timeout: int = None) -> tuple:
+ async def _exec_in_container(self, container_id: str, command: str, timeout: Optional[int] = None) -> tuple: # noqa: ASYNC109 - operation-specific timeout is intentional
"""Execute command in container with timeout.
Args:
@@ -112,12 +113,12 @@ class WebWiresharkManager:
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.error(f"Command timeout after {timeout}s: {command}")
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2)
- except asyncio.TimeoutError:
+ except TimeoutError:
proc.kill()
return (-1, "", f"timeout after {timeout}s")
except Exception as e:
@@ -197,7 +198,7 @@ class WebWiresharkManager:
stdout, _ = await proc.communicate()
# Build parent->children mapping
- children_map = {}
+ children_map: dict[int, list[int]] = {}
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
@@ -326,7 +327,7 @@ class WebWiresharkManager:
return
# Build parent->children mapping and collect process info
- children_map = {} # ppid -> [pid]
+ children_map: dict[int, list[int]] = {} # ppid -> [pid]
process_info = {} # pid -> (ppid, command)
for line in stdout.decode().strip().split("\n"):
@@ -480,7 +481,7 @@ class WebWiresharkManager:
logger.debug(f"Cannot get URL from Config: {e}")
return None
- async def _get_container_gateway_ip(self, container_id: str = None) -> Optional[str]:
+ async def _get_container_gateway_ip(self, container_id: Optional[str] = None) -> Optional[str]:
"""Get the Docker bridge gateway IP for container to access host.
Args:
@@ -519,7 +520,7 @@ class WebWiresharkManager:
return None
- async def get_container_ip(self, container_name: str, container_id: str = None) -> Optional[str]:
+ async def get_container_ip(self, container_name: str, container_id: Optional[str] = None) -> Optional[str]:
"""Get the container IP address in the wireshark network.
Args:
@@ -626,7 +627,7 @@ class WebWiresharkManager:
project_id: str,
image: str = "gns3/web-wireshark:latest",
memory: str = "2g",
- memory_swap: str = None,
+ memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000,
) -> str:
@@ -935,10 +936,10 @@ class WebWiresharkManager:
project_id: str,
link_id: str,
jwt_token: str,
- capture_stream_url: str = None,
+ capture_stream_url: Optional[str] = None,
image: str = "gns3/web-wireshark:latest",
memory: str = "2g",
- memory_swap: str = None,
+ memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000,
):
diff --git a/gns3server/agent/web_wireshark/setup_wireshark_image.py b/gns3server/agent/web_wireshark/setup_wireshark_image.py
index 92949a923..01159fee3 100644
--- a/gns3server/agent/web_wireshark/setup_wireshark_image.py
+++ b/gns3server/agent/web_wireshark/setup_wireshark_image.py
@@ -23,12 +23,11 @@ This script pulls or builds the gns3/web-wireshark Docker image.
Run with: pip install gns3server && gns3server-web-wireshark-setup
"""
+import argparse
import os
-import sys
import shutil
import subprocess
-import argparse
-
+import sys
DOCKER_IMAGE = "gns3/web-wireshark:latest"
DOCKERFILE_NAME = "Dockerfile"
diff --git a/gns3server/agent/web_wireshark/stats.py b/gns3server/agent/web_wireshark/stats.py
index 78dc7b0c0..de9b66091 100644
--- a/gns3server/agent/web_wireshark/stats.py
+++ b/gns3server/agent/web_wireshark/stats.py
@@ -7,7 +7,7 @@ about Web Wireshark containers and sessions.
import logging
import subprocess
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -30,7 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
"""
from .manager import WebWiresharkManager
- stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
+ stats: Dict[str, Any] = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
# Create a single manager instance and reuse it
manager = WebWiresharkManager()
diff --git a/gns3server/api/errors.py b/gns3server/api/errors.py
new file mode 100644
index 000000000..adf9d35a9
--- /dev/null
+++ b/gns3server/api/errors.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+Error response helpers shared by the exception handlers and the OpenAPI schema.
+"""
+
+from collections.abc import Mapping
+from http import HTTPStatus
+from typing import Any
+
+from fastapi.responses import JSONResponse
+
+from gns3server.schemas.common import ErrorMessage
+
+AUTHENTICATED_ERROR_RESPONSES = {
+ 401: "Authentication is required or the credentials are invalid",
+ 403: "Not enough privileges to perform this operation",
+ 422: "Request validation error",
+}
+
+
+def code_for_status(status_code: int) -> str:
+ try:
+ phrase = HTTPStatus(status_code).phrase
+ except ValueError:
+ return "error"
+ return phrase.lower().replace(" ", "_").replace("-", "_")
+
+
+def error_response(
+ status_code: int,
+ message: Any,
+ code: str | None = None,
+ details: dict[str, Any] | None = None,
+ headers: Mapping[str, str] | None = None,
+) -> JSONResponse:
+ content: dict[str, Any] = {"message": message, "code": code or code_for_status(status_code)}
+ if details:
+ content["details"] = details
+ return JSONResponse(status_code=status_code, content=content, headers=headers)
+
+
+def customize_openapi_errors(schema: dict[str, Any]) -> dict[str, Any]:
+ components = schema.setdefault("components", {}).setdefault("schemas", {})
+ components.pop("HTTPValidationError", None)
+ components.pop("ValidationError", None)
+ if "ErrorMessage" not in components:
+ error_schema = ErrorMessage.model_json_schema(ref_template="#/components/schemas/{model}")
+ components["ErrorMessage"] = {k: v for k, v in error_schema.items() if k != "$defs"}
+
+ for path_item in schema.get("paths", {}).values():
+ for operation in path_item.values():
+ if not isinstance(operation, dict) or "responses" not in operation:
+ continue
+ responses = operation["responses"]
+ wanted = ["422"] if "422" in responses else []
+ if operation.get("security"):
+ wanted = ["401", "403", "422"]
+ for status_code in wanted:
+ responses[status_code] = {
+ "description": AUTHENTICATED_ERROR_RESPONSES[int(status_code)],
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}},
+ }
+ return schema
diff --git a/gns3server/api/openapi.py b/gns3server/api/openapi.py
new file mode 100644
index 000000000..cf5dce209
--- /dev/null
+++ b/gns3server/api/openapi.py
@@ -0,0 +1,47 @@
+#
+# 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 .
+
+from typing import Any, Dict
+
+BINARY_SCHEMA: Dict[str, Any] = {"type": "string", "format": "binary"}
+
+NOTIFICATION_LINE_SCHEMA: Dict[str, Any] = {
+ "type": "object",
+ "description": "One notification event per line",
+ "properties": {
+ "action": {"type": "string"},
+ "event": {"description": "Event payload, its shape depends on the action"},
+ },
+ "required": ["action", "event"],
+ "additionalProperties": True,
+}
+
+PCAP_MEDIA_TYPE = "application/vnd.tcpdump.pcap"
+NDJSON_MEDIA_TYPE = "application/x-ndjson"
+OCTET_STREAM_MEDIA_TYPE = "application/octet-stream"
+ZIP_MEDIA_TYPE = "application/zip"
+
+
+def binary_response(media_type: str = OCTET_STREAM_MEDIA_TYPE, description: str = "Binary content") -> Dict[str, Any]:
+ return {"description": description, "content": {media_type: {"schema": BINARY_SCHEMA}}}
+
+
+def ndjson_response(description: str = "Stream of newline-delimited JSON notifications") -> Dict[str, Any]:
+ return {"description": description, "content": {NDJSON_MEDIA_TYPE: {"schema": NOTIFICATION_LINE_SCHEMA}}}
+
+
+def binary_request_body(media_type: str = OCTET_STREAM_MEDIA_TYPE) -> Dict[str, Any]:
+ return {"requestBody": {"required": True, "content": {media_type: {"schema": BINARY_SCHEMA}}}}
diff --git a/gns3server/api/operation_ids.py b/gns3server/api/operation_ids.py
new file mode 100644
index 000000000..ae8c9a2ae
--- /dev/null
+++ b/gns3server/api/operation_ids.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2026 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+Readable and unique OpenAPI operationIds.
+"""
+
+import re
+from typing import Callable
+
+from fastapi import APIRouter
+from fastapi.routing import APIRoute
+
+STUB_METHODS = ("GET", "POST", "DELETE", "PATCH", "PUT")
+
+
+def _slug(value: str) -> str:
+
+ return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
+
+
+def generate_operation_id(route: APIRoute) -> str:
+ """
+ Builds an operationId such as "nodes_get_node" from the first tag and the route name.
+ """
+
+ name = _slug(route.name)
+ if route.tags:
+ tag = _slug(str(route.tags[0]))
+ if tag:
+ return f"{tag}_{name}"
+ return name
+
+
+def add_stub_routes(router: APIRouter, endpoint: Callable, name: str) -> None:
+ """
+ Registers a catch-all route per HTTP method so that every method gets its own operationId.
+ """
+
+ for method in STUB_METHODS:
+ router.add_api_route("/{path:path}", endpoint, methods=[method], name=f"{name}_{method.lower()}")
diff --git a/gns3server/api/responses.py b/gns3server/api/responses.py
new file mode 100644
index 000000000..55330c038
--- /dev/null
+++ b/gns3server/api/responses.py
@@ -0,0 +1,41 @@
+#
+# 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 .
+
+from typing import Any
+
+from fastapi.responses import JSONResponse
+
+from gns3server import schemas
+
+NDJSON_MEDIA_TYPE = "application/x-ndjson"
+
+
+class NDJSONResponse(JSONResponse):
+ """
+ Only used as the response_class of streaming routes to publish the application/x-ndjson media type
+ and the schema of the streamed objects in the OpenAPI document.
+ """
+
+ media_type = NDJSON_MEDIA_TYPE
+
+
+NOTIFICATION_STREAM_RESPONSES: dict[int | str, dict[str, Any]] = {
+ 200: {
+ "model": schemas.Notification,
+ "description": "Stream of notifications, one JSON object per line. "
+ "Each line is a Notification, the first one is a ping and then a ping is sent at least every 5 seconds.",
+ }
+}
diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py
index 7606e6ddf..8e48ee48d 100644
--- a/gns3server/api/routes/compute/__init__.py
+++ b/gns3server/api/routes/compute/__init__.py
@@ -15,43 +15,46 @@
# along with this program. If not, see .
-from fastapi import FastAPI, Request, Depends
+import logging
+
+from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
-from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError
-from gns3server.compute.error import ImageMissingError, NodeError
-from gns3server.compute.ubridge.ubridge_error import UbridgeError
-
-from .dependencies.authentication import compute_authentication
+from gns3server.api.operation_ids import generate_operation_id
from gns3server.compute.compute_error import (
ComputeError,
+ ComputeForbiddenError,
ComputeNotFoundError,
ComputeTimeoutError,
- ComputeForbiddenError,
ComputeUnauthorizedError,
)
+from gns3server.compute.error import ImageMissingError, NodeError
+from gns3server.compute.ubridge.ubridge_error import UbridgeError
+from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError
+from gns3server.version import __api_version__
-from . import capabilities
-from . import compute
-from . import projects
-from . import notifications
-from . import images
-from . import atm_switch_nodes
-from . import cloud_nodes
-from . import docker_nodes
-from . import dynamips_nodes
-from . import ethernet_hub_nodes
-from . import ethernet_switch_nodes
-from . import frame_relay_switch_nodes
-from . import iou_nodes
-from . import nat_nodes
-from . import qemu_nodes
-from . import virtualbox_nodes
-from . import vmware_nodes
-from . import vpcs_nodes
-
-import logging
+from . import (
+ atm_switch_nodes,
+ capabilities,
+ cloud_nodes,
+ compute,
+ docker_nodes,
+ dynamips_nodes,
+ ethernet_hub_nodes,
+ ethernet_switch_nodes,
+ frame_relay_switch_nodes,
+ images,
+ iou_nodes,
+ nat_nodes,
+ notifications,
+ projects,
+ qemu_nodes,
+ virtualbox_nodes,
+ vmware_nodes,
+ vpcs_nodes,
+)
+from .dependencies.authentication import compute_authentication
log = logging.getLogger(__name__)
@@ -59,7 +62,9 @@ log = logging.getLogger(__name__)
compute_api = FastAPI(
title="GNS3 compute API",
description="This page describes the private compute API for GNS3. PLEASE DO NOT USE DIRECTLY!",
- version="3.0.0",
+ generate_unique_id_function=generate_operation_id,
+ version=__api_version__,
+ servers=[{"url": "/v3/compute"}],
)
compute_api.state.controller_host = None
diff --git a/gns3server/api/routes/compute/atm_switch_nodes.py b/gns3server/api/routes/compute/atm_switch_nodes.py
index 65bc1157d..e62a54eb1 100644
--- a/gns3server/api/routes/compute/atm_switch_nodes.py
+++ b/gns3server/api/routes/compute/atm_switch_nodes.py
@@ -19,17 +19,20 @@ API routes for ATM switch nodes.
"""
import os
+from typing import Any
+from uuid import UUID
-from fastapi import APIRouter, Depends, Body, Path, status, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from uuid import UUID
from gns3server import schemas
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.atm_switch import ATMSwitch
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or ATM switch node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or ATM switch node"}
+}
router = APIRouter(responses=responses)
@@ -57,13 +60,13 @@ async def create_atm_switch(project_id: UUID, node_data: schemas.ATMSwitchCreate
# Use the Dynamips ATM switch to simulate this node
dynamips_manager = Dynamips.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await dynamips_manager.create_node(
- node_data.get("name"),
+ data.get("name"),
str(project_id),
- node_data.get("node_id"),
+ data.get("node_id"),
node_type="atm_switch",
- mappings=node_data.get("mappings"),
+ mappings=data.get("mappings"),
)
return node.asdict()
@@ -97,11 +100,11 @@ async def update_atm_switch(
Update an ATM switch node.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- if "name" in node_data and node.name != node_data["name"]:
- await node.set_name(node_data["name"])
- if "mappings" in node_data:
- node.mappings = node_data["mappings"]
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ if "name" in data and node.name != data["name"]:
+ await node.set_name(data["name"])
+ if "mappings" in data:
+ node.mappings = data["mappings"]
node.updated()
return node.asdict()
diff --git a/gns3server/api/routes/compute/capabilities.py b/gns3server/api/routes/compute/capabilities.py
index f472f6231..b0fc30566 100644
--- a/gns3server/api/routes/compute/capabilities.py
+++ b/gns3server/api/routes/compute/capabilities.py
@@ -19,27 +19,71 @@ API routes for capabilities
"""
import sys
-import psutil
+import psutil
from fastapi import APIRouter, Request
-from gns3server.version import __version__
-from gns3server.compute import MODULES
-from gns3server.utils.path import get_default_project_directory
from gns3server import schemas
+from gns3server.compute import MODULES
+from gns3server.compute.ubridge.tc_probe import (
+ probe_bridge_tap_support,
+ probe_iol_tap_support,
+ probe_tap_support,
+ probe_tc_capabilities,
+ probe_vxlan_support,
+)
+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__
router = APIRouter()
@router.get("/capabilities", response_model=schemas.Capabilities)
-def get_capabilities(request: Request) -> dict:
+async def get_capabilities(request: Request) -> dict:
node_types = []
for module in MODULES:
node_types.extend(module.node_types())
+ # Kernel-datapath packet filters: what this host's uBridge can run,
+ # probed as the server's own user (None = unknown, old/missing uBridge).
+ ubridge_tc = None
+ caps = await probe_tc_capabilities()
+ if caps:
+ ubridge_tc = {
+ "netem": [keyword for keyword in caps.get("netem", "").split(",") if keyword],
+ "ebpf": caps.get("ebpf") == "1",
+ "ebpf_modes": list(usable_ebpf_modes(caps)),
+ "cbpf": caps.get("cbpf") == "1",
+ }
+
+ # Persistent TAP support: the anchor QEMU adapters need for the kernel
+ # datapath. None = unknown (probe failed or uBridge too old).
+ ubridge_tap = await probe_tap_support()
+
+ # IOL-port TAP termination (iol_bridge add_nio_tap): the anchor IOU's
+ # Ethernet ports need for the kernel datapath. None = unknown.
+ ubridge_iol_tap = await probe_iol_tap_support()
+
+ # Swappable TAP leg on the generic bridge module (bridge delete_nio_tap):
+ # what lets an IOL runner container's per-port bridge carry a kernel-link
+ # 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()
+
+ # VXLAN device creation (the vxlan module): the tunnel a cross-compute
+ # kernel link rides. None = unknown (probe failed, uBridge too old, or
+ # the kernel refuses vxlan devices here).
+ ubridge_vxlan = await probe_vxlan_support()
+
# record the controller hostname or IP address
- request.app.state.controller_host = request.client.host
+ if request.client:
+ request.app.state.controller_host = request.client.host
return {
"version": __version__,
@@ -48,4 +92,10 @@ def get_capabilities(request: Request) -> dict:
"memory": psutil.virtual_memory().total,
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"node_types": node_types,
+ "ubridge_tc": ubridge_tc,
+ "ubridge_tap": ubridge_tap,
+ "ubridge_iol_tap": ubridge_iol_tap,
+ "ubridge_bridge_tap": ubridge_bridge_tap,
+ "vpcs_tap": vpcs_tap,
+ "ubridge_vxlan": ubridge_vxlan,
}
diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py
index 7baf8ca17..53b4ed432 100644
--- a/gns3server/api/routes/compute/cloud_nodes.py
+++ b/gns3server/api/routes/compute/cloud_nodes.py
@@ -19,18 +19,20 @@ API routes for cloud nodes.
"""
import os
+from typing import Any, Union
+from uuid import UUID
-from fastapi import APIRouter, Depends, Path, status, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Path, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from typing import Union
-from uuid import UUID
from gns3server import schemas
from gns3server.compute.builtin import Builtin
from gns3server.compute.builtin.nodes.cloud import Cloud
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or cloud node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or cloud node"}
+}
router = APIRouter(responses=responses)
@@ -57,21 +59,21 @@ async def create_cloud(project_id: UUID, node_data: schemas.CloudCreate) -> sche
"""
builtin_manager = Builtin.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await builtin_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
+ data.get("node_id"),
node_type="cloud",
- ports=node_data.get("ports_mapping"),
+ ports=data.get("ports_mapping"),
)
# add the remote console settings
- node.remote_console_host = node_data.get("remote_console_host", node.remote_console_host)
- node.remote_console_port = node_data.get("remote_console_port", node.remote_console_port)
- node.remote_console_type = node_data.get("remote_console_type", node.remote_console_type)
- node.remote_console_http_path = node_data.get("remote_console_http_path", node.remote_console_http_path)
- node.usage = node_data.get("usage", "")
+ node.remote_console_host = data.get("remote_console_host", node.remote_console_host)
+ node.remote_console_port = data.get("remote_console_port", node.remote_console_port)
+ node.remote_console_type = data.get("remote_console_type", node.remote_console_type)
+ node.remote_console_http_path = data.get("remote_console_http_path", node.remote_console_http_path)
+ node.usage = data.get("usage", "")
return node.asdict()
@@ -90,8 +92,8 @@ async def update_cloud(node_data: schemas.CloudUpdate, node: Cloud = Depends(dep
Update a cloud node.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- for name, value in node_data.items():
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ for name, value in data.items():
if hasattr(node, name) and getattr(node, name) != value:
setattr(node, name, value)
node.updated()
@@ -178,7 +180,7 @@ async def update_cloud_nio(
nio = node.get_nio(port_number)
nio.filters.clear()
- if nio_data.filters:
+ if isinstance(nio_data, schemas.UDPNIO) and nio_data.filters:
nio.filters = nio_data.filters
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
nio.markers = getattr(nio_data, "markers", None) or {}
diff --git a/gns3server/api/routes/compute/compute.py b/gns3server/api/routes/compute/compute.py
index 1a862787c..e83424cf6 100644
--- a/gns3server/api/routes/compute/compute.py
+++ b/gns3server/api/routes/compute/compute.py
@@ -19,27 +19,23 @@
API routes for compute.
"""
-import os
-import psutil
-import cpuinfo
-
from functools import lru_cache
-from gns3server.config import Config
-from gns3server.utils.cpu_percent import CpuPercent
-from gns3server.version import __version__
-from gns3server.utils.path import get_default_project_directory, is_safe_path
+from typing import List
+from uuid import UUID
+
+import cpuinfo
+import psutil
+from fastapi import APIRouter, HTTPException, status
+
from gns3server.compute.port_manager import PortManager
from gns3server.compute.project_manager import ProjectManager
-from gns3server.utils.interfaces import interfaces
from gns3server.compute.qemu import Qemu
from gns3server.compute.virtualbox import VirtualBox
from gns3server.compute.vmware import VMware
-from gns3server import schemas
-
-from fastapi import APIRouter, HTTPException, Body, Response, status
-from fastapi.encoders import jsonable_encoder
-from uuid import UUID
-from typing import Optional, List
+from gns3server.utils.cpu_percent import CpuPercent
+from gns3server.utils.interfaces import interfaces
+from gns3server.utils.path import get_default_project_directory
+from gns3server.version import __version__
router = APIRouter()
@@ -163,7 +159,7 @@ def compute_statistics() -> dict:
@router.get("/qemu/capabilities")
async def get_qemu_capabilities() -> dict:
- capabilities = {"kvm": []}
+ capabilities: dict[str, list] = {"kvm": []}
kvms = await Qemu.get_kvm_archs()
if kvms:
capabilities["kvm"] = kvms
diff --git a/gns3server/api/routes/compute/dependencies/authentication.py b/gns3server/api/routes/compute/dependencies/authentication.py
index 14ba07f91..db5a3f0d0 100644
--- a/gns3server/api/routes/compute/dependencies/authentication.py
+++ b/gns3server/api/routes/compute/dependencies/authentication.py
@@ -14,16 +14,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-import secrets
import base64
import binascii
import logging
+import secrets
+from typing import Optional, Union
from fastapi import Depends, HTTPException, WebSocket, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.security.utils import get_authorization_scheme_param
+
from gns3server.config import Config
-from typing import Optional, Union
log = logging.getLogger(__name__)
security = HTTPBasic(auto_error=False)
@@ -91,9 +92,9 @@ async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSock
if not separator:
raise invalid_user_credentials_exc
- username = secrets.compare_digest(username, server_settings.compute_username)
- password = secrets.compare_digest(password, server_settings.compute_password.get_secret_value())
- if not (username and password):
+ username_valid = secrets.compare_digest(username, server_settings.compute_username)
+ password_valid = secrets.compare_digest(password, server_settings.compute_password.get_secret_value())
+ if not (username_valid and password_valid):
raise invalid_user_credentials_exc
except HTTPException as e:
@@ -101,5 +102,6 @@ async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSock
websocket_error = {"action": "log.error", "event": {"message": err_msg}}
await websocket.send_json(websocket_error)
log.error(err_msg)
- return await websocket.close(code=1008)
+ await websocket.close(code=1008)
+ return None
return websocket
diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py
index 6091cf37b..dc251fdac 100644
--- a/gns3server/api/routes/compute/docker_nodes.py
+++ b/gns3server/api/routes/compute/docker_nodes.py
@@ -19,19 +19,23 @@ API routes for Docker nodes.
"""
import os
+from typing import Any, Union
+from uuid import UUID
-from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException, WebSocket, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from uuid import UUID
-from typing import Union
from gns3server import schemas
from gns3server.compute.docker import Docker
+from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.docker.docker_vm import DockerVM
+
from .dependencies.authentication import compute_authentication, ws_compute_authentication
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}
+}
router = APIRouter(responses=responses)
@@ -53,13 +57,13 @@ def dep_node(project_id: UUID, node_id: UUID) -> DockerVM:
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}},
dependencies=[Depends(compute_authentication)],
)
-async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker:
+async def create_docker_node(project_id: UUID, node_create: schemas.DockerCreate) -> schemas.Docker:
"""
Create a new Docker node.
"""
docker_manager = Docker.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ node_data = jsonable_encoder(node_create, exclude_unset=True)
container = await docker_manager.create_node(
node_data.pop("name"),
str(project_id),
@@ -124,7 +128,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
@router.put("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
-async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
+async def update_docker_node(node_update: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
"""
Update a Docker node.
"""
@@ -151,15 +155,28 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
"cpus",
]
- changed = False
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- for prop in props:
- # hasattr: startup_config_content only exists on IOLDockerVM
- if prop in node_data and hasattr(node, prop) and node_data[prop] != getattr(node, prop):
- setattr(node, prop, node_data[prop])
- changed = True
+ node_data = jsonable_encoder(node_update, exclude_unset=True)
+ # hasattr: startup_config_content only exists on IOLDockerVM
+ changed_props = [
+ prop for prop in props if prop in node_data and hasattr(node, prop) and node_data[prop] != getattr(node, prop)
+ ]
+
+ # A property change is applied by destroying and recreating the container,
+ # so it discards whatever was written inside it. Refuse that while the
+ # container is up (like QEMU, IOU, Dynamips, VPCS, VirtualBox and VMware do
+ # for their own restart-requiring settings) instead of resetting a running
+ # node behind the user's back. Nothing is applied before this check: a
+ # refused update must leave the node exactly as it was.
+ if changed_props and await node.is_up():
+ raise DockerError(
+ f'"{node.name}" must be stopped to change {", ".join(sorted(changed_props))}: '
+ "the container is recreated and changes made inside it are lost"
+ )
+
+ for prop in changed_props:
+ setattr(node, prop, node_data[prop])
# We don't call container.update for nothing because it will restart the container
- if changed:
+ if changed_props:
await node.update()
node.updated()
return node.asdict()
@@ -257,12 +274,15 @@ async def duplicate_docker_node(
@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_docker_node_nio(
- adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
-) -> schemas.UDPNIO:
+ adapter_number: int,
+ port_number: int,
+ nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
+ node: DockerVM = Depends(dep_node),
+) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
The port number on the Docker node is always 0.
@@ -276,12 +296,15 @@ async def create_docker_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_docker_node_nio(
- adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
-) -> schemas.UDPNIO:
+ adapter_number: int,
+ port_number: int,
+ nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
+ node: DockerVM = Depends(dep_node),
+) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
The port number on the Docker node is always 0.
diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py
index ce1c1b42b..419d96b1a 100644
--- a/gns3server/api/routes/compute/dynamips_nodes.py
+++ b/gns3server/api/routes/compute/dynamips_nodes.py
@@ -19,20 +19,23 @@ API routes for Dynamips nodes.
"""
import os
-
-from fastapi import APIRouter, WebSocket, Body, Depends, status, HTTPException
-from fastapi.encoders import jsonable_encoder
-from fastapi.responses import StreamingResponse
-from typing import List, Union
+from typing import Any, List, Optional, Union
from uuid import UUID
+from fastapi import APIRouter, Body, Depends, HTTPException, WebSocket, status
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import StreamingResponse
+
+from gns3server import schemas
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.router import Router
-from gns3server import schemas
+from gns3server.compute.nios.nio_bridge import NIOBridge
from .dependencies.authentication import compute_authentication, ws_compute_authentication
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Dynamips node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or Dynamips node"}
+}
router = APIRouter(responses=responses)
@@ -64,25 +67,26 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) ->
dynamips_manager = Dynamips.instance()
platform = node_data.platform
+ chassis: Optional[str]
if not node_data.chassis and platform in DEFAULT_CHASSIS:
chassis = DEFAULT_CHASSIS[platform]
else:
chassis = node_data.chassis
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
vm = await dynamips_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
- dynamips_id=node_data.get("dynamips_id"),
+ data.get("node_id"),
+ dynamips_id=data.get("dynamips_id"),
platform=platform,
- console=node_data.get("console"),
- console_type=node_data.get("console_type", "telnet"),
- aux=node_data.get("aux"),
- aux_type=node_data.pop("aux_type", "none"),
+ console=data.get("console"),
+ console_type=data.get("console_type", "telnet"),
+ aux=data.get("aux"),
+ aux_type=data.pop("aux_type", "none"),
chassis=chassis,
node_type="dynamips",
)
- await dynamips_manager.update_vm_settings(vm, node_data)
+ await dynamips_manager.update_vm_settings(vm, data)
return vm.asdict()
@@ -170,12 +174,15 @@ async def reload_router(node: Router = 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_nio(
- adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
-) -> schemas.UDPNIO:
+ adapter_number: int,
+ port_number: int,
+ nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
+ node: Router = Depends(dep_node),
+) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
"""
@@ -188,12 +195,15 @@ async def create_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_nio(
- adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
-) -> schemas.UDPNIO:
+ adapter_number: int,
+ port_number: int,
+ nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
+ node: Router = Depends(dep_node),
+) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
"""
@@ -203,6 +213,10 @@ async def update_nio(
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
+ # Suspend is what the compute turns into an admin-down anchor on a
+ # kernel link (native carrier), so it must reach the NIO like it does
+ # on the Docker/QEMU/IOU routes.
+ nio.suspend = getattr(nio_data, "suspend", None) or False
await node.slot_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
@@ -218,7 +232,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
"""
nio = await node.slot_remove_nio_binding(adapter_number, port_number)
- await nio.delete()
+ if not isinstance(nio, NIOBridge):
+ # A kernel NIO has no hypervisor side left: its TAP NIO was deleted
+ # by the slot binding removal itself.
+ await nio.delete()
@router.post(
diff --git a/gns3server/api/routes/compute/ethernet_hub_nodes.py b/gns3server/api/routes/compute/ethernet_hub_nodes.py
index 350105789..05985100c 100644
--- a/gns3server/api/routes/compute/ethernet_hub_nodes.py
+++ b/gns3server/api/routes/compute/ethernet_hub_nodes.py
@@ -19,17 +19,20 @@ API routes for Ethernet hub nodes.
"""
import os
-
-from fastapi import APIRouter, Depends, Body, Path, status, HTTPException
-from fastapi.encoders import jsonable_encoder
-from fastapi.responses import StreamingResponse
+from typing import Any
from uuid import UUID
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import StreamingResponse
+
+from gns3server import schemas
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.ethernet_hub import EthernetHub
-from gns3server import schemas
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet hub node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet hub node"}
+}
router = APIRouter(responses=responses)
@@ -57,13 +60,13 @@ async def create_ethernet_hub(project_id: UUID, node_data: schemas.EthernetHubCr
# Use the Dynamips Ethernet hub to simulate this node
dynamips_manager = Dynamips.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await dynamips_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
+ data.get("node_id"),
node_type="ethernet_hub",
- ports=node_data.get("ports_mapping"),
+ ports=data.get("ports_mapping"),
)
return node.asdict()
@@ -97,11 +100,11 @@ async def update_ethernet_hub(
Update an Ethernet hub.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- if "name" in node_data and node.name != node_data["name"]:
- await node.set_name(node_data["name"])
- if "ports_mapping" in node_data:
- node.ports_mapping = node_data["ports_mapping"]
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ if "name" in data and node.name != data["name"]:
+ await node.set_name(data["name"])
+ if "ports_mapping" in data:
+ node.ports_mapping = data["ports_mapping"]
node.updated()
return node.asdict()
diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py
index bedfac0b6..82546f118 100644
--- a/gns3server/api/routes/compute/ethernet_switch_nodes.py
+++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py
@@ -23,17 +23,20 @@ through uBridge's ``brctl`` module (see
"""
import os
-
-from fastapi import APIRouter, Depends, Body, Path, status, HTTPException
-from fastapi.encoders import jsonable_encoder
-from fastapi.responses import StreamingResponse
+from typing import Any, Union
from uuid import UUID
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import StreamingResponse
+
+from gns3server import schemas
from gns3server.compute.builtin import Builtin
from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch
-from gns3server import schemas
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}
+}
router = APIRouter(responses=responses)
@@ -60,17 +63,17 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
"""
builtin_manager = Builtin.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await builtin_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
- console=node_data.get("console"),
- console_type=node_data.get("console_type"),
+ data.get("node_id"),
+ console=data.get("console"),
+ console_type=data.get("console_type"),
node_type="ethernet_switch",
- ports=node_data.get("ports_mapping"),
+ ports=data.get("ports_mapping"),
)
- node.usage = node_data.get("usage", "")
+ node.usage = data.get("usage", "")
return node.asdict()
@@ -100,16 +103,29 @@ async def update_ethernet_switch(
Update an Ethernet switch.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- if "name" in node_data and node.name != node_data["name"]:
- node.name = node_data["name"]
- if "usage" in node_data:
- node.usage = node_data["usage"]
- if "ports_mapping" in node_data:
- node.ports_mapping = node_data["ports_mapping"]
- await node.update_port_settings()
- if "console_type" in node_data:
- node.console_type = node_data["console_type"]
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ if "name" in data and node.name != data["name"]:
+ node.name = data["name"]
+ if "usage" in data:
+ node.usage = data["usage"]
+ if "ports_mapping" in data:
+ # capture the mapping before the setter replaces it: the VLAN
+ # reconcile diffs old against new to touch only what changed, in
+ # place (the setter itself keeps the port-count guard)
+ previous_mapping = [dict(port) for port in node.ports_mapping]
+ node.ports_mapping = data["ports_mapping"]
+ try:
+ await node.update_port_settings(previous_mapping)
+ except Exception:
+ # Nothing, or only part, of the new mapping was applied — keep
+ # the old one so the failed change stays visible in GET and a
+ # retry still diffs against it. A kept-new mapping would turn
+ # the retry into a no-op that reports success with the VLANs
+ # never applied.
+ node.ports_mapping = previous_mapping
+ raise
+ if "console_type" in data:
+ node.console_type = data["console_type"]
node.updated()
return node.asdict()
@@ -171,15 +187,20 @@ def reload_ethernet_switch(node: EthernetSwitch = 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.AnchorNIO],
)
async def create_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
- nio_data: schemas.UDPNIO,
+ nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
node: EthernetSwitch = Depends(dep_node),
-) -> schemas.UDPNIO:
+) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
+ """
+ Add a NIO (Network Input/Output) to the node: a UDP NIO wires the relay
+ datapath, an anchor NIO absorbs the peer's kernel anchor into this
+ switch's kernel bridge. The adapter number on the switch is always 0.
+ """
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.add_nio(nio, port_number)
@@ -189,19 +210,21 @@ async def create_ethernet_switch_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.AnchorNIO],
)
async def update_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
- nio_data: schemas.UDPNIO,
+ nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
node: EthernetSwitch = Depends(dep_node),
-) -> schemas.UDPNIO:
+) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
"""
Update a NIO (Network Input/Output) on the node: re-apply the packet
- filters and traffic-insight markers carried by the NIO onto the port's
- uBridge relay. The adapter number on the switch is always 0.
+ filters, traffic-insight markers and suspend state carried by the NIO —
+ on the port's uBridge relay for a relay port, on the absorbed anchor
+ (tc + kernel markers + carrier) for a kernel port. The adapter number
+ on the switch is always 0.
"""
nio = node.get_nio(port_number)
@@ -209,6 +232,10 @@ async def update_ethernet_switch_nio(
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
+ # Suspend is what the compute turns into an admin-down anchor on a
+ # kernel link (native carrier), so it must reach the NIO like it does
+ # on the other node routes.
+ nio.suspend = getattr(nio_data, "suspend", None) or False
await node.update_nio(port_number, nio)
return nio.asdict()
diff --git a/gns3server/api/routes/compute/frame_relay_switch_nodes.py b/gns3server/api/routes/compute/frame_relay_switch_nodes.py
index f425b3200..0c298f8b9 100644
--- a/gns3server/api/routes/compute/frame_relay_switch_nodes.py
+++ b/gns3server/api/routes/compute/frame_relay_switch_nodes.py
@@ -19,17 +19,20 @@ API routes for Frame Relay switch nodes.
"""
import os
+from typing import Any
+from uuid import UUID
-from fastapi import APIRouter, Depends, Body, Path, status, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from uuid import UUID
from gns3server import schemas
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.frame_relay_switch import FrameRelaySwitch
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Frame Relay switch node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or Frame Relay switch node"}
+}
router = APIRouter(responses=responses)
@@ -59,13 +62,13 @@ async def create_frame_relay_switch(
# Use the Dynamips Frame Relay switch to simulate this node
dynamips_manager = Dynamips.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await dynamips_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
+ data.get("node_id"),
node_type="frame_relay_switch",
- mappings=node_data.get("mappings"),
+ mappings=data.get("mappings"),
)
return node.asdict()
@@ -99,11 +102,11 @@ async def update_frame_relay_switch(
Update an Frame Relay switch node.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- if "name" in node_data and node.name != node_data["name"]:
- await node.set_name(node_data["name"])
- if "mappings" in node_data:
- node.mappings = node_data["mappings"]
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ if "name" in data and node.name != data["name"]:
+ await node.set_name(data["name"])
+ if "mappings" in data:
+ node.mappings = data["mappings"]
node.updated()
return node.asdict()
diff --git a/gns3server/api/routes/compute/images.py b/gns3server/api/routes/compute/images.py
index fd49974a1..5e5f6410b 100644
--- a/gns3server/api/routes/compute/images.py
+++ b/gns3server/api/routes/compute/images.py
@@ -20,10 +20,10 @@ API routes for images.
import os
import urllib.parse
+from typing import List, Optional
-from fastapi import APIRouter, Body, Request, status, Response, HTTPException
+from fastapi import APIRouter, Body, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
-from typing import List
from gns3server.compute.docker import Docker
from gns3server.compute.dynamips import Dynamips
@@ -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/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py
index 0282d0f0f..7e0501651 100644
--- a/gns3server/api/routes/compute/iou_nodes.py
+++ b/gns3server/api/routes/compute/iou_nodes.py
@@ -19,12 +19,12 @@ API routes for IOU nodes.
"""
import os
+from typing import Any, Union
+from uuid import UUID
-from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException, WebSocket, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from typing import Union
-from uuid import UUID
from gns3server import schemas
from gns3server.compute.iou import IOU
@@ -32,7 +32,9 @@ from gns3server.compute.iou.iou_vm import IOUVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or IOU node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or IOU node"}
+}
router = APIRouter(responses=responses)
@@ -60,18 +62,18 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
"""
iou = IOU.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
vm = await iou.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
- application_id=node_data.get("application_id"),
- path=node_data.get("path"),
- console=node_data.get("console"),
- console_type=node_data.get("console_type", "telnet"),
+ data.get("node_id"),
+ application_id=data.get("application_id"),
+ path=data.get("path"),
+ console=data.get("console"),
+ console_type=data.get("console_type", "telnet"),
)
- for name, value in node_data.items():
+ for name, value in data.items():
if hasattr(vm, name) and getattr(vm, name) != value:
if name == "application_id":
continue # we must ignore this to avoid overwriting the application_id allocated by the controller
@@ -79,7 +81,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
continue
if name == "private_config_content" and (vm.private_config_content and len(vm.private_config_content) > 0):
continue
- if node_data.get("use_default_iou_values") and (name == "ram" or name == "nvram"):
+ if data.get("use_default_iou_values") and (name == "ram" or name == "nvram"):
continue
setattr(vm, name, value)
return vm.asdict()
@@ -100,11 +102,9 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de
Update an IOU node.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- for name, value in node_data.items():
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ for name, value in data.items():
if hasattr(node, name) and getattr(node, name) != value:
- if name == "application_id":
- continue # we must ignore this to avoid overwriting the application_id allocated by the IOU manager
setattr(node, name, value)
if node.use_default_iou_values:
@@ -147,8 +147,8 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep
Start an IOU node.
"""
- start_data = jsonable_encoder(start_data, exclude_unset=True)
- for name, value in start_data.items():
+ start_values = jsonable_encoder(start_data, exclude_unset=True)
+ for name, value in start_values.items():
if hasattr(node, name) and getattr(node, name) != value:
setattr(node, name, value)
@@ -187,15 +187,15 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
- response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
+ response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_iou_node_nio(
adapter_number: int,
port_number: int,
- nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
+ nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node),
-) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
+) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
"""
@@ -208,25 +208,31 @@ async def create_iou_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
- response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
+ response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_iou_node_nio(
adapter_number: int,
port_number: int,
- nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
+ nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node),
-) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
+) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
"""
nio = node.get_nio(adapter_number, port_number)
nio.filters.clear()
- if nio_data.filters:
+ # EthernetNIO/TAPNIO carry no filters field; UDPNIO (relay) and
+ # BridgeNIO (kernel datapath) both do.
+ if isinstance(nio_data, (schemas.UDPNIO, schemas.BridgeNIO)) and nio_data.filters:
nio.filters = nio_data.filters
- # NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
+ # NIO type is a Union (Ethernet/TAP/UDP/bridge); only UDPNIO and
+ # BridgeNIO carry markers and suspend. Suspend is what the compute turns
+ # into an admin-down anchor on a kernel link (native carrier), so it must
+ # reach the NIO like it does on the Docker/QEMU routes.
nio.markers = getattr(nio_data, "markers", None) or {}
+ nio.suspend = getattr(nio_data, "suspend", None) or False
await node.adapter_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
diff --git a/gns3server/api/routes/compute/nat_nodes.py b/gns3server/api/routes/compute/nat_nodes.py
index 60654b603..43d1e33d5 100644
--- a/gns3server/api/routes/compute/nat_nodes.py
+++ b/gns3server/api/routes/compute/nat_nodes.py
@@ -19,18 +19,20 @@ API routes for NAT nodes.
"""
import os
+from typing import Any, Union
+from uuid import UUID
-from fastapi import APIRouter, Depends, Path, status, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Path, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
-from typing import Union
-from uuid import UUID
from gns3server import schemas
from gns3server.compute.builtin import Builtin
from gns3server.compute.builtin.nodes.nat import Nat
-responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or NAT node"}}
+responses: dict[int | str, dict[str, Any]] = {
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project or NAT node"}
+}
router = APIRouter(responses=responses)
@@ -57,16 +59,16 @@ async def create_nat_node(project_id: UUID, node_data: schemas.NATCreate) -> sch
"""
builtin_manager = Builtin.instance()
- node_data = jsonable_encoder(node_data, exclude_unset=True)
+ data = jsonable_encoder(node_data, exclude_unset=True)
node = await builtin_manager.create_node(
- node_data.pop("name"),
+ data.pop("name"),
str(project_id),
- node_data.get("node_id"),
+ data.get("node_id"),
node_type="nat",
- ports=node_data.get("ports_mapping"),
+ ports=data.get("ports_mapping"),
)
- node.usage = node_data.get("usage", "")
+ node.usage = data.get("usage", "")
return node.asdict()
@@ -85,8 +87,8 @@ async def update_nat_node(node_data: schemas.NATUpdate, node: Nat = Depends(dep_
Update a NAT node.
"""
- node_data = jsonable_encoder(node_data, exclude_unset=True)
- for name, value in node_data.items():
+ data = jsonable_encoder(node_data, exclude_unset=True)
+ for name, value in data.items():
if hasattr(node, name) and getattr(node, name) != value:
setattr(node, name, value)
node.updated()
@@ -170,7 +172,7 @@ async def update_nat_node_nio(
nio = node.get_nio(port_number)
nio.filters.clear()
- if nio_data.filters:
+ if isinstance(nio_data, schemas.UDPNIO) and nio_data.filters:
nio.filters = nio_data.filters
await node.update_nio(port_number, nio)
return nio.asdict()
diff --git a/gns3server/api/routes/compute/notifications.py b/gns3server/api/routes/compute/notifications.py
index 2c6ca4ad5..24f8ee059 100644
--- a/gns3server/api/routes/compute/notifications.py
+++ b/gns3server/api/routes/compute/notifications.py
@@ -18,14 +18,15 @@
API routes for compute notifications.
"""
-from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
+import logging
from typing import Union
+
+from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from websockets.exceptions import ConnectionClosed, WebSocketException
from gns3server.compute.notification_manager import NotificationManager
-from .dependencies.authentication import ws_compute_authentication
-import logging
+from .dependencies.authentication import ws_compute_authentication
log = logging.getLogger(__name__)
@@ -39,14 +40,15 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
"""
if websocket:
- log.info(f"New client {websocket.client.host}:{websocket.client.port} has connected to compute WebSocket")
+ client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown"
+ log.info(f"New client {client} has connected to compute WebSocket")
try:
with NotificationManager.instance().queue() as queue:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
- log.info(f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute WebSocket")
+ log.info(f"Client {client} has disconnected from compute WebSocket")
except WebSocketException as e:
log.warning(f"Error while sending to controller event to WebSocket client: {e}")
@@ -66,7 +68,7 @@ if __name__ == "__main__":