Merge pull request #2872 from yueguobin/pr/3.1-20260906

feat: iol-runner Docker nodes, marker tag replay and copilot start-node rework
This commit is contained in:
Jeremy Grossmann 2026-09-05 20:45:03 +02:00 committed by GitHub
commit fa282e0f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3802 additions and 412 deletions

View File

@ -56,3 +56,18 @@ Always pass `secret_key=DEFAULT_JWT_SECRET_KEY`: the autouse `run_around_tests`
## Failure-Diagnosis Heuristic
**Passes in isolation, fails in a class run → suspect shared fixture state first** (`base_client.headers`, the `Config` singleton, class-scoped DB rows) — never the product code. Reproduce with `-k "test_a or test_b"` pairs to find the polluting test. Do not add debug prints to product code to chase test-order issues; make the test order-independent with explicit per-request headers instead.
## Order Independence
The suite runs in collection order by default, and a full run is green — but that hides order dependencies. Two real incident classes so far:
1. **The frozen from-import** (fixed 2026-09): `run_around_tests` monkeypatches `gns3server.utils.path.get_default_project_directory` with a lambda. A product module first-imported *while that patch is active* (e.g. `from gns3server.api.server import app` written inside a test body) freezes the patched lambda into its namespace forever — later tests then get a deleted tmpdir path (`FileNotFoundError` from `psutil.disk_usage`). The patched lambda now resolves `Config.instance()` at call time, so freezing is benign — keep it that way.
2. **Sequential-scenario DB tests**: `tests/api/routes/controller/test_users.py`, `test_roles.py`, `test_pools.py`, `test_templates.py`, `test_images.py`, `test_groups.py`, `test_appliances.py`, `test_acl.py` and `tests/controller/test_rbac.py` build shared rows across tests within a file (a test asserts on users/roles created by earlier tests). They are known-red under any reordering — do not copy this pattern into new files.
### Rules for new tests
- **Import product modules at test-module top level**, never first-import inside a test body (an autouse fixture's monkeypatches are live there, and module import executes product `from`-imports).
- **Autouse patch replacements must resolve state at call time** (`Config.instance().settings...`), never close over test-local values (tmppaths, fixture objects) — a closed-over value survives into other tests if the replacement object gets frozen anywhere.
- **Verify a new test file is order-independent**: `venv/bin/python -m pytest tests/<new_file>.py --random-order --random-order-seed=1 -q` (and a second seed). It must pass shuffled. `pytest-random-order` is pinned in `dev-requirements.txt`; it is inert unless `--random-order` is passed.
- A test that needs specific rows creates them itself (or via a fixture) — never relies on rows another test in the file created.
- Diagnosing a suspected order bug: rerun the exact failing pair with the seed printed by `--random-order` (`--random-order-seed=<n>` reproduces it), then bisect to the polluting test.

View File

@ -3,4 +3,5 @@ flake8==7.3.0
pytest-timeout==2.4.0
pytest-asyncio==1.4.0
httpx==0.28.1
httpx_ws==0.7.2 # upgrading leads to failures in tests
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)

View File

@ -39,6 +39,8 @@ docs/
│ ├── 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)
├── design/ # Design proposals & roadmaps (not yet implemented)
│ └── docker-image-type.md # Docker image types: vendor profile discriminator + registry
├── gns3-copilot/ # AI Copilot feature documentation
│ ├── netmiko_devices.md # Netmiko supported devices (366 types)
│ ├── template-based-configuration-roadmap.md # Future: template-based config with HITL
@ -75,12 +77,25 @@ Web-based packet capture analysis using Docker + xpra HTML5 client. Zero-install
### Marker (Traffic Insight) (`features/marker-traffic-insight.md`)
Real-time traffic insight via per-link BPF markers and project-level inherited definitions. A marker taps a link in uBridge, emitting match notifications and pcap capture on BPF hit; definitions fan out to every capable link automatically.
### 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.
### 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.
### Cisco XRd Control Plane (`features/vendor-nos-xrd.md`)
Cisco XRd as a GNS3 Docker router: vendor path + shm/device injection (`GNS3_SHM_SIZE`/`GNS3_DEVICES`), config-file injection (`extra_configs`), udev masking (`GNS3_MASK_UDEV`) so privileged systemd containers don't disturb the host, and the host-readiness check.
### IOL Images with iol-runner (`features/iol-runner-docker.md`)
Cisco CML containerized IOL (e.g. `iol-xe/iol-xe:17-18-02`) as GNS3 Docker routers: generic unix-socket NIO (`GNS3_UNIX_SOCKET_NIO` — adapters wired via AF_UNIX datagram socket pairs instead of TAP/netns) plus `IOLDockerVM` (`GNS3_IOL_RUNNER=1` — per-start config generation, `/tmp/run` preparation, stale-socket cleanup, console on PID 1 stdio).
---
## Design & Roadmaps (`design/`)
### Docker Image Types (`design/docker-image-type.md`)
Proposed `image_type` discriminator on Docker templates plus a compute-side profile registry: vendor parameters graduate from environment markers into schema-gated fields, generic-feature applicability becomes declared capability data, and the existing markers remain as a compatibility fallback. Not a new node type — vendor images are content, not mechanism.
---
## GNS3 AI Copilot (`gns3-copilot/`)
@ -127,4 +142,4 @@ Quick-start guide for Ubuntu 24.04: install via PPA, set up dependencies, and ru
---
_Last updated: 2026-08-14_
_Last updated: 2026-09-01_

View File

@ -0,0 +1,144 @@
# Docker Image Types (Vendor Profiles) — Roadmap
Status: **proposal, not implemented**. This is the agreed direction for the
next iteration of vendor Docker support. Four vendor profiles already
exist across branches and prototypes — iol-runner (this branch), XRd and
the SR Linux prototype (vendor/skip-init family), and the FRR registry
appliance — enough evidence to justify the registry. Implement it as a
follow-up to the current PR series, shaped by all four rather than by
iol-runner alone.
## Problem
All Docker nodes share one template type (`template_type: "docker"`) and
one template schema. Vendor images (iol-runner, SR Linux, XRd, …) are
selected and configured today through **environment markers** — free-form
strings parsed by the compute (`GNS3_IOL_RUNNER`, `GNS3_IOL_MEMORY`,
`GNS3_IOL_STARTUP_CONFIG`, `GNS3_SKIP_INIT`, `GNS3_UNIX_SOCKET_NIO`, …).
Three pains have already surfaced:
1. **Invisible and unvalidated.** The markers do not exist in the API
schema: no OpenAPI documentation, no validation, typo or bad value
silently falls back to a default (`GNS3_IOL_MEMORY=notanumber` → 2048).
2. **The shared-schema dilemma.** Adding a vendor-specific field to the
Docker template schema pollutes every Docker template with a field only
one vendor reads; not adding it pushes everything into environment
strings. The `startup_config` discussion (ended in the
`GNS3_IOL_STARTUP_CONFIG` knob) is the canonical example — the tension
exists because there is no legitimate discriminator dimension.
3. **Generic-feature applicability is implicit.** Which generic Docker
features apply to which image is only documented in code comments:
`mac_address` is meaningless under unix-socket NIO, `/etc/network`
seeding is dead weight for skip-init images, `extra_configs` targets
under persisted volumes get a (correct-but-noisy) shadow warning.
## Non-goal: new node types
A `template_type`/`node_type` per vendor (`"iol"`, `"srlinux"`, …) is
explicitly rejected. `node_type` is a top-level concept: it drives compute
module routing (`/projects/{id}/{node_type}/nodes`), capability
reporting, GUI node types and link handling. N vendor types that are all
Docker underneath would multiply routing and schema surface for zero
mechanism — vendor images are *content*, not mechanism, and GNS3's
appliance/template system is where content belongs.
## Design
### One discriminator field on Docker templates
```json
{
"template_type": "docker",
"image_type": "iol-runner",
"image": "iol-xe/iol-xe:17-18-02"
}
```
`image_type` is optional; absent (or `"generic"`) keeps today's plain
`DockerVM` behavior and marker sniffing. The profiles that exist today,
mapped to their current mechanisms and archetypes:
| Profile | Current mechanism | Archetype |
|---|---|---|
| FRR appliance | plain `DockerVM`: init.sh `/etc/network` + `start_command` (frrinit.sh), console on PID 1 | generic |
| XRd | `VendorDockerVM` skip-init + `GNS3_SHM_SIZE`/`GNS3_DEVICES`, `extra_configs`, udev masking | vendor skip-init |
| SR Linux (prototype) | `VendorDockerVM` skip-init + `docker_exec` console + `GNS3_INTERFACE_NAMES` | vendor skip-init |
| iol-runner | `IOLDockerVM`: unix-socket NIO, per-start config generation, NVRAM startup-config | iol-runner |
Possible first values: `generic`, `iol-runner`, and a value for the
skip-init vendor NOS family once its common shape settles (XRd and SR
Linux may end up sharing it or splitting — that is exactly what the
registry should decide with all four in front of us).
### A compute-side registry
```python
IMAGE_PROFILES = {
"iol-runner": {
"class": IOLDockerVM,
"fields": ("iol_memory", "startup_config"), # schema-gated
"capabilities": {...}, # see below
},
...
}
```
* **Class selection** moves from environment sniffing
(`Docker._select_node_class`) to the field, with the existing markers
kept as a fallback for templates created before the field existed
(zero-migration compatibility).
* **Vendor parameters graduate into schema fields**, gated by pydantic
conditional validation (accepted — and validated — only when
`image_type` matches). This resolves the shared-schema dilemma properly:
the field exists, but only means something for its profile. The
environment knobs remain as the wire-level compatibility entry.
* **Capability declaration** makes generic-feature applicability data
instead of comments:
| Capability | plain Docker | vendor skip-init | iol-runner |
|---|---|---|---|
| `mac_address` honored | yes | yes | **no** (IOL derives MACs from the app id) |
| `/etc/network` seeding | yes | no | no |
| `extra_configs` targets under volumes | shadowed | works (real-path binds) | works |
| console | telnet/http/… | `docker_exec` | PID 1 stdio (telnet) |
| startup config | `extra_configs` injection | image-specific | nvram build (`nvram_import`) |
Consumers: error/warning quality (don't warn about inapplicable
features), the WebUI (vendor-aware template forms — a bonus, not a
driver), and documentation generation.
The existing class hierarchy (`DockerVM` → `VendorDockerVM` →
`IOLDockerVM`) is unchanged — the registry only externalizes selection
and declaration.
## Migration & compatibility
* Old templates (markers only) keep working via the fallback; a one-time
optional converter can rewrite markers → `image_type` + fields.
* The controller-side materialization conventions
(`GNS3_IOL_STARTUP_CONFIG` file → `startup_config_content`, sent once)
carry over unchanged — the field version references the same content
pipeline.
## Roadmap steps
1. Add `image_type` to the Docker template schema (+ DB column +
Alembic migration — see the three-place rule for template fields) and
wire class selection through the registry, markers as fallback.
2. Move iol-runner knobs to gated fields (`iol_memory`,
`startup_config`), deprecating-but-supporting the env forms.
3. Introduce the capability table and use it to silence inapplicable
warnings (`mac_address`, `/etc/network`, extra-config shadowing).
4. Revisit per-vendor *schemas* (a sub-model per profile) only if a
profile grows more than a handful of fields — not before.
## Open questions
* Field name: `image_type` vs `vendor` vs `runner` — decide when the
second profile lands; the value vocabulary should name the *runtime
contract*, not the vendor.
* Should appliances (`.gns3a`) carry `image_type` explicitly, or should
installation keep deriving it from the appliance's environment block?
* Where the gated vendor fields live long-term: flat on the Docker
template (simple, gated) vs nested `{"image_type": ..., "settings":
{...}}` (cleaner, more schema churn).

View File

@ -0,0 +1,218 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# IOL Images with iol-runner (Cisco CML containerized IOL) as Docker Nodes
## Overview
IOL images packaged with Cisco CML's container runner — for example
`iol-xe/iol-xe:17-18-02` (IOS-XE 17.18.02 IOL in a scratch image driven by
`iol-runner`, module `virl.lab/cmd/iol-runner`) — run as first-class GNS3
Docker router nodes with **zero changes to the image**. The integration adds
two generic server mechanisms:
1. **Unix-socket NIO** (`GNS3_UNIX_SOCKET_NIO=1`, on `VendorDockerVM`): link
adapters through per-interface AF_UNIX datagram sockets instead of a TAP
interface moved into the container's network namespace.
2. **`IOLDockerVM`** (marker `GNS3_IOL_RUNNER=1`): generates the runner's
config file per start, prepares its runtime directory and cleans up stale
sockets — the iol-runner-specific glue on top of the vendor path.
## How the image works
```mermaid
graph LR
subgraph Container["scratch container (PID 1)"]
RUNNER["/iol-runner -config /config/iol-config.json -stdio"]
IOL["IOL process<br/>(IOS-XE 17.18.02)"]
NETIOMUX["netiomux"]
SOCKETS["/tmp/s00.sock (recv)<br/>/tmp/c00.sock (send-to path)<br/>… one pair per interface"]
RUNNER -->|"spawn -e/-s/-m + app id"| IOL
IOL -->|"netio bus /tmp/netio&lt;uid&gt;/"| NETIOMUX --> SOCKETS
end
subgraph Host
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/&lt;node&gt;/cNN.sock …<br/>+ add_nio_udp (topology)"]
RTDIR["/run/user/&lt;uid&gt;/gns3/unixio/&lt;node&gt;<br/>(bind-mounted at /tmp)"]
VOL["project-files/docker/&lt;node&gt;/config<br/>(+ tmp/run, nested bind at /tmp/run)"]
end
SOCKETS <-->|"same files, two spellings<br/>(the /tmp bind)"| RTDIR <--> UBRIDGE
UBRIDGE -.->|"iol-config.json +<br/>persistent /tmp/run"| VOL
```
* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio`
entrypoint flag). The plain `console_type: "telnet"` attaches to it — no
`docker_exec` needed. The runner requires a TTY, which GNS3 always
allocates; without one the runner exits (`inappropriate ioctl for device`).
* **Networking**: the runner does not touch the container's network
namespace. Per interface N it creates, inside the container's `/tmp`: a
receive socket `s%02d.sock` (frames sent there are injected into guest
interface N) and a send-to path `c%02d.sock` (whoever binds it receives
the guest's frames). Frames are **raw Ethernet**, one datagram per frame —
the same two-mailbox convention uBridge's `add_nio_unix` natively speaks
(it binds the c-socket, sends to the s-socket). GNS3 bind-mounts a
per-node directory from the runtime directory
(`/run/user/<uid>/gns3/unixio/<node-id>`, next to the uBridge control
sockets) at the container's `/tmp`, so uBridge reaches the sockets as
plain host files: the path stays far under AF_UNIX's 107-byte `sun_path`
cap (a projects-tree node path alone exceeds it) and the directory is
owned by the server user, to whom the runner drops its privileges. This
mirrors how CML itself runs the image (`source=…/tmp,target=/tmp` in its
node definition). The directory is ephemeral and removed with the node.
* **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc`
pair, and the runner regenerates the license from the host ID at boot —
nothing to configure.
* **Persistence**: `/tmp/run` (the IOL working directory) holds the NETMAP,
the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`).
It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the
node directory's `tmp/run/` at `/tmp/run` (nested inside the runtime-dir
bind), so the router's configuration survives stop/start and container
recreation while sockets, netio buses and runner logs stay ephemeral.
The generated config maps the runner to the server's uid/gid
(`user-id`/`group-id`), so all files it creates are owned by the server
user (no permission-fix pass needed).
## Template
Create the template once via `POST /v3/templates` (authenticated — see the
API docs for the auth flow), or in the Web UI under
*Edit → Preferences → Docker templates → New* with the same fields:
```json
{
"name": "IOS-XE 17.18.02 IOL",
"template_type": "docker",
"image": "iol-xe/iol-xe:17-18-02",
"category": "router",
"symbol": ":/symbols/router.svg",
"adapters": 2,
"console_type": "telnet",
"environment": "GNS3_IOL_RUNNER=1",
"extra_volumes": ["/config"]
}
```
| Field | Value | Why |
|---|---|---|
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048), `GNS3_IOL_STARTUP_CONFIG=<file>` (initial configuration, see below). |
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
| `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. |
### Verify
1. The node's port list shows the grouped IOL interfaces
(`Ethernet0/0`–`Ethernet1/3` for two adapters), addressed
(adapter, port).
2. Drop a node into a project and start it — the console shows the
`Linux Unix (i686)` banner within seconds.
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
pair per port).
4. A node created with a startup-config boots straight to the configured
hostname — no initial configuration dialog (interface names in the
config are `Ethernet0/0`, not `GigabitEthernet0/0`).
## Startup configuration
IOL Docker nodes load an initial configuration exactly like native IOU
nodes: the **template references a config file**, and every node created
from it boots with that configuration as its personal starting point.
```json
"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt"
```
* The file lives in the controller's configs directory (the `configs_path`
server setting, by default `~/GNS3/configs`) — the same place IOU and
VPCS base configs live. A minimal `iol-xe-base.txt` ships with the
server and is installed there on startup (never overwriting a
user-modified copy); an absolute path in the knob bypasses the directory
entirely. `%h` in the content is replaced with the node name at start.
* **Creation materializes it once**: the controller reads the file and
sends its content with the node; afterwards the knob is consumed and the
template file is never referenced again — editing it does not affect
existing nodes. The knob also disappears from the node's `environment`
(`GNS3_IOL_RUNNER=1` remains — it re-selects the node class on every
recreation), so a node whose environment shows only `GNS3_IOL_RUNNER=1`
is the normal sign that the file was found and its content delivered;
when the file is missing the knob stays in the environment (with a
server-side warning) and the lookup is retried at the next creation.
* **NVRAM is the source of truth at boot** (verified on the runner): the
content is built into the node's `tmp/run/nvram_<app id>` — IOL and IOU
share the nvram container format, so the server-side `nvram_import`
utility produces a file IOL boots from directly. Consequences:
* `write memory` survives stop/start and container recreation (a plain
restart never re-applies the startup-config over it). The first
`write memory` after a server-built NVRAM asks for a one-time
`[confirm]` (the builder stamps an IOS 15.4 version marker into the
nvram header; press return — IOS then writes its own).
* Editing a node's `startup_config_content` property (PUT) re-applies it
on the next start, overwriting what `write memory` had saved — the
explicit edit wins, as with IOU.
* Renaming a node rewrites the `hostname` line in its NVRAM, so a
duplicated/renamed node boots under its new name.
* **Resetting a node to its template config**: stop the node, delete
`project-files/docker/<node>/tmp/run/nvram_*`, then re-apply the content
(or recreate the node).
## Server mechanisms
| Mechanism | Where | What it does |
|---|---|---|
| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix <dir>/c{N:02d}.sock <dir>/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. |
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`tmp/run` is never touched). |
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
| `GNS3_IOL_STARTUP_CONFIG=<file>` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_<app id>` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. |
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
(L1 keepalives) or `-debug 9` (verbose `process.log` — very useful when
diagnosing wiring issues).
## Notes and caveats
* **Console typing latency**: single keystrokes are echoed on a ~50 ms
cadence — the image services console input on an internal poll.
Measured directly on the container console (bypassing the GNS3 server
entirely, through the same Docker attach endpoint it uses): keystroke
echo is bimodal — 1–11 ms when the keystroke lands just before a poll
tick, ~50–100 ms when it just misses one — while a pasted line is
picked up as one batch (~1.4 ms) and command output streams
back-to-back (inter-frame gaps ~0.01 ms). A control container on the
same attach endpoint echoes in ~1 ms, so the GNS3 telnet path is not a
factor; this matches IOS's low-priority console-input polling and there
is nothing to fix server-side. Paste long commands instead of typing
them.
* **Memory sizing**: `memory` caps the whole container; the IOL process gets
`GNS3_IOL_MEMORY` (default 2048 MB). Keep container memory at IOL memory
+ ~512 MB headroom or the OOM-killer will shoot the router.
* **MAC addresses**: the `mac_address` template field and per-adapter custom
MACs are ignored — IOL derives its own scheme from the node's application
ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The controller
allocates the ID at node creation from the upper half of the id space
(512–1022, disjoint from IOU's 1–511, limit 511 IOL Docker nodes across
opened projects sharing computes). Starting a node without an allocation
(raw compute API, pre-allocation topologies) is an error, not a fallback —
an uncoordinated ID could collide with the pool and make nodes silently
drop each other's frames as MAC loops.
* **Interface names are IOL-style `Ethernet0/0`**, not `GigabitEthernet0/0`
(4 ports per unit, matching the adapter-count granularity) — startup
configs addressing `GigabitEthernet…` are rejected by the parser.
* **Adapters**: one adapter is a 4-port unit (the IOU model): change the
count while the node is stopped; the config is regenerated on the next
start (`num-eth` = adapters × 4) and the runner creates the matching
socket set.
* **Stop before editing**: NVRAM is only flushed on a graceful stop (SIGTERM,
"cleanup done" in `process.log`); a kill loses the running-config changes
since the last `write memory`.
* **Class selection is create-time**: toggling `GNS3_IOL_RUNNER` via PUT
takes effect after a project reload (same as `docker_exec`).
* The startup-config lives at `project-files/docker/<node>/tmp/run/config`;
`extra_configs` targets under persisted volumes are warned against by the
generic create path — edit the file directly or paste via the console.

View File

@ -0,0 +1,257 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# Marker Tag Replay (Aggregate Playback)
## Overview
Replays traffic captured by [markers](marker-traffic-insight.md) **across links**, keyed by
`tag`. Markers on different links that share a tag form one *distributed capture session*;
once every marker under the tag is paused, their per-marker pcaps are merged into a single
timestamp-ordered timeline. The Web UI browses that timeline and fetches individual frames
on demand — each fetch decodes exactly one frame via `tshark` into a self-describing JSON
protocol tree.
The unique observable: the delta between the same packet hitting two consecutive links
measures the **intermediate node's forwarding latency** (host view) — something a
single-link capture can never show.
## Architecture
```mermaid
graph TB
UI["Web UI"]
subgraph Controller["Controller (replay endpoints)"]
GATE["Tag gate<br/>(all markers under tag paused?)"]
SCAN["Timeline scan<br/>(pcap record headers)"]
MAP["PDML → JSON<br/>isomorphic mapper"]
end
FS[("markers dir<br/>{node}_{link}_{marker}.pcap")]
TS["tshark -T pdml<br/>(one frame at a time)"]
TMP["/tmp scratch copy<br/>(hardened-profile workaround)"]
UI -->|"GET range / frames"| GATE
GATE --> SCAN
SCAN --> FS
UI -->|"GET frame detail (lazy)"| MAP
MAP --> TMP
TMP --> TS
MAP -->|"hex: raw bytes"| FS
```
Two deliberately separated performance regimes:
| Path | Work | tshark |
|------|------|--------|
| Timeline | 16-byte record-header scan per frame, cross-file merge sort | **never invoked** — browsing works without tshark |
| Frame detail | locate frame → `tshark -T pdml -Y "frame.number == N"` → map to JSON | forked once per frame the user opens |
tshark call count equals user clicks — no caching or rate limiting needed, and a tshark
failure (501/502) affects only that one frame, never timeline browsing. pcap files are
compute-side (`<project>/project-files/markers/`); the initial scope is single-server
deployments (controller and compute in one process, direct file access) — remote computes
will reuse the existing capture-file proxy pattern.
## Business Process
```mermaid
sequenceDiagram
participant UI as Web UI
participant C as Controller
participant PC as markers dir (pcaps)
participant TS as tshark
Note over UI,PC: ① configure — same tag on every link's marker
UI->>C: POST markers (bpf, tag=666) on each link
Note over PC: ② capture — uBridge appends matches (flushed per packet), replay forbidden
UI->>C: GET range
C--xC: 409 (a marker under tag 666 is still enabled)
Note over UI: ③ pause — every marker under the tag
UI->>C: PUT markers {"enabled": false} × each
Note over UI,TS: ④ replay
UI->>C: GET /markers/tags/666/replay/range
C->>PC: scan record headers, merge sort
C-->>UI: {start, end, sources, frames}
UI->>C: GET frames?ts=T&window_ms=W
C-->>UI: frames in [T, T+W] — or {"frames": []}
UI->>C: GET frame/detail?ts=…&node_id=…&link_id=…&marker=…
C->>PC: locate frame, read raw bytes (hex)
C->>TS: -T pdml (reads the /tmp copy)
TS-->>C: PDML
C-->>UI: protocol tree + hex
```
## The tag gate
Replay reads append-only pcaps, so it is only available while the data is at rest. Every
replay endpoint evaluates the same gate: walk every marker in the project carrying the
requested tag; if any has `enabled: true` → 409 (the response names them); a tag with no
markers at all → 404.
| Marker state under the tag | pcap file | Replay |
|---------------------------|-----------|--------|
| any `enabled: true` (capturing) | growing | denied — 409 |
| all `enabled: false` (paused) | retained, frozen | **allowed** |
| deleted | file unlinked | no data |
| `bpf`/`tag`/`direction` changed (rebuild) | pcap reopened (truncated) — new session | prior history gone |
| capture node (re)started | pcap reopened (truncated) — new session | prior history gone |
- **Pause, not delete.** Deleting a marker (or its definition) deletes its pcap — replay
before deleting or the data is gone.
- **Pause → resume → pause is fine.** The pcap accumulates the full history; replay covers
everything up to the current pause point.
- **The replay window ends when nodes restart.** A pcap's lifetime equals its uBridge's
lifetime: a fresh uBridge reinstalls every desired marker — paused ones too (install
first, then turn the filter off) — and uBridge opens the pcap with truncate semantics
(`pcap_dump_open`, not `_append`). Server restart + project reopen **without starting
nodes** is safe: nothing touches the files until a uBridge comes up (verified live).
Docker nodes effectively restart on server restart as well (stale-container cleanup),
so their window is shorter still.
- uBridge flushes every matched packet to the pcap immediately (`pcap_dump_flush` per
packet under a mutex — verified in the uBridge source), so a pause boundary never loses
tail frames.
## API Endpoints
All read-only; JWT bearer token, privilege `Project.Audit`.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/range` | Timeline metadata + full frame list for the tag |
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frames?ts=&window_ms=&limit=` | Frames with ts in `[T, T+window]`, merged across sources |
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frame/detail?ts=&node_id=&link_id=&marker=` | Single frame: tshark protocol tree + raw hex |
### `range` — the timeline
```json
{
"tag": 666,
"start": "1788196663.226372",
"end": "1788196713.706634",
"frame_count": 20,
"truncated": false,
"sources": [
{ "node_id": "b764c434…", "link_id": "316ef8fd…", "marker": "global-def-…",
"data_link_type": "DLT_EN10MB", "count": 10 }
],
"frames": [
{ "ts": "1788196663.226372", "len": 98, "node_id": "b764c434…",
"link_id": "316ef8fd…", "marker": "global-def-…", "frame_number": 1 }
]
}
```
- `frames` is the **full, merged, time-ordered list** (cap 5000) — the Web UI lays out the
whole timeline from one request. Over the cap, `frames` is omitted and per-second
`buckets` are returned instead with `truncated: true`.
- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating
tuple for the detail request.
### `frames` — point / window query
A time with no frames is a normal, successful answer — an empty array, no sentinel strings:
```json
GET …/replay/frames?ts=1788196700.000&window_ms=500
→ { "frames": [] }
```
### `frame/detail` — lazy single-frame decode
Invoked only when the user opens a frame. The `ts` must be the **exact string received in
the timeline/frame list** (round-tripped verbatim — never re-serialized through a float);
`node_id + link_id + marker` identify the pcap. The server re-resolves the ts against the
file, guarding against a capture rebuilt between the timeline view and this click.
```json
{
"ts": "1788196663.226372",
"source": { "node_id": "b764c434…", "link_id": "316ef8fd…",
"marker": "global-def-…", "frame_number": 1 },
"tshark_version": "TShark (Wireshark) 4.6.7 …",
"field_count": 85,
"hex": "45000062…",
"tree": [
{ "element": "proto", "name": "ip",
"showname": "Internet Protocol Version 4, Src: 10.1.10.101, Dst: 203.0.113.1",
"children": [
{ "element": "field", "name": "ip.ttl", "show": "64",
"showname": "Time to Live: 64", "value": "40", "size": "1",
"pos": "22", "children": [] }
] }
]
}
```
- `tree` mirrors PDML **isomorphically**: every `<proto>`/`<field>` becomes a node, every
XML attribute (`name`, `show`, `showname`, `value`, `size`, `pos`, `hide`, `mask`,
`unmaskedvalue`) becomes a JSON key, plus one structural key `element` (proto/field).
Nothing is selected out, nothing interpreted, and **all values stay strings** — numeric
conversion is the client's business.
- `hex` is the raw frame bytes read straight from the pcap (not via tshark); keeping
`pos`/`size` on every field enables Wireshark-style *click field → highlight bytes*.
- `field_count` is the mapped node count — a client-side sanity check.
## Ordering and timestamps
- `ts` is the pcap record timestamp (µs) written by uBridge at match time — a userspace
`gettimeofday()` instant measured after the packet has crossed the kernel twice. The
last digit or two are scheduling noise; microseconds are sufficient in a simulated
environment.
- The sort key is `(ts, source file, frame_number)` — ts alone is **not** unique (two
links can hit the same microsecond); the tiebreaker yields a stable, determined order
instead of a fictional one. Index structures must never use ts as a dict key, or
same-microsecond frames silently overwrite each other.
- The cross-link delta is the intermediate node's end-to-end forwarding latency
(veth/TAP → guest protocol stack → back to host), typically hundreds of microseconds to
milliseconds. UI labels should read *node forwarding latency (host view)*, not link
propagation delay. A live capture pair confirmed it end-to-end: same `ip.id`,
TTL 64→63, 509 µs between two links.
## Fidelity guarantee (PDML → JSON)
The conversion is an isomorphic structure map, not a semantic transform, with two rules:
**map every attribute** and **keep values as strings**. A round-trip test enforces both:
PDML element count equals JSON node count, and every XML attribute survives with an
identical JSON value (`tests/controller/test_marker_replay.py`). The raw frame bytes —
the one thing PDML genuinely does not contain — are covered by `hex` read directly from
the pcap.
## Error Responses
| Status | Description |
|--------|-------------|
| 401 | Not authenticated |
| 404 | Tag has no markers in the project; detail source unknown, or ts does not match the file (the capture may have been rebuilt) |
| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them) |
| 501 | tshark not installed / unavailable — affects detail only; the timeline never needs tshark |
| 502 | tshark failed or timed out (10 s); truncated output never reaches the mapper |
## Notes
- **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.
Malformed packets are tshark's problem: it emits `[Malformed Packet]` as regular PDML
and carries on.
- **Hardened tshark profiles.** openSUSE-style profiles (AppArmor &c.) can deny tshark
access to the project directory and the user's home even though the server process can
read both. The detail path therefore copies the pcap to a real file under `/tmp`
(not a symlink — the profile resolves real paths) and gives tshark a scratch `HOME`;
the copy is unlinked afterwards. The hex view still reads the original file.
- **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.

View File

@ -94,6 +94,7 @@ 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
@ -113,7 +114,8 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3UpdateNodeNameTool(), # Update node name
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands
# (READ-ONLY)
@ -127,7 +129,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
GNS3TemplateTool(), # Get GNS3 node templates
GNS3CreateNodeTool(), # Create new nodes in GNS3
GNS3LinkTool(), # Create links between nodes
GNS3StartNodeTool(), # Start GNS3 nodes
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
GNS3StopNodeTool(), # Stop GNS3 nodes
GNS3SuspendNodeTool(), # Suspend GNS3 nodes (preserve state)
GNS3UpdateNodeNameTool(), # Update node name

View File

@ -40,7 +40,8 @@ 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_start_node: GNS3 node startup tool
- 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
- gns3_update_node_name: GNS3 node name update tool
- gns3_packet_filter: GNS3 packet filter management tool
@ -62,6 +63,7 @@ from .gns3_start_node import GNS3StartNodeTool
from .gns3_stop_node import GNS3StopNodeTool
from .gns3_suspend_node import GNS3SuspendNodeTool
from .gns3_update_node_name import GNS3UpdateNodeNameTool
from .gns3_wait import GNS3WaitTool
from .packet_analysis_tool import PacketAnalysisTool
# Dynamic version management
@ -89,6 +91,7 @@ __all__ = [
"GNS3SuspendNodeTool",
"GNS3UpdateNodeNameTool",
"GNS3TemplateTool",
"GNS3WaitTool",
"PacketAnalysisTool",
]

View File

@ -26,13 +26,15 @@
GNS3 node startup tool for network device activation.
Provides functionality to start one or multiple nodes in GNS3 projects
with progress tracking and status monitoring.
Sends start commands and returns immediately — it never blocks on a fixed
boot timer. A failed start command (e.g. a 409 from the compute) is
reported in the same round-trip instead of after a two-minute progress
bar. Use the wait_seconds tool between this and any status check to give
nodes time to boot.
"""
import json
import logging
import time
from pprint import pprint
from typing import Any
@ -48,290 +50,15 @@ from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
# Configure logging
logger = logging.getLogger(__name__)
# Node startup time configuration by device type
# Based on typical boot times for different emulators
# Conservative timing to account for slower hardware environments
NODE_STARTUP_TIME = {
"vpcs": {"base": 15, "extra_per_node": 2}, # VPCS: Very fast startup
"iou": {"base": 25, "extra_per_node": 3}, # IOU: Fast startup
"default": {"base": 120, "extra_per_node": 10}, # Other devices: Conservative time
}
def calculate_startup_time(nodes: list) -> int:
"""
Calculate startup wait time based on node types.
Strategy:
- If all nodes are fast devices (VPCS/IOU): use fast startup time
- If any node is a slow device: use conservative startup time
Args:
nodes: List of node dicts with a "node_type" key
Returns:
Calculated wait time in seconds
"""
if not nodes:
return 60 # Default: 60 seconds for empty list
# Get all node types
node_types = [node.get("node_type") or "default" for node in nodes]
# Check if all nodes are fast startup devices (VPCS or IOU)
fast_types = {"vpcs", "iou"}
all_fast = all(node_type in fast_types for node_type in node_types)
if all_fast:
# Use fast startup time: base + (count - 1) * extra_per_node
# Use the largest base time among the fast devices
max_fast_base = max(
NODE_STARTUP_TIME[nt]["base"]
for nt in node_types if nt in fast_types
)
# Use the smallest extra_per_node among the fast devices
min_fast_extra = min(
NODE_STARTUP_TIME[nt]["extra_per_node"]
for nt in node_types if nt in fast_types
)
total_time = max_fast_base + (len(nodes) - 1) * min_fast_extra
logger.info(
"All fast devices detected (%s), using fast startup time: %ds",
node_types,
total_time
)
return total_time
else:
# Use conservative startup time for mixed or slow devices
config = NODE_STARTUP_TIME["default"]
total_time = config["base"] + (len(nodes) - 1) * config["extra_per_node"]
logger.info(
"Mixed or slow devices detected (%s), using conservative startup time: %ds",
node_types,
total_time
)
return total_time
def show_progress_bar(
duration: int = 120, interval: int = 1, node_count: int = 1
) -> None:
"""
Display a simple text progress bar for node startup.
Args:
duration: Total duration of the progress bar in seconds
interval: Update interval in seconds
node_count: Number of nodes being started
"""
print(f"Starting {node_count} node(s), please wait...")
for elapsed in range(duration):
# Calculate progress percentage
progress = (elapsed + 1) / duration * 100
# Create progress bar display
bar_length = 30
filled_length = int(bar_length * elapsed // duration)
progress_string = (
"=" * filled_length + ">" + " " * (bar_length - filled_length - 1)
)
# Print progress bar with node count
print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True)
time.sleep(interval)
print(f"\n{node_count} node(s) startup completed!")
class GNS3StartNodeTool(BaseTool):
"""
A LangChain tool to start one or multiple nodes in a GNS3 project.
**Input**:
A JSON object with project_id and node_ids (list of node IDs).
Example:
{
"project_id": "uuid-of-project",
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
}
**Output**:
A dictionary with all nodes' details:
{
"project_id": "...",
"total_nodes": 2,
"successful": 2,
"failed": 0,
"nodes": [
{"node_id": "...", "name": "...", "status": "..."},
{"node_id": "...", "name": "...", "status": "..."}
]
}
"""
name: str = "start_gns3_node"
description: str = """
Starts one or multiple nodes in a GNS3 project.
Input: JSON with project_id and node_ids (list of node IDs).
Returns: A dict with all nodes' details (success/failure status).
"""
def _run(
self,
tool_input: str,
run_manager: CallbackManagerForToolRun | None = None,
) -> dict[str, Any]:
try:
# Parse input JSON
input_data = json.loads(tool_input)
project_id = input_data.get("project_id")
node_ids = input_data.get("node_ids")
# Validate input
if not project_id or not node_ids:
logger.error(
"Missing required fields: project_id or node_ids."
)
return {
"error": "Missing required fields: "
"project_id and node_ids."
}
if not isinstance(node_ids, list):
logger.error("node_ids must be a list.")
return {"error": "node_ids must be a list."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
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."
}
# Phase 1: fetch node info (including node_type) in one call
logger.info(
"Retrieving node info for %d nodes in project %s...",
len(node_ids),
project_id,
)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
nodes = [nodes_by_id[nid] for nid in node_ids if nid in nodes_by_id]
for node in nodes:
logger.info(
"Node %s (%s) type: %s",
node["node_id"],
node.get("name"),
node.get("node_type"),
)
for nid in node_ids:
if nid not in nodes_by_id:
logger.error(
"Node %s not found in project %s", nid, project_id
)
# Calculate startup time based on node types
wait_time = calculate_startup_time(nodes)
# Phase 2: send start commands for all nodes (parallel batch)
logger.info(
"Sending start commands for %d nodes in project %s...",
len(nodes),
project_id,
)
start_results = start_node_handler(
{"project_id": project_id, "node_ids": [n["node_id"] for n in nodes]},
gns3_ctx,
)
for r in start_results:
if r.get("status") == "error":
logger.error(
"Failed to send start command for node %s: %s",
r.get("node_id"),
r.get("error"),
)
else:
logger.info("Start command sent for node %s", r.get("node_id"))
# Show progress bar with calculated wait time
show_progress_bar(
duration=wait_time, interval=1, node_count=len(nodes)
)
# Phase 3: get final status for all nodes (one call)
results = []
logger.info("Retrieving status for %d nodes...", len(nodes))
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
final_by_id = {n["node_id"]: n for n in listing["nodes"]}
for node in nodes:
node_info = final_by_id.get(node["node_id"], node)
results.append(
{
"node_id": node["node_id"],
"name": node_info.get("name") or "N/A",
"status": node_info.get("status") or "unknown",
}
)
# Handle nodes that failed to be retrieved initially
retrieved_node_ids = {node["node_id"] for node in nodes}
for node_id in node_ids:
if node_id not in retrieved_node_ids:
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": "Node not found during info retrieval",
}
)
# Analyze results
successful_nodes = [
r for r in results if r.get("status") != "error"
]
failed_nodes = [r for r in results if r.get("status") == "error"]
# Construct final response
response = {
"project_id": project_id,
"total_nodes": len(node_ids),
"successful": len(successful_nodes),
"failed": len(failed_nodes),
"nodes": results,
}
logger.info(
"Start operation completed: %d successful, %d failed",
len(successful_nodes),
len(failed_nodes),
)
return response
except json.JSONDecodeError as e:
logger.error("Invalid JSON input: %s", e)
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)}"}
class GNS3StartNodeQuickTool(BaseTool):
"""
A LangChain tool to start nodes in a GNS3 project WITHOUT waiting.
This tool sends start commands to all nodes and immediately returns status,
without blocking for startup completion. Suitable for automated deployment
workflows where long waits would cause HTTP timeouts.
Sends the start commands in a parallel batch and returns each node's
status immediately — nodes keep booting in the background. It does NOT
wait for boot completion: follow up with the wait_seconds tool and a
status/topology check to confirm nodes actually came up.
**Input**:
A JSON object with project_id and node_ids (list of node IDs).
@ -350,19 +77,20 @@ class GNS3StartNodeQuickTool(BaseTool):
"failed": 0,
"nodes": [
{"node_id": "...", "name": "...", "status": "started"},
{"node_id": "...", "name": "...", "status": "started"}
{"node_id": "...", "name": "...", "status": "error", "error": "..."}
],
"note": "Start commands sent. Nodes are booting in background."
}
"""
name: str = "start_gns3_node_quick"
name: str = "start_gns3_node"
description: str = """
Starts nodes in a GNS3 project WITHOUT waiting for startup completion.
Use this for automated deployments to avoid HTTP timeouts.
Starts one or multiple nodes in a GNS3 project and returns immediately
(nodes boot in the background; start failures are reported right away).
After calling this, use wait_seconds (VPCS/IOU ~15-30s, IOS routers
~60-120s, heavy NOS images 2-5min) before checking node status.
Input: JSON with project_id and node_ids (list of node IDs).
Returns: Dict with nodes' details after start commands are sent.
NOTE: Nodes will continue booting in background after this tool returns.
Returns: Dict with per-node start command results.
"""
def _run(
@ -490,7 +218,7 @@ class GNS3StartNodeQuickTool(BaseTool):
"nodes": results,
"note": (
"Start commands sent. Nodes are booting in background. "
"Check node status later."
"Use wait_seconds, then check node status."
),
}
@ -510,6 +238,11 @@ class GNS3StartNodeQuickTool(BaseTool):
return {"error": f"Failed to start nodes: {str(e)}"}
# Backward-compat alias: the waiting variant was removed; both names now
# point at the immediate-return tool.
GNS3StartNodeQuickTool = GNS3StartNodeTool
if __name__ == "__main__":
# Test with single node
print("=== Testing single node startup ===")
@ -524,19 +257,3 @@ if __name__ == "__main__":
tool = GNS3StartNodeTool()
result_single = tool._run(test_input_single)
pprint(result_single)
# Test with multiple nodes
print("\n=== Testing multiple nodes startup ===")
test_input_multiple = json.dumps(
{
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [
"fbeda109-9a74-4d8c-a749-cc3847911a90",
# Replace with actual node UUIDs
"another-node-uuid-here",
"third-node-uuid-here",
],
}
)
result_multiple = tool._run(test_input_multiple)
pprint(result_multiple)

View File

@ -0,0 +1,109 @@
# 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 <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
GNS3-Copilot wait tool.
start_gns3_node returns as soon as the start commands are accepted — nodes
keep booting in the background. This tool gives the agent a deliberate
pause it controls itself (instead of a hard-coded progress bar inside the
start tool), so the usual flow is: start_gns3_node → wait_seconds → check
node status / run show commands.
"""
import json
import logging
import time
from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
# Configure logging
logger = logging.getLogger(__name__)
# Hard ceiling so a hallucinated "wait 999999" cannot wedge the agent loop.
MAX_WAIT_SECONDS = 600
# Log a liveness line every few seconds so long waits are visible in the
# server log.
LOGBOOK_TICK = 5
class GNS3WaitTool(BaseTool):
"""
A LangChain tool that sleeps for a given number of seconds.
**Input**:
A JSON object with seconds (integer, 1-600).
Example:
{"seconds": 30}
**Output**:
{"waited": 30}
"""
name: str = "wait_seconds"
description: str = """
Pause execution for a given number of seconds (1-600), then continue.
Use after start_gns3_node (which returns immediately) to let nodes
boot before checking status: VPCS/IOU ~15-30s, IOS/IOL routers
~60-120s, heavy NOS images (XRd, SR Linux) 2-5min. Prefer several
short waits with a status check in between over one long blind wait.
Input: JSON with seconds (integer).
Returns: {"waited": <seconds>}.
"""
def _run(
self,
tool_input: str,
run_manager: CallbackManagerForToolRun | None = None,
) -> dict[str, Any]:
try:
input_data = json.loads(tool_input)
seconds = input_data.get("seconds")
if isinstance(seconds, str) and seconds.strip().isdigit():
seconds = int(seconds.strip())
if not isinstance(seconds, int) or isinstance(seconds, bool):
return {"error": "seconds must be an integer (1-600)."}
if not 1 <= seconds <= MAX_WAIT_SECONDS:
return {
"error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}."
}
logger.info("Waiting %d seconds...", seconds)
waited = 0
while waited < seconds:
tick = min(LOGBOOK_TICK, seconds - waited)
time.sleep(tick)
waited += tick
logger.info("Waited %d/%d seconds", waited, seconds)
return {"waited": waited}
except json.JSONDecodeError as e:
logger.error("Invalid JSON input: %s", e)
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)}"}

View File

@ -140,6 +140,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
"extra_hosts",
"extra_volumes",
"extra_configs",
"startup_config_content",
"memory",
"cpus",
]
@ -147,7 +148,8 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
changed = False
node_data = jsonable_encoder(node_data, exclude_unset=True)
for prop in props:
if prop in node_data and node_data[prop] != getattr(node, prop):
# 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
# We don't call container.update for nothing because it will restart the container
@ -284,7 +286,7 @@ async def create_docker_node_nio(
"""
nio = Docker.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.adapter_add_nio_binding(adapter_number, nio)
await node.adapter_add_nio_binding(adapter_number, nio, port_number)
return nio.asdict()
@ -302,13 +304,13 @@ async def update_docker_node_nio(
The port number on the Docker node is always 0.
"""
nio = node.get_nio(adapter_number)
nio = node.get_nio(adapter_number, port_number)
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
nio.suspend = nio_data.suspend
await node.adapter_update_nio_binding(adapter_number, nio)
await node.adapter_update_nio_binding(adapter_number, nio, port_number)
return nio.asdict()
@ -327,7 +329,7 @@ async def delete_docker_node_nio(
The port number on the Docker node is always 0.
"""
await node.adapter_remove_nio_binding(adapter_number)
await node.adapter_remove_nio_binding(adapter_number, port_number)
@router.post(
@ -346,7 +348,7 @@ async def start_docker_node_capture(
"""
pcap_file_path = os.path.join(node.project.capture_working_directory(), node_capture_data.capture_file_name)
await node.start_capture(adapter_number, pcap_file_path)
await node.start_capture(adapter_number, pcap_file_path, port_number)
return {"pcap_file_path": str(pcap_file_path)}
@ -365,7 +367,7 @@ async def stop_docker_node_capture(
The port number on the Docker node is always 0.
"""
await node.stop_capture(adapter_number)
await node.stop_capture(adapter_number, port_number)
@router.get(
@ -382,7 +384,7 @@ async def stream_pcap_file(
The port number on the Docker node is always 0.
"""
nio = node.get_nio(adapter_number)
nio = node.get_nio(adapter_number, port_number)
stream = Docker.instance().stream_pcap_file(nio, node.project.id)
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")

View File

@ -144,8 +144,14 @@ async def _add_nio_binding(node, adapter_number, port_number, nio):
manager_name = type(node.manager).__name__
# Adapter-based nodes: docker / qemu / vmware / virtualbox take
# (adapter_number, nio); iou additionally takes port_number.
if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"):
# (adapter_number, nio); iou additionally takes port_number. Docker
# adapters can be multi-port (e.g. iol-runner nodes model 4 ports per
# adapter): dropping port_number would bind every NIO to port 0, where
# add_nio() silently overwrites — the last entry per node wins and links
# end up cross-wired (observed as dead direct links after reopen).
if manager_name == "Docker":
await node.adapter_add_nio_binding(adapter_number, nio, port_number)
elif manager_name in ("Qemu", "VMware", "VirtualBox"):
await node.adapter_add_nio_binding(adapter_number, nio)
elif manager_name == "IOU":
await node.adapter_add_nio_binding(adapter_number, port_number, nio)
@ -176,7 +182,9 @@ def _get_existing_nio(node, adapter_number, port_number):
"""
manager_name = type(node.manager).__name__
if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"):
if manager_name == "Docker":
return node.get_nio(adapter_number, port_number)
elif manager_name in ("Qemu", "VMware", "VirtualBox"):
return node.get_nio(adapter_number)
elif manager_name == "IOU":
return node.get_nio(adapter_number, port_number)
@ -207,7 +215,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio):
"""
manager_name = type(node.manager).__name__
if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"):
if manager_name == "Docker":
await node.adapter_update_nio_binding(adapter_number, nio, port_number)
elif manager_name in ("Qemu", "VMware", "VirtualBox"):
await node.adapter_update_nio_binding(adapter_number, nio)
elif manager_name == "IOU":
await node.adapter_update_nio_binding(adapter_number, port_number, nio)

View File

@ -44,6 +44,8 @@ from gns3server.controller.link import _UNSET
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
from gns3server.controller import marker_replay
from gns3server.controller.marker_replay import TsharkError, TsharkMissingError
from gns3server.utils.asyncio import aiozipstream
from gns3server.utils.path import is_safe_path
from gns3server.db.repositories.templates import TemplatesRepository
@ -219,6 +221,87 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict:
return project.markers
@router.get(
"/{project_id}/markers/tags/{tag}/replay/range",
dependencies=[Depends(has_privilege("Project.Audit"))],
)
def replay_tag_range(tag: int, project: Project = Depends(dep_project)) -> dict:
"""
Aggregate replay timeline for a tag: merges the pcap of every marker
carrying ``tag`` into one timestamp-ordered view (design reference:
``marker_replay`` module docstring).
The tag gate applies: every marker under the tag must be paused
(``enabled: false``) — 409 otherwise. The response carries the timeline
bounds, per-source stats, and the full merged frame list while under the
frame cap (5000); above it the list is replaced by per-second buckets.
Required privilege: Project.Audit
"""
return marker_replay.build_timeline(project, tag)
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frames",
dependencies=[Depends(has_privilege("Project.Audit"))],
)
def replay_tag_frames(
tag: int,
ts: str,
window_ms: int = 100,
limit: int = 1000,
project: Project = Depends(dep_project),
) -> dict:
"""
Frames with ts in ``[ts, ts + window_ms]`` merged across every source of
the tag. A time with no frames is a normal successful answer:
``{"frames": []}``. The tag gate applies (409 while any marker captures).
``ts`` must be the exact string returned by the range response — never
re-serialize it through a float.
Required privilege: Project.Audit
"""
return marker_replay.query_frames(project, tag, ts, window_ms=window_ms, limit=limit)
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frame_detail(
tag: int,
ts: str,
node_id: str,
link_id: str,
marker: str,
project: Project = Depends(dep_project),
) -> dict:
"""
Decode exactly one frame (lazy — invoked when the user opens a frame,
never by the timeline itself): raw bytes for the hex view read straight
from the pcap, protocol tree from ``tshark -T pdml`` mapped isomorphically
to JSON (every PDML attribute survives, values stay strings).
``ts`` must be the exact string from the frame list; ``node_id`` +
``link_id`` + ``marker`` identify the source pcap. The tag gate applies.
Required privilege: Project.Audit
"""
try:
return await marker_replay.decode_frame(project, tag, ts, node_id, link_id, marker)
except TsharkMissingError:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="tshark is not installed on this server — frame detail is unavailable",
)
except TsharkError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
# ---------------------------------------------------------------------------
# Project-level marker definitions (global rules inherited by every link)
# ---------------------------------------------------------------------------

View File

@ -33,6 +33,7 @@ from gns3server.utils.asyncio import locking
from gns3server.compute.base_manager import BaseManager
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error
log = logging.getLogger(__name__)
@ -62,9 +63,21 @@ class Docker(BaseManager):
self._host_checked = False
def _select_node_class(self, **kwargs):
"""Select the node class based on console_type."""
"""
Select the node class based on console_type and GNS3_* environment
markers. Like console_type, the environment is fixed at node creation
time: toggling a marker via PUT takes effect after a project reload.
"""
if kwargs.get("console_type") == "docker_exec":
return VendorDockerVM
environment = kwargs.get("environment") or ""
for line in environment.splitlines():
line = line.strip().rstrip(",")
if line.startswith("GNS3_IOL_RUNNER="):
return IOLDockerVM
if line.startswith("GNS3_UNIX_SOCKET_NIO="):
# Generic capability, usable without the IOL specifics.
return VendorDockerVM
return DockerVM
async def create_node(self, name, project_id, node_id, *args, **kwargs):

View File

@ -899,19 +899,24 @@ class DockerVM(BaseNode):
await self._start_ubridge(require_privileged_access=True)
for adapter_number in range(0, self.adapters):
nio = self._ethernet_adapters[adapter_number].get_nio(0)
async with self.manager.ubridge_lock:
try:
await self._add_ubridge_connection(nio, adapter_number)
except UbridgeNamespaceError:
log.error("Container %s failed to start", self.name)
await self.stop()
adapter = self._ethernet_adapters[adapter_number]
# Single-port adapters (the standard case) loop once, keeping
# the historical command sequence; multi-port adapters
# (e.g. IOL's 4-port units) get one bridge per port.
for port_number in range(0, adapter.interfaces):
nio = adapter.get_nio(port_number)
async with self.manager.ubridge_lock:
try:
await self._add_ubridge_connection(nio, adapter_number, port_number)
except UbridgeNamespaceError:
log.error("Container %s failed to start", self.name)
await self.stop()
# The container can crash soon after the start, this means we can not move the interface to the container namespace
logdata = await self._get_log()
for line in logdata.split("\n"):
log.error(line)
raise DockerError(logdata)
# The container can crash soon after the start, this means we can not move the interface to the container namespace
logdata = await self._get_log()
for line in logdata.split("\n"):
log.error(line)
raise DockerError(logdata)
await self._start_console_server()
@ -1406,6 +1411,21 @@ class DockerVM(BaseNode):
"""
return f"eth{adapter_number}"
def _bridge_name(self, adapter_number, port_number=0):
"""
uBridge bridge name for an adapter port. Adapters with a single
port (every standard Docker node) keep the historical
"bridge{adapter}" name; multi-port adapters (e.g. IOL's 4-port
units) get one bridge per port.
:param adapter_number: adapter number
:param port_number: port number on the adapter
"""
if port_number:
return f"bridge{adapter_number}_{port_number}"
return f"bridge{adapter_number}"
async def _start_interface_monitor(self):
"""Monitor administrative state changes for this container's adapters.
@ -1558,12 +1578,14 @@ class DockerVM(BaseNode):
if writer:
await self._close_interface_monitor_writer(writer)
async def _add_ubridge_connection(self, nio, adapter_number):
async def _add_ubridge_connection(self, nio, adapter_number, port_number=0):
"""
Creates a connection in uBridge.
:param nio: NIO instance or None if it's a dummy interface (if an interface is missing in ubridge you can't see it via ifconfig in the container)
:param adapter_number: adapter number
:param port_number: port number on the adapter (standard Docker
adapters have a single port, so this is always 0 on the TAP path)
"""
try:
@ -1575,6 +1597,13 @@ class DockerVM(BaseNode):
)
)
if port_number and adapter.interfaces == 1:
raise DockerError(
"Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format(
name=self.name, port_number=port_number, adapter_number=adapter_number
)
)
for index in range(4096):
if f"tap-gns3-e{index}" not in psutil.net_if_addrs():
adapter.host_ifc = f"tap-gns3-e{str(index)}"
@ -1585,12 +1614,12 @@ class DockerVM(BaseNode):
name=self.name, adapter_number=adapter_number
)
)
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
await self._ubridge_send(
"bridge add_nio_tap bridge{adapter_number} {hostif} off".format(
adapter_number=adapter_number, hostif=adapter.host_ifc
"bridge add_nio_tap {bridge_name} {hostif} off".format(
bridge_name=bridge_name, hostif=adapter.host_ifc
)
)
@ -1618,23 +1647,24 @@ class DockerVM(BaseNode):
log.debug(f"Created adapter {adapter_number} with MAC address {mac_address} in namespace {self._namespace}")
if nio:
await self._connect_nio(adapter_number, nio)
await self._set_adapter_carrier(adapter_number, not nio.suspend)
await self._connect_nio(adapter_number, nio, port_number)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
async def _get_namespace(self):
result = await self.manager.query("GET", f"containers/{self._cid}/json")
return int(result["State"]["Pid"])
async def _set_adapter_carrier(self, adapter_number, connected):
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
"""Replicate a Docker adapter's connection state on its TAP device."""
bridge_name = self._bridge_name(adapter_number, port_number)
state = "on" if connected else "off"
await self._ubridge_send(f"bridge set_nio_tap_carrier bridge{adapter_number} {state}")
await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}")
async def _connect_nio(self, adapter_number, nio):
async def _connect_nio(self, adapter_number, nio, port_number=0):
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(
"bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format(
bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
@ -1650,12 +1680,13 @@ class DockerVM(BaseNode):
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_add_nio_binding(self, adapter_number, nio):
async def adapter_add_nio_binding(self, adapter_number, nio, port_number=0):
"""
Adds an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to add to the slot/port
:param nio: NIO instance to add to the adapter/port
:param port_number: port number on the adapter (0 for single-port adapters)
"""
try:
@ -1667,38 +1698,47 @@ class DockerVM(BaseNode):
)
)
if self.status == "started" and self.ubridge:
await self._connect_nio(adapter_number, nio)
await self._set_adapter_carrier(adapter_number, not nio.suspend)
if not adapter.port_exists(port_number):
raise DockerError(
"Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format(
name=self.name, port_number=port_number, adapter_number=adapter_number
)
)
adapter.add_nio(0, nio)
if self.status == "started" and self.ubridge:
await self._connect_nio(adapter_number, nio, port_number)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
adapter.add_nio(port_number, nio)
log.debug(
"Docker container '{name}' [{id}]: {nio} added to adapter {adapter_number}".format(
name=self.name, id=self._id, nio=nio, adapter_number=adapter_number
)
)
async def adapter_update_nio_binding(self, adapter_number, nio):
async def adapter_update_nio_binding(self, adapter_number, nio, port_number=0):
"""
Update an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to update the adapter
:param port_number: port number on the adapter (0 for single-port adapters)
"""
if self.ubridge:
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if bridge_name in self._bridges:
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
if self.status == "started":
await self._set_adapter_carrier(adapter_number, not nio.suspend)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
async def adapter_remove_nio_binding(self, adapter_number):
async def adapter_remove_nio_binding(self, adapter_number, port_number=0):
"""
Removes an adapter NIO binding.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
:returns: NIO instance
"""
@ -1712,20 +1752,20 @@ class DockerVM(BaseNode):
)
)
await self.stop_capture(adapter_number)
await self.stop_capture(adapter_number, port_number)
if self.ubridge:
nio = adapter.get_nio(0)
bridge_name = f"bridge{adapter_number}"
nio = adapter.get_nio(port_number)
bridge_name = self._bridge_name(adapter_number, port_number)
if self.status == "started":
await self._set_adapter_carrier(adapter_number, False)
await self._set_adapter_carrier(adapter_number, False, port_number)
await self._ubridge_send(f"bridge stop {bridge_name}")
await self._ubridge_send(
"bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format(
adapter=adapter_number, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
"bridge remove_nio_udp {bridge_name} {lport} {rhost} {rport}".format(
bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
adapter.remove_nio(0)
adapter.remove_nio(port_number)
log.debug(
"Docker VM '{name}' [{id}]: {nio} removed from adapter {adapter_number}".format(
@ -1733,11 +1773,12 @@ class DockerVM(BaseNode):
)
)
def get_nio(self, adapter_number):
def get_nio(self, adapter_number, port_number=0):
"""
Gets an adapter NIO binding.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
:returns: NIO instance
"""
@ -1751,10 +1792,10 @@ class DockerVM(BaseNode):
)
)
nio = adapter.get_nio(0)
nio = adapter.get_nio(port_number)
if not nio:
raise DockerError(f"Adapter {adapter_number} is not connected")
raise DockerError(f"Adapter {adapter_number} port {port_number} is not connected")
return nio
@ -1800,46 +1841,49 @@ class DockerVM(BaseNode):
await self.manager.pull_image(image, progress_callback=callback)
async def _start_ubridge_capture(self, adapter_number, output_file):
async def _start_ubridge_capture(self, adapter_number, output_file, port_number=0):
"""
Starts a packet capture in uBridge.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
:param port_number: port number on the adapter (0 for single-port adapters)
"""
adapter = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot start the packet capture: uBridge is not running")
await self._ubridge_send(f'bridge start_capture {adapter} "{output_file}"')
await self._ubridge_send(f'bridge start_capture {bridge_name} "{output_file}"')
async def _stop_ubridge_capture(self, adapter_number):
async def _stop_ubridge_capture(self, adapter_number, port_number=0):
"""
Stops a packet capture in uBridge.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
"""
adapter = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot stop the packet capture: uBridge is not running")
await self._ubridge_send(f"bridge stop_capture {adapter}")
await self._ubridge_send(f"bridge stop_capture {bridge_name}")
async def start_capture(self, adapter_number, output_file):
async def start_capture(self, adapter_number, output_file, port_number=0):
"""
Starts a packet capture.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
:param port_number: port number on the adapter (0 for single-port adapters)
"""
nio = self.get_nio(adapter_number)
nio = self.get_nio(adapter_number, port_number)
if nio.capturing:
raise DockerError(f"Packet capture is already activated on adapter {adapter_number}")
nio.start_packet_capture(output_file)
if self.status == "started" and self.ubridge:
await self._start_ubridge_capture(adapter_number, output_file)
await self._start_ubridge_capture(adapter_number, output_file, port_number)
log.debug(
"Docker VM '{name}' [{id}]: starting packet capture on adapter {adapter_number}".format(
@ -1847,19 +1891,20 @@ class DockerVM(BaseNode):
)
)
async def stop_capture(self, adapter_number):
async def stop_capture(self, adapter_number, port_number=0):
"""
Stops a packet capture.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
"""
nio = self.get_nio(adapter_number)
nio = self.get_nio(adapter_number, port_number)
if not nio.capturing:
return
nio.stop_packet_capture()
if self.status == "started" and self.ubridge:
await self._stop_ubridge_capture(adapter_number)
await self._stop_ubridge_capture(adapter_number, port_number)
log.debug(
"Docker VM '{name}' [{id}]: stopping packet capture on adapter {adapter_number}".format(

View File

@ -0,0 +1,375 @@
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
IOL (IOS on Linux) Docker container subclass.
Supports IOL images packaged with Cisco CML's container runner
(``iol-runner``, ``virl.lab/cmd/iol-runner``), e.g. ``iol-xe/iol-xe:17-18-02``:
a scratch image whose ENTRYPOINT is ``iol-runner -config /config/iol-config.json
-stdio``. The runner generates the license, writes the NETMAP, manages NVRAM
and muxes the IOS console onto PID 1 stdio (works with the plain ``telnet``
console type; requires a TTY, which GNS3 always allocates).
Networking does not use the container's network namespace at all: the runner's
netiomux exposes per-interface AF_UNIX datagram sockets in the container's
``/tmp`` (``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames),
wired by the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM
(uBridge reaches them through a per-node runtime directory bound at /tmp —
see ``VendorDockerVM._unix_socket_host_dir``). The controller allocates the
IOL application ID (upper half of the id space, disjoint from IOU's) so that
linked nodes get distinct MACs; starting a node without an allocation is an
error, not a fallback — an uncoordinated id could collide with the pool and
blackhole traffic as a MAC loop.
This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
"""
import contextlib
import glob
import json
import logging
import os
import re
import shutil
from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
log = logging.getLogger(__name__)
class IOLDockerVM(VendorDockerVM):
"""
VendorDockerVM subclass for iol-runner images.
Extra opt-in knob (beyond the inherited vendor ones):
* ``GNS3_IOL_MEMORY=<MB>`` — IOL router memory passed via the generated
config (default 2048). The template ``memory`` field caps the whole
container: keep it at IOL memory + ~512 MB headroom or the kernel
OOM-killer will fire.
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring,
and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
Startup configuration follows the IOU model: a template may reference a
config file with the ``GNS3_IOL_STARTUP_CONFIG`` environment knob; the
controller materializes the file content into ``startup_config_content``
when the node is created. The content is built into the node's NVRAM
(``tmp/run/nvram_<app id>``) on the next start — IOL boots straight from
NVRAM, so a plain stop/start never re-applies it and ``write memory``
survives restarts.
"""
_IOL_CONFIG_DIR = "/config"
_IOL_RUN_DIR = "/tmp/run"
# The runner launches IOL with a fixed 256KB nvram (-n 256)
_IOL_NVRAM_SIZE_KB = 256
# Payload-delivered state. Deliberately NOT initialized in
# _parse_vendor_environment(): create() re-runs that parser on every
# (re)create (a stop removes the container, so every start recreates it)
# to pick up environment changes — resetting these there would lose the
# controller-allocated application id (MACs would flip to the fallback
# hash, colliding with the allocation pool) and any pending
# startup-config delivered by a PUT.
_application_id = None
_startup_config_content = None
_startup_config_dirty = False
def _parse_vendor_environment(self):
super()._parse_vendor_environment()
# The image has no shell (scratch): init.sh could neither run (its
# #!/bin/sh shebang doesn't exist) nor wait for eth interfaces that
# are never created. The console is IOS itself on PID 1 stdio.
self._gns3_init = False
self._unix_socket_nio = True
self._unix_socket_dir = "/tmp"
self._iol_memory = 2048
if self._environment:
for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",")
if _line.startswith("GNS3_IOL_MEMORY="):
try:
memory = int(_line.split("=", 1)[1].strip())
if memory > 0:
self._iol_memory = memory
except ValueError:
pass
@property
def application_id(self) -> int:
"""
IOL application ID: drives interface MACs (aabb.cc{app}{iface}) and
the NVRAM file name. Allocated by the controller from the IOL Docker
half of the id space (disjoint from IOU's) — there is deliberately
no fallback: an id derived any other way could silently collide with
an allocation and blackhole traffic as a MAC loop. Starting a node
without one raises (see _prepare_iol_runtime).
"""
return self._application_id
@application_id.setter
def application_id(self, value) -> None:
self._application_id = int(value)
@property
def startup_config_content(self):
"""
Startup-config content, delivered by the controller when the node is
created from a template carrying GNS3_IOL_STARTUP_CONFIG (or updated
with new content).
"""
return self._startup_config_content
@startup_config_content.setter
def startup_config_content(self, content):
"""
Record new startup-config content; it is built into the node's NVRAM
on the next start. IOL boots from NVRAM whenever it holds a config, so
the content is only pushed when it actually changes — a plain
stop/start never re-applies it and `write memory` survives restarts.
"""
if not content or content == self._startup_config_content:
# An empty value is ignored: erasing the config is not supported
# (mirrors the IOU setter) and Web clients PUT "" for unset fields.
return
self._startup_config_content = content
self._startup_config_dirty = True
def _iol_nvram_file(self) -> str:
"""
The IOL NVRAM file inside the persistent /tmp/run volume. The name
embeds the application id (nvram_00772-style, like CML).
"""
return os.path.join(self.working_dir, "tmp", "run", f"nvram_{self.application_id:05d}")
def _apply_pending_startup_config(self) -> None:
"""
Build the node's NVRAM from the recorded startup-config content. IOL
and IOU share the same NVRAM container format (startup-config stored
as text inside the nvram file system), so the IOU nvram_import
utility produces a file IOL boots from directly — valid config, no
initial configuration dialog.
"""
content = self._startup_config_content.replace("%h", self._name)
nvram_file = self._iol_nvram_file()
os.makedirs(os.path.dirname(nvram_file), exist_ok=True)
try:
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
with open(nvram_file, "wb") as f:
f.write(nvram)
except (OSError, ValueError) as e:
raise DockerError(f"Could not write IOL startup-config to NVRAM of container '{self._name}': {e}")
log.debug("IOL container '%s': startup-config written to %s", self._name, nvram_file)
@DockerVM.name.setter
def name(self, new_name):
"""
Override: keep the hostname line inside the NVRAM in sync with the
node name (IOU parity), so a renamed or duplicated node boots under
its new name. Skipped while a content change is pending — the next
start pushes the new content with the already-updated name.
"""
if not self._startup_config_dirty and self._application_id is not None:
nvram_file = self._iol_nvram_file()
if os.path.exists(nvram_file):
try:
with open(nvram_file, "rb") as f:
startup_config, _ = nvram_export(f.read())
if startup_config:
content = re.sub(
r"hostname .+$",
"hostname " + new_name,
startup_config.decode("utf-8", errors="replace"),
flags=re.MULTILINE,
)
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
with open(nvram_file, "wb") as f:
f.write(nvram)
except (OSError, ValueError) as e:
log.warning(f"Could not update hostname in NVRAM of IOL container '{self._name}': {e}")
super(IOLDockerVM, IOLDockerVM).name.__set__(self, new_name)
def asdict(self):
"""
Override: expose the recorded startup-config content (empty for nodes
created before the knob existed or reloaded from a topology).
"""
result = super().asdict()
result["startup_config_content"] = self._startup_config_content
return result
@DockerVM.adapters.setter
def adapters(self, adapters):
"""
Override: one IOL adapter is a 4-port unit — the IOU model. The
template's adapter count is the number of units (2 adapters =
Ethernet0/0-3 + Ethernet1/0-3); the generated config asks the runner
for adapters × 4 interfaces and links address ports as
(adapter_number, port_number 0-3).
"""
if len(self._ethernet_adapters) == adapters:
return
self._ethernet_adapters.clear()
for _ in range(0, adapters):
self._ethernet_adapters.append(EthernetAdapter(interfaces=4))
log.debug(
"IOL container '%s': number of 4-port Ethernet adapters set to %d",
self._name, adapters,
)
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
Override: the runner requires ``/config`` (its config file, generated
below) and ``/tmp/run`` (its working directory: startup-config and
NVRAM live there — NETMAP and the netiomux sockets are ephemeral and
stay in the container's own /tmp). Auto-add both so a minimal
template cannot be misconfigured.
"""
volumes = super()._persistent_volume_list(image_info, include_network_config)
for needed in (self._IOL_CONFIG_DIR, self._IOL_RUN_DIR):
if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes):
volumes.append(needed)
return volumes
async def start(self):
await self._prepare_iol_runtime()
await super().start()
async def restart(self):
"""
Override: the base restart is a bare ``docker restart`` — the runner
would read a stale config (no adapter-count/memory refresh) and
uBridge would keep wiring to the previous run's sockets. Stop
gracefully (SIGTERM lets the runner flush NVRAM) and start again.
"""
await self.stop(graceful=True)
await self.start()
async def _prepare_iol_runtime(self):
"""
Regenerate the node's runtime files before the container starts:
* ``<working_dir>/tmp/run/`` must exist or the IOL process dies at
boot (the runner writes NETMAP there but does not create it).
* ``<working_dir>/config/iol-config.json`` is rewritten on every
start so adapter-count and memory changes take effect.
* Sockets and netio bus directories left in the wiring directory by a
previous (possibly SIGKILLed) run are removed — the runner rebinds
them on boot and would fail on a stale file.
``tmp/run`` (startup-config, NVRAM) is never touched. Neither is
anything while the container is already running (idempotent start of
a live node: the sockets belong to the running runner).
"""
try:
state = await self._get_container_state()
except DockerHttp404Error:
state = "stopped"
if self._application_id is None:
raise DockerError(
f"IOL container '{self._name}' has no application ID: nodes must be "
"created through the controller (which allocates one from the pool "
"shared with IOU), or created with an explicit application_id "
"(512-1022) on the compute API. Without a coordinated ID two nodes "
"would share MACs and drop each other's frames as loops."
)
os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True)
self._write_iol_config()
if state == "running":
return
# Pending startup-config is materialized here rather than in the
# property setter: at create-payload time the application id may not
# be final yet (the create route applies fields in schema order) and
# a running container must not have its NVRAM swapped mid-flight.
if self._startup_config_dirty:
self._apply_pending_startup_config()
self._startup_config_dirty = False
wiring_dir = self._unix_socket_wiring_dir()
for pattern in ("s??.sock", "c??.sock"):
for stale in glob.glob(os.path.join(wiring_dir, pattern)):
with contextlib.suppress(OSError):
os.unlink(stale)
for netio_dir in glob.glob(os.path.join(wiring_dir, "netio*")):
shutil.rmtree(netio_dir, ignore_errors=True)
def _write_iol_config(self):
"""
Write the runner's config file on the host side of the /config volume.
The runner drops to user-id/group-id after its setup, so everything it
creates is owned by the server user — which is also what lets the
(unprivileged) uBridge write into the node's socket directory.
"""
config = {
"binary": "/binary.iol",
"memory": self._iol_memory,
"num-eth": self.adapters * 4, # every adapter is a 4-port unit
"num-serial": 0, # GNS3 docker adapters are ethernet-only
# IOL derives interface MACs from the local application ID
# (aabb.cc{app}{iface}); every node needs a distinct one or
# linked routers share MACs and drop each other's frames as
# loops. Allocated by the controller per node (upper half of
# the id space, disjoint from IOU's — CML does the same with
# its per-deployment iol_app_id).
"local-app": self.application_id,
"remote-app": 1023, # netiomux's fake peer application ID
"user-id": os.getuid(),
"group-id": os.getgid(),
}
config_file = os.path.join(self.working_dir, "config", "iol-config.json")
os.makedirs(os.path.dirname(config_file), exist_ok=True)
with open(config_file, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
f.write("\n")
log.debug("Wrote iol-runner config for '%s': %s", self._name, config)
async def _fix_permissions(self):
"""
Override: no-op. The generated config maps the runner to the server's
uid/gid, so no root-owned files ever appear in the volumes, and this
image has no shell for the container-side busybox pass anyway.
"""
self._permissions_fixed = True

View File

@ -32,7 +32,9 @@ import json
import logging
import os
import shutil
import tempfile
from gns3server.utils.asyncio import wait_for_file_creation
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error
@ -64,6 +66,23 @@ class VendorDockerVM(DockerVM):
sessions on the shared exec.
* ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping
the container (default 60; Docker SIGKILLs once it expires).
* ``GNS3_UNIX_SOCKET_NIO=1`` — wire adapters through AF_UNIX datagram
socket files instead of a TAP interface moved into the container's
network namespace. For images whose network agent exposes, per adapter
``N``, a receive socket ``s%02d.sock`` and a send-to path ``c%02d.sock``
(raw Ethernet frames, one datagram per frame) inside the container —
e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge binds
``c{N:02d}.sock`` (its receive side) and sends to ``s{N:02d}.sock``.
Unless the directory is already covered by a persisted volume, a
per-node directory from the runtime directory is bind-mounted there —
owned by the server user (such agents drop privileges and cannot write
into a root-owned directory) and short enough for an AF_UNIX path,
which a node directory inside the projects tree exceeds. No TAP is
created, the container's network namespace is untouched and the
``mac_address`` template field is ignored (the image's agent owns the
MAC scheme).
* ``GNS3_UNIX_SOCKET_DIR=<dir>`` — in-container directory holding the
socket files (default ``/tmp``).
"""
def __init__(self, *args, **kwargs):
@ -86,6 +105,8 @@ class VendorDockerVM(DockerVM):
self._console_cmd = None
self._console_resize = True
self._stop_timeout = 60
self._unix_socket_nio = False
self._unix_socket_dir = "/tmp"
if self._environment:
for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",")
@ -99,6 +120,12 @@ class VendorDockerVM(DockerVM):
self._console_cmd = _line.split("=", 1)[1].strip()
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
elif _line.startswith("GNS3_UNIX_SOCKET_NIO="):
self._unix_socket_nio = _line.split("=", 1)[1].strip().lower() in ("1", "true", "yes")
elif _line.startswith("GNS3_UNIX_SOCKET_DIR="):
socket_dir = _line.split("=", 1)[1].strip().rstrip("/") or "/"
if os.path.isabs(socket_dir) and ".." not in socket_dir.split("/"):
self._unix_socket_dir = socket_dir
elif _line.startswith("GNS3_STOP_TIMEOUT="):
try:
timeout = int(_line.split("=", 1)[1].strip())
@ -142,6 +169,22 @@ class VendorDockerVM(DockerVM):
are never shadowed by an empty mount.
"""
binds = super()._mount_binds(image_info)
if self._unix_socket_nio:
socket_dir = self._unix_socket_dir.rstrip("/")
if not any(
v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/")
for v in self._volumes
):
# The image's network agent drops privileges before using the
# socket directory, so it must be writable by the server user:
# bind a per-node directory from the runtime directory (see
# _unix_socket_host_dir).
binds.append({
"Type": "bind",
"Source": self._unix_socket_host_dir(),
"Target": socket_dir,
"BindOptions": {"Propagation": "rprivate"},
})
if self._gns3_init:
return binds
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"]
@ -284,6 +327,165 @@ class VendorDockerVM(DockerVM):
return self._interface_names[adapter_number]
return f"eth{adapter_number}"
def _unix_socket_host_dir(self):
"""
Host-side directory bind-mounted at the in-container socket directory
when the latter is not already covered by a persisted volume: a
per-node directory under the runtime directory, next to the uBridge
control sockets.
AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL) — a
node directory inside the projects tree (projects/<uuid>/
project-files/docker/<uuid>/tmp) alone is ~120, so uBridge would
reject the NIO with "invalid file path size". The runtime directory
path stays short no matter where the projects live, and being owned
by the server user, the image's privilege-dropping agent can write
to it.
"""
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
host_dir = os.path.join(runtime_dir, "gns3", "unixio", self.id)
try:
os.makedirs(host_dir, mode=0o700, exist_ok=True)
except OSError as e:
raise DockerError(
f"Could not create unix-socket directory '{host_dir}' for container '{self._name}': {e}"
)
return host_dir
def _remove_unix_socket_host_dir(self):
"""Best-effort removal of the per-node unix-socket directory."""
if self._unix_socket_nio:
shutil.rmtree(self._unix_socket_host_dir(), ignore_errors=True)
def _unix_socket_wiring_dir(self):
"""
Directory referenced in the uBridge unix-NIO commands: the persisted
volume holding the socket directory, or the per-node runtime
directory bound there by _mount_binds.
"""
socket_dir = self._unix_socket_dir.rstrip("/")
for volume in self._volumes:
if socket_dir == volume.rstrip("/") or socket_dir.startswith(volume.rstrip("/") + "/"):
return os.path.join(self.working_dir, os.path.relpath(socket_dir, "/"))
return self._unix_socket_host_dir()
async def delete(self):
# The per-node socket directory is ephemeral; the node is not.
await super().delete()
self._remove_unix_socket_host_dir()
async def _add_ubridge_connection(self, nio, adapter_number, port_number=0):
"""
Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter port through
the image's AF_UNIX datagram socket pair (raw Ethernet frames)
instead of a TAP interface moved into the container's network
namespace.
Ports are addressed flat across adapters: interface index = adapter
number × ports-per-adapter + port number (single-port adapters
reduce to the adapter number). Per interface N the image's network
agent is expected to create, inside GNS3_UNIX_SOCKET_DIR:
* ``s{N:02d}.sock`` — its receive socket; frames sent there are
injected into guest interface N;
* ``c{N:02d}.sock`` — the path it sends guest-egress frames to.
uBridge binds the c-socket as its receive side and sends to the
s-socket (both on the host side of the socket directory — see
_unix_socket_wiring_dir). No TAP is allocated, the namespace is
untouched and guest MAC addresses are whatever the image's agent
uses.
"""
if not self._unix_socket_nio:
return await super()._add_ubridge_connection(nio, adapter_number, port_number)
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
raise DockerError(
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
name=self.name, adapter_number=adapter_number
)
)
interface_number = adapter_number * adapter.interfaces + port_number
bridge_name = self._bridge_name(adapter_number, port_number)
try:
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
wiring_dir = self._unix_socket_wiring_dir()
local_sock = os.path.join(wiring_dir, f"c{interface_number:02d}.sock")
remote_sock = os.path.join(wiring_dir, f"s{interface_number:02d}.sock")
# A c-socket left over from a previous ubridge run would fail its bind.
with contextlib.suppress(OSError):
os.unlink(local_sock)
# The socket appears when the container's agent finishes its interface
# setup; wait instead of silently blackholing the adapter.
try:
await wait_for_file_creation(remote_sock, timeout=30)
except asyncio.TimeoutError:
raise DockerError(
f"Socket '{remote_sock}' for adapter {adapter_number} of container "
f"'{self._name}' did not appear within 30 seconds. Check that the "
f"container's port count covers adapter {adapter_number} and that "
f"its network agent creates the per-adapter socket pair in "
f"'{self._unix_socket_dir}'."
)
await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"')
except Exception:
# A half-wired bridge would make the next start fail with
# "bridge already exist": uBridge stops with the failed start.
await self._stop_ubridge()
raise
adapter.host_ifc = local_sock # bookkeeping / removal logging only
log.debug(
"Adapter %d port %d of container '%s' wired via unix sockets %s <-> %s",
adapter_number, port_number, self._name, local_sock, remote_sock,
)
if nio:
await self._connect_nio(adapter_number, nio, port_number)
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
"""
Override: with GNS3_UNIX_SOCKET_NIO the bridges carry only unix and
UDP NIOs — there is no TAP, and uBridge rejects the carrier command
("bridge has no TAP NIO"), which would fail every link create,
update or delete on a running node. The link state is the socket
pair itself.
"""
if not self._unix_socket_nio:
await super()._set_adapter_carrier(adapter_number, connected, port_number)
async def _start_interface_monitor(self):
"""
Override: with GNS3_UNIX_SOCKET_NIO no GNS3-managed eth interface
exists in the container's network namespace — the busybox poll would
idle forever (or misreport the Docker default eth0 as adapter 0
status). Vendor images with TAP wiring keep the base monitor.
"""
if not self._unix_socket_nio:
await super()._start_interface_monitor()
async def _stop_interface_monitor(self):
"""
Override: mirror _start_interface_monitor — there is nothing to stop
when the monitor never started under unix-socket NIO.
"""
if not self._unix_socket_nio:
await super()._stop_interface_monitor()
def _cleanup_console_resources(self):
"""
Override: close the docker-exec pty socket, if any, so the next

View File

@ -114,13 +114,24 @@ class MarkerListener(asyncio.DatagramProtocol):
# signals that carry no `link=`.
signal_link = link if link and link != "-" else None
# Normalize the tag to int so the event matches the REST schema
# (MarkerCreate.tag is Optional[int]): the signal merely echoes the
# decimal we installed via `mark <bpf> tag <id>`, so parsing cannot
# fail for well-formed signals; a malformed value keeps the registered
# int, and the tag is None only when neither side carries one.
event_tag = registered_tag
if tag and tag != "-":
try:
event_tag = int(tag)
except ValueError:
pass # malformed signal tag: keep the registered value
event = {
"project_id": project_id,
"node_id": node_id,
"link_id": signal_link or link_id,
"filter": filter_name,
# Prefer the value carried in the signal; fall back to the one we registered.
"tag": tag if tag and tag != "-" else registered_tag,
"tag": event_tag,
"ts": ts,
"len": int(length) if length and length.isdigit() else 0,
# Travel direction relative to the capture node (node_id above);

View File

@ -0,0 +1,16 @@
!
service timestamps debug datetime msec
service timestamps log datetime msec
!
hostname %h
!
no ip domain lookup
!
ip tcp synwait-time 5
!
line con 0
exec-timeout 0 0
privilege level 15
logging synchronous
!
end

View File

@ -0,0 +1,428 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tag-keyed aggregate replay over paused markers' pcap files.
Markers on different links sharing a ``tag`` form one distributed capture
session. This module merges their per-marker pcaps
(``<project>/project-files/markers/{node_id}_{link_id}_{name}.pcap``) into a
single timestamp-ordered timeline and decodes individual frames on demand.
Two deliberately separated performance regimes:
* the timeline path reads only the 16-byte pcap record headers — tshark is
never invoked, so browsing works even where tshark is not installed;
* the detail path runs one ``tshark -T pdml`` per frame the caller asks about
(call count = user clicks) and maps the XML to JSON isomorphically — every
PDML attribute survives as a JSON key, values stay strings, nothing is
selected out or interpreted.
Timestamps are uBridge's userspace ``gettimeofday`` at match time (µs, a
value measured after the packet has crossed the kernel twice — the last
digit or two are scheduling noise). A timestamp is NOT a unique key: the
merge sorts by ``(ts, source file, frame number)`` and index structures must
never use ts alone as a dict key, or same-microsecond frames silently
overwrite each other.
"""
import asyncio
import logging
import os
import shutil
import struct
import tempfile
import xml.etree.ElementTree as ET
from .controller_error import ControllerError, ControllerNotFoundError, ControllerBadRequestError
log = logging.getLogger(__name__)
# Full frame list is embedded in the range response while under this cap;
# above it the response degrades to start/end + per-second buckets so the
# client is never flooded by a high-traffic BPF.
FRAME_LIST_CAP = 5000
# One tshark decode per user click: a generous ceiling, not a rate limiter.
TSHARK_TIMEOUT_SECONDS = 10.0
class TsharkMissingError(ControllerError):
"""tshark is not installed (or not on PATH) — frame detail unavailable."""
class TsharkError(ControllerError):
"""tshark exited non-zero / timed out / produced unusable output."""
# ---------------------------------------------------------------------------
# pcap record-header scanning (timeline path — no tshark)
# ---------------------------------------------------------------------------
# magic → (byte order, timestamp unit). Both pcap families uBridge can write
# (libpcap default µs; ns variant accepted defensively) and both endiannesses.
_PCAP_MAGICS = {
0xA1B2C3D4: ("<", 1), # little-endian, microseconds
0xD4C3B2A1: (">", 1), # big-endian, microseconds
0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds
0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds
}
def _format_ts(sec: int, usec: int) -> str:
"""Canonical ts string — the exact form clients must round-trip back."""
return f"{sec}.{usec:06d}"
def _parse_ts(ts: str) -> int:
"""Parse a round-tripped ts string to integer microseconds (exact, no floats)."""
try:
sec, _, frac = ts.partition(".")
usec = int(frac.ljust(6, "0")[:6]) if frac else 0
return int(sec) * 1_000_000 + usec
except ValueError:
raise ControllerBadRequestError(f"Invalid timestamp: {ts!r}")
def scan_pcap_frames(path):
"""
Walk a pcap file reading only record headers.
:returns: list of ``(ts_sec, ts_usec, incl_len)`` per frame, 1-based order.
Tolerates a truncated tail (stops when a record header claims more bytes
than the file holds) so a snapshot mid-write never raises.
"""
frames = []
try:
with open(path, "rb") as f:
data = f.read()
except OSError as e:
raise ControllerError(f"Cannot read marker pcap {path}: {e}")
if len(data) < 24:
return frames # not even a global header — zero frames
magic = struct.unpack("<I", data[:4])[0]
spec = _PCAP_MAGICS.get(magic)
if spec is None:
raise ControllerError(f"Unrecognized pcap magic in {path}")
byteorder, unit = spec
pos = 24
while pos + 16 <= len(data):
ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(
byteorder + "IIII", data[pos:pos + 16]
)
if incl_len > 0xFFFF or pos + 16 + incl_len > len(data):
break # truncated tail (snapshot mid-write / torn final record)
# Normalize ns pcaps to µs by truncation — uBridge writes µs anyway.
frames.append((ts_sec, ts_frac // unit if unit > 1 else ts_frac, incl_len))
pos += 16 + incl_len
return frames
def read_frame_bytes(path, frame_number):
"""
Read one frame's raw bytes (hex view) straight from the pcap — never via
tshark. ``frame_number`` is 1-based (the same number tshark's
``frame.number`` filter uses).
"""
frames = scan_pcap_frames(path)
if not 1 <= frame_number <= len(frames):
return None
# Offset arithmetic mirrors the header scan: global header + every full
# record before the target + the target's own record header.
offset = 24 + sum(16 + incl for _s, _u, incl in frames[:frame_number - 1]) + 16
incl_len = frames[frame_number - 1][2]
with open(path, "rb") as f:
f.seek(offset)
return f.read(incl_len).hex()
# ---------------------------------------------------------------------------
# Tag gate + timeline assembly
# ---------------------------------------------------------------------------
def _tag_markers(project, tag):
"""
Every marker entry in the project carrying ``tag`` (flat
``project.markers`` values: link_id / node_id / name keys included).
"""
entries = []
for key, info in project.markers.items():
if info.get("tag") == tag:
link_id, _, name = key.partition("/")
entries.append({
"node_id": info["node_id"],
"link_id": link_id,
"marker": name,
"enabled": info.get("enabled", True),
"data_link_type": info.get("data_link_type", "DLT_EN10MB"),
})
return entries
def gate_tag(project, tag):
"""
Replay reads append-only pcaps, so it is only available while the data is
at rest: every marker under the tag must be paused. Raises 409 listing
the still-running markers, 404 when the tag has no markers at all.
"""
entries = _tag_markers(project, tag)
if not entries:
raise ControllerNotFoundError(f"No markers with tag {tag} in project")
running = [f"{e['marker']} on link {e['link_id']}" for e in entries if e["enabled"]]
if running:
raise ControllerError(
f"Cannot replay tag {tag} while markers are capturing: {', '.join(running)}. "
"Pause every marker under the tag first."
)
return entries
def _merged_frames(project, entries):
"""
Scan every source pcap and merge into one list sorted by
``(ts, source file, frame number)`` — ts alone is not unique (two links
can hit the same microsecond); the tiebreaker yields a stable, determined
order instead of a fictional one.
"""
markers_dir = project.markers_directory
merged = []
sources = []
for entry in entries:
pcap = os.path.join(
markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap"
)
frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else []
sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
"count": len(frames)})
for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1):
merged.append({
"ts": _format_ts(sec, usec),
"ts_us": sec * 1_000_000 + usec,
"len": incl_len,
"node_id": entry["node_id"],
"link_id": entry["link_id"],
"marker": entry["marker"],
"frame_number": frame_number,
"_source": f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}",
})
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
for frame in merged:
del frame["ts_us"]
del frame["_source"]
return merged, sources
def build_timeline(project, tag, frame_cap=FRAME_LIST_CAP):
"""
The ``range`` response: timeline bounds, per-source stats, and (under
``frame_cap``) the full merged frame list for one-request timeline
layout. Over the cap the list is replaced by per-second buckets.
"""
entries = gate_tag(project, tag)
frames, sources = _merged_frames(project, entries)
response = {
"tag": tag,
"start": frames[0]["ts"] if frames else None,
"end": frames[-1]["ts"] if frames else None,
"frame_count": len(frames),
"truncated": len(frames) > frame_cap,
"sources": sources,
}
if len(frames) <= frame_cap:
response["frames"] = frames
else:
buckets = {}
for frame in frames:
second = _parse_ts(frame["ts"]) // 1_000_000
buckets[second] = buckets.get(second, 0) + 1
response["buckets"] = [
{"ts": _format_ts(second, 0), "count": count}
for second, count in sorted(buckets.items())
]
return response
def query_frames(project, tag, ts, window_ms=100, limit=1000):
"""
Frames with ts in ``[T, T+window_ms]`` merged across sources. A time with
no frames is a normal, successful answer — ``{"frames": []}``.
"""
entries = gate_tag(project, tag)
frames, _sources = _merged_frames(project, entries)
start_us = _parse_ts(ts)
end_us = start_us + max(window_ms, 0) * 1000
hits = [f for f in frames if start_us <= _parse_ts(f["ts"]) <= end_us]
return {"frames": hits[:max(limit, 0)]}
# ---------------------------------------------------------------------------
# Frame detail (tshark path — lazy, one frame per call)
# ---------------------------------------------------------------------------
async def _tshark_version():
try:
proc = await asyncio.create_subprocess_exec(
"tshark", "--version",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=TSHARK_TIMEOUT_SECONDS)
return stdout.decode(errors="replace").splitlines()[0].strip()
except (OSError, asyncio.TimeoutError, IndexError):
raise TsharkMissingError("tshark is not available on this server")
def _tshark_scratch_copy(pcap):
"""
Copy the pcap to a scratch file under the system temp dir for tshark to
read. Hardened tshark profiles (AppArmor &c.) can deny it access to the
project directory / the user's home while still allowing /tmp — a real
copy, deliberately not a symlink, since the profile resolves real paths.
Caller must unlink the returned path.
"""
fd, scratch = tempfile.mkstemp(suffix=".pcap", prefix="gns3-replay-")
os.close(fd)
shutil.copyfile(pcap, scratch)
return scratch
def _tshark_env():
"""Scratch HOME so tshark never even tries to read the user's home."""
env = dict(os.environ)
env["HOME"] = tempfile.gettempdir()
return env
def _pdml_to_nodes(element):
"""
Isomorphic PDML → JSON mapping: every XML attribute becomes a JSON key
verbatim (values stay strings), children nest under ``children``. The
element tag ("proto"/"field") is carried as ``element`` — the one
structural key beyond the attributes, so a renderer can tell a protocol
group from a leaf field (geninfo's tagless names make names unreliable).
"""
return {
"element": element.tag,
**element.attrib,
"children": [_pdml_to_nodes(child) for child in element],
}
def _count_nodes(nodes):
return 1 + sum(_count_nodes(child) for child in nodes.get("children", []))
async def decode_frame(project, tag, ts, node_id, link_id, marker):
"""
Decode exactly one frame: locate its pcap by source identity, verify the
round-tripped ts still matches the file (guards a rebuild between the
timeline view and this click), read the raw bytes for the hex view, and
map tshark's PDML of that single frame to JSON.
"""
entries = gate_tag(project, tag)
entry = next(
(e for e in entries
if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker),
None,
)
if entry is None:
raise ControllerNotFoundError(
f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}"
)
pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap")
if not os.path.exists(pcap):
raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)")
frames = scan_pcap_frames(pcap)
# The ts must be the exact string the timeline returned; find the frame
# it identifies rather than trusting any position hint from the client.
frame_number = next(
(i for i, (sec, usec, _len) in enumerate(frames, start=1)
if _format_ts(sec, usec) == ts),
None,
)
if frame_number is None:
raise ControllerNotFoundError(
f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
)
raw_hex = read_frame_bytes(pcap, frame_number)
if shutil.which("tshark") is None:
raise TsharkMissingError("tshark is not installed — frame detail is unavailable")
version = await _tshark_version()
# Hand tshark a scratch copy under the temp dir: hardened profiles may
# deny it the project directory even though this process can read it
# (the hex view above reads the original directly).
scratch = _tshark_scratch_copy(pcap)
try:
try:
proc = await asyncio.create_subprocess_exec(
"tshark", "-r", scratch, "-T", "pdml", "-Y", f"frame.number == {frame_number}",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_tshark_env(),
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=TSHARK_TIMEOUT_SECONDS
)
except OSError as e:
raise TsharkError(f"Could not run tshark: {e}")
except asyncio.TimeoutError:
raise TsharkError(f"tshark timed out after {TSHARK_TIMEOUT_SECONDS:.0f}s")
if proc.returncode != 0 or not stdout.strip():
# Never feed truncated/failed output to the mapper.
raise TsharkError(f"tshark failed: {stderr.decode(errors='replace').strip()[:500]}")
finally:
try:
os.unlink(scratch)
except OSError:
pass
try:
root = ET.fromstring(stdout)
except ET.ParseError as e:
raise TsharkError(f"Malformed PDML from tshark: {e}")
packet = root.find("./packet")
tree = [_pdml_to_nodes(child) for child in packet] if packet is not None else []
return {
"ts": ts,
"source": {"node_id": node_id, "link_id": link_id, "marker": marker,
"frame_number": frame_number},
"tshark_version": version,
"field_count": sum(_count_nodes(node) for node in tree),
"hex": raw_hex,
"tree": tree,
}

View File

@ -30,6 +30,7 @@ from .controller_error import (
from .node_types import BUILTIN_NODE_TYPES
from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory
from ..utils.images import images_directories
from ..utils.application_id import is_iol_runner_environment
from ..utils import macaddress_to_int, int_to_macaddress
from ..config import Config
@ -39,6 +40,30 @@ import logging
log = logging.getLogger(__name__)
def _extract_iol_startup_config_knob(environment):
"""
Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment
(iol-runner images): the value is a config file name, resolved by the
controller in its configs directory.
:returns: (filename, environment without the knob line); filename is None
when the knob is absent or empty.
"""
if not environment or "GNS3_IOL_STARTUP_CONFIG=" not in environment:
return None, environment
filename = None
kept = []
for line in environment.splitlines():
stripped = line.strip().rstrip(",")
if stripped.startswith("GNS3_IOL_STARTUP_CONFIG="):
if filename is None:
filename = stripped.split("=", 1)[1].strip()
else:
kept.append(line)
return filename or None, "\n".join(kept)
class Node:
# These properties are used only on controller and are not forwarded to the compute
CONTROLLER_ONLY_PROPERTIES = [
@ -572,6 +597,25 @@ class Node:
data[v] = self._base_config_file_content(self._properties[k])
del data[k]
del self._properties[k] # We send the file only one time
# IOL runner (Docker) startup-config: translate the config file
# referenced by the GNS3_IOL_STARTUP_CONFIG environment knob into
# content, like the mappings above. Sent only once — afterwards
# the node's configuration lives in its NVRAM on the compute (a
# reloaded node is recreated without the knob and boots from the
# persistent NVRAM, preserving `write memory`).
if self._node_type == "docker":
filename, environment = _extract_iol_startup_config_knob(self._properties.get("environment"))
if filename:
content = self._base_config_file_content(filename)
if content:
data["startup_config_content"] = content
data["environment"] = environment
self._properties["environment"] = environment
else:
log.warning(
f"Cannot load IOL startup-config file '{filename}' for node '{self._name}': file not found"
)
data["name"] = self._name
# For remote computes, convert absolute image paths to relative paths
@ -804,22 +848,35 @@ class Node:
self._ports = DynamipsPortFactory(self._properties)
return
elif self._node_type == "docker":
for adapter_number in range(0, self._properties["adapters"]):
custom_adapter_settings = {}
if self.custom_adapters:
for custom_adapter in self.custom_adapters:
if custom_adapter["adapter_number"] == adapter_number:
custom_adapter_settings = custom_adapter
break
port_name = f"eth{adapter_number}"
port_name = custom_adapter_settings.get("port_name", port_name)
mac_address = custom_adapter_settings.get("mac_address")
if not mac_address and "mac_address" in self._properties:
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
if is_iol_runner_environment(self._properties.get("environment")):
# IOL adapters are 4-port units (the IOU model): ports are
# Ethernet0/0-3, Ethernet1/0-3, … addressed as
# (adapter_number, port_number 0-3).
self._ports = StandardPortFactory(
self._properties,
4,
self._first_port_name,
"Ethernet{segment0}/{port0}",
4,
self.custom_adapters,
)
else:
for adapter_number in range(0, self._properties["adapters"]):
custom_adapter_settings = {}
if self.custom_adapters:
for custom_adapter in self.custom_adapters:
if custom_adapter["adapter_number"] == adapter_number:
custom_adapter_settings = custom_adapter
break
port_name = f"eth{adapter_number}"
port_name = custom_adapter_settings.get("port_name", port_name)
mac_address = custom_adapter_settings.get("mac_address")
if not mac_address and "mac_address" in self._properties:
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
self._ports.append(port)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
self._ports.append(port)
elif self._node_type in ("ethernet_switch", "ethernet_hub"):
# Basic node we don't want to have adapter number
port_number = 0

View File

@ -40,7 +40,7 @@ from .udp_link import UDPLink
from .link import _UNSET
from ..config import Config
from ..utils.path import check_path_allowed, get_default_project_directory
from ..utils.application_id import get_next_application_id
from ..utils.application_id import get_next_application_id, is_iol_runner_environment
from ..utils.asyncio.pool import Pool
from ..utils.packet_filter_validation import validate_bpf_syntax
from ..utils.asyncio import locking
@ -69,6 +69,21 @@ def open_required(func):
return wrapper
def _is_iol_docker_kwargs(kwargs) -> bool:
"""
Whether add_node() kwargs describe an iol-runner Docker node. The
environment can arrive nested in a ``properties`` dict or as a top-level
kwarg (the template path spreads it), matching the two shapes IOU
application-id injection handles.
"""
if "properties" in kwargs.keys():
environment = (kwargs.get("properties") or {}).get("environment")
else:
environment = kwargs.get("environment")
return is_iol_runner_environment(environment)
class Project:
"""
A project inside a controller
@ -159,7 +174,7 @@ class Project:
assert self._status != "closed"
self.dump()
self._iou_id_lock = asyncio.Lock()
self._application_id_lock = asyncio.Lock()
# Serialise the "ensure project exists on this compute" check in
# _create_node: without it, concurrent node creations all pass the
# `compute not in _project_created_on_compute` check before any has
@ -504,6 +519,17 @@ class Project:
os.makedirs(path, exist_ok=True)
return path
@property
def markers_directory(self):
"""
Location of the marker pcap files (same layout the compute side writes
via ``markers_working_directory`` — single-server deployments share the
project directory, which is what tag replay reads).
"""
path = os.path.join(self._path, "project-files", "markers")
os.makedirs(path, exist_ok=True)
return path
@property
def pictures_directory(self):
"""
@ -646,7 +672,7 @@ class Project:
self._computes.append(compute.id)
if node_type == "iou":
async with self._iou_id_lock:
async with self._application_id_lock:
# IOU application IDs must be allocated serially to avoid duplicates.
# The lock must also cover _create_node() because get_next_application_id()
# checks in-memory nodes (self._nodes), which are only registered
@ -658,6 +684,23 @@ class Project:
elif "application_id" not in kwargs.keys() and not kwargs.get("properties"):
kwargs["application_id"] = get_next_application_id(self._controller.projects, self._computes)
node = await self._create_node(compute, name, node_id, node_type, **kwargs)
elif node_type == "docker" and _is_iol_docker_kwargs(kwargs):
# IOL Docker nodes derive interface MACs from the application ID
# exactly like IOU; they draw from the disjoint upper half of the
# id space so the two node types can neither collide nor starve
# each other.
async with self._application_id_lock:
if "properties" in kwargs.keys():
properties = kwargs.get("properties") or {}
if "application_id" not in properties:
properties["application_id"] = get_next_application_id(
self._controller.projects, self._computes, iol_docker=True
)
elif "application_id" not in kwargs.keys():
kwargs["application_id"] = get_next_application_id(
self._controller.projects, self._computes, iol_docker=True
)
node = await self._create_node(compute, name, node_id, node_type, **kwargs)
else:
node = await self._create_node(compute, name, node_id, node_type, **kwargs)
self.emit_notification("node.created", node.asdict())

View File

@ -26,7 +26,7 @@ class DockerBase(BaseModel):
Common Docker node properties.
"""
@field_validator("start_command", "environment", "extra_hosts", mode="before")
@field_validator("start_command", "environment", "extra_hosts", "startup_config_content", mode="before")
@classmethod
def _empty_string_to_none(cls, value):
# Web clients serialize empty form fields as "" while unset values are
@ -59,6 +59,9 @@ class DockerBase(BaseModel):
extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)")
extra_volumes: Optional[List[str]] = Field(None, description="Additional directories to make persistent")
extra_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)")
startup_config_content: Optional[str] = Field(
None, description="Startup-config content (IOL runner images: materialized into the node's NVRAM at start)"
)
memory: Optional[int] = Field(None, ge=0, description="Maximum amount of memory the container can use in MB")
cpus: Optional[float] = Field(None, ge=0, description="Maximum amount of CPU resources the container can use")
custom_adapters: Optional[List[CustomAdapter]] = Field(None, description="Custom adapters")
@ -69,7 +72,9 @@ class DockerCreate(DockerBase):
Properties to create a Docker node.
"""
pass
application_id: Optional[int] = Field(
None, ge=1, le=1022, description="IOL application ID for iol-runner images (allocated by the controller)"
)
class DockerUpdate(DockerBase):

View File

@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from gns3server.controller.controller_error import ControllerError
@ -20,13 +21,32 @@ import logging
log = logging.getLogger(__name__)
# IOU draws from the lower half: uBridge's iol_bridge uses application_id + 512
# for its netio peer endpoint. IOL Docker nodes (iol-runner images) draw from
# the upper half: their netiomux peer is the fixed id 1023. Both node types
# derive interface MACs from the id (aabb.cc{app}{iface}), so the two pools
# must stay disjoint — a shared id silently blackholes traffic between the
# nodes as a MAC loop.
IOU_APPLICATION_ID_POOL = range(1, 512)
IOL_DOCKER_APPLICATION_ID_POOL = range(512, 1023)
def get_next_application_id(projects, computes):
def is_iol_runner_environment(environment) -> bool:
"""
Whether a Docker node environment carries the GNS3_IOL_RUNNER marker —
the same signal the compute uses to select the IOLDockerVM class.
"""
return "GNS3_IOL_RUNNER=" in (environment or "")
def get_next_application_id(projects, computes, iol_docker=False):
"""
Calculates free application_id from given nodes
:param projects: all projects managed by controller
:param computes: all computes used by the project
:param iol_docker: allocate for an IOL Docker (iol-runner) node instead of IOU
:raises HTTPConflict when exceeds number
:return: integer first free id
"""
@ -38,12 +58,26 @@ def get_next_application_id(projects, computes):
if project.status == "opened":
nodes.extend(list(project.nodes.values()))
used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes}
pool = set(range(1, 512))
if iol_docker:
used = {
n.properties["application_id"]
for n in nodes
if n.node_type == "docker"
and n.compute.id in computes
and "application_id" in n.properties
and is_iol_runner_environment(n.properties.get("environment"))
}
pool = set(IOL_DOCKER_APPLICATION_ID_POOL)
limit = "511 IOL Docker nodes"
else:
used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes}
pool = set(IOU_APPLICATION_ID_POOL)
limit = "512 nodes"
try:
application_id = (pool - used).pop()
return application_id
except KeyError:
raise ControllerError(
"Cannot create a new IOU node (limit of 512 nodes across all opened projects using the same computes)"
f"Cannot create a new {'IOL Docker' if iol_docker else 'IOU'} node "
f"(limit of {limit} across all opened projects using the same computes)"
)

View File

@ -20,9 +20,11 @@ import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
from unittest.mock import patch
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.project import Project
pytestmark = pytest.mark.asyncio
@ -98,6 +100,46 @@ class TestDockerNodesRoutes:
assert response.json()["extra_hosts"] == "test:127.0.0.1"
async def test_docker_create_iol_startup_config(
self, app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict
) -> None:
"""
The controller materializes the GNS3_IOL_STARTUP_CONFIG knob into
startup_config_content; the create route must carry it onto the
IOLDockerVM (plain Docker nodes have no such property and skip it).
"""
params = dict(base_params)
params["image"] = "iol-xe/iol-xe:17-18-02"
params["environment"] = "GNS3_IOL_RUNNER=1"
params["startup_config_content"] = "hostname %h\n"
create_response = {
"Id": "8bd8153ea8f5",
"Warnings": [],
"Config": {
"Entrypoint": ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"],
"Cmd": [],
"Volumes": {},
},
}
seed_proc = MagicMock()
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid\n", b""))
seed_proc.returncode = 0
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe/iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=create_response):
with patch("asyncio.subprocess.create_subprocess_exec", return_value=seed_proc):
response = await compute_client.post(
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["startup_config_content"] == "hostname %h\n"
vm = Docker.instance().get_node(response.json()["node_id"], project_id=str(compute_project.id))
assert isinstance(vm, IOLDockerVM)
assert vm.startup_config_content == "hostname %h\n"
@pytest.mark.parametrize(
"name, status_code",
(

View File

@ -315,6 +315,37 @@ class TestBatchNIOEdgeCases:
await _add_nio_binding(node, 1, 2, nio)
node.adapter_add_nio_binding.assert_called_once_with(1, 2, nio)
@pytest.mark.asyncio
async def test_docker_dispatch_keeps_port_number(self):
"""
Docker adapters can be multi-port (iol-runner nodes model 4 ports per
adapter). Dropping port_number binds every NIO to port 0 where
add_nio() silently overwrites — reopened projects then end up with
cross-wired links (the last entry per node wins).
"""
from unittest.mock import AsyncMock, MagicMock
from gns3server.api.routes.compute.projects import (
_add_nio_binding,
_get_existing_nio,
_update_nio_binding,
)
node = MagicMock()
type(node.manager).__name__ = "Docker"
node.adapter_add_nio_binding = AsyncMock()
node.adapter_update_nio_binding = AsyncMock()
nio = MagicMock()
node.get_nio = MagicMock(return_value=nio)
await _add_nio_binding(node, 0, 1, nio)
node.adapter_add_nio_binding.assert_called_once_with(0, nio, 1)
assert _get_existing_nio(node, 0, 1) is nio
node.get_nio.assert_called_once_with(0, 1)
await _update_nio_binding(node, 0, 1, nio)
node.adapter_update_nio_binding.assert_called_once_with(0, nio, 1)
@pytest.mark.asyncio
async def test_vpcs_dispatch_to_port_add_nio_binding(self):
"""_add_nio_binding dispatches VPCS to port_add_nio_binding."""

View File

@ -0,0 +1,190 @@
#!/usr/bin/env python
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
HTTP-route tests for the tag replay endpoints: the tag gate (409 while any
marker captures, 404 unknown tag), the merged timeline, window queries
(empty window = success), and the lazy frame detail (ts guard, isomorphic
JSON, 501 when tshark is unavailable).
"""
import shutil
from unittest.mock import patch
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller.project import Project
from gns3server.controller.udp_link import UDPLink
from tests.controller.test_marker_replay import _write_pcap, _icmp_frame
pytestmark = pytest.mark.asyncio
tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed")
def _add_marker(project, tag, enabled, node_id, frames=None):
"""Create a paused/capturing link+marker and optionally its pcap."""
link = UDPLink(project)
link._markers["icmp"] = {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None,
"highlight_duration": None, "capture_node_id": node_id,
"direction": None, "data_link_type": "DLT_EN10MB"}
project._links[link.id] = link
if frames is not None:
_write_pcap(
f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames
)
return link
class TestReplayRoutes:
async def test_range_409_while_capturing(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
_add_marker(project, tag=7, enabled=False, node_id="n1")
running = _add_marker(project, tag=7, enabled=True, node_id="n2")
response = await client.get(
app.url_path_for("replay_tag_range", project_id=project.id, tag=7)
)
assert response.status_code == status.HTTP_409_CONFLICT
assert "icmp" in response.json()["message"]
assert running.id in response.json()["message"]
async def test_range_404_unknown_tag(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
_add_marker(project, tag=7, enabled=False, node_id="n1")
response = await client.get(
app.url_path_for("replay_tag_range", project_id=project.id, tag=99)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
async def test_range_merges_sources_in_ts_order(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
# r1→r2 captures at t1 and t3; r2→r3 captures at t2 and t3 (same µs
# as source A's t3 — the tiebreak must keep both frames).
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 500000, b"a" * 60),
(1693472002, 0, b"a" * 60),
])
_add_marker(project, tag=7, enabled=False, node_id="n2", frames=[
(1693472001, 0, b"b" * 60),
(1693472002, 0, b"b" * 60),
])
response = await client.get(
app.url_path_for("replay_tag_range", project_id=project.id, tag=7)
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["tag"] == 7
assert body["frame_count"] == 4
assert body["start"] == "1693472000.500000"
assert body["end"] == "1693472002.000000"
assert body["truncated"] is False
assert [f["node_id"] for f in body["frames"]] == ["n1", "n2", "n1", "n2"]
assert [f["ts"] for f in body["frames"]] == [
"1693472000.500000", "1693472001.000000",
"1693472002.000000", "1693472002.000000",
]
assert len(body["sources"]) == 2
async def test_frames_window_miss_is_empty_success(
self, app: FastAPI, client: AsyncClient, project: Project
) -> None:
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 0, b"a" * 60),
])
response = await client.get(
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
params={"ts": "1693472001.000000", "window_ms": 100},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"frames": []}
async def test_frames_window_hit(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 0, b"a" * 60),
(1693472000, 150000, b"a" * 60),
])
response = await client.get(
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
params={"ts": "1693472000.000000", "window_ms": 150},
)
assert response.status_code == status.HTTP_200_OK
assert [f["ts"] for f in response.json()["frames"]] == [
"1693472000.000000", "1693472000.150000"
]
async def test_detail_404_on_ts_mismatch(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 123456, _icmp_frame()),
])
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params={"ts": "1.000000", "node_id": "n1", "link_id": link.id, "marker": "icmp"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "rebuilt" in response.json()["message"]
async def test_detail_501_without_tshark(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 123456, _icmp_frame()),
])
with patch("gns3server.controller.marker_replay.shutil.which", return_value=None):
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params={"ts": "1693472000.123456", "node_id": "n1",
"link_id": link.id, "marker": "icmp"},
)
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
@tshark_present
async def test_detail_decodes_single_frame(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
(1693472000, 123456, _icmp_frame()),
])
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params={"ts": "1693472000.123456", "node_id": "n1",
"link_id": link.id, "marker": "icmp"},
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["source"]["frame_number"] == 1
assert body["hex"] == _icmp_frame().hex()
assert body["field_count"] > 10
assert "tshark" in body["tshark_version"].lower()
ip = next(p for p in body["tree"] if p.get("name") == "ip")
ttl = next(f for f in ip["children"] if f.get("name") == "ip.ttl")
# Values arrive as strings, exactly as tshark emitted them.
assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64"

View File

@ -1198,7 +1198,7 @@ async def test_start(vm, manager, free_console_port, tmpdir):
await vm.start()
mock_query.assert_called_with("POST", "containers/e90e34656842/start")
vm._add_ubridge_connection.assert_called_once_with(nio, 0)
vm._add_ubridge_connection.assert_called_once_with(nio, 0, 0)
assert vm._start_ubridge.called
assert vm._start_console.called
assert vm._start_aux.called
@ -1253,7 +1253,7 @@ async def test_start_namespace_failed(vm, manager, free_console_port):
await vm.start()
mock_query.assert_any_call("POST", "containers/e90e34656842/start")
mock_add_ubridge_connection.assert_called_once_with(nio, 0)
mock_add_ubridge_connection.assert_called_once_with(nio, 0, 0)
assert mock_start_ubridge.called
assert vm.status == "stopped"
@ -1798,7 +1798,7 @@ async def test_adapter_add_nio_binding_sets_carrier(vm):
await vm.adapter_add_nio_binding(0, nio)
vm._set_adapter_carrier.assert_called_once_with(0, True)
vm._set_adapter_carrier.assert_called_once_with(0, True, 0)
@pytest.mark.asyncio
@ -1831,7 +1831,7 @@ async def test_adapter_update_nio_binding_sets_suspended_carrier(vm):
await vm.adapter_update_nio_binding(0, nio)
vm._set_adapter_carrier.assert_called_once_with(0, False)
vm._set_adapter_carrier.assert_called_once_with(0, False, 0)
@pytest.mark.asyncio

View File

@ -0,0 +1,821 @@
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for the IOLDockerVM subclass (Cisco CML iol-runner images, e.g.
iol-xe/iol-xe:17-18-02) and its unix-socket NIO wiring.
Image-free: everything is asserted against generated files, parsed knobs and
the uBridge command stream.
"""
import asyncio
import glob
import json
import os
import uuid
import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock, call
from tests.utils import asyncio_patch, AsyncioMagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
IOL_ENTRYPOINT = ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"]
def _create_response(entrypoint=None, volumes=None):
"""Build the Docker /containers/create response (with image info merged)."""
return {
"Id": "e90e34656806",
"Warnings": [],
"Config": {
"Entrypoint": entrypoint or IOL_ENTRYPOINT,
"Cmd": [],
"Volumes": volumes or {},
},
}
@pytest_asyncio.fixture
async def manager(port_manager):
m = Docker.instance()
m.port_manager = port_manager
return m
def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1",
extra_volumes=None, adapters=4, console_type="telnet"):
"""Build an IOLDockerVM with a fake cid (no create() called)."""
vm = IOLDockerVM(
"iol-xe-1", str(uuid.uuid4()), compute_project, manager, "iol-xe/iol-xe:17-18-02",
console_type=console_type, environment=environment,
extra_volumes=extra_volumes or [], adapters=adapters,
)
vm._cid = "e90e34656842"
# mirrors the controller flow, which always delivers an allocated id
vm.application_id = 700
return vm
def _mock_start(vm, state="stopped"):
"""Mock everything DockerVM.start() needs besides the runtime prep."""
vm._get_container_state = AsyncioMagicMock(return_value=state)
vm._start_ubridge = AsyncioMagicMock()
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_console_server = AsyncioMagicMock()
def _seed_proc(stdout=b"seedcid\n", returncode=0):
proc = MagicMock()
proc.communicate = AsyncioMagicMock(return_value=(stdout, b""))
proc.returncode = returncode
return proc
@pytest.fixture(autouse=True)
def runtime_dir(tmp_path, monkeypatch):
"""
Point the unix-socket runtime directory at a per-test temporary path so
the wiring/mount tests never create per-node directories in the real one.
"""
rt = tmp_path / "run"
monkeypatch.setenv("XDG_RUNTIME_DIR", str(rt))
return rt
def _mock_wiring(vm):
"""Mock everything _add_ubridge_connection's unix-NIO path needs."""
vm._ubridge_hypervisor = MagicMock()
def _wiring_dir(vm):
"""The per-node socket directory this VM wires through."""
return os.path.join(os.environ["XDG_RUNTIME_DIR"], "gns3", "unixio", vm.id)
# ---------------------------------------------------------------------------
# Factory selection
# ---------------------------------------------------------------------------
def test_factory_selects_iol_for_env_marker(manager):
assert manager._select_node_class(console_type="telnet",
environment="GNS3_IOL_RUNNER=1") is IOLDockerVM
def test_factory_tolerates_whitespace_and_comma(manager):
assert manager._select_node_class(console_type="telnet",
environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM
def test_factory_docker_exec_wins_over_iol_marker(manager):
assert manager._select_node_class(console_type="docker_exec",
environment="GNS3_IOL_RUNNER=1") is VendorDockerVM
def test_factory_plain_environment_is_base(manager):
assert manager._select_node_class(console_type="telnet",
environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM
def test_factory_generic_unix_knob_selects_vendor(manager):
assert manager._select_node_class(console_type="telnet",
environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM
# ---------------------------------------------------------------------------
# Knob parsing
# ---------------------------------------------------------------------------
def test_marker_forces_skip_init_and_unix_nio(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1")
assert vm._gns3_init is False
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/tmp"
assert vm._iol_memory == 2048
def test_iol_memory_knob(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096")
assert vm._iol_memory == 4096
vm = _make_vm(compute_project, manager,
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber")
assert vm._iol_memory == 2048
# ---------------------------------------------------------------------------
# create()
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_keeps_image_entrypoint(compute_project, manager):
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
vm = _make_vm(compute_project, manager)
await vm.create()
sent = mock.call_args.kwargs["data"]
# the iol-runner entrypoint runs as PID 1, untouched
assert sent["Entrypoint"] == IOL_ENTRYPOINT
assert sent["Cmd"] == []
@pytest.mark.asyncio
async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, manager):
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
vm = _make_vm(compute_project, manager, extra_volumes=[])
await vm.create()
sent = mock.call_args.kwargs["data"]
mounts = sent["HostConfig"]["Mounts"]
targets = [m["Target"] for m in mounts if m.get("Type") == "bind"]
# /config (runner config) and /tmp/run (startup-config + NVRAM)
# are forced and bound at their real in-container paths
# (skip-init retargeting); /tmp is the ephemeral runtime-dir
# bind holding the netiomux sockets
assert "/config" in targets
assert "/tmp/run" in targets
tmp_mounts = [m for m in mounts if m["Target"] == "/tmp"]
assert len(tmp_mounts) == 1
assert tmp_mounts[0]["Source"] == _wiring_dir(vm)
assert not any(t.startswith("/gns3volumes/") for t in targets)
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
assert "/config" in vol_env and "/tmp/run" in vol_env
@pytest.mark.asyncio
async def test_create_start_command_becomes_runner_flags(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.start_command = "-keep"
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
await vm.create()
sent = mock.call_args.kwargs["data"]
# start_command is the container CMD = extra iol-runner flags
assert sent["Cmd"] == ["-keep"]
# ---------------------------------------------------------------------------
# start() — runtime preparation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_writes_iol_config(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=4)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f:
config = json.load(f)
assert config["binary"] == "/binary.iol"
assert config["num-eth"] == 16 # 4 adapters, each a 4-port unit
assert config["num-serial"] == 0
assert config["local-app"] == 700
assert config["remote-app"] == 1023
assert config["memory"] == 2048
assert config["user-id"] == os.getuid()
assert config["group-id"] == os.getgid()
assert vm.status == "started"
@pytest.mark.asyncio
async def test_missing_application_id_is_an_actionable_error(compute_project, manager):
# There is no fallback id on purpose: an uncoordinated one could collide
# with a pool allocation and blackhole traffic as a MAC loop. Nodes come
# through the controller, which always allocates.
vm = _make_vm(compute_project, manager)
vm._application_id = None
vm._get_container_state = AsyncioMagicMock(return_value="stopped")
with pytest.raises(DockerError, match="no application ID"):
await vm._prepare_iol_runtime()
@pytest.mark.asyncio
async def test_allocated_application_id_used(compute_project, manager):
# The controller allocates the application ID (upper half of the id
# space, disjoint from IOU's); the node uses it verbatim.
vm = _make_vm(compute_project, manager)
vm.application_id = 701
assert vm.application_id == 701
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f:
config = json.load(f)
assert config["local-app"] == 701
@pytest.mark.asyncio
async def test_start_rewrites_config_on_adapter_change(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=4)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
vm.adapters = 8
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f:
assert json.load(f)["num-eth"] == 32 # 8 adapters × 4 ports
@pytest.mark.asyncio
async def test_start_creates_run_dir(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
assert os.path.isdir(os.path.join(vm.working_dir, "tmp", "run"))
@pytest.mark.asyncio
async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager):
vm = _make_vm(compute_project, manager)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"):
open(os.path.join(wiring_dir, name), "w").close()
os.makedirs(os.path.join(wiring_dir, "netio1000"))
run_dir = os.path.join(vm.working_dir, "tmp", "run")
os.makedirs(run_dir, exist_ok=True)
open(os.path.join(run_dir, "nvram_00001"), "w").close()
open(os.path.join(run_dir, "config"), "w").close()
_mock_start(vm, state="stopped")
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
assert glob.glob(os.path.join(wiring_dir, "s??.sock")) == []
assert glob.glob(os.path.join(wiring_dir, "c??.sock")) == []
assert not os.path.exists(os.path.join(wiring_dir, "netio1000"))
# the persistent runtime survives the cleanup
assert os.path.exists(os.path.join(run_dir, "nvram_00001"))
assert os.path.exists(os.path.join(run_dir, "config"))
@pytest.mark.asyncio
async def test_start_skips_cleanup_when_already_running(compute_project, manager):
vm = _make_vm(compute_project, manager)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
_mock_start(vm, state="running")
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
# live runner sockets must not be deleted behind its back
assert os.path.exists(os.path.join(wiring_dir, "s00.sock"))
@pytest.mark.asyncio
async def test_fix_permissions_is_noop(compute_project, manager):
vm = _make_vm(compute_project, manager)
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
await vm._fix_permissions()
mock_exec.assert_not_called()
assert vm._permissions_fixed is True
@pytest.mark.asyncio
async def test_restart_is_graceful_stop_then_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.stop = AsyncioMagicMock()
vm.start = AsyncioMagicMock()
await vm.restart()
vm.stop.assert_called_once_with(graceful=True)
vm.start.assert_called_once()
# ---------------------------------------------------------------------------
# Wiring — unix-socket NIO
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_ubridge_connection_unix_wiring(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
# the runner's receive socket must exist (created by the container)
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 0)
sent = [c for c in vm._ubridge_hypervisor.method_calls if "send" in str(c)]
flat = "\n".join(str(c) for c in sent)
local_sock = os.path.join(wiring_dir, "c00.sock")
remote_sock = os.path.join(wiring_dir, "s00.sock")
assert call.send("bridge create bridge0") in sent
assert call.send(f'bridge add_nio_unix bridge0 "{local_sock}" "{remote_sock}"') in sent
assert "add_nio_udp bridge0 4242 127.0.0.1 4343" in flat
assert "bridge start bridge0" in flat
# the TAP/namespace path must not be used at all
assert "add_nio_tap" not in flat
assert "move_to_ns" not in flat
assert "set_mac_addr" not in flat
assert vm._ethernet_adapters[0].host_ifc == local_sock
@pytest.mark.asyncio
async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_dir, "c00.sock"), "w").close()
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
await vm._add_ubridge_connection(None, 0)
assert not os.path.exists(os.path.join(wiring_dir, "c00.sock"))
@pytest.mark.asyncio
async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
with pytest.raises(DockerError):
await vm._add_ubridge_connection(None, 42)
@pytest.mark.asyncio
async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
vm._stop_ubridge = AsyncioMagicMock()
async def raise_timeout(path, timeout=60):
raise asyncio.TimeoutError()
with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation",
side_effect=raise_timeout):
with pytest.raises(DockerError) as excinfo:
await vm._add_ubridge_connection(None, 0)
# the message names the adapter and the exact wiring path
assert "adapter 0" in str(excinfo.value)
assert "s00.sock" in str(excinfo.value)
# uBridge must not survive a half-wired bridge: the retry would fail
# with "bridge already exist"
assert vm._stop_ubridge.called
@pytest.mark.asyncio
async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
await vm._add_ubridge_connection(None, 0)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert "bridge create bridge0" in flat
assert "add_nio_unix" in flat
# no link yet: no UDP NIO, no bridge start (matches base semantics)
assert "add_nio_udp" not in flat
assert "bridge start" not in flat
# ---------------------------------------------------------------------------
# IOU-style port model — 1 adapter = 4 ethernet ports
# ---------------------------------------------------------------------------
def test_adapters_are_four_port_units(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
assert vm.adapters == 2
assert len(vm._ethernet_adapters) == 2
for adapter in vm._ethernet_adapters:
assert adapter.interfaces == 4
for port_number in range(4):
assert adapter.port_exists(port_number)
assert not adapter.port_exists(4)
@pytest.mark.asyncio
async def test_wiring_addresses_ports_within_adapters(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
# adapter 1, port 2 -> flat interface index 6 (1 * 4 + 2)
open(os.path.join(wiring_dir, "s06.sock"), "w").close()
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 1, port_number=2)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert 'bridge add_nio_unix bridge1_2 ' in flat
assert f'"{os.path.join(wiring_dir, "c06.sock")}"' in flat
assert f'"{os.path.join(wiring_dir, "s06.sock")}"' in flat
assert "add_nio_udp bridge1_2 4242 127.0.0.1 4343" in flat
assert vm._ethernet_adapters[1].host_ifc == os.path.join(wiring_dir, "c06.sock")
@pytest.mark.asyncio
async def test_nio_binding_rejects_port_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
with pytest.raises(DockerError) as excinfo:
await vm.adapter_add_nio_binding(1, nio, port_number=4)
assert "Port 4" in str(excinfo.value)
with pytest.raises(DockerError):
await vm.adapter_add_nio_binding(9, nio, port_number=0)
# ---------------------------------------------------------------------------
# Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM
# ---------------------------------------------------------------------------
def test_env_unix_socket_nio_parsing(compute_project, manager):
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/var/run/socks"
# invalid dirs are rejected, keeping the default
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=yes\nGNS3_UNIX_SOCKET_DIR=../../etc")
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/tmp"
# off by default / explicit off
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec", environment="GNS3_SKIP_INIT=1")
assert vm._unix_socket_nio is False
def test_unix_socket_dir_bound_from_runtime_dir(compute_project, manager):
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
extra_volumes=[])
# the socket directory is an ephemeral per-node directory from the
# runtime dir, not a volume: writable by the (unprivileged) agent and
# short enough for AF_UNIX
binds = vm._mount_binds({"Config": {"Volumes": {}}})
socket_binds = [b for b in binds if b.get("Target") == "/tmp"]
assert len(socket_binds) == 1
assert socket_binds[0]["Type"] == "bind"
assert socket_binds[0]["Source"] == _wiring_dir(vm)
# a socket dir already covered by a persisted volume gets no extra bind
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
extra_volumes=["/tmp"])
binds = vm._mount_binds({"Config": {"Volumes": {}}})
assert not any(b.get("Source") == _wiring_dir(vm) for b in binds)
assert any(b.get("Target") == "/tmp" for b in binds)
@pytest.mark.asyncio
async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manager):
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
await vm._add_ubridge_connection(None, 0)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert f'"{os.path.join(wiring_dir, "s00.sock")}"' in flat
assert "add_nio_tap" not in flat
# ---------------------------------------------------------------------------
# Upstream link-carrier / interface-monitor vs unix-socket NIO
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_link_operations_never_send_tap_carrier(compute_project, manager):
"""
uBridge rejects set_nio_tap_carrier on a bridge without a TAP NIO
("bridge has no TAP NIO"), and unix-socket NIO bridges never have one:
every link create/update/delete on a running node would fail. The
vendor override must keep the carrier command out of all three paths.
"""
vm = _make_vm(compute_project, manager)
vm._ubridge_hypervisor = MagicMock() # truthy ubridge, like a started node
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm.adapter_add_nio_binding(0, nio, 0)
await vm.adapter_update_nio_binding(0, nio, 0)
await vm.adapter_remove_nio_binding(0, 0)
commands = [str(c.args[0]) for c in vm._ubridge_send.call_args_list]
assert any(c.startswith("bridge add_nio_udp") for c in commands)
assert not any("set_nio_tap_carrier" in c for c in commands)
@pytest.mark.asyncio
async def test_interface_monitor_disabled_under_unix_nio(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.manager.query = AsyncioMagicMock()
await vm._start_interface_monitor() # must be a no-op: no eth NICs to poll
await vm._stop_interface_monitor()
vm.manager.query.assert_not_called()
assert vm._interface_monitor_task is None
@pytest.mark.asyncio
async def test_tap_wired_vendor_keeps_carrier_and_monitor(compute_project, manager):
"""
Vendor nodes with TAP wiring (XRd, SR Linux, ...) keep the base
behavior: the guards only apply to unix-socket NIO.
"""
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec", environment="GNS3_SKIP_INIT=1")
with patch.object(DockerVM, "_set_adapter_carrier", new=AsyncioMagicMock()) as carrier, \
patch.object(DockerVM, "_start_interface_monitor", new=AsyncioMagicMock()) as monitor:
await vm._set_adapter_carrier(0, True)
await vm._start_interface_monitor()
# the base-class attribute is replaced by a plain mock (no descriptor
# binding), so super() calls arrive without self
carrier.assert_called_once_with(0, True, 0)
monitor.assert_called_once_with()
# ---------------------------------------------------------------------------
# Startup configuration
# ---------------------------------------------------------------------------
def _nvram_path(vm):
return os.path.join(vm.working_dir, "tmp", "run", f"nvram_{vm.application_id:05d}")
def _read_nvram(vm):
with open(_nvram_path(vm), "rb") as f:
return f.read()
async def _start(vm, state="stopped"):
_mock_start(vm, state=state)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
@pytest.mark.asyncio
async def test_startup_config_built_into_nvram_at_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h\ninterface Ethernet0/0\n ip address dhcp\n no shutdown"
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
content = startup.decode("utf-8")
# %h is substituted with the node name, like the IOU startup-config
assert f"hostname {vm.name}" in content
assert "ip address dhcp" in content
assert vm._startup_config_dirty is False # consumed
@pytest.mark.asyncio
async def test_plain_restart_never_reapplies_startup_config(compute_project, manager):
# IOL boots from NVRAM whenever it holds a config: a plain stop/start must
# not rebuild it or `write memory` done in the router would be lost.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
# simulate `write memory`: IOS rewrites the NVRAM behind our back
with open(_nvram_path(vm), "wb") as f:
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
await _start(vm) # plain restart, no new content delivered
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_changed_content_reapplied_at_next_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
vm.startup_config_content = "hostname RouterTwo"
assert vm._startup_config_dirty is True
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterTwo" in startup
@pytest.mark.asyncio
async def test_empty_content_never_builds_nvram(compute_project, manager):
# Web clients PUT "" for unset fields: must be ignored, not treated as a
# config change (mirrors the IOU setter's erase protection).
vm = _make_vm(compute_project, manager)
vm.startup_config_content = ""
vm.startup_config_content = None
await _start(vm)
assert not os.path.exists(_nvram_path(vm))
@pytest.mark.asyncio
async def test_reput_unchanged_content_keeps_written_nvram(compute_project, manager):
# A full PUT that carries the same content again (WebUI round-trips what
# the GET returned) must not re-apply it over `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
with open(_nvram_path(vm), "wb") as f: # simulate `write memory`
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
vm.startup_config_content = "hostname RouterOne" # unchanged re-PUT
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_running_container_keeps_pending_config(compute_project, manager):
# The NVRAM is only (re)built on a genuine start of a stopped container:
# swapping it mid-flight would be lost to the next `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm, state="running")
assert not os.path.exists(_nvram_path(vm))
assert vm._startup_config_dirty is True
@pytest.mark.asyncio
async def test_rename_rewrites_hostname_in_nvram(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h"
await _start(vm)
vm.name = "renamed-1" # IOU parity: the node boots under its new name
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname renamed-1" in startup
def test_asdict_exposes_startup_config_content(compute_project, manager):
vm = _make_vm(compute_project, manager)
assert vm.asdict()["startup_config_content"] is None
vm.startup_config_content = "hostname RouterOne"
assert vm.asdict()["startup_config_content"] == "hostname RouterOne"
def test_reparse_on_recreate_keeps_payload_state(compute_project, manager):
# create() re-runs _parse_vendor_environment on every (re)create (a stop
# removes the container, so every start recreates it): payload-delivered
# state must survive the re-parse. Losing the allocated application id
# would flip MACs to the fallback hash (colliding with the allocation
# pool); losing the startup-config would drop pending PUT edits.
vm = _make_vm(compute_project, manager)
vm.application_id = 700
vm.startup_config_content = "hostname RouterOne"
vm._parse_vendor_environment()
assert vm.application_id == 700
assert vm.startup_config_content == "hostname RouterOne"
assert vm._startup_config_dirty is True
# environment-derived state still re-derives
assert vm._gns3_init is False and vm._unix_socket_nio is True

View File

@ -121,7 +121,11 @@ class TestMarkerListener:
assert ev["node_id"] == "n1"
assert ev["link_id"] == "l1"
assert ev["filter"] == "f1"
assert ev["tag"] == "7"
# The event tag is normalized to int to match the REST schema — the
# signal echoes the decimal we installed, so str and int variants of
# the same tag must never reach consumers as different keys.
assert ev["tag"] == 7
assert isinstance(ev["tag"], int)
assert ev["ts"] == pytest.approx(1700000000.123456)
assert ev["len"] == 98
# No dir= in the signal (legacy uBridge) → undirected.
@ -163,6 +167,24 @@ class TestMarkerListener:
lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None)
assert fmgr.events[0][1]["tag"] == 42
def test_non_numeric_signal_tag_falls_back_to_registered(self):
# A corrupt/unknown signal value must not leak a str tag into the
# event stream — the registry's int wins.
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=42)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 2.0 node=n filter=f tag=oops len=20\n", None)
assert fmgr.events[0][1]["tag"] == 42
def test_no_tag_anywhere_is_none(self):
fmgr = FakeMarkerManager()
fmgr.register("p", "n", "f", "l", tag=None)
lis = MarkerListener(fmgr)
lis.connection_made(None)
lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None)
assert fmgr.events[0][1]["tag"] is None
def test_link_in_signal_overrides_registry_link(self):
# Per-link attribution (contract §3.2/§3.3): the signal's `link=` is
# authoritative and must disambiguate links sharing a node+filter —

View File

@ -418,7 +418,13 @@ def run_around_tests(monkeypatch, config, port_manager):
# avoid monitoring for new images while testing
config.settings.Server.auto_discover_images = False
monkeypatch.setattr("gns3server.utils.path.get_default_project_directory", lambda *args: os.path.join(tmppath, 'projects'))
# Resolve the projects directory from the Config singleton at call time instead of
# closing over this test's tmppath: a from-import executed anywhere while this
# patch is active (e.g. the first import of gns3server.api.server from inside a
# test body) freezes the patched object into the importing module's namespace
# forever, and a closed-over path would then point at a deleted directory in
# every later test (order-dependent FileNotFoundError in psutil.disk_usage).
monkeypatch.setattr("gns3server.utils.path.get_default_project_directory", lambda *args: Config.instance().settings.Server.projects_path)
# Force sys.platform to the original value. Because it seems not be restored correctly after each test
sys.platform = sys.original_platform

View File

@ -25,6 +25,7 @@ from unittest.mock import patch, MagicMock
from gns3server.controller.project import Project
from gns3server.controller.compute import Compute
from gns3server.api.server import app as gns3_app
from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
@ -581,8 +582,10 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp
compute._http_session = session
# allow the reconnection to be scheduled during the test
# (import gns3_app at module top: a first import from inside a test body
# would execute module-level from-imports while the autouse fixture's
# monkeypatches are active, freezing patched objects into namespaces)
monkeypatch.delattr(sys, "_called_from_test", raising=False)
from gns3server.api.server import app as gns3_app
monkeypatch.setattr(gns3_app.state, "exiting", False)
async def fake_connect():

View File

@ -462,6 +462,9 @@ async def test_install_base_configs(controller, config, tmpdir):
await controller._install_base_configs()
assert os.path.exists(str(tmpdir / 'iou_l3_base_startup-config.txt'))
# the IOL docker base config ships with the server too (referenced by the
# GNS3_IOL_STARTUP_CONFIG knob in the documented template)
assert os.path.exists(str(tmpdir / 'iol-xe-base.txt'))
# Check is the file has not been overwritten
with open(str(tmpdir / 'iou_l2_base_startup-config.txt')) as f:

View File

@ -0,0 +1,346 @@
#!/usr/bin/env python
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Unit tests for the tag-keyed aggregate replay module (controller layer):
* pcap record-header scanning (ts extraction, truncated-tail tolerance,
nanosecond-magic normalization) and raw-bytes reads for the hex view
* the tag gate (404 unknown tag, 409 while any marker captures)
* timeline assembly: cross-source merge ordered by (ts, source, frame number),
same-microsecond tiebreak, missing-pcap sources, frame-cap degradation to
per-second buckets
* window queries (inclusive bounds, empty-window success)
* the lazy frame detail: round-tripped-ts guard, and — where tshark is
installed — the isomorphic PDML → JSON mapping with a round-trip check
(element count and attribute coverage).
"""
import os
import shutil
import struct
import pytest
from types import SimpleNamespace
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
from gns3server.controller.marker_replay import (
build_timeline,
decode_frame,
query_frames,
read_frame_bytes,
scan_pcap_frames,
_format_ts,
_parse_ts,
)
pytestmark = pytest.mark.asyncio
PCAP_MAGIC_US = 0xA1B2C3D4
PCAP_MAGIC_NS = 0xA1B23C4D
def _write_pcap(path, frames, magic=PCAP_MAGIC_US, snaplen=65535):
"""frames: list of (sec, frac, payload bytes); frac is µs (or ns for the ns magic)."""
with open(path, "wb") as f:
f.write(struct.pack("<IHHiIII", magic, 2, 4, 0, 0, snaplen, 1))
for sec, frac, payload in frames:
f.write(struct.pack("<IIII", sec, frac, len(payload), len(payload)))
f.write(payload)
def _icmp_frame():
"""A minimal well-formed ICMP echo request (10.0.0.1 → 10.0.0.3)."""
def cksum(data):
if len(data) % 2:
data = data + b"\x00" # RFC 1071 odd-length padding
s = 0
for i in range(0, len(data), 2):
s += (data[i] << 8) + data[i + 1]
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
icmp = bytes([8, 0, 0, 0]) + struct.pack(">HHH", 1, 1, 0) + b"payload12"
icmp = icmp[:2] + struct.pack(">H", cksum(icmp)) + icmp[4:]
ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0,
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]))
ip = ip0[:10] + struct.pack(">H", cksum(ip0)) + ip0[12:]
return bytes.fromhex("0200000000020200000000010800") + ip + icmp
def _fake_project(tmp_path, markers, markers_dir=None):
"""markers: the flat project.markers shape ({'link/name': {..., node_id}})."""
return SimpleNamespace(markers=markers, markers_directory=str(markers_dir or tmp_path))
def _marker_entry(tag, enabled=True, node_id="node-1"):
return {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None,
"highlight_duration": None, "capture_node_id": node_id,
"direction": None, "data_link_type": "DLT_EN10MB",
"node_id": node_id}
# ---------------------------------------------------------------------------
# pcap scanning
# ---------------------------------------------------------------------------
class TestScanPcap:
async def test_scans_frames_and_truncated_tail(self, tmp_path):
pcap = tmp_path / "a.pcap"
_write_pcap(pcap, [
(1693472000, 123456, b"x" * 60),
(1693472001, 654321, b"y" * 40),
])
# Tear the final record in half: a snapshot mid-write must not raise.
data = bytearray(pcap.read_bytes())
pcap.write_bytes(data[:len(data) - 20])
frames = scan_pcap_frames(str(pcap))
assert frames == [(1693472000, 123456, 60)]
async def test_ns_magic_normalized_to_us(self, tmp_path):
pcap = tmp_path / "ns.pcap"
_write_pcap(pcap, [(1693472000, 1500000, b"z" * 10)], magic=PCAP_MAGIC_NS)
assert scan_pcap_frames(str(pcap)) == [(1693472000, 1500, 10)] # 1.5 ms in µs
async def test_read_frame_bytes_offsets(self, tmp_path):
pcap = tmp_path / "b.pcap"
_write_pcap(pcap, [
(100, 0, b"first" + b"0" * 55), # 60 bytes
(200, 0, b"second"), # 6 bytes
])
assert read_frame_bytes(str(pcap), 2) == b"second".hex()
assert read_frame_bytes(str(pcap), 1) == (b"first" + b"0" * 55).hex()
assert read_frame_bytes(str(pcap), 3) is None
async def test_ts_string_round_trip_is_exact(self):
ts = _format_ts(1693472000, 5)
assert ts == "1693472000.000005"
assert _parse_ts(ts) == 1693472000000005
assert _parse_ts(_format_ts(1693472000, 123456)) == 1693472000123456
# ---------------------------------------------------------------------------
# Tag gate + timeline
# ---------------------------------------------------------------------------
class TestGateAndTimeline:
async def test_unknown_tag_404(self, tmp_path):
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=1)})
with pytest.raises(ControllerNotFoundError):
build_timeline(project, tag=7)
async def test_gate_409_while_capturing(self, tmp_path):
project = _fake_project(tmp_path, {
"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"),
"linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"),
})
with pytest.raises(ControllerError, match="linkB"):
build_timeline(project, tag=7)
async def test_merge_orders_by_ts_with_stable_tiebreak(self, tmp_path):
# Two sources, deliberately interleaved in time, colliding on one µs.
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
(1693472000, 500000, b"a" * 60), # t1 sourceA
(1693472002, 000000, b"a" * 60), # t3 sourceA
])
_write_pcap(tmp_path / "n2_linkB_icmp.pcap", [
(1693472001, 000000, b"b" * 60), # t2 sourceB
(1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA
])
project = _fake_project(tmp_path, {
"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"),
"linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"),
})
timeline = build_timeline(project, tag=7)
assert timeline["frame_count"] == 4
assert timeline["start"] == "1693472000.500000"
assert timeline["end"] == "1693472002.000000"
assert [f["node_id"] for f in timeline["frames"]] == ["n1", "n2", "n1", "n2"]
# Same-microsecond pair keeps both frames (a ts dict key would drop one).
assert [f["ts"] for f in timeline["frames"]][2:] == ["1693472002.000000"] * 2
assert [f["frame_number"] for f in timeline["frames"]] == [1, 1, 2, 2]
assert {s["count"] for s in timeline["sources"]} == {2}
async def test_missing_pcap_is_zero_count_source(self, tmp_path):
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False)})
timeline = build_timeline(project, tag=7)
assert timeline["frame_count"] == 0
assert timeline["start"] is None and timeline["end"] is None
assert timeline["frames"] == []
assert timeline["sources"][0]["count"] == 0
async def test_over_cap_degrades_to_buckets(self, tmp_path):
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
(1693472000, 0, b"a" * 60), (1693472000, 500000, b"a" * 60),
(1693472001, 0, b"a" * 60),
])
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
timeline = build_timeline(project, tag=7, frame_cap=2)
assert timeline["truncated"] is True
assert "frames" not in timeline
assert timeline["buckets"] == [
{"ts": "1693472000.000000", "count": 2},
{"ts": "1693472001.000000", "count": 1},
]
# ---------------------------------------------------------------------------
# Window query
# ---------------------------------------------------------------------------
class TestQueryFrames:
def _project(self, tmp_path):
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
(1693472000, 0, b"a" * 60),
(1693472000, 150000, b"a" * 60),
(1693472005, 0, b"a" * 60),
])
return _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
async def test_window_inclusive_bounds(self, tmp_path):
result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000", window_ms=150)
assert [f["ts"] for f in result["frames"]] == ["1693472000.000000", "1693472000.150000"]
async def test_window_miss_is_empty_success(self, tmp_path):
result = query_frames(self._project(tmp_path), tag=7, ts="1693472001.000000", window_ms=100)
assert result == {"frames": []}
async def test_limit_applies(self, tmp_path):
result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000",
window_ms=150, limit=1)
assert len(result["frames"]) == 1
# ---------------------------------------------------------------------------
# Frame detail (tshark path)
# ---------------------------------------------------------------------------
tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed")
class TestDecodeFrame:
def _project(self, tmp_path):
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
(1693472000, 123456, _icmp_frame()),
])
return _fake_project(tmp_path, {
"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"),
})
async def test_ts_mismatch_guard_404(self, tmp_path):
project = self._project(tmp_path)
with pytest.raises(ControllerNotFoundError, match="rebuilt"):
await decode_frame(project, tag=7, ts="1.000000",
node_id="n1", link_id="linkA", marker="icmp")
async def test_unknown_source_404(self, tmp_path):
project = self._project(tmp_path)
with pytest.raises(ControllerNotFoundError):
await decode_frame(project, tag=7, ts="1693472000.123456",
node_id="nobody", link_id="linkA", marker="icmp")
async def test_decode_feeds_tshark_a_scratch_copy(self, tmp_path):
"""Hardened tshark profiles deny the project dir — tshark must read a
/tmp copy (a real copy, not a symlink) that is unlinked afterwards."""
import tempfile
from unittest.mock import patch, AsyncMock
observed = []
PDML = (b'<pdml><packet><proto name="frame" showname="Frame 1: 60 bytes">'
b'<field name="frame.len" show="60" showname="Frame Length: 60"/>'
b'</proto></packet></pdml>')
class FakeProc:
returncode = 0
async def communicate(self):
return PDML, b""
async def fake_exec(*args, **kwargs):
r_index = args.index("-r")
observed.append((args[r_index + 1], kwargs.get("env")))
return FakeProc()
project = self._project(tmp_path)
with patch("gns3server.controller.marker_replay.shutil.which", return_value="tshark"), \
patch("gns3server.controller.marker_replay._tshark_version", AsyncMock(return_value="tshark 4.6.7")), \
patch("gns3server.controller.marker_replay.asyncio.create_subprocess_exec", side_effect=fake_exec):
detail = await decode_frame(project, tag=7, ts="1693472000.123456",
node_id="n1", link_id="linkA", marker="icmp")
assert detail["field_count"] == 2 # proto + field from the canned PDML
(scratch, env), = observed
original = str(tmp_path / "n1_linkA_icmp.pcap")
assert scratch != original
assert scratch.startswith(tempfile.gettempdir()) and scratch.endswith(".pcap")
assert env["HOME"] == tempfile.gettempdir()
assert not os.path.exists(scratch) # cleaned up after the decode
@tshark_present
async def test_decode_isomorphic_mapping(self, tmp_path):
import asyncio
import xml.etree.ElementTree as ET
project = self._project(tmp_path)
detail = await decode_frame(project, tag=7, ts="1693472000.123456",
node_id="n1", link_id="linkA", marker="icmp")
assert detail["source"]["frame_number"] == 1
assert detail["hex"] == _icmp_frame().hex()
assert detail["field_count"] > 0
assert "tshark" in detail["tshark_version"].lower()
# Round-trip fidelity: node count equals the PDML element count
# (protos + fields, excluding the <packet> container itself)…
proc = await asyncio.create_subprocess_exec(
"tshark", "-r", str(tmp_path / "n1_linkA_icmp.pcap"), "-T", "pdml",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
packet = ET.fromstring(stdout).find("./packet")
xml_elements = [e for e in packet.iter() if e is not packet]
assert detail["field_count"] == len(xml_elements)
# …and every XML attribute survives verbatim as a JSON string key.
def walk(element, node):
for key, value in element.attrib.items():
assert node.get(key) == value
assert all(isinstance(v, str) for k, v in node.items() if k != "children")
for child, child_node in zip(element, node["children"]):
walk(child, child_node)
for element, node in zip(packet, detail["tree"]):
walk(element, node)
# Values stay strings (no numeric re-typing).
ttl = next(
f for p in detail["tree"] if p.get("name") == "ip"
for f in p["children"] if f.get("name") == "ip.ttl"
)
assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64"

View File

@ -46,6 +46,85 @@ def node(compute, project):
return node
def test_docker_iol_ports_grouped_in_four_port_units(compute, project):
"""
IOL docker nodes (GNS3_IOL_RUNNER) model adapters as 4-port units: ports
are Ethernet0/0-3, Ethernet1/0-3, … addressed (adapter, port 0-3), like
the native IOU node type. Plain docker nodes keep the flat eth naming.
"""
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"adapters": 2, "environment": "GNS3_IOL_RUNNER=1"})
ports = node.ports
assert [p.asdict()["name"] for p in ports] == [
"Ethernet0/0", "Ethernet0/1", "Ethernet0/2", "Ethernet0/3",
"Ethernet1/0", "Ethernet1/1", "Ethernet1/2", "Ethernet1/3",
]
assert (ports[5].adapter_number, ports[5].port_number) == (1, 1)
assert ports[5].short_name == "e1/1"
node = Node(project, compute, "web",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"adapters": 2})
assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"]
def test_docker_iol_startup_config_materialized_once(compute, project, monkeypatch):
"""
The GNS3_IOL_STARTUP_CONFIG environment knob references a config file in
the controller's configs directory: like the IOU startup_config mapping,
its content is sent to the compute exactly once (the knob is consumed) —
afterwards the node's configuration lives in its NVRAM on the compute.
"""
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: "hostname %h\n")
data = node._node_data()
assert data["startup_config_content"] == "hostname %h\n"
assert data["environment"] == "GNS3_IOL_RUNNER=1"
assert "GNS3_IOL_STARTUP_CONFIG" not in node.properties["environment"]
# sent only once: a later sync (e.g. project reload) carries neither
data = node._node_data()
assert "startup_config_content" not in data
def test_docker_iol_startup_config_missing_file_keeps_knob(compute, project, monkeypatch):
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: None)
data = node._node_data()
assert "startup_config_content" not in data
# the knob is kept so the config still applies once the file shows up
assert "GNS3_IOL_STARTUP_CONFIG" in node.properties["environment"]
def test_extract_iol_startup_config_knob_variants():
from gns3server.controller.node import _extract_iol_startup_config_knob
assert _extract_iol_startup_config_knob(None) == (None, None)
assert _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1") == (None, "GNS3_IOL_RUNNER=1")
# whitespace and trailing commas are tolerated, like the compute env parsing
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1,\n GNS3_IOL_STARTUP_CONFIG=cfg.txt ,")
assert filename == "cfg.txt"
assert environment == "GNS3_IOL_RUNNER=1,"
# an empty value is treated as absent
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_STARTUP_CONFIG=")
assert filename is None
def test_name(compute, project):
"""
If node use a name template generate names

View File

@ -296,6 +296,47 @@ async def test_add_node_iou(controller):
assert node3.properties["application_id"] == 3
@pytest.mark.asyncio
async def test_add_node_iol_docker(controller):
"""
IOL Docker nodes (GNS3_IOL_RUNNER marker) get an application ID from the
upper half of the id space, disjoint from IOU's lower half
"""
compute = MagicMock()
compute.id = "local"
project = await controller.add_project(project_id=str(uuid.uuid4()), name="test1")
project.emit_notification = MagicMock()
response = MagicMock()
compute.post = AsyncioMagicMock(return_value=response)
# template shape: environment as a top-level kwarg
node1 = await project.add_node(
compute, "iol1", None, node_type="docker", image="iol-xe/iol-xe:17-18-02", environment="GNS3_IOL_RUNNER=1"
)
# raw API shape: environment nested in properties
node2 = await project.add_node(
compute,
"iol2",
None,
node_type="docker",
properties={"image": "iol-xe/iol-xe:17-18-02", "environment": "GNS3_IOL_RUNNER=1"},
)
# plain docker nodes are left alone
node3 = await project.add_node(
compute, "web", None, node_type="docker", image="nginx", environment="FOO=1", adapters=1
)
assert node1.properties["application_id"] == 512
assert node2.properties["application_id"] == 513
assert "application_id" not in node3.properties
# IOU keeps its own pool: a subsequent IOU node still gets the lower half
node4 = await project.add_node(compute, "iou1", None, node_type="iou")
assert node4.properties["application_id"] == 1
@pytest.mark.asyncio
async def test_add_node_iou_with_multiple_projects(controller):
"""