From 9262dcfaef94b3cd99dbfa1022e07edd252ded73 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 1 Sep 2026 01:34:19 +0800 Subject: [PATCH 01/29] fix: normalize marker.match event tag to int to match the REST schema The WS event carried the tag as str (verbatim from the MARK signal) when present and as int when falling back to the registry, so one tag reached consumers as two different values. Parse the signal's decimal (the exact value we installed via 'mark tag ') and keep the registered int on malformed input; None only when neither side carries one. --- gns3server/compute/marker/marker_listener.py | 15 ++++++++++-- tests/compute/marker/test_marker_manager.py | 24 +++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index 40cdd97b6..a30769c1e 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -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 tag `, 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); diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index da495ae92..5917870d7 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -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 — From c646d6df5a297d089fd8fdcf65fd100f5c0a7e8d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 1 Sep 2026 01:34:33 +0800 Subject: [PATCH 02/29] feat: add tag-keyed aggregate replay over paused markers' pcaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markers on different links sharing a tag form one distributed capture session. Once every marker under the tag is paused (409 otherwise), three read-only endpoints replay it: - GET .../markers/tags/{tag}/replay/range merges the per-marker pcaps by scanning 16-byte record headers only (no tshark) into a timestamp- ordered frame list (per-second buckets above a 5000-frame cap) - GET .../replay/frames?ts=&window_ms= returns frames in [T, T+window]; an empty window is a normal empty array - GET .../replay/frame/detail lazily decodes one frame the user opened: raw bytes for the hex view read straight from the pcap, protocol tree from 'tshark -T pdml' mapped isomorphically to JSON (every attribute survives, values stay strings). tshark reads a /tmp scratch copy with a scratch HOME — hardened profiles deny it the project directory. Sort key is (ts, source file, frame number): ts is not unique across a merge. The ts parameter round-trips as the exact string from the frame list. Round-trip tests pin the PDML→JSON fidelity (element count and attribute coverage). --- docs/README.md | 5 +- docs/features/marker-tag-replay.md | 247 ++++++++++ gns3server/api/routes/controller/projects.py | 83 ++++ gns3server/controller/marker_replay.py | 428 ++++++++++++++++++ gns3server/controller/project.py | 11 + .../routes/controller/test_marker_replay.py | 190 ++++++++ tests/controller/test_marker_replay.py | 346 ++++++++++++++ 7 files changed, 1309 insertions(+), 1 deletion(-) create mode 100644 docs/features/marker-tag-replay.md create mode 100644 gns3server/controller/marker_replay.py create mode 100644 tests/api/routes/controller/test_marker_replay.py create mode 100644 tests/controller/test_marker_replay.py diff --git a/docs/README.md b/docs/README.md index c347479cc..bc3698989 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,6 +75,9 @@ 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. @@ -127,4 +130,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_ diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md new file mode 100644 index 000000000..d8439fed0 --- /dev/null +++ b/docs/features/marker-tag-replay.md @@ -0,0 +1,247 @@ + + +> 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
(all markers under tag paused?)"] + SCAN["Timeline scan
(pcap record headers)"] + MAP["PDML → JSON
isomorphic mapper"] + end + + FS[("markers dir
{node}_{link}_{marker}.pcap")] + TS["tshark -T pdml
(one frame at a time)"] + TMP["/tmp scratch copy
(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-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 | + +- **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. +- 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 ``/`` 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). diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index f9012a8ef..0595bde7b 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -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) # --------------------------------------------------------------------------- diff --git a/gns3server/controller/marker_replay.py b/gns3server/controller/marker_replay.py new file mode 100644 index 000000000..993bb31f8 --- /dev/null +++ b/gns3server/controller/marker_replay.py @@ -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 . + +""" +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-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(" 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, + } diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 01506a734..7f1fad683 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -504,6 +504,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): """ diff --git a/tests/api/routes/controller/test_marker_replay.py b/tests/api/routes/controller/test_marker_replay.py new file mode 100644 index 000000000..9a19e291d --- /dev/null +++ b/tests/api/routes/controller/test_marker_replay.py @@ -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-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" diff --git a/tests/controller/test_marker_replay.py b/tests/controller/test_marker_replay.py new file mode 100644 index 000000000..e33fb9a8c --- /dev/null +++ b/tests/controller/test_marker_replay.py @@ -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 . + +""" +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("> 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'' + b'' + b'') + + 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 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" From 49c8a721036c23c0aaf3212841fea671d34c59fc Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 4 Sep 2026 14:07:35 +0800 Subject: [PATCH 03/29] fix: make the patched get_default_project_directory order-safe in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product module first-imported while run_around_tests' autouse monkeypatch of gns3server.utils.path.get_default_project_directory is active (e.g. 'from gns3server.api.server import app' inside a test body) binds the patched lambda into its own namespace forever: module-level from-imports capture the object by value. Once the patch is reverted, that module keeps calling the stale lambda, which closed over the very test's tmppath — a directory deleted at that test's teardown. Every later test hitting psutil.disk_usage(get_default_project_directory()) then fails with FileNotFoundError, but only when the importing test runs before the API test files (full-suite collection order hides it). The replacement now resolves Config.instance().settings at call time instead of closing over the tmppath, so a frozen reference stays correct; the test that triggered this imports gns3_app at module top so no product import ever happens inside a patched window. Also pins pytest-random-order (inert without --random-order) and documents the order-independence rules for new tests in the gns3-api-test-writing skill, including the known-red legacy files whose tests share rows sequentially. --- .claude/skills/gns3-api-test-writing/SKILL.md | 15 +++++++++++++++ dev-requirements.txt | 3 ++- tests/conftest.py | 8 +++++++- tests/controller/test_compute.py | 5 ++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.claude/skills/gns3-api-test-writing/SKILL.md b/.claude/skills/gns3-api-test-writing/SKILL.md index 6149bf21f..991df03f2 100644 --- a/.claude/skills/gns3-api-test-writing/SKILL.md +++ b/.claude/skills/gns3-api-test-writing/SKILL.md @@ -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/.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=` reproduces it), then bisect to the polluting test. diff --git a/dev-requirements.txt b/dev-requirements.txt index 55aac6eaa..80d7da3b4 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -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 \ No newline at end of file +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) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index f67b04839..b0994ebb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 58ba606c4..671b8e7a0 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -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(): From d4f2a429f409e9060255afe538c5b1948105e5c7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 2 Sep 2026 22:33:23 +0800 Subject: [PATCH 04/29] docs: document that restarting capture nodes truncates marker pcaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker 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 pcap_dump_open truncates. Server restart + project reopen without starting nodes is safe until a uBridge comes up (verified live); Docker nodes restart effectively on server restart via stale-container cleanup. Recorded in the tag-gate table, a lifecycle note, and a pcap_dump_open_append follow-up. --- docs/features/marker-tag-replay.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md index d8439fed0..4d148a5c8 100644 --- a/docs/features/marker-tag-replay.md +++ b/docs/features/marker-tag-replay.md @@ -103,11 +103,19 @@ markers at all → 404. | 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. @@ -244,4 +252,6 @@ the pcap. 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). + 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. From e272ad915b8f5d3999ffb7f1a3b7a58368a51bd1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 12:16:12 +0800 Subject: [PATCH 05/29] feat: add IOL (iol-runner) Docker node support Run Cisco CML containerized IOL images (e.g. iol-xe/iol-xe:17-18-02, driven by virl.lab/cmd/iol-runner) as GNS3 Docker router nodes: - VendorDockerVM: generic GNS3_UNIX_SOCKET_NIO/GNS3_UNIX_SOCKET_DIR knobs wiring adapters through AF_UNIX datagram socket pairs (add_nio_unix cNN.sock sNN.sock) instead of TAP + move_to_ns; node creation fails if the socket dir is not a persisted volume. - IOLDockerVM (GNS3_IOL_RUNNER=1): forces skip-init + unix-socket NIO + /config,/tmp volumes, writes iol-config.json on every start (num-eth tracks adapters, runner drops to the server uid/gid so the sockets are reachable), pre-creates tmp/run, cleans stale sockets after unclean kills, and makes reload a graceful stop + full start (NVRAM flush + rewiring). Console stays telnet on PID 1 stdio. - Manager selects the node class from console_type or GNS3_* environment markers (create-time, like console_type). - Image-free tests (25) and feature documentation. --- docs/README.md | 3 + docs/features/iol-runner-docker.md | 118 +++++ gns3server/compute/docker/__init__.py | 15 +- gns3server/compute/docker/iol_docker_vm.py | 189 +++++++ gns3server/compute/docker/vendor_docker_vm.py | 106 ++++ tests/compute/docker/test_iol_docker_vm.py | 487 ++++++++++++++++++ 6 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 docs/features/iol-runner-docker.md create mode 100644 gns3server/compute/docker/iol_docker_vm.py create mode 100644 tests/compute/docker/test_iol_docker_vm.py diff --git a/docs/README.md b/docs/README.md index c347479cc..50c432faf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,6 +81,9 @@ Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PI ### 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). + --- ## GNS3 AI Copilot (`gns3-copilot/`) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md new file mode 100644 index 000000000..10bca7685 --- /dev/null +++ b/docs/features/iol-runner-docker.md @@ -0,0 +1,118 @@ + + +> 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
(IOS-XE 17.18.02)"] + NETIOMUX["netiomux"] + SOCKETS["/tmp/s00.sock (recv)
/tmp/c00.sock (send-to path)
… one pair per interface"] + RUNNER -->|"spawn -e/-s/-m + app id"| IOL + IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS + end + subgraph Host + UBRIDGE["uBridge bridgeN
add_nio_unix c00.sock s00.sock
+ add_nio_udp (topology)"] + VOL["project-files/docker/<node>/tmp"] + end + SOCKETS <-->|"raw Ethernet frames
(bind volume dir)"| VOL <--> UBRIDGE +``` + +* **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 `/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. Because `/tmp` is a + persisted volume (bind-mounted from the node directory), uBridge can bind + `cNN.sock` and send to `sNN.sock` on the host. +* **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/` inside the volume holds the NETMAP, the + startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`), so + the router's configuration survives stop/start and container recreation. + The generated config maps the runner to the server's uid/gid + (`user-id`/`group-id`), which is also what makes the `/tmp` sockets + reachable by uBridge and all volume files owned by the server user (no + permission-fix pass needed). + +## Template + +```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": 4, + "console_type": "telnet", + "environment": "GNS3_IOL_RUNNER=1", + "extra_volumes": ["/config", "/tmp"], + "memory": 2560 +} +``` + +`/config` and `/tmp` are auto-added even if omitted; listing them keeps the +template self-documenting. + +## Server mechanisms + +| Mechanism | Where | What it does | +|---|---|---| +| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /c{N:02d}.sock /s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. Fails node creation if the socket dir is not a persisted volume. | +| `GNS3_UNIX_SOCKET_DIR=` | `VendorDockerVM` | 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 two volumes; on every start writes `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/tmp/run/` (the IOL process dies without it) and removes stale `s/c??.sock` + `netio*` left by an unclean kill (`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_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 + +* **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 (`aabb.cc00.0XY0`). +* **Adapters**: change the adapter count while the node is stopped; the + config is regenerated on the next start and the runner creates the + matching socket set (IOL granularity is 4 ports per unit). +* **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//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. diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index ed37ca626..f4027ea59 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -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): diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py new file mode 100644 index 000000000..1c03bc4af --- /dev/null +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -0,0 +1,189 @@ +# +# 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 . + +""" +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 ``/tmp`` +(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by +the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the +netio bus directory is private to the node's ``/tmp`` volume, the application +IDs are fixed constants with no cross-node collisions. + +This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. +""" + +import glob +import json +import logging +import os +import shutil + +from gns3server.compute.docker.docker_error import DockerHttp404Error +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM + +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=`` — 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`` persistent volumes, so a + template containing only ``GNS3_IOL_RUNNER=1`` is fully configured. + """ + + _IOL_CONFIG_DIR = "/config" + _IOL_RUN_DIR = "/tmp/run" + + 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 + + def _persistent_volume_list(self, image_info, include_network_config=True): + """ + Override: the runner requires ``/config`` (its config file, generated + below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted + volumes — /tmp because uBridge must reach the sockets on the host. + 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, "/tmp"): + 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. + + * ``/tmp/run/`` must exist or the IOL process dies at + boot (the runner writes NETMAP there but does not create it). + * ``/config/iol-config.json`` is rewritten on every + start so adapter-count and memory changes take effect. + * Ephemeral sockets left by a previous (possibly SIGKILLed) run are + removed — the runner rebinds them on boot and would fail on a + stale file. ``tmp/run`` is never touched: startup-config and NVRAM + persist there. + """ + + try: + state = await self._get_container_state() + except DockerHttp404Error: + state = "stopped" + + os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True) + self._write_iol_config() + + if state == "running": + # Idempotent start of a live node: the sockets belong to the + # running runner; base start() will return early. + return + + tmp_dir = os.path.join(self.working_dir, "tmp") + for pattern in ("s??.sock", "c??.sock"): + for stale in glob.glob(os.path.join(tmp_dir, pattern)): + try: + os.unlink(stale) + except OSError: + pass + for netio_dir in glob.glob(os.path.join(tmp_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 inside the volumes is owned by the server user (which is also + what makes the /tmp sockets reachable by uBridge). + """ + + config = { + "binary": "/binary.iol", + "memory": self._iol_memory, + "num-eth": self.adapters, + "num-serial": 0, # GNS3 docker adapters are ethernet-only + "local-app": 1, + "remote-app": 2, + "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 diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 6089663f2..8b8f5fb52 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -33,6 +33,7 @@ import logging import os import shutil +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 +65,20 @@ 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 a persisted volume + directory — 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``. 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=`` — in-container directory holding the + socket files (default ``/tmp``). Must be a persisted volume + (extra_volumes) so uBridge can reach the sockets on the host; node + creation fails with an actionable error otherwise. """ def __init__(self, *args, **kwargs): @@ -86,6 +101,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 +116,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 +165,17 @@ 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 + ): + raise DockerError( + f"GNS3_UNIX_SOCKET_NIO requires socket directory '{self._unix_socket_dir}' of " + f"container '{self._name}' to be a persisted volume (add it to extra_volumes) " + f"so uBridge can reach the sockets on the host" + ) if self._gns3_init: return binds binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] @@ -284,6 +318,78 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" + @property + def _unix_socket_host_dir(self): + """ + Host-side path of the in-container unix-socket directory: the bind + source of the persisted volume it lives in. + """ + return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/")) + + async def _add_ubridge_connection(self, nio, adapter_number): + """ + Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter through the + image's AF_UNIX datagram socket pair (raw Ethernet frames) instead of + a TAP interface moved into the container's network namespace. + + Per adapter N the image's network agent is expected to create, inside + GNS3_UNIX_SOCKET_DIR (a persisted volume, enforced by _mount_binds): + + * ``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. 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) + + 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 + ) + ) + + bridge_name = f"bridge{adapter_number}" + await self._ubridge_send(f"bridge create {bridge_name}") + self._bridges.add(bridge_name) + + host_dir = self._unix_socket_host_dir + local_sock = os.path.join(host_dir, f"c{adapter_number:02d}.sock") + remote_sock = os.path.join(host_dir, f"s{adapter_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"'{self._unix_socket_dir}' is bind-mounted from the node directory." + ) + + await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"') + adapter.host_ifc = local_sock # bookkeeping / removal logging only + log.debug( + "Adapter %d of container '%s' wired via unix sockets %s <-> %s", + adapter_number, self._name, local_sock, remote_sock, + ) + + if nio: + await self._connect_nio(adapter_number, nio) + def _cleanup_console_resources(self): """ Override: close the docker-exec pty socket, if any, so the next diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py new file mode 100644 index 000000000..8d12307ef --- /dev/null +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -0,0 +1,487 @@ +# +# 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 . + +""" +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 + + +# --------------------------------------------------------------------------- +# 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" + 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 + + +# --------------------------------------------------------------------------- +# 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_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"] + targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + # both volumes are forced and bound at their real in-container + # paths (skip-init retargeting), reachable by uBridge on the host + assert "/config" in targets + assert "/tmp" in targets + 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" 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"] == 4 + assert config["num-serial"] == 0 + assert config["local-app"] == 1 + assert config["remote-app"] == 2 + 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_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"] == 8 + + +@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) + tmp_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True) + for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"): + open(os.path.join(tmp_dir, name), "w").close() + os.makedirs(os.path.join(tmp_dir, "netio1000")) + open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close() + open(os.path.join(tmp_dir, "run", "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(tmp_dir, "s??.sock")) == [] + assert glob.glob(os.path.join(tmp_dir, "c??.sock")) == [] + assert not os.path.exists(os.path.join(tmp_dir, "netio1000")) + # the persistent runtime survives the cleanup + assert os.path.exists(os.path.join(tmp_dir, "run", "nvram_00001")) + assert os.path.exists(os.path.join(tmp_dir, "run", "config")) + + +@pytest.mark.asyncio +async def test_start_skips_cleanup_when_already_running(compute_project, manager): + + vm = _make_vm(compute_project, manager) + tmp_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + open(os.path.join(tmp_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(tmp_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) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + # the runner's receive socket must exist (created by the container) + open(os.path.join(host_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) + assert call.send("bridge create bridge0") in sent + assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(host_dir, "c00.sock")}" ' + f'"{os.path.join(host_dir, "s00.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 == os.path.join(host_dir, "c00.sock") + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_dir, "c00.sock"), "w").close() + open(os.path.join(host_dir, "s00.sock"), "w").close() + + await vm._add_ubridge_connection(None, 0) + assert not os.path.exists(os.path.join(host_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) + vm._ubridge_hypervisor = MagicMock() + 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) + vm._ubridge_hypervisor = MagicMock() + + 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 socket directory + assert "adapter 0" in str(excinfo.value) + assert "/tmp" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_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 + + +# --------------------------------------------------------------------------- +# 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 + + +@pytest.mark.asyncio +async def test_unix_socket_dir_must_be_a_volume(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=[]) + with pytest.raises(DockerError) as excinfo: + vm._mount_binds({"Config": {"Volumes": {}}}) + assert "extra_volumes" in str(excinfo.value) + + 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 any(b["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") + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "var", "run", "socks") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_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(host_dir, "s00.sock")}"' in flat + assert "add_nio_tap" not in flat From 42e26717e6361b721e17bc22dfc4d9fa930fdd54 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 12:21:17 +0800 Subject: [PATCH 06/29] fix: alias long unix-socket NIO paths through the runtime dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AF_UNIX sun_path caps at 107 bytes and node volume directories (projects//project-files/docker//tmp) exceed it — uBridge rejects the NIO with 'invalid file path size'. When the wiring path is too long, create /gns3/unixio- as a symlink to the real volume directory (same trick as the uBridge control socket) and reference the alias in the uBridge commands; the symlink is removed when uBridge stops. Found during E2E with iol-xe:17-18-02. --- gns3server/compute/docker/vendor_docker_vm.py | 51 ++++++++++++++++++- tests/compute/docker/test_iol_docker_vm.py | 41 +++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 8b8f5fb52..4eba1a705 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -32,6 +32,7 @@ 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 @@ -103,6 +104,7 @@ class VendorDockerVM(DockerVM): self._stop_timeout = 60 self._unix_socket_nio = False self._unix_socket_dir = "/tmp" + self._unix_socket_aliases = set() if self._environment: for _line in self._environment.splitlines(): _line = _line.strip().rstrip(",") @@ -326,6 +328,50 @@ class VendorDockerVM(DockerVM): """ return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/")) + def _unix_socket_wiring_dir(self): + """ + Directory to reference in the uBridge unix-NIO commands. + + AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL), and a + node volume directory (projects//project-files/docker//tmp) + alone is ~120 — uBridge would reject the NIO with "invalid file path + size". Long directories are therefore aliased through a symlink in the + runtime directory (same trick as the uBridge control socket). uBridge + resolves the symlink; the socket files themselves always live in the + persisted volume, and the container-side /tmp paths are unaffected. + """ + + host_dir = self._unix_socket_host_dir + if len(host_dir) + len("/c00.sock") <= 107: + return host_dir + + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + alias = os.path.join(runtime_dir, "gns3", f"unixio-{self.id}") + try: + os.makedirs(os.path.dirname(alias), mode=0o700, exist_ok=True) + if os.path.lexists(alias): + os.unlink(alias) + os.symlink(host_dir, alias) + except OSError as e: + raise DockerError( + f"Could not create unix-socket alias '{alias}' for container '{self._name}': {e}" + ) + self._unix_socket_aliases.add(alias) + return alias + + async def _stop_ubridge(self): + """ + Override: also drop the short-path aliases created for the unix-socket + NIO (the sockets themselves are removed by the image's agent / the + next start's cleanup). + """ + + await super()._stop_ubridge() + for alias in self._unix_socket_aliases: + with contextlib.suppress(OSError): + os.unlink(alias) + self._unix_socket_aliases.clear() + async def _add_ubridge_connection(self, nio, adapter_number): """ Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter through the @@ -361,8 +407,9 @@ class VendorDockerVM(DockerVM): self._bridges.add(bridge_name) host_dir = self._unix_socket_host_dir - local_sock = os.path.join(host_dir, f"c{adapter_number:02d}.sock") - remote_sock = os.path.join(host_dir, f"s{adapter_number:02d}.sock") + wiring_dir = self._unix_socket_wiring_dir() + local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") + remote_sock = os.path.join(wiring_dir, f"s{adapter_number:02d}.sock") # A c-socket left over from a previous ubridge run would fail its bind. with contextlib.suppress(OSError): diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 8d12307ef..5986e4dd0 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -355,16 +355,17 @@ async def test_add_ubridge_connection_unix_wiring(compute_project, manager): sent = [c for c in vm._ubridge_hypervisor.method_calls if "send" in str(c)] flat = "\n".join(str(c) for c in sent) + wiring_dir = vm._unix_socket_wiring_dir() # may alias host_dir when long assert call.send("bridge create bridge0") in sent - assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(host_dir, "c00.sock")}" ' - f'"{os.path.join(host_dir, "s00.sock")}"') in sent + assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(wiring_dir, "c00.sock")}" ' + f'"{os.path.join(wiring_dir, "s00.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 == os.path.join(host_dir, "c00.sock") + assert vm._ethernet_adapters[0].host_ifc == os.path.join(wiring_dir, "c00.sock") @pytest.mark.asyncio @@ -483,5 +484,37 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage 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(host_dir, "s00.sock")}"' in flat + assert f'"{os.path.join(vm._unix_socket_wiring_dir(), "s00.sock")}"' in flat assert "add_nio_tap" not in flat + + +@pytest.mark.asyncio +async def test_long_socket_path_aliased_through_runtime_dir(compute_project, manager, monkeypatch, tmp_path): + """ + Node volume paths exceed sun_path's 107 bytes; the wiring must alias them + through a short symlink so uBridge accepts the NIO (and remove the alias + when uBridge stops). + """ + + from unittest.mock import PropertyMock + + long_dir = str(tmp_path / "a-very-long-project-directory-name" / ("x" * 40) / "tmp") + os.makedirs(long_dir, exist_ok=True) + open(os.path.join(long_dir, "s00.sock"), "w").close() + runtime_dir = tmp_path / "run" + monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir)) + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + with patch.object(type(vm), "_unix_socket_host_dir", new_callable=PropertyMock, + return_value=long_dir): + await vm._add_ubridge_connection(None, 0) + alias = os.path.join(str(runtime_dir), "gns3", f"unixio-{vm.id}") + assert os.path.islink(alias) and os.readlink(alias) == long_dir + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert f'"{os.path.join(alias, "s00.sock")}"' in flat + assert long_dir not in flat # uBridge only ever sees the short path + + vm._ubridge_hypervisor.stop = AsyncioMagicMock() + await vm._stop_ubridge() + assert not os.path.lexists(alias) From 165f271bb83ae38ceb083357c9f09854b463c4e1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 13:25:20 +0800 Subject: [PATCH 07/29] refactor: reach unix-socket NIOs through the container root in /proc The unix-socket NIO no longer requires the socket directory to be a persisted volume: uBridge references the sockets through /proc//root/..., which is always well under the 107-byte sun_path cap (a project directory path alone exceeds it) and leaves the sockets ephemeral in the container's own filesystem. This replaces the runtime-dir symlink alias and its cleanup, and the mount-time volume enforcement. IOLDockerVM now persists only /tmp/run (the IOL working directory: startup-config + NVRAM) as a nested bind instead of the whole /tmp; stale socket cleanup is gone with it, as containers are recreated on every start. --- docs/features/iol-runner-docker.md | 49 ++--- gns3server/compute/docker/iol_docker_vm.py | 60 ++---- gns3server/compute/docker/vendor_docker_vm.py | 101 +++------- tests/compute/docker/test_iol_docker_vm.py | 177 ++++++------------ 4 files changed, 135 insertions(+), 252 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 10bca7685..c9b02841c 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -36,10 +36,11 @@ graph LR IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS end subgraph Host - UBRIDGE["uBridge bridgeN
add_nio_unix c00.sock s00.sock
+ add_nio_udp (topology)"] - VOL["project-files/docker/<node>/tmp"] + UBRIDGE["uBridge bridgeN
add_nio_unix /proc/PID/root/tmp/cNN.sock …
+ add_nio_udp (topology)"] + VOL["project-files/docker/<node>/config
(+ tmp/run nested bind)"] end - SOCKETS <-->|"raw Ethernet frames
(bind volume dir)"| VOL <--> UBRIDGE + SOCKETS <-->|"raw Ethernet frames via
the container root in /proc"| UBRIDGE + UBRIDGE -.->|"iol-config.json +
persistent /tmp/run"| VOL ``` * **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio` @@ -47,21 +48,27 @@ graph LR `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 `/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. Because `/tmp` is a - persisted volume (bind-mounted from the node directory), uBridge can bind - `cNN.sock` and send to `sNN.sock` on the host. + 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. + uBridge reaches both through the container's root in `/proc` + (`/proc//root/tmp/…`) — a short path (AF_UNIX names are + capped at 107 bytes, which a project directory path alone would exceed) + that requires no volume mount and leaves the sockets ephemeral in the + container, fresh in every container GNS3 creates. * **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/` inside the volume holds the NETMAP, the - startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`), so - the router's configuration survives stop/start and container recreation. - The generated config maps the runner to the server's uid/gid - (`user-id`/`group-id`), which is also what makes the `/tmp` sockets - reachable by uBridge and all volume files owned by the server user (no +* **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`, so the router's configuration + survives stop/start and container recreation while everything else in + `/tmp` stays 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 — which is also what lets an unprivileged uBridge + traverse `/proc//root` to reach the container's sockets (no permission-fix pass needed). ## Template @@ -76,21 +83,21 @@ graph LR "adapters": 4, "console_type": "telnet", "environment": "GNS3_IOL_RUNNER=1", - "extra_volumes": ["/config", "/tmp"], + "extra_volumes": ["/config"], "memory": 2560 } ``` -`/config` and `/tmp` are auto-added even if omitted; listing them keeps the -template self-documenting. +`/config` and `/tmp/run` are auto-added even if omitted; listing `/config` +keeps the template self-documenting. ## Server mechanisms | Mechanism | Where | What it does | |---|---|---| -| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /c{N:02d}.sock /s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. Fails node creation if the socket dir is not a persisted volume. | -| `GNS3_UNIX_SOCKET_DIR=` | `VendorDockerVM` | 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 two volumes; on every start writes `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/tmp/run/` (the IOL process dies without it) and removes stale `s/c??.sock` + `netio*` left by an unclean kill (`tmp/run` is never touched). | +| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /proc//root/c{N:02d}.sock /proc//root/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched, no volume required. | +| `GNS3_UNIX_SOCKET_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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048) and creates `/tmp/run/` (the IOL process dies without it). | | `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_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 1c03bc4af..cc3302b81 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -25,22 +25,20 @@ 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 ``/tmp`` -(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by -the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the -netio bus directory is private to the node's ``/tmp`` volume, the application -IDs are fixed constants with no cross-node collisions. +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 the container's root in /proc). Because the +netio bus directory is private to the container's /tmp, the application IDs +are fixed constants with no cross-node collisions. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ -import glob import json import logging import os -import shutil -from gns3server.compute.docker.docker_error import DockerHttp404Error from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM log = logging.getLogger(__name__) @@ -58,7 +56,7 @@ class IOLDockerVM(VendorDockerVM): OOM-killer will fire. The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring, - and auto-adds the ``/config`` and ``/tmp`` persistent volumes, so a + and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a template containing only ``GNS3_IOL_RUNNER=1`` is fully configured. """ @@ -90,13 +88,14 @@ class IOLDockerVM(VendorDockerVM): def _persistent_volume_list(self, image_info, include_network_config=True): """ Override: the runner requires ``/config`` (its config file, generated - below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted - volumes — /tmp because uBridge must reach the sockets on the host. - Auto-add both so a minimal template cannot be misconfigured. + 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, "/tmp"): + 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 @@ -119,47 +118,28 @@ class IOLDockerVM(VendorDockerVM): async def _prepare_iol_runtime(self): """ - Regenerate the node's runtime files before the container starts. + Regenerate the node's runtime files before the container starts: * ``/tmp/run/`` must exist or the IOL process dies at boot (the runner writes NETMAP there but does not create it). * ``/config/iol-config.json`` is rewritten on every start so adapter-count and memory changes take effect. - * Ephemeral sockets left by a previous (possibly SIGKILLed) run are - removed — the runner rebinds them on boot and would fail on a - stale file. ``tmp/run`` is never touched: startup-config and NVRAM - persist there. - """ - try: - state = await self._get_container_state() - except DockerHttp404Error: - state = "stopped" + The netiomux sockets and the netio bus directory need no cleanup: + they live in the container's own /tmp, which is fresh in every + container GNS3 creates (containers are recreated on each start). + """ os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True) self._write_iol_config() - if state == "running": - # Idempotent start of a live node: the sockets belong to the - # running runner; base start() will return early. - return - - tmp_dir = os.path.join(self.working_dir, "tmp") - for pattern in ("s??.sock", "c??.sock"): - for stale in glob.glob(os.path.join(tmp_dir, pattern)): - try: - os.unlink(stale) - except OSError: - pass - for netio_dir in glob.glob(os.path.join(tmp_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 inside the volumes is owned by the server user (which is also - what makes the /tmp sockets reachable by uBridge). + creates is owned by the server user — which is also what lets an + unprivileged uBridge traverse /proc//root to reach the + container's sockets. """ config = { diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 4eba1a705..7e4abfd4d 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -32,7 +32,6 @@ 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 @@ -70,16 +69,16 @@ class VendorDockerVM(DockerVM): 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 a persisted volume - directory — 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``. 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). + (raw Ethernet frames, one datagram per frame) inside the container — + e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge reaches the + sockets through the container's root in ``/proc`` (see + ``_unix_socket_wiring_dir``), so no volume mount is required; it binds + ``c{N:02d}.sock`` (its receive side) and sends to ``s{N:02d}.sock``. + 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=`` — in-container directory holding the - socket files (default ``/tmp``). Must be a persisted volume - (extra_volumes) so uBridge can reach the sockets on the host; node - creation fails with an actionable error otherwise. + socket files (default ``/tmp``). """ def __init__(self, *args, **kwargs): @@ -104,7 +103,6 @@ class VendorDockerVM(DockerVM): self._stop_timeout = 60 self._unix_socket_nio = False self._unix_socket_dir = "/tmp" - self._unix_socket_aliases = set() if self._environment: for _line in self._environment.splitlines(): _line = _line.strip().rstrip(",") @@ -167,17 +165,6 @@ 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 - ): - raise DockerError( - f"GNS3_UNIX_SOCKET_NIO requires socket directory '{self._unix_socket_dir}' of " - f"container '{self._name}' to be a persisted volume (add it to extra_volumes) " - f"so uBridge can reach the sockets on the host" - ) if self._gns3_init: return binds binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] @@ -320,57 +307,20 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" - @property - def _unix_socket_host_dir(self): + async def _unix_socket_wiring_dir(self): """ - Host-side path of the in-container unix-socket directory: the bind - source of the persisted volume it lives in. - """ - return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/")) + Host-side directory to reference in the uBridge unix-NIO commands: + the container's socket directory reached through its root in /proc. - def _unix_socket_wiring_dir(self): - """ - Directory to reference in the uBridge unix-NIO commands. - - AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL), and a - node volume directory (projects//project-files/docker//tmp) - alone is ~120 — uBridge would reject the NIO with "invalid file path - size". Long directories are therefore aliased through a symlink in the - runtime directory (same trick as the uBridge control socket). uBridge - resolves the symlink; the socket files themselves always live in the - persisted volume, and the container-side /tmp paths are unaffected. + The sockets live in the container's own filesystem (no volume mount + needed); /proc//root/ resolves to the same files uBridge + sees, from outside any namespace. This also keeps the path well under + the 107-byte sun_path cap — a node directory (projects//…) + alone exceeds it. """ - host_dir = self._unix_socket_host_dir - if len(host_dir) + len("/c00.sock") <= 107: - return host_dir - - runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() - alias = os.path.join(runtime_dir, "gns3", f"unixio-{self.id}") - try: - os.makedirs(os.path.dirname(alias), mode=0o700, exist_ok=True) - if os.path.lexists(alias): - os.unlink(alias) - os.symlink(host_dir, alias) - except OSError as e: - raise DockerError( - f"Could not create unix-socket alias '{alias}' for container '{self._name}': {e}" - ) - self._unix_socket_aliases.add(alias) - return alias - - async def _stop_ubridge(self): - """ - Override: also drop the short-path aliases created for the unix-socket - NIO (the sockets themselves are removed by the image's agent / the - next start's cleanup). - """ - - await super()._stop_ubridge() - for alias in self._unix_socket_aliases: - with contextlib.suppress(OSError): - os.unlink(alias) - self._unix_socket_aliases.clear() + pid = await self._get_namespace() + return f"/proc/{pid}/root{self._unix_socket_dir}" async def _add_ubridge_connection(self, nio, adapter_number): """ @@ -379,15 +329,16 @@ class VendorDockerVM(DockerVM): a TAP interface moved into the container's network namespace. Per adapter N the image's network agent is expected to create, inside - GNS3_UNIX_SOCKET_DIR (a persisted volume, enforced by _mount_binds): + 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. No TAP is allocated, the namespace is untouched and guest - MAC addresses are whatever the image's agent uses. + s-socket (both reached through /proc — 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: @@ -406,8 +357,7 @@ class VendorDockerVM(DockerVM): await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) - host_dir = self._unix_socket_host_dir - wiring_dir = self._unix_socket_wiring_dir() + wiring_dir = await self._unix_socket_wiring_dir() local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") remote_sock = os.path.join(wiring_dir, f"s{adapter_number:02d}.sock") @@ -424,7 +374,8 @@ class VendorDockerVM(DockerVM): 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"'{self._unix_socket_dir}' is bind-mounted from the node directory." + 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}"') diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 5986e4dd0..4a82d9a27 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -23,7 +23,6 @@ the uBridge command stream. """ import asyncio -import glob import json import os import uuid @@ -98,6 +97,23 @@ def _seed_proc(stdout=b"seedcid\n", returncode=0): return proc +# A container PID that can never exist on a real host (Linux caps PIDs at +# 2^22): lets the wiring tests run against /proc paths without any risk of +# touching a live process's files. +_FAKE_PID = 4194304 + + +async def _no_wait(path, timeout=None): + """Stand-in for wait_for_file_creation: pretend the socket is there.""" + return None + + +def _mock_wiring(vm): + """Mock everything _add_ubridge_connection's unix-NIO path needs.""" + vm._ubridge_hypervisor = MagicMock() + vm._get_namespace = AsyncioMagicMock(return_value=_FAKE_PID) + + # --------------------------------------------------------------------------- # Factory selection # --------------------------------------------------------------------------- @@ -178,7 +194,7 @@ async def test_create_keeps_image_entrypoint(compute_project, manager): @pytest.mark.asyncio -async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager): +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"}]): @@ -190,13 +206,16 @@ async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager) await vm.create() sent = mock.call_args.kwargs["data"] targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] - # both volumes are forced and bound at their real in-container - # paths (skip-init retargeting), reachable by uBridge on the host + # /config (runner config) and /tmp/run (startup-config + NVRAM) + # are forced and bound at their real in-container paths + # (skip-init retargeting); the sockets stay in the container's + # own /tmp, reached via /proc, so /tmp itself is not a volume assert "/config" in targets - assert "/tmp" in targets + assert "/tmp/run" in targets + assert "/tmp" not in targets 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" in vol_env + assert "/config" in vol_env and "/tmp/run" in vol_env @pytest.mark.asyncio @@ -274,14 +293,16 @@ async def test_start_creates_run_dir(compute_project, manager): @pytest.mark.asyncio -async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager): +async def test_start_does_no_host_side_socket_cleanup(compute_project, manager): + """ + Sockets live in the container's own /tmp (fresh in every container GNS3 + creates), so start() must not touch anything under the node's tmp/ + beyond creating tmp/run. + """ vm = _make_vm(compute_project, manager) tmp_dir = os.path.join(vm.working_dir, "tmp") os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True) - for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"): - open(os.path.join(tmp_dir, name), "w").close() - os.makedirs(os.path.join(tmp_dir, "netio1000")) open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close() open(os.path.join(tmp_dir, "run", "config"), "w").close() @@ -290,31 +311,11 @@ async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager with asyncio_patch("gns3server.compute.docker.Docker.query"): await vm.start() - assert glob.glob(os.path.join(tmp_dir, "s??.sock")) == [] - assert glob.glob(os.path.join(tmp_dir, "c??.sock")) == [] - assert not os.path.exists(os.path.join(tmp_dir, "netio1000")) - # the persistent runtime survives the cleanup + # the persistent runtime is untouched assert os.path.exists(os.path.join(tmp_dir, "run", "nvram_00001")) assert os.path.exists(os.path.join(tmp_dir, "run", "config")) -@pytest.mark.asyncio -async def test_start_skips_cleanup_when_already_running(compute_project, manager): - - vm = _make_vm(compute_project, manager) - tmp_dir = os.path.join(vm.working_dir, "tmp") - os.makedirs(tmp_dir, exist_ok=True) - open(os.path.join(tmp_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(tmp_dir, "s00.sock")) - - @pytest.mark.asyncio async def test_fix_permissions_is_noop(compute_project, manager): @@ -344,49 +345,36 @@ async def test_restart_is_graceful_stop_then_start(compute_project, manager): async def test_add_ubridge_connection_unix_wiring(compute_project, manager): vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() - host_dir = os.path.join(vm.working_dir, "tmp") - os.makedirs(host_dir, exist_ok=True) - # the runner's receive socket must exist (created by the container) - open(os.path.join(host_dir, "s00.sock"), "w").close() + _mock_wiring(vm) nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) - await vm._add_ubridge_connection(nio, 0) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): + 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) - wiring_dir = vm._unix_socket_wiring_dir() # may alias host_dir when long + local_sock = f"/proc/{_FAKE_PID}/root/tmp/c00.sock" + remote_sock = f"/proc/{_FAKE_PID}/root/tmp/s00.sock" + # the wiring path rides the container's root in /proc — well under + # sun_path's 107 bytes no matter how deep the project directory is + assert len(local_sock) <= 107 assert call.send("bridge create bridge0") in sent - assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(wiring_dir, "c00.sock")}" ' - f'"{os.path.join(wiring_dir, "s00.sock")}"') 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 == os.path.join(wiring_dir, "c00.sock") - - -@pytest.mark.asyncio -async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager): - - vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() - host_dir = os.path.join(vm.working_dir, "tmp") - os.makedirs(host_dir, exist_ok=True) - open(os.path.join(host_dir, "c00.sock"), "w").close() - open(os.path.join(host_dir, "s00.sock"), "w").close() - - await vm._add_ubridge_connection(None, 0) - assert not os.path.exists(os.path.join(host_dir, "c00.sock")) + assert vm._ethernet_adapters[0].host_ifc == local_sock @pytest.mark.asyncio async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager): vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() + _mock_wiring(vm) with pytest.raises(DockerError): await vm._add_ubridge_connection(None, 42) @@ -395,7 +383,7 @@ async def test_add_ubridge_connection_adapter_out_of_range(compute_project, mana async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager): vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() + _mock_wiring(vm) async def raise_timeout(path, timeout=60): raise asyncio.TimeoutError() @@ -404,21 +392,19 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man side_effect=raise_timeout): with pytest.raises(DockerError) as excinfo: await vm._add_ubridge_connection(None, 0) - # the message names the adapter and the socket directory + # the message names the adapter and the exact wiring path assert "adapter 0" in str(excinfo.value) - assert "/tmp" in str(excinfo.value) + assert f"/proc/{_FAKE_PID}/root/tmp/s00.sock" in str(excinfo.value) @pytest.mark.asyncio async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager): vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() - host_dir = os.path.join(vm.working_dir, "tmp") - os.makedirs(host_dir, exist_ok=True) - open(os.path.join(host_dir, "s00.sock"), "w").close() - - await vm._add_ubridge_connection(None, 0) + _mock_wiring(vm) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): + 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 @@ -452,23 +438,16 @@ def test_env_unix_socket_nio_parsing(compute_project, manager): assert vm._unix_socket_nio is False -@pytest.mark.asyncio -async def test_unix_socket_dir_must_be_a_volume(compute_project, manager): +def test_unix_socket_dir_needs_no_volume(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=[]) - with pytest.raises(DockerError) as excinfo: - vm._mount_binds({"Config": {"Volumes": {}}}) - assert "extra_volumes" in str(excinfo.value) - - 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"]) + # sockets are reached through /proc, not through a volume bind — creating + # the container without the socket dir in extra_volumes is fine binds = vm._mount_binds({"Config": {"Volumes": {}}}) - assert any(b["Target"] == "/tmp" for b in binds) + assert not any(b["Target"] == "/tmp" for b in binds) @pytest.mark.asyncio @@ -477,44 +456,10 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage 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") - vm._ubridge_hypervisor = MagicMock() - host_dir = os.path.join(vm.working_dir, "var", "run", "socks") - os.makedirs(host_dir, exist_ok=True) - open(os.path.join(host_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(vm._unix_socket_wiring_dir(), "s00.sock")}"' in flat - assert "add_nio_tap" not in flat - - -@pytest.mark.asyncio -async def test_long_socket_path_aliased_through_runtime_dir(compute_project, manager, monkeypatch, tmp_path): - """ - Node volume paths exceed sun_path's 107 bytes; the wiring must alias them - through a short symlink so uBridge accepts the NIO (and remove the alias - when uBridge stops). - """ - - from unittest.mock import PropertyMock - - long_dir = str(tmp_path / "a-very-long-project-directory-name" / ("x" * 40) / "tmp") - os.makedirs(long_dir, exist_ok=True) - open(os.path.join(long_dir, "s00.sock"), "w").close() - runtime_dir = tmp_path / "run" - monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir)) - - vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() - with patch.object(type(vm), "_unix_socket_host_dir", new_callable=PropertyMock, - return_value=long_dir): + _mock_wiring(vm) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): await vm._add_ubridge_connection(None, 0) - alias = os.path.join(str(runtime_dir), "gns3", f"unixio-{vm.id}") - assert os.path.islink(alias) and os.readlink(alias) == long_dir - flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) - assert f'"{os.path.join(alias, "s00.sock")}"' in flat - assert long_dir not in flat # uBridge only ever sees the short path - - vm._ubridge_hypervisor.stop = AsyncioMagicMock() - await vm._stop_ubridge() - assert not os.path.lexists(alias) + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert f'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat + assert "add_nio_tap" not in flat From d04a99f93474382aa12b58edfecd28bc426e0fc5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 14:13:35 +0800 Subject: [PATCH 08/29] refactor: wire unix-socket NIOs through a per-node runtime directory uBridge cannot reach sockets bind-mounted from the node's projects directory: the path exceeds AF_UNIX's 107-byte sun_path cap. The /proc detour from the previous commit is a dead end too - the runner drops from root to the server uid, which makes the process non-dumpable and its /proc//root unreadable for the unprivileged server. Instead, mirror how CML itself runs the image (source=/tmp, target=/tmp in its node definition): bind a per-node directory from the runtime directory (/gns3/unixio/, next to the uBridge control sockets) at the image's socket directory. The path is short, the directory is owned by the server user (whom the agent drops its privileges to), and it is removed with the node. A socket directory covered by a persisted volume keeps working as before. IOLDockerVM persists only /tmp/run (nested bind at /tmp/run) and cleans stale sockets/netio dirs from the socket directory on start, skipping the cleanup when the container is already running. A failed wiring now stops uBridge so the next start does not hit 'bridge already exist'. --- docs/features/iol-runner-docker.md | 44 +++-- gns3server/compute/docker/iol_docker_vm.py | 41 ++++- gns3server/compute/docker/vendor_docker_vm.py | 146 +++++++++++----- tests/compute/docker/test_iol_docker_vm.py | 156 ++++++++++++------ 4 files changed, 271 insertions(+), 116 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index c9b02841c..08d5a215c 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -36,10 +36,11 @@ graph LR IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS end subgraph Host - UBRIDGE["uBridge bridgeN
add_nio_unix /proc/PID/root/tmp/cNN.sock …
+ add_nio_udp (topology)"] - VOL["project-files/docker/<node>/config
(+ tmp/run nested bind)"] + UBRIDGE["uBridge bridgeN
add_nio_unix …/gns3/unixio/<node>/cNN.sock …
+ add_nio_udp (topology)"] + RTDIR["/run/user/<uid>/gns3/unixio/<node>
(bind-mounted at /tmp)"] + VOL["project-files/docker/<node>/config
(+ tmp/run, nested bind at /tmp/run)"] end - SOCKETS <-->|"raw Ethernet frames via
the container root in /proc"| UBRIDGE + SOCKETS <-->|"same files, two spellings
(the /tmp bind)"| RTDIR <--> UBRIDGE UBRIDGE -.->|"iol-config.json +
persistent /tmp/run"| VOL ``` @@ -51,25 +52,29 @@ graph LR 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. - uBridge reaches both through the container's root in `/proc` - (`/proc//root/tmp/…`) — a short path (AF_UNIX names are - capped at 107 bytes, which a project directory path alone would exceed) - that requires no volume mount and leaves the sockets ephemeral in the - container, fresh in every container GNS3 creates. + 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//gns3/unixio/`, 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`, so the router's configuration - survives stop/start and container recreation while everything else in - `/tmp` stays 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 — which is also what lets an unprivileged uBridge - traverse `/proc//root` to reach the container's sockets (no - permission-fix pass needed). + 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 @@ -95,9 +100,9 @@ keeps the template self-documenting. | Mechanism | Where | What it does | |---|---|---| -| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /proc//root/c{N:02d}.sock /proc//root/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched, no volume required. | +| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /c{N:02d}.sock /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=` | `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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048) and creates `/tmp/run/` (the IOL process dies without it). | +| `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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/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_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop. @@ -112,6 +117,9 @@ diagnosing wiring issues). + ~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 (`aabb.cc00.0XY0`). +* **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**: change the adapter count while the node is stopped; the config is regenerated on the next start and the runner creates the matching socket set (IOL granularity is 4 ports per unit). diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index cc3302b81..6243f8a94 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -28,17 +28,22 @@ 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 the container's root in /proc). Because the -netio bus directory is private to the container's /tmp, the application IDs -are fixed constants with no cross-node collisions. +(uBridge reaches them through a per-node runtime directory bound at /tmp — +see ``VendorDockerVM._unix_socket_host_dir``). Because the netio bus +directory is private to the node, the application IDs are fixed constants +with no cross-node collisions. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ +import contextlib +import glob import json import logging import os +import shutil +from gns3server.compute.docker.docker_error import DockerHttp404Error from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM log = logging.getLogger(__name__) @@ -124,22 +129,40 @@ class IOLDockerVM(VendorDockerVM): boot (the runner writes NETMAP there but does not create it). * ``/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. - The netiomux sockets and the netio bus directory need no cleanup: - they live in the container's own /tmp, which is fresh in every - container GNS3 creates (containers are recreated on each start). + ``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" + os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True) self._write_iol_config() + if state == "running": + return + + 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 an - unprivileged uBridge traverse /proc//root to reach the - container's sockets. + creates is owned by the server user — which is also what lets the + (unprivileged) uBridge write into the node's socket directory. """ config = { diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 7e4abfd4d..6e788a83d 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -32,6 +32,7 @@ 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 @@ -70,13 +71,16 @@ class VendorDockerVM(DockerVM): 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 reaches the - sockets through the container's root in ``/proc`` (see - ``_unix_socket_wiring_dir``), so no volume mount is required; it binds + 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``. - 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). + 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=`` — in-container directory holding the socket files (default ``/tmp``). """ @@ -165,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"] @@ -307,20 +327,55 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" - async def _unix_socket_wiring_dir(self): + def _unix_socket_host_dir(self): """ - Host-side directory to reference in the uBridge unix-NIO commands: - the container's socket directory reached through its root in /proc. + 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. - The sockets live in the container's own filesystem (no volume mount - needed); /proc//root/ resolves to the same files uBridge - sees, from outside any namespace. This also keeps the path well under - the 107-byte sun_path cap — a node directory (projects//…) - alone exceeds it. + AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL) — a + node directory inside the projects tree (projects// + project-files/docker//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. """ - pid = await self._get_namespace() - return f"/proc/{pid}/root{self._unix_socket_dir}" + 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): """ @@ -336,9 +391,10 @@ class VendorDockerVM(DockerVM): * ``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 reached through /proc — see _unix_socket_wiring_dir). - No TAP is allocated, the namespace is untouched and guest MAC - addresses are whatever the image's agent uses. + 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: @@ -354,31 +410,37 @@ class VendorDockerVM(DockerVM): ) bridge_name = f"bridge{adapter_number}" - await self._ubridge_send(f"bridge create {bridge_name}") - self._bridges.add(bridge_name) - - wiring_dir = await self._unix_socket_wiring_dir() - local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") - remote_sock = os.path.join(wiring_dir, f"s{adapter_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 create {bridge_name}") + self._bridges.add(bridge_name) - await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"') + wiring_dir = self._unix_socket_wiring_dir() + local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") + remote_sock = os.path.join(wiring_dir, f"s{adapter_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 of container '%s' wired via unix sockets %s <-> %s", diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 4a82d9a27..672325012 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -23,6 +23,7 @@ the uBridge command stream. """ import asyncio +import glob import json import os import uuid @@ -97,21 +98,25 @@ def _seed_proc(stdout=b"seedcid\n", returncode=0): return proc -# A container PID that can never exist on a real host (Linux caps PIDs at -# 2^22): lets the wiring tests run against /proc paths without any risk of -# touching a live process's files. -_FAKE_PID = 4194304 - - -async def _no_wait(path, timeout=None): - """Stand-in for wait_for_file_creation: pretend the socket is there.""" - return None +@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() - vm._get_namespace = AsyncioMagicMock(return_value=_FAKE_PID) + + +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) # --------------------------------------------------------------------------- @@ -205,14 +210,17 @@ async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, mana vm = _make_vm(compute_project, manager, extra_volumes=[]) await vm.create() sent = mock.call_args.kwargs["data"] - targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + 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); the sockets stay in the container's - # own /tmp, reached via /proc, so /tmp itself is not a volume + # (skip-init retargeting); /tmp is the ephemeral runtime-dir + # bind holding the netiomux sockets assert "/config" in targets assert "/tmp/run" in targets - assert "/tmp" not 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 @@ -293,27 +301,47 @@ async def test_start_creates_run_dir(compute_project, manager): @pytest.mark.asyncio -async def test_start_does_no_host_side_socket_cleanup(compute_project, manager): - """ - Sockets live in the container's own /tmp (fresh in every container GNS3 - creates), so start() must not touch anything under the node's tmp/ - beyond creating tmp/run. - """ +async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager): vm = _make_vm(compute_project, manager) - tmp_dir = os.path.join(vm.working_dir, "tmp") - os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True) - open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close() - open(os.path.join(tmp_dir, "run", "config"), "w").close() + 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() - # the persistent runtime is untouched - assert os.path.exists(os.path.join(tmp_dir, "run", "nvram_00001")) - assert os.path.exists(os.path.join(tmp_dir, "run", "config")) + 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 @@ -346,19 +374,18 @@ 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"}) - with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", - side_effect=_no_wait): - await vm._add_ubridge_connection(nio, 0) + 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 = f"/proc/{_FAKE_PID}/root/tmp/c00.sock" - remote_sock = f"/proc/{_FAKE_PID}/root/tmp/s00.sock" - # the wiring path rides the container's root in /proc — well under - # sun_path's 107 bytes no matter how deep the project directory is - assert len(local_sock) <= 107 + 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 @@ -370,6 +397,20 @@ async def test_add_ubridge_connection_unix_wiring(compute_project, manager): 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): @@ -384,6 +425,7 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man vm = _make_vm(compute_project, manager) _mock_wiring(vm) + vm._stop_ubridge = AsyncioMagicMock() async def raise_timeout(path, timeout=60): raise asyncio.TimeoutError() @@ -394,7 +436,10 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man 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 f"/proc/{_FAKE_PID}/root/tmp/s00.sock" 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 @@ -402,9 +447,11 @@ async def test_add_ubridge_connection_without_nio_still_wires(compute_project, m vm = _make_vm(compute_project, manager) _mock_wiring(vm) - with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", - side_effect=_no_wait): - await vm._add_ubridge_connection(None, 0) + 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 @@ -438,16 +485,29 @@ def test_env_unix_socket_nio_parsing(compute_project, manager): assert vm._unix_socket_nio is False -def test_unix_socket_dir_needs_no_volume(compute_project, manager): +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=[]) - # sockets are reached through /proc, not through a volume bind — creating - # the container without the socket dir in extra_volumes is fine + # 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": {}}}) - assert not any(b["Target"] == "/tmp" for b in binds) + 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 @@ -457,9 +517,11 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage console_type="docker_exec", environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") _mock_wiring(vm) - with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", - side_effect=_no_wait): - await vm._add_ubridge_connection(None, 0) + 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'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat + assert f'"{os.path.join(wiring_dir, "s00.sock")}"' in flat assert "add_nio_tap" not in flat From 28f8f665f7dd1c66395fd5eefc0e579f87b74975 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 14:17:38 +0800 Subject: [PATCH 09/29] docs: template creation how-to for iol-runner nodes API/Web-UI creation steps, a field table with the extra_volumes=/tmp footgun, and a 3-step verification checklist. --- docs/features/iol-runner-docker.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 08d5a215c..ddd0178ec 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -78,6 +78,10 @@ graph LR ## 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", @@ -93,8 +97,23 @@ graph LR } ``` -`/config` and `/tmp/run` are auto-added even if omitted; listing `/config` -keeps the template self-documenting. +| Field | Value | Why | +|---|---|---| +| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | +| `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. | +| `memory` | IOL memory + ~512 MB | Caps the whole container; below that the OOM-killer shoots the router. | +| `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. | +| `adapters` | multiple of 4 | IOL port granularity; interfaces are `Ethernet0/0`-style. | + +### Verify + +1. Drop a node into a project and start it — the console shows the + `Linux Unix (i686)` banner within seconds. +2. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one + pair per adapter). +3. The startup-config lives at + `project-files/docker//tmp/run/config` (interface names + `Ethernet0/0`, not `GigabitEthernet0/0`). ## Server mechanisms From cd17306372a3c918cb55d4dc93eba959cad0dd21 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 14:22:10 +0800 Subject: [PATCH 10/29] docs: docker memory is optional for iol-runner nodes 0 (the default) passes through as Docker's no-limit; the IOL-memory+512 guidance only applies when a cap is set. --- docs/features/iol-runner-docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index ddd0178ec..f76da6a4b 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -101,7 +101,7 @@ API docs for the auth flow), or in the Web UI under |---|---|---| | `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | | `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. | -| `memory` | IOL memory + ~512 MB | Caps the whole container; below that the OOM-killer shoots the router. | +| `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. | | `adapters` | multiple of 4 | IOL port granularity; interfaces are `Ethernet0/0`-style. | From 4c818c6b3794d90f9b226e800c717d97de947648 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 14:25:19 +0800 Subject: [PATCH 11/29] feat: show IOL-style interface names on iol-runner ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name ports after the IOL interface they map to (Ethernet0/0, one 4-port unit per adapter range) via the _get_container_ifname override point, so the GNS3 UI matches the IOS CLI. Display only — the flat adapter number remains the socket index. --- docs/features/iol-runner-docker.md | 2 +- gns3server/compute/docker/iol_docker_vm.py | 9 +++++++++ tests/compute/docker/test_iol_docker_vm.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index f76da6a4b..bdee70e80 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -103,7 +103,7 @@ API docs for the auth flow), or in the Web UI under | `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. | | `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. | -| `adapters` | multiple of 4 | IOL port granularity; interfaces are `Ethernet0/0`-style. | +| `adapters` | multiple of 4 | IOL port granularity. Ports are shown as `Ethernet0/0`-style (one 4-port unit per adapter range), matching the IOS CLI. | ### Verify diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 6243f8a94..b5cd588c7 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -105,6 +105,15 @@ class IOLDockerVM(VendorDockerVM): volumes.append(needed) return volumes + def _get_container_ifname(self, adapter_number): + """ + Override: name ports after the IOL interface they map to + (Ethernet0/0, Ethernet0/1, … — a new 4-port unit every four + adapters), so what the GNS3 UI shows matches the IOS CLI. Wiring is + unaffected: the flat adapter number remains the socket index. + """ + return f"Ethernet{adapter_number // 4}/{adapter_number % 4}" + async def start(self): await self._prepare_iol_runtime() diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 672325012..006eb5a77 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -177,6 +177,20 @@ def test_iol_memory_knob(compute_project, manager): assert vm._iol_memory == 2048 +# --------------------------------------------------------------------------- +# Port naming +# --------------------------------------------------------------------------- + +def test_ports_are_named_after_iol_interfaces(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=8) + assert vm._get_container_ifname(0) == "Ethernet0/0" + assert vm._get_container_ifname(3) == "Ethernet0/3" + # the second 4-port unit starts its own slot numbering + assert vm._get_container_ifname(4) == "Ethernet1/0" + assert vm._get_container_ifname(7) == "Ethernet1/3" + + # --------------------------------------------------------------------------- # create() # --------------------------------------------------------------------------- From 9b55164064b6a87cd0cfff438df22d3c8538a6b2 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 14:37:08 +0800 Subject: [PATCH 12/29] revert IOL port-name override; document custom_adapters instead _get_container_ifname only names the kernel interface inside the container's network namespace (single call site: docker move_to_ns) - IOL never uses that path, and the controller port list never reads it. Name the ports through the template's custom_adapters instead (the mechanism SR Linux appliances use for mgmt0/e1-1): adapter N shows as Ethernet{N/4}/{N%4}, matching the IOS CLI, with no server-side changes. --- docs/features/iol-runner-docker.md | 18 +++++++++++++----- gns3server/compute/docker/iol_docker_vm.py | 9 --------- tests/compute/docker/test_iol_docker_vm.py | 14 -------------- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index bdee70e80..465dd981d 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -93,7 +93,12 @@ API docs for the auth flow), or in the Web UI under "console_type": "telnet", "environment": "GNS3_IOL_RUNNER=1", "extra_volumes": ["/config"], - "memory": 2560 + "custom_adapters": [ + {"adapter_number": 0, "port_name": "Ethernet0/0"}, + {"adapter_number": 1, "port_name": "Ethernet0/1"}, + {"adapter_number": 2, "port_name": "Ethernet0/2"}, + {"adapter_number": 3, "port_name": "Ethernet0/3"} + ] } ``` @@ -101,17 +106,20 @@ API docs for the auth flow), or in the Web UI under |---|---|---| | `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | | `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. | +| `custom_adapters` | one entry per adapter | Names each port after the IOL interface it maps to: adapter `N` → `Ethernet{N/4}/{N%4}` (same mechanism SR Linux uses for `mgmt0`/`e1-1`). Raise the list up to `Ethernet7/3` (32 ports, the IOL maximum) when raising `adapters`; adapters beyond the list fall back to `eth{N}`. | | `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. | -| `adapters` | multiple of 4 | IOL port granularity. Ports are shown as `Ethernet0/0`-style (one 4-port unit per adapter range), matching the IOS CLI. | +| `adapters` | multiple of 4 | IOL port granularity (one 4-port unit per range). | ### Verify -1. Drop a node into a project and start it — the console shows the +1. The node's port list shows `Ethernet0/0`… (from the template's + `custom_adapters`). +2. Drop a node into a project and start it — the console shows the `Linux Unix (i686)` banner within seconds. -2. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one +3. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one pair per adapter). -3. The startup-config lives at +4. The startup-config lives at `project-files/docker//tmp/run/config` (interface names `Ethernet0/0`, not `GigabitEthernet0/0`). diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index b5cd588c7..6243f8a94 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -105,15 +105,6 @@ class IOLDockerVM(VendorDockerVM): volumes.append(needed) return volumes - def _get_container_ifname(self, adapter_number): - """ - Override: name ports after the IOL interface they map to - (Ethernet0/0, Ethernet0/1, … — a new 4-port unit every four - adapters), so what the GNS3 UI shows matches the IOS CLI. Wiring is - unaffected: the flat adapter number remains the socket index. - """ - return f"Ethernet{adapter_number // 4}/{adapter_number % 4}" - async def start(self): await self._prepare_iol_runtime() diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 006eb5a77..672325012 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -177,20 +177,6 @@ def test_iol_memory_knob(compute_project, manager): assert vm._iol_memory == 2048 -# --------------------------------------------------------------------------- -# Port naming -# --------------------------------------------------------------------------- - -def test_ports_are_named_after_iol_interfaces(compute_project, manager): - - vm = _make_vm(compute_project, manager, adapters=8) - assert vm._get_container_ifname(0) == "Ethernet0/0" - assert vm._get_container_ifname(3) == "Ethernet0/3" - # the second 4-port unit starts its own slot numbering - assert vm._get_container_ifname(4) == "Ethernet1/0" - assert vm._get_container_ifname(7) == "Ethernet1/3" - - # --------------------------------------------------------------------------- # create() # --------------------------------------------------------------------------- From 0ca9ccc63710affa790281b609a7194e08aefe71 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 21:33:38 +0800 Subject: [PATCH 13/29] feat: model IOL adapters as 4-port units like the IOU node type IOL interfaces come in 4-port units (Ethernet0/0-3, Ethernet1/0-3, ...), addressed like IOU as (adapter_number, port_number 0-3): - IOLDockerVM builds EthernetAdapter(interfaces=4) per adapter and asks the runner for adapters x 4 interfaces (num-eth). - DockerVM threads port_number through the NIO/capture API (the compute routes parsed it from the URL but dropped it); single-port adapters keep the historical bridge{N} names and command sequence, multi-port adapters get one bridge per port (bridge{a}_{p}). - The unix-socket NIO wiring addresses sockets flat across adapters: adapter x ports-per-adapter + port, so single-port images keep their exact socket layout. - The controller port list for GNS3_IOL_RUNNER docker nodes is generated by StandardPortFactory with the IOU naming (Ethernet{segment0}/{port0}, segment size 4); plain docker nodes keep eth{N}. --- docs/features/iol-runner-docker.md | 27 ++-- gns3server/api/routes/compute/docker_nodes.py | 14 +- gns3server/compute/docker/docker_vm.py | 153 +++++++++++------- gns3server/compute/docker/iol_docker_vm.py | 26 ++- gns3server/compute/docker/vendor_docker_vm.py | 30 ++-- gns3server/controller/node.py | 43 +++-- tests/compute/docker/test_docker_vm.py | 4 +- tests/compute/docker/test_iol_docker_vm.py | 54 ++++++- tests/controller/test_node.py | 26 +++ 9 files changed, 267 insertions(+), 110 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 465dd981d..64aeb3a72 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -89,16 +89,10 @@ API docs for the auth flow), or in the Web UI under "image": "iol-xe/iol-xe:17-18-02", "category": "router", "symbol": ":/symbols/router.svg", - "adapters": 4, + "adapters": 2, "console_type": "telnet", "environment": "GNS3_IOL_RUNNER=1", - "extra_volumes": ["/config"], - "custom_adapters": [ - {"adapter_number": 0, "port_name": "Ethernet0/0"}, - {"adapter_number": 1, "port_name": "Ethernet0/1"}, - {"adapter_number": 2, "port_name": "Ethernet0/2"}, - {"adapter_number": 3, "port_name": "Ethernet0/3"} - ] + "extra_volumes": ["/config"] } ``` @@ -106,19 +100,19 @@ API docs for the auth flow), or in the Web UI under |---|---|---| | `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | | `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. | -| `custom_adapters` | one entry per adapter | Names each port after the IOL interface it maps to: adapter `N` → `Ethernet{N/4}/{N%4}` (same mechanism SR Linux uses for `mgmt0`/`e1-1`). Raise the list up to `Ethernet7/3` (32 ports, the IOL maximum) when raising `adapters`; adapters beyond the list fall back to `eth{N}`. | +| `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. | -| `adapters` | multiple of 4 | IOL port granularity (one 4-port unit per range). | ### Verify -1. The node's port list shows `Ethernet0/0`… (from the template's - `custom_adapters`). +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//` contains `s00.sock`… (one - pair per adapter). + pair per port). 4. The startup-config lives at `project-files/docker//tmp/run/config` (interface names `Ethernet0/0`, not `GigabitEthernet0/0`). @@ -147,9 +141,10 @@ diagnosing wiring issues). * **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**: change the adapter count while the node is stopped; the - config is regenerated on the next start and the runner creates the - matching socket set (IOL granularity is 4 ports per unit). +* **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`. diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 63d8bea91..5070cd855 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -284,7 +284,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 +302,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 +327,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 +346,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 +365,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 +382,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") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 44a89f968..b226a0f39 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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( diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 6243f8a94..df53f2a78 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -43,7 +43,9 @@ import logging import os import shutil +from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter from gns3server.compute.docker.docker_error import DockerHttp404Error +from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM log = logging.getLogger(__name__) @@ -90,6 +92,28 @@ class IOLDockerVM(VendorDockerVM): except ValueError: pass + @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 @@ -168,7 +192,7 @@ class IOLDockerVM(VendorDockerVM): config = { "binary": "/binary.iol", "memory": self._iol_memory, - "num-eth": self.adapters, + "num-eth": self.adapters * 4, # every adapter is a 4-port unit "num-serial": 0, # GNS3 docker adapters are ethernet-only "local-app": 1, "remote-app": 2, diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 6e788a83d..d6157eea4 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -377,14 +377,17 @@ class VendorDockerVM(DockerVM): await super().delete() self._remove_unix_socket_host_dir() - async def _add_ubridge_connection(self, nio, adapter_number): + async def _add_ubridge_connection(self, nio, adapter_number, port_number=0): """ - Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter through the - image's AF_UNIX datagram socket pair (raw Ethernet frames) instead of - a TAP interface moved into the container's network namespace. + 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. - Per adapter N the image's network agent is expected to create, inside - GNS3_UNIX_SOCKET_DIR: + 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; @@ -398,7 +401,7 @@ class VendorDockerVM(DockerVM): """ if not self._unix_socket_nio: - return await super()._add_ubridge_connection(nio, adapter_number) + return await super()._add_ubridge_connection(nio, adapter_number, port_number) try: adapter = self._ethernet_adapters[adapter_number] @@ -409,14 +412,15 @@ class VendorDockerVM(DockerVM): ) ) - bridge_name = f"bridge{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{adapter_number:02d}.sock") - remote_sock = os.path.join(wiring_dir, f"s{adapter_number:02d}.sock") + 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): @@ -443,12 +447,12 @@ class VendorDockerVM(DockerVM): raise adapter.host_ifc = local_sock # bookkeeping / removal logging only log.debug( - "Adapter %d of container '%s' wired via unix sockets %s <-> %s", - adapter_number, self._name, local_sock, remote_sock, + "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) + await self._connect_nio(adapter_number, nio, port_number) def _cleanup_console_resources(self): """ diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 8ea491b3c..d144cfa47 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -804,22 +804,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 "GNS3_IOL_RUNNER=" in (self._properties.get("environment") or ""): + # 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 diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 10336e2b1..71dbc2c39 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -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" diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 672325012..4f19cd8c8 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -259,7 +259,7 @@ async def test_start_writes_iol_config(compute_project, manager): 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"] == 4 + assert config["num-eth"] == 16 # 4 adapters, each a 4-port unit assert config["num-serial"] == 0 assert config["local-app"] == 1 assert config["remote-app"] == 2 @@ -285,7 +285,7 @@ async def test_start_rewrites_config_on_adapter_change(compute_project, manager) await vm.start() with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f: - assert json.load(f)["num-eth"] == 8 + assert json.load(f)["num-eth"] == 32 # 8 adapters × 4 ports @pytest.mark.asyncio @@ -460,6 +460,56 @@ async def test_add_ubridge_connection_without_nio_still_wires(compute_project, m 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 # --------------------------------------------------------------------------- diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index a20405637..9ba4c0ca6 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -46,6 +46,32 @@ 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_name(compute, project): """ If node use a name template generate names From b17d021bf644bb81438719d8ed19cb253ff80a2c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 4 Sep 2026 10:53:46 +0800 Subject: [PATCH 14/29] fix: derive per-node IOL app id so linked routers get distinct MACs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IOL derives interface MACs from its application ID. With a constant local-app every node shared the same MACs, and linked routers dropped each other's frames as MAC loops — ARP never resolved and pings were 100% lost even though the whole uBridge datapath was forwarding. Derive local-app from the node UUID (stable across restarts, kept in 1..1022) and align remote-app with the netiomux convention CML uses (1023). Verified end to end: two iol-xe nodes, Ethernet0/0 link, ARP resolves both ways, ping 4/5 (first loss = ARP), graceful stop/start keeps the NVRAM config. --- docs/features/iol-runner-docker.md | 5 ++++- gns3server/compute/docker/iol_docker_vm.py | 14 +++++++++----- tests/compute/docker/test_iol_docker_vm.py | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 64aeb3a72..0c16a1540 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -137,7 +137,10 @@ diagnosing wiring issues). `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 (`aabb.cc00.0XY0`). + MACs are ignored — IOL derives its own scheme from the node's application + ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The ID is derived from + the node UUID so linked routers always get distinct MACs (nodes sharing an + ID would 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. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index df53f2a78..3dddf87b7 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -29,9 +29,8 @@ 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``). Because the netio bus -directory is private to the node, the application IDs are fixed constants -with no cross-node collisions. +see ``VendorDockerVM._unix_socket_host_dir``). The local application ID is +derived from the node ID so that linked nodes get distinct MACs. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ @@ -194,8 +193,13 @@ class IOLDockerVM(VendorDockerVM): "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 - "local-app": 1, - "remote-app": 2, + # IOL derives interface MACs from the local application ID + # (aabb.cc00.00); every node needs a distinct one or + # linked routers share MACs and drop each other's frames as loops. + # Derived from the node ID: stable across restarts, unique enough + # (CML allocates per-lab sequential IDs for the same reason). + "local-app": int(self.id.replace("-", ""), 16) % 1022 + 1, + "remote-app": 1023, # netiomux's fake peer application ID "user-id": os.getuid(), "group-id": os.getgid(), } diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 4f19cd8c8..28ea48afa 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -261,14 +261,25 @@ async def test_start_writes_iol_config(compute_project, manager): 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"] == 1 - assert config["remote-app"] == 2 + assert config["local-app"] == int(vm.id.replace("-", ""), 16) % 1022 + 1 + 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" +def test_local_app_is_distinct_per_node(compute_project, manager): + # IOL derives interface MACs from the app ID: two nodes sharing one would + # drop each other's frames as MAC loops, so IDs must differ per node. + vm1 = _make_vm(compute_project, manager) + vm2 = _make_vm(compute_project, manager) + id1 = int(vm1.id.replace("-", ""), 16) % 1022 + 1 + id2 = int(vm2.id.replace("-", ""), 16) % 1022 + 1 + assert id1 != id2 + assert 1 <= id1 <= 1022 and 1 <= id2 <= 1022 + + @pytest.mark.asyncio async def test_start_rewrites_config_on_adapter_change(compute_project, manager): From 0119373690f5967230dfe9b2c656a2e9353ef7e3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 4 Sep 2026 13:05:49 +0800 Subject: [PATCH 15/29] feat: allocate IOL Docker application IDs from a pool disjoint from IOU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IOL interface MACs derive from the application ID (aabb.cc{app}{iface}), so ids must be unique across opened projects sharing computes — the same reason IOU has its allocator. IOL Docker nodes draw from the upper half (512-1022, netiomux's fixed peer is 1023) so the two node types can neither collide nor starve each other; IOU behavior is unchanged. The controller sniffs the same GNS3_IOL_RUNNER environment marker the compute uses to select IOLDockerVM (both the nested-properties and template/top-level-kwarg shapes), stores the id in node properties like IOU does, and passes it through the Docker create payload. Without an allocation the compute falls back to a stable node-derived id in the same upper range. --- docs/features/iol-runner-docker.md | 10 ++++-- gns3server/compute/docker/iol_docker_vm.py | 38 ++++++++++++++++---- gns3server/controller/node.py | 3 +- gns3server/controller/project.py | 38 ++++++++++++++++++-- gns3server/schemas/compute/docker_nodes.py | 4 ++- gns3server/utils/application_id.py | 42 +++++++++++++++++++--- tests/compute/docker/test_iol_docker_vm.py | 25 ++++++++++--- tests/controller/test_project.py | 41 +++++++++++++++++++++ 8 files changed, 177 insertions(+), 24 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 0c16a1540..1b91301b3 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -138,9 +138,13 @@ diagnosing wiring issues). + ~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 ID is derived from - the node UUID so linked routers always get distinct MACs (nodes sharing an - ID would silently drop each other's frames as MAC loops). + 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); without an allocation (raw compute API, + pre-allocation topologies) a stable node-derived fallback in the same + range is used. Nodes sharing an ID would 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. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 3dddf87b7..ce4c03e64 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -29,8 +29,10 @@ 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 local application ID is -derived from the node ID so that linked nodes get distinct MACs. +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; without an allocation a stable node-derived +fallback is used. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ @@ -91,6 +93,27 @@ class IOLDockerVM(VendorDockerVM): except ValueError: pass + # Application ID allocated by the controller (upper half of the id + # space, disjoint from IOU's); None until the create payload carries it. + self._application_id = None + + @property + def application_id(self) -> int: + """ + IOL application ID: drives interface MACs (aabb.cc{app}{iface}) and + the NVRAM file name. Falls back to a stable per-node value in the + IOL Docker half of the id space when no allocation is available + (raw compute API use, topologies created before allocation existed). + """ + + if self._application_id is None: + return 512 + int(self.id.replace("-", ""), 16) % 511 + return self._application_id + + @application_id.setter + def application_id(self, value) -> None: + self._application_id = int(value) + @DockerVM.adapters.setter def adapters(self, adapters): """ @@ -194,11 +217,12 @@ class IOLDockerVM(VendorDockerVM): "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.cc00.00); every node needs a distinct one or - # linked routers share MACs and drop each other's frames as loops. - # Derived from the node ID: stable across restarts, unique enough - # (CML allocates per-lab sequential IDs for the same reason). - "local-app": int(self.id.replace("-", ""), 16) % 1022 + 1, + # (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(), diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index d144cfa47..e72dfbc9d 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -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 @@ -804,7 +805,7 @@ class Node: self._ports = DynamipsPortFactory(self._properties) return elif self._node_type == "docker": - if "GNS3_IOL_RUNNER=" in (self._properties.get("environment") or ""): + 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). diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 01506a734..d9ebdfae8 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -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 @@ -646,7 +661,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 +673,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()) diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index 7b39921f6..e3523cad8 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -69,7 +69,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): diff --git a/gns3server/utils/application_id.py b/gns3server/utils/application_id.py index 9c9d8b347..488fb70b8 100644 --- a/gns3server/utils/application_id.py +++ b/gns3server/utils/application_id.py @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# 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)" ) diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 28ea48afa..b3aa8cbab 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -261,7 +261,7 @@ async def test_start_writes_iol_config(compute_project, manager): 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"] == int(vm.id.replace("-", ""), 16) % 1022 + 1 + assert config["local-app"] == 512 + int(vm.id.replace("-", ""), 16) % 511 assert config["remote-app"] == 1023 assert config["memory"] == 2048 assert config["user-id"] == os.getuid() @@ -274,10 +274,25 @@ def test_local_app_is_distinct_per_node(compute_project, manager): # drop each other's frames as MAC loops, so IDs must differ per node. vm1 = _make_vm(compute_project, manager) vm2 = _make_vm(compute_project, manager) - id1 = int(vm1.id.replace("-", ""), 16) % 1022 + 1 - id2 = int(vm2.id.replace("-", ""), 16) % 1022 + 1 - assert id1 != id2 - assert 1 <= id1 <= 1022 and 1 <= id2 <= 1022 + assert vm1.application_id != vm2.application_id + for vm in (vm1, vm2): + assert 512 <= vm.application_id <= 1022 + + +@pytest.mark.asyncio +async def test_allocated_application_id_overrides_fallback(compute_project, manager): + # The controller allocates the application ID (upper half of the id + # space); the node-derived hash is only a fallback without one. + vm = _make_vm(compute_project, manager) + vm.application_id = 700 + assert vm.application_id == 700 + _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"] == 700 @pytest.mark.asyncio diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 31a4a2e7a..373d05e10 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -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): """ From 51b3c81859f351294067383551e241af66d05d7c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 4 Sep 2026 14:07:35 +0800 Subject: [PATCH 16/29] fix: make the patched get_default_project_directory order-safe in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product module first-imported while run_around_tests' autouse monkeypatch of gns3server.utils.path.get_default_project_directory is active (e.g. 'from gns3server.api.server import app' inside a test body) binds the patched lambda into its own namespace forever: module-level from-imports capture the object by value. Once the patch is reverted, that module keeps calling the stale lambda, which closed over the very test's tmppath — a directory deleted at that test's teardown. Every later test hitting psutil.disk_usage(get_default_project_directory()) then fails with FileNotFoundError, but only when the importing test runs before the API test files (full-suite collection order hides it). The replacement now resolves Config.instance().settings at call time instead of closing over the tmppath, so a frozen reference stays correct; the test that triggered this imports gns3_app at module top so no product import ever happens inside a patched window. Also pins pytest-random-order (inert without --random-order) and documents the order-independence rules for new tests in the gns3-api-test-writing skill, including the known-red legacy files whose tests share rows sequentially. --- .claude/skills/gns3-api-test-writing/SKILL.md | 15 +++++++++++++++ dev-requirements.txt | 3 ++- tests/conftest.py | 8 +++++++- tests/controller/test_compute.py | 5 ++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.claude/skills/gns3-api-test-writing/SKILL.md b/.claude/skills/gns3-api-test-writing/SKILL.md index 6149bf21f..991df03f2 100644 --- a/.claude/skills/gns3-api-test-writing/SKILL.md +++ b/.claude/skills/gns3-api-test-writing/SKILL.md @@ -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/.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=` reproduces it), then bisect to the polluting test. diff --git a/dev-requirements.txt b/dev-requirements.txt index 55aac6eaa..80d7da3b4 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -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 \ No newline at end of file +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) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index f67b04839..b0994ebb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 58ba606c4..671b8e7a0 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -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(): From 5c285f631bf5eac9ee74721dad067668e1593625 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 1 Sep 2026 01:34:19 +0800 Subject: [PATCH 17/29] fix: normalize marker.match event tag to int to match the REST schema The WS event carried the tag as str (verbatim from the MARK signal) when present and as int when falling back to the registry, so one tag reached consumers as two different values. Parse the signal's decimal (the exact value we installed via 'mark tag ') and keep the registered int on malformed input; None only when neither side carries one. --- gns3server/compute/marker/marker_listener.py | 15 ++++++++++-- tests/compute/marker/test_marker_manager.py | 24 +++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index 40cdd97b6..a30769c1e 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -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 tag `, 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); diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index da495ae92..5917870d7 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -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 — From a8fa529252a74bd9e885087a4be7214a1eb69d5e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 1 Sep 2026 01:34:33 +0800 Subject: [PATCH 18/29] feat: add tag-keyed aggregate replay over paused markers' pcaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markers on different links sharing a tag form one distributed capture session. Once every marker under the tag is paused (409 otherwise), three read-only endpoints replay it: - GET .../markers/tags/{tag}/replay/range merges the per-marker pcaps by scanning 16-byte record headers only (no tshark) into a timestamp- ordered frame list (per-second buckets above a 5000-frame cap) - GET .../replay/frames?ts=&window_ms= returns frames in [T, T+window]; an empty window is a normal empty array - GET .../replay/frame/detail lazily decodes one frame the user opened: raw bytes for the hex view read straight from the pcap, protocol tree from 'tshark -T pdml' mapped isomorphically to JSON (every attribute survives, values stay strings). tshark reads a /tmp scratch copy with a scratch HOME — hardened profiles deny it the project directory. Sort key is (ts, source file, frame number): ts is not unique across a merge. The ts parameter round-trips as the exact string from the frame list. Round-trip tests pin the PDML→JSON fidelity (element count and attribute coverage). --- docs/README.md | 5 +- docs/features/marker-tag-replay.md | 247 ++++++++++ gns3server/api/routes/controller/projects.py | 83 ++++ gns3server/controller/marker_replay.py | 428 ++++++++++++++++++ gns3server/controller/project.py | 11 + .../routes/controller/test_marker_replay.py | 190 ++++++++ tests/controller/test_marker_replay.py | 346 ++++++++++++++ 7 files changed, 1309 insertions(+), 1 deletion(-) create mode 100644 docs/features/marker-tag-replay.md create mode 100644 gns3server/controller/marker_replay.py create mode 100644 tests/api/routes/controller/test_marker_replay.py create mode 100644 tests/controller/test_marker_replay.py diff --git a/docs/README.md b/docs/README.md index 50c432faf..55b7a9155 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,6 +75,9 @@ 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. @@ -130,4 +133,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_ diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md new file mode 100644 index 000000000..d8439fed0 --- /dev/null +++ b/docs/features/marker-tag-replay.md @@ -0,0 +1,247 @@ + + +> 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
(all markers under tag paused?)"] + SCAN["Timeline scan
(pcap record headers)"] + MAP["PDML → JSON
isomorphic mapper"] + end + + FS[("markers dir
{node}_{link}_{marker}.pcap")] + TS["tshark -T pdml
(one frame at a time)"] + TMP["/tmp scratch copy
(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-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 | + +- **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. +- 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 ``/`` 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). diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index f9012a8ef..0595bde7b 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -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) # --------------------------------------------------------------------------- diff --git a/gns3server/controller/marker_replay.py b/gns3server/controller/marker_replay.py new file mode 100644 index 000000000..993bb31f8 --- /dev/null +++ b/gns3server/controller/marker_replay.py @@ -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 . + +""" +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-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(" 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, + } diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index d9ebdfae8..b69a587b6 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -519,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): """ diff --git a/tests/api/routes/controller/test_marker_replay.py b/tests/api/routes/controller/test_marker_replay.py new file mode 100644 index 000000000..9a19e291d --- /dev/null +++ b/tests/api/routes/controller/test_marker_replay.py @@ -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-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" diff --git a/tests/controller/test_marker_replay.py b/tests/controller/test_marker_replay.py new file mode 100644 index 000000000..e33fb9a8c --- /dev/null +++ b/tests/controller/test_marker_replay.py @@ -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 . + +""" +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("> 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'' + b'' + b'') + + 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 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" From 9147deccaa79a3f3a6b22891179d899f3dbe4d45 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 2 Sep 2026 22:33:23 +0800 Subject: [PATCH 19/29] docs: document that restarting capture nodes truncates marker pcaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker 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 pcap_dump_open truncates. Server restart + project reopen without starting nodes is safe until a uBridge comes up (verified live); Docker nodes restart effectively on server restart via stale-container cleanup. Recorded in the tag-gate table, a lifecycle note, and a pcap_dump_open_append follow-up. --- docs/features/marker-tag-replay.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md index d8439fed0..4d148a5c8 100644 --- a/docs/features/marker-tag-replay.md +++ b/docs/features/marker-tag-replay.md @@ -103,11 +103,19 @@ markers at all → 404. | 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. @@ -244,4 +252,6 @@ the pcap. 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). + 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. From 8b3aafbbdc3701c50e5a8317c49b33bb9bbfa4b4 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:01:18 +0800 Subject: [PATCH 20/29] feat: IOU-style startup-config for IOL Docker nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates reference a config file with the GNS3_IOL_STARTUP_CONFIG environment knob; the controller materializes the file content into startup_config_content on node creation (sent once, knob consumed — the same pattern as the IOU startup_config mapping). The compute builds the content into the node's nvram_ at the next start using the IOU nvram_import utility (IOL and IOU share the nvram container format, verified against iol-xe 17.18.02): valid config at boot, no setup dialog, %h hostname substitution, hostname rewrite on rename. Semantics verified against the runner: IOL boots from NVRAM whenever it holds a config, so a plain stop/start never re-applies the startup config and 'write memory' survives restarts; an explicit content edit (PUT) is re-applied on the next start and wins over the saved config, like IOU. --- docs/features/iol-runner-docker.md | 42 +++++- gns3server/api/routes/compute/docker_nodes.py | 4 +- gns3server/compute/docker/iol_docker_vm.py | 123 +++++++++++++++- gns3server/controller/node.py | 43 ++++++ gns3server/schemas/compute/docker_nodes.py | 5 +- tests/api/routes/compute/test_docker_nodes.py | 46 +++++- tests/compute/docker/test_iol_docker_vm.py | 133 ++++++++++++++++++ tests/controller/test_node.py | 53 +++++++ 8 files changed, 440 insertions(+), 9 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 1b91301b3..03b263282 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -98,7 +98,7 @@ API docs for the auth flow), or in the Web UI under | Field | Value | Why | |---|---|---| -| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | +| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048), `GNS3_IOL_STARTUP_CONFIG=` (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. | @@ -113,9 +113,42 @@ API docs for the auth flow), or in the Web UI under `Linux Unix (i686)` banner within seconds. 3. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one pair per port). -4. The startup-config lives at - `project-files/docker//tmp/run/config` (interface names - `Ethernet0/0`, not `GigabitEthernet0/0`). +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, e.g. `~/.config/GNS3/3.1/configs`) — the same place IOU + and VPCS base configs live. `%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. +* **NVRAM is the source of truth at boot** (verified on the runner): the + content is built into the node's `tmp/run/nvram_` — 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). + * 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//tmp/run/nvram_*`, then re-apply the content + (or recreate the node). ## Server mechanisms @@ -125,6 +158,7 @@ API docs for the auth flow), or in the Web UI under | `GNS3_UNIX_SOCKET_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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/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=` | 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_` 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` diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 5070cd855..4c7f319c3 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -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 diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index ce4c03e64..096f3efad 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -42,12 +42,15 @@ 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 DockerHttp404Error +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__) @@ -66,10 +69,20 @@ class IOLDockerVM(VendorDockerVM): 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_``) 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 def _parse_vendor_environment(self): @@ -97,6 +110,13 @@ class IOLDockerVM(VendorDockerVM): # space, disjoint from IOU's); None until the create payload carries it. self._application_id = None + # Startup-config content materialized by the controller from the file + # referenced by GNS3_IOL_STARTUP_CONFIG. Applied to the NVRAM at start + # time (not in the setter: the application id may not be final yet + # when the create payload is applied field by field). + self._startup_config_content = None + self._startup_config_dirty = False + @property def application_id(self) -> int: """ @@ -114,6 +134,99 @@ class IOLDockerVM(VendorDockerVM): 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: + 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): """ @@ -195,6 +308,14 @@ class IOLDockerVM(VendorDockerVM): 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)): diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index e72dfbc9d..c9189f8d6 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -40,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 = [ @@ -573,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 diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index e3523cad8..e2023d407 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -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") diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index efadd604b..b9fa79fd8 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -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", ( diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index b3aa8cbab..a5022eea2 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -40,6 +40,8 @@ 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 # --------------------------------------------------------------------------- @@ -601,3 +603,134 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage 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 + + +# --------------------------------------------------------------------------- +# 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" diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 9ba4c0ca6..e4ed15bd6 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -72,6 +72,59 @@ def test_docker_iol_ports_grouped_in_four_port_units(compute, project): 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 From 5685f40dd69f2b70f42eb5e6e0ad682ed61d74cb Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:26:04 +0800 Subject: [PATCH 21/29] fix: keep payload-delivered state across container (re)creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create() re-runs _parse_vendor_environment on every (re)create — and a stop removes the container, so every start recreates it. Resetting the controller-allocated application id there silently flipped nodes to the hash fallback after their first stop/start (MAC change, plus collision risk with the allocation pool), and dropped any pending startup-config delivered by a PUT. The application id and startup-config state now default via class attributes instead of being re-initialized by the parser, and the hash fallback is removed outright: starting an IOL node without a coordinated allocation raises an actionable error — an uncoordinated id could collide with the pool and blackhole traffic as a MAC loop. Found in the E2E run: node booted app id 512, came back as 566 (hash fallback) after one stop/start; the PUT-ed config edit vanished the same way. --- docs/features/iol-runner-docker.md | 13 +++--- gns3server/compute/docker/iol_docker_vm.py | 48 ++++++++++++-------- tests/compute/docker/test_iol_docker_vm.py | 52 ++++++++++++++++------ 3 files changed, 75 insertions(+), 38 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 03b263282..3d2c62fbe 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -140,7 +140,10 @@ from it boots with that configuration as its personal starting point. 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). + 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. @@ -175,10 +178,10 @@ diagnosing wiring issues). 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); without an allocation (raw compute API, - pre-allocation topologies) a stable node-derived fallback in the same - range is used. Nodes sharing an ID would silently drop each other's - frames as MAC loops. + 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. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 096f3efad..38042de6d 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -31,8 +31,9 @@ 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; without an allocation a stable node-derived -fallback is used. +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. """ @@ -84,6 +85,17 @@ class IOLDockerVM(VendorDockerVM): # 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() @@ -106,28 +118,17 @@ class IOLDockerVM(VendorDockerVM): except ValueError: pass - # Application ID allocated by the controller (upper half of the id - # space, disjoint from IOU's); None until the create payload carries it. - self._application_id = None - - # Startup-config content materialized by the controller from the file - # referenced by GNS3_IOL_STARTUP_CONFIG. Applied to the NVRAM at start - # time (not in the setter: the application id may not be final yet - # when the create payload is applied field by field). - self._startup_config_content = None - self._startup_config_dirty = False - @property def application_id(self) -> int: """ IOL application ID: drives interface MACs (aabb.cc{app}{iface}) and - the NVRAM file name. Falls back to a stable per-node value in the - IOL Docker half of the id space when no allocation is available - (raw compute API use, topologies created before allocation existed). + 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). """ - if self._application_id is None: - return 512 + int(self.id.replace("-", ""), 16) % 511 return self._application_id @application_id.setter @@ -197,7 +198,7 @@ class IOLDockerVM(VendorDockerVM): start pushes the new content with the already-updated name. """ - if not self._startup_config_dirty: + 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: @@ -302,6 +303,15 @@ class IOLDockerVM(VendorDockerVM): 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() diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index a5022eea2..2bfc4f4ec 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -81,6 +81,8 @@ def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1", 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 @@ -263,7 +265,7 @@ async def test_start_writes_iol_config(compute_project, manager): 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"] == 512 + int(vm.id.replace("-", ""), 16) % 511 + assert config["local-app"] == 700 assert config["remote-app"] == 1023 assert config["memory"] == 2048 assert config["user-id"] == os.getuid() @@ -271,30 +273,33 @@ async def test_start_writes_iol_config(compute_project, manager): assert vm.status == "started" -def test_local_app_is_distinct_per_node(compute_project, manager): - # IOL derives interface MACs from the app ID: two nodes sharing one would - # drop each other's frames as MAC loops, so IDs must differ per node. - vm1 = _make_vm(compute_project, manager) - vm2 = _make_vm(compute_project, manager) - assert vm1.application_id != vm2.application_id - for vm in (vm1, vm2): - assert 512 <= vm.application_id <= 1022 +@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_overrides_fallback(compute_project, manager): +async def test_allocated_application_id_used(compute_project, manager): # The controller allocates the application ID (upper half of the id - # space); the node-derived hash is only a fallback without one. + # space, disjoint from IOU's); the node uses it verbatim. vm = _make_vm(compute_project, manager) - vm.application_id = 700 - assert vm.application_id == 700 + 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"] == 700 + assert config["local-app"] == 701 @pytest.mark.asyncio @@ -734,3 +739,22 @@ def test_asdict_exposes_startup_config_content(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 From e318afef868e7b94c2964e6c025c52c76b01b648 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:36:07 +0800 Subject: [PATCH 22/29] feat: ship an iol-xe-base.txt base config for IOL Docker nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the IOU pattern: the file lives in gns3server/configs/ and the controller installs it into the user's configs directory on startup (never overwriting a customized copy). Referenced by the documented GNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt template knob — without it a fresh install had no file for the knob to resolve and nodes booted to the initial configuration dialog. Also fixes the configs directory named in the docs (~/GNS3/configs, the configs_path server setting default — not ~/.config/GNS3/...). --- docs/features/iol-runner-docker.md | 8 +++++--- gns3server/configs/iol-xe-base.txt | 16 ++++++++++++++++ tests/controller/test_controller.py | 3 +++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 gns3server/configs/iol-xe-base.txt diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 3d2c62fbe..231d6732d 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -128,9 +128,11 @@ from it boots with that configuration as its personal starting point. ``` * The file lives in the controller's configs directory (the `configs_path` - server setting, e.g. `~/.config/GNS3/3.1/configs`) — the same place IOU - and VPCS base configs live. `%h` in the content is replaced with the node - name at start. + 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 diff --git a/gns3server/configs/iol-xe-base.txt b/gns3server/configs/iol-xe-base.txt new file mode 100644 index 000000000..fb275da85 --- /dev/null +++ b/gns3server/configs/iol-xe-base.txt @@ -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 diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py index dad07581e..412a9b019 100644 --- a/tests/controller/test_controller.py +++ b/tests/controller/test_controller.py @@ -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: From 1ccea916c3eec4d38125b2380d5e405543527940 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:46:37 +0800 Subject: [PATCH 23/29] docs: roadmap for docker image types (vendor profiles) Design proposal for an 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, existing markers stay as a compatibility fallback. Explicitly not a new node type. Four profiles already exist to shape it (iol-runner, XRd, SR Linux prototype, FRR appliance); implementation follows the current PR series. --- docs/README.md | 9 ++ docs/design/docker-image-type.md | 144 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 docs/design/docker-image-type.md diff --git a/docs/README.md b/docs/README.md index 55b7a9155..ea1581292 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 @@ -89,6 +91,13 @@ Cisco CML containerized IOL (e.g. `iol-xe/iol-xe:17-18-02`) as GNS3 Docker route --- +## 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/`) ### Implemented Features diff --git a/docs/design/docker-image-type.md b/docs/design/docker-image-type.md new file mode 100644 index 000000000..db00cc345 --- /dev/null +++ b/docs/design/docker-image-type.md @@ -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). From e365427d1b83c36b86e352c8f402e07b5eb2535f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:55:12 +0800 Subject: [PATCH 24/29] docs: spell out IOL startup-config knob consumption in the node environment --- docs/features/iol-runner-docker.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 231d6732d..c5b9fa56d 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -136,7 +136,12 @@ from it boots with that configuration as its personal starting point. * **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. + 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_` — IOL and IOU share the nvram container format, so the server-side `nvram_import` From 8892f9dc91a1fdc4aa96e08246ae05604092e513 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 01:14:40 +0800 Subject: [PATCH 25/29] docs: record IOL console typing latency characteristics --- docs/features/iol-runner-docker.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index c5b9fa56d..893d77493 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -177,6 +177,18 @@ 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. From e12ef7f27275980bfcf14df83720f8019ed6728a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 21:58:44 +0800 Subject: [PATCH 26/29] tests: expect port_number in adapter carrier calls Upstream #2870's carrier tests assert the two-argument _set_adapter_carrier call; the 4-port-unit commit threads port_number through every carrier call site, so single-port adapters now pass 0. --- tests/compute/docker/test_docker_vm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 71dbc2c39..6c6e109a1 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -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 From 27b55b72f9dbd3117d5593df6db540f3a835f1d6 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 22:19:22 +0800 Subject: [PATCH 27/29] fix: keep upstream link-carrier commands off unix-socket NIO bridges Upstream #2870 added TAP carrier control (set_nio_tap_carrier) and a busybox interface-status monitor to the Docker base class. Both assume TAP wiring: unix-socket NIO bridges carry no TAP NIO, so uBridge rejects the carrier command ('bridge has no TAP NIO') and every link create, update or delete on a running iol-runner node would fail; the monitor polls eth{adapter} interfaces that do not exist in the container's network namespace (or misreports the Docker default eth0 as adapter 0). VendorDockerVM now short-circuits _set_adapter_carrier and the interface monitor under GNS3_UNIX_SOCKET_NIO; TAP-wired vendor nodes keep the base behavior. --- gns3server/compute/docker/vendor_docker_vm.py | 32 ++++++++++ tests/compute/docker/test_iol_docker_vm.py | 61 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index d6157eea4..b156209ae 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -454,6 +454,38 @@ class VendorDockerVM(DockerVM): 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 diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 2bfc4f4ec..4826b00a7 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -610,6 +610,67 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage 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 # --------------------------------------------------------------------------- From a841175fbf67b6d3503da5bca72504be935e6b10 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 22:52:02 +0800 Subject: [PATCH 28/29] feat(copilot): make start_gns3_node immediate-return, add wait_seconds tool The waiting variant blocked on a fixed progress bar (up to 120s) even when every start command had already failed (e.g. the uBridge >= 1.2.3 409), burying the error behind two minutes of silence. Drop it: the single start_gns3_node now sends the commands and returns per-node results immediately (GNS3StartNodeQuickTool kept as an alias). Boot waiting moves to a dedicated wait_seconds tool the agent calls explicitly between start and status checks, with a 600s ceiling and liveness logging every 5s. --- .../agent/gns3_copilot/agent/gns3_copilot.py | 7 +- .../agent/gns3_copilot/tools_v2/__init__.py | 5 +- .../gns3_copilot/tools_v2/gns3_start_node.py | 327 ++---------------- .../agent/gns3_copilot/tools_v2/gns3_wait.py | 109 ++++++ 4 files changed, 140 insertions(+), 308 deletions(-) create mode 100644 gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index b5173668c..7478301f8 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -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 diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py index 49957fc79..18f122727 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/__init__.py +++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py @@ -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", ] diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index 8e749d35a..e7ae2dd82 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -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": "", # 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) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py new file mode 100644 index 000000000..d31e9e6d2 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py @@ -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 . +# +# 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": }. + """ + + 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)}"} From a8c96dd3f72ddae29c41bb0e1ef0545a000b0b51 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 6 Sep 2026 00:15:43 +0800 Subject: [PATCH 29/29] fix: keep port_number in Docker NIO dispatch of the batch endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project-open bulk path (_add_nio_binding / _get_existing_nio / _update_nio_binding in routes/compute/projects.py) dropped port_number for Docker nodes, unlike the per-node routes and the IOU branch. With multi-port docker adapters (iol-runner nodes model 4 ports per adapter, 0ca9ccc63) every batched NIO landed on port 0 where Adapter.add_nio() silently overwrites — the last entry per node won — so reopening a project clobbered the port-0 links with the port-1 NIOs: links ended up cross-wired between the wrong node pairs and the real links died (IOL direct-link ping failures after close/reopen, EXCESSCOLL storms from the phantom loops). --- gns3server/api/routes/compute/projects.py | 18 ++++++++++--- tests/api/routes/compute/test_projects.py | 31 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index 7da980a08..54d7e460e 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -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) diff --git a/tests/api/routes/compute/test_projects.py b/tests/api/routes/compute/test_projects.py index 949c0d891..6131db742 100644 --- a/tests/api/routes/compute/test_projects.py +++ b/tests/api/routes/compute/test_projects.py @@ -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."""