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