From 9262dcfaef94b3cd99dbfa1022e07edd252ded73 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 1 Sep 2026 01:34:19 +0800 Subject: [PATCH 1/3] 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 2/3] 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 d4f2a429f409e9060255afe538c5b1948105e5c7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 2 Sep 2026 22:33:23 +0800 Subject: [PATCH 3/3] 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.