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..4d148a5c8
--- /dev/null
+++ b/docs/features/marker-tag-replay.md
@@ -0,0 +1,257 @@
+
+
+> 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 |
+| capture node (re)started | pcap reopened (truncated) — new session | prior history gone |
+
+- **Pause, not delete.** Deleting a marker (or its definition) deletes its pcap — replay
+ before deleting or the data is gone.
+- **Pause → resume → pause is fine.** The pcap accumulates the full history; replay covers
+ everything up to the current pause point.
+- **The replay window ends when nodes restart.** A pcap's lifetime equals its uBridge's
+ lifetime: a fresh uBridge reinstalls every desired marker — paused ones too (install
+ first, then turn the filter off) — and uBridge opens the pcap with truncate semantics
+ (`pcap_dump_open`, not `_append`). Server restart + project reopen **without starting
+ nodes** is safe: nothing touches the files until a uBridge comes up (verified live).
+ Docker nodes effectively restart on server restart as well (stale-container cleanup),
+ so their window is shorter still.
+- uBridge flushes every matched packet to the pcap immediately (`pcap_dump_flush` per
+ packet under a mutex — verified in the uBridge source), so a pause boundary never loses
+ tail frames.
+
+## API Endpoints
+
+All read-only; JWT bearer token, privilege `Project.Audit`.
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/range` | Timeline metadata + full frame list for the tag |
+| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frames?ts=&window_ms=&limit=` | Frames with ts in `[T, T+window]`, merged across sources |
+| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frame/detail?ts=&node_id=&link_id=&marker=` | Single frame: tshark protocol tree + raw hex |
+
+### `range` — the timeline
+
+```json
+{
+ "tag": 666,
+ "start": "1788196663.226372",
+ "end": "1788196713.706634",
+ "frame_count": 20,
+ "truncated": false,
+ "sources": [
+ { "node_id": "b764c434…", "link_id": "316ef8fd…", "marker": "global-def-…",
+ "data_link_type": "DLT_EN10MB", "count": 10 }
+ ],
+ "frames": [
+ { "ts": "1788196663.226372", "len": 98, "node_id": "b764c434…",
+ "link_id": "316ef8fd…", "marker": "global-def-…", "frame_number": 1 }
+ ]
+}
+```
+
+- `frames` is the **full, merged, time-ordered list** (cap 5000) — the Web UI lays out the
+ whole timeline from one request. Over the cap, `frames` is omitted and per-second
+ `buckets` are returned instead with `truncated: true`.
+- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating
+ tuple for the detail request.
+
+### `frames` — point / window query
+
+A time with no frames is a normal, successful answer — an empty array, no sentinel strings:
+
+```json
+GET …/replay/frames?ts=1788196700.000&window_ms=500
+→ { "frames": [] }
+```
+
+### `frame/detail` — lazy single-frame decode
+
+Invoked only when the user opens a frame. The `ts` must be the **exact string received in
+the timeline/frame list** (round-tripped verbatim — never re-serialized through a float);
+`node_id + link_id + marker` identify the pcap. The server re-resolves the ts against the
+file, guarding against a capture rebuilt between the timeline view and this click.
+
+```json
+{
+ "ts": "1788196663.226372",
+ "source": { "node_id": "b764c434…", "link_id": "316ef8fd…",
+ "marker": "global-def-…", "frame_number": 1 },
+ "tshark_version": "TShark (Wireshark) 4.6.7 …",
+ "field_count": 85,
+ "hex": "45000062…",
+ "tree": [
+ { "element": "proto", "name": "ip",
+ "showname": "Internet Protocol Version 4, Src: 10.1.10.101, Dst: 203.0.113.1",
+ "children": [
+ { "element": "field", "name": "ip.ttl", "show": "64",
+ "showname": "Time to Live: 64", "value": "40", "size": "1",
+ "pos": "22", "children": [] }
+ ] }
+ ]
+}
+```
+
+- `tree` mirrors PDML **isomorphically**: every ``/`` becomes a node, every
+ XML attribute (`name`, `show`, `showname`, `value`, `size`, `pos`, `hide`, `mask`,
+ `unmaskedvalue`) becomes a JSON key, plus one structural key `element` (proto/field).
+ Nothing is selected out, nothing interpreted, and **all values stay strings** — numeric
+ conversion is the client's business.
+- `hex` is the raw frame bytes read straight from the pcap (not via tshark); keeping
+ `pos`/`size` on every field enables Wireshark-style *click field → highlight bytes*.
+- `field_count` is the mapped node count — a client-side sanity check.
+
+## Ordering and timestamps
+
+- `ts` is the pcap record timestamp (µs) written by uBridge at match time — a userspace
+ `gettimeofday()` instant measured after the packet has crossed the kernel twice. The
+ last digit or two are scheduling noise; microseconds are sufficient in a simulated
+ environment.
+- The sort key is `(ts, source file, frame_number)` — ts alone is **not** unique (two
+ links can hit the same microsecond); the tiebreaker yields a stable, determined order
+ instead of a fictional one. Index structures must never use ts as a dict key, or
+ same-microsecond frames silently overwrite each other.
+- The cross-link delta is the intermediate node's end-to-end forwarding latency
+ (veth/TAP → guest protocol stack → back to host), typically hundreds of microseconds to
+ milliseconds. UI labels should read *node forwarding latency (host view)*, not link
+ propagation delay. A live capture pair confirmed it end-to-end: same `ip.id`,
+ TTL 64→63, 509 µs between two links.
+
+## Fidelity guarantee (PDML → JSON)
+
+The conversion is an isomorphic structure map, not a semantic transform, with two rules:
+**map every attribute** and **keep values as strings**. A round-trip test enforces both:
+PDML element count equals JSON node count, and every XML attribute survives with an
+identical JSON value (`tests/controller/test_marker_replay.py`). The raw frame bytes —
+the one thing PDML genuinely does not contain — are covered by `hex` read directly from
+the pcap.
+
+## Error Responses
+
+| Status | Description |
+|--------|-------------|
+| 401 | Not authenticated |
+| 404 | Tag has no markers in the project; detail source unknown, or ts does not match the file (the capture may have been rebuilt) |
+| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them) |
+| 501 | tshark not installed / unavailable — affects detail only; the timeline never needs tshark |
+| 502 | tshark failed or timed out (10 s); truncated output never reaches the mapper |
+
+## Notes
+
+- **Heterogeneous link types coexist.** Frames are never merged into a single pcap
+ (mergecap is deliberately not used) — each frame carries its source and is decoded
+ individually, so Ethernet and serial (cHDLC/PPP) markers can share one timeline.
+ Malformed packets are tshark's problem: it emits `[Malformed Packet]` as regular PDML
+ and carries on.
+- **Hardened tshark profiles.** openSUSE-style profiles (AppArmor &c.) can deny tshark
+ access to the project directory and the user's home even though the server process can
+ read both. The detail path therefore copies the pcap to a real file under `/tmp`
+ (not a symlink — the profile resolves real paths) and gives tshark a scratch `HOME`;
+ the copy is unlinked afterwards. The hex view still reads the original file.
+- **Tag type.** REST and the `marker.match` WS event both carry `tag` as `int` (the
+ listener normalizes); replay keys on that int value.
+- **Follow-ups.** Remote-compute support via the existing capture-file proxy pattern;
+ convenience APIs (`GET …/markers/tags` to list tags, `POST …/markers/tags/{tag}/pause`
+ to batch-pause — a one-call path to the replayable state); uBridge-side
+ `pcap_dump_open_append` (with a linktype-header check on the existing file) so capture
+ history survives node restarts instead of being truncated on every reinstall.
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/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/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/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 —
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"