mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-25 21:30:21 +03:00
feat: drive marker replay with resident sharkd sessions
sharkd (the Wireshark daemon) is now the single decode engine — the tshark/PDML path is gone, and without sharkd every replay endpoint returns 501 (no degraded mode: one engine, one rendering shape for the Web UI). - Frame entries gain packet-list columns from sharkd's frames RPC: src/dst/proto/info plus the Wireshark coloring hints bg/fg - range and frames accept ?filter=<display filter>, applied before counting and slicing; invalid expressions are 400 carrying sharkd's original text; filters travel as single argv-style elements, capped at 2000 chars; filtered frames keep their original pcap frame numbers - frame detail returns sharkd's protocol tree with keys renamed into the REST contract (element/label/name/filter_expr/pos+size/expert/ generated/children): a census-verified closed key set, values untouched, unknown keys passed through verbatim, Wireshark-internal hf ids dropped. filter_expr gives the UI click-to-filter; pos/size drives hex highlighting (hex still read straight from the pcap) - one resident 'sharkd -' session per source pcap: lazy spawn, /tmp scratch copy + scratch HOME (hardened profiles), per-request (mtime,size) validation with respawn, LRU bound, per-session lock, per-RPC timeout, bounded close Timeline backbone (gate, record-header scan, merge ordering, canonical ts strings, hex reads) stays plain Python — identity and ordering never depend on the engine.
This commit is contained in:
parent
0c540abbf2
commit
790c423c26
@ -12,14 +12,18 @@ See LICENSE file for licensing information.
|
|||||||
Replays traffic captured by [markers](marker-traffic-insight.md) **across links**, keyed by
|
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*;
|
`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
|
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
|
timestamp-ordered timeline. The Web UI browses that timeline (with Wireshark-style packet
|
||||||
on demand — each fetch decodes exactly one frame via `tshark` into a self-describing JSON
|
list columns) and fetches individual frames on demand — each fetch decodes exactly one
|
||||||
protocol tree.
|
frame via the resident **sharkd** daemon into a self-describing JSON protocol tree.
|
||||||
|
|
||||||
The unique observable: the delta between the same packet hitting two consecutive links
|
The unique observable: the delta between the same packet hitting two consecutive links
|
||||||
measures the **intermediate node's forwarding latency** (host view) — something a
|
measures the **intermediate node's forwarding latency** (host view) — something a
|
||||||
single-link capture can never show.
|
single-link capture can never show.
|
||||||
|
|
||||||
|
**sharkd is a hard requirement** (part of the Wireshark package). Without it every replay
|
||||||
|
endpoint returns 501 — there is deliberately no degraded mode; one engine, one rendering
|
||||||
|
shape for the Web UI.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@ -28,35 +32,38 @@ graph TB
|
|||||||
|
|
||||||
subgraph Controller["Controller (replay endpoints)"]
|
subgraph Controller["Controller (replay endpoints)"]
|
||||||
GATE["Tag gate<br/>(all markers under tag paused?)"]
|
GATE["Tag gate<br/>(all markers under tag paused?)"]
|
||||||
SCAN["Timeline scan<br/>(pcap record headers)"]
|
SCAN["Timeline backbone<br/>(pcap record-header scan,<br/>merge ordering, hex reads)"]
|
||||||
MAP["PDML → JSON<br/>isomorphic mapper"]
|
SESS["sharkd sessions<br/>(one per source pcap)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
FS[("markers dir<br/>{node}_{link}_{marker}.pcap")]
|
FS[("markers dir<br/>{node}_{link}_{marker}.pcap")]
|
||||||
TS["tshark -T pdml<br/>(one frame at a time)"]
|
TMP["/tmp scratch copies<br/>(hardened-profile workaround)"]
|
||||||
TMP["/tmp scratch copy<br/>(hardened-profile workaround)"]
|
SK["sharkd -<br/>(resident JSON-RPC on stdio)"]
|
||||||
|
|
||||||
UI -->|"GET range / frames"| GATE
|
UI -->|"GET range / frames [?filter=]"| GATE
|
||||||
GATE --> SCAN
|
GATE --> SCAN
|
||||||
SCAN --> FS
|
SCAN -->|"columns / filter matches"| SESS
|
||||||
UI -->|"GET frame detail (lazy)"| MAP
|
SESS --> TMP --> SK
|
||||||
MAP --> TMP
|
SCAN -->|"hex: raw bytes"| FS
|
||||||
TMP --> TS
|
UI -->|"GET frame detail (lazy)"| SESS
|
||||||
MAP -->|"hex: raw bytes"| FS
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Two deliberately separated performance regimes:
|
Backbone vs engine, deliberately separated:
|
||||||
|
|
||||||
| Path | Work | tshark |
|
- **Timeline backbone** (plain Python): the tag gate, 16-byte-per-frame pcap record-header
|
||||||
|------|------|--------|
|
scan, cross-source merge ordering, canonical ts strings, and raw-bytes reads for the hex
|
||||||
| Timeline | 16-byte record-header scan per frame, cross-file merge sort | **never invoked** — browsing works without tshark |
|
view. Identity and ordering never depend on the engine.
|
||||||
| Frame detail | locate frame → `tshark -T pdml -Y "frame.number == N"` → map to JSON | forked once per frame the user opens |
|
- **Engine layer (sharkd)**: packet-list columns, display filters, and per-frame protocol
|
||||||
|
trees. One resident `sharkd -` process per source pcap (sharkd loads one file at a
|
||||||
|
time), spawned lazily on first use, addressed with one-line JSON-RPC on stdio.
|
||||||
|
|
||||||
tshark call count equals user clicks — no caching or rate limiting needed, and a tshark
|
Session lifecycle: each session is validated per request against the source pcap's
|
||||||
failure (501/502) affects only that one frame, never timeline browsing. pcap files are
|
`(mtime, size)` — a mismatch (e.g. the capture node restarted and uBridge truncated the
|
||||||
compute-side (`<project>/project-files/markers/`); the initial scope is single-server
|
pcap while paused) kills and respawns it. Sessions are LRU-bounded (8), each RPC has a
|
||||||
deployments (controller and compute in one process, direct file access) — remote computes
|
timeout and is serialized by a per-session lock (sharkd serves one request at a time).
|
||||||
will reuse the existing capture-file proxy pattern.
|
sharkd reads a `/tmp` scratch copy of the pcap with a scratch `HOME` — hardened profiles
|
||||||
|
(AppArmor &c.) deny it the project directory and the user's home even though the server
|
||||||
|
process can read both.
|
||||||
|
|
||||||
## Business Process
|
## Business Process
|
||||||
|
|
||||||
@ -64,29 +71,22 @@ will reuse the existing capture-file proxy pattern.
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant UI as Web UI
|
participant UI as Web UI
|
||||||
participant C as Controller
|
participant C as Controller
|
||||||
participant PC as markers dir (pcaps)
|
participant SK as sharkd session
|
||||||
participant TS as tshark
|
|
||||||
|
|
||||||
Note over UI,PC: ① configure — same tag on every link's marker
|
Note over UI: ① configure — same tag on every link's marker
|
||||||
UI->>C: POST markers (bpf, tag=666) on each link
|
Note over C: ② capture — uBridge appends matches, replay forbidden (409)
|
||||||
|
Note over UI: ③ pause every marker under the tag
|
||||||
|
|
||||||
Note over PC: ② capture — uBridge appends matches (flushed per packet), replay forbidden
|
Note over UI,SK: ④ replay
|
||||||
UI->>C: GET range
|
UI->>C: GET /markers/tags/666/replay/range[?filter=…]
|
||||||
C--xC: 409 (a marker under tag 666 is still enabled)
|
C->>C: gate → scan record headers → merge order
|
||||||
|
C->>SK: frames {filter, skip, limit} → columns + matches
|
||||||
Note over UI: ③ pause — every marker under the tag
|
C-->>UI: {start, end, sources, frames[] with src/dst/proto/info/bg/fg}
|
||||||
UI->>C: PUT markers {"enabled": false} × each
|
UI->>C: GET frames?ts=T&window_ms=W (paging — {"frames": []} on a miss)
|
||||||
|
|
||||||
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=…
|
UI->>C: GET frame/detail?ts=…&node_id=…&link_id=…&marker=…
|
||||||
C->>PC: locate frame, read raw bytes (hex)
|
C->>C: hex straight from the pcap
|
||||||
C->>TS: -T pdml (reads the /tmp copy)
|
C->>SK: frame {frame: N, proto: true}
|
||||||
TS-->>C: PDML
|
SK-->>C: tree (keys renamed to the REST contract)
|
||||||
C-->>UI: protocol tree + hex
|
C-->>UI: protocol tree + hex
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -110,25 +110,22 @@ markers at all → 404.
|
|||||||
- **Pause → resume → pause is fine.** The pcap accumulates the full history; replay covers
|
- **Pause → resume → pause is fine.** The pcap accumulates the full history; replay covers
|
||||||
everything up to the current pause point.
|
everything up to the current pause point.
|
||||||
- **The replay window ends when nodes restart.** A pcap's lifetime equals its uBridge's
|
- **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
|
lifetime: a fresh uBridge reinstalls every desired marker — paused ones too — and
|
||||||
first, then turn the filter off) — and uBridge opens the pcap with truncate semantics
|
uBridge opens the pcap with truncate semantics (`pcap_dump_open`, not `_append`). Server
|
||||||
(`pcap_dump_open`, not `_append`). Server restart + project reopen **without starting
|
restart + project reopen **without starting nodes** is safe: nothing touches the files
|
||||||
nodes** is safe: nothing touches the files until a uBridge comes up (verified live).
|
until a uBridge comes up. Docker nodes effectively restart on server restart as well
|
||||||
Docker nodes effectively restart on server restart as well (stale-container cleanup),
|
(stale-container cleanup), so their window is shorter still.
|
||||||
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
|
## API Endpoints
|
||||||
|
|
||||||
All read-only; JWT bearer token, privilege `Project.Audit`.
|
All read-only; JWT bearer token, privilege `Project.Audit`. All require sharkd — 501
|
||||||
|
without it.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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/range[?filter=]` | Timeline metadata + full merged frame list with packet-list columns |
|
||||||
| 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/frames?ts=&window_ms=&limit=[&filter=]` | 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 |
|
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frame/detail?ts=&node_id=&link_id=&marker=` | Single frame: protocol tree + raw hex (lazy — one call per frame the user opens) |
|
||||||
|
|
||||||
### `range` — the timeline
|
### `range` — the timeline
|
||||||
|
|
||||||
@ -144,62 +141,95 @@ All read-only; JWT bearer token, privilege `Project.Audit`.
|
|||||||
"data_link_type": "DLT_EN10MB", "count": 10 }
|
"data_link_type": "DLT_EN10MB", "count": 10 }
|
||||||
],
|
],
|
||||||
"frames": [
|
"frames": [
|
||||||
{ "ts": "1788196663.226372", "len": 98, "node_id": "b764c434…",
|
{ "ts": "1788196663.226372", "len": 98,
|
||||||
"link_id": "316ef8fd…", "marker": "global-def-…", "frame_number": 1 }
|
"node_id": "b764c434…", "link_id": "316ef8fd…",
|
||||||
|
"marker": "global-def-…", "frame_number": 1,
|
||||||
|
"src": "10.1.10.101", "dst": "203.0.113.1",
|
||||||
|
"proto": "ICMP", "info": "Echo (ping) request id=0x6ed5, seq=1/0, ttl=64",
|
||||||
|
"bg": "ffffff", "fg": "000000" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `frames` is the **full, merged, time-ordered list** (cap 5000) — the Web UI lays out the
|
- `frames` is the **full, merged, time-ordered list** (cap 5000) — one request lays out
|
||||||
whole timeline from one request. Over the cap, `frames` is omitted and per-second
|
the whole timeline. Over the cap, `frames` is omitted and per-second `buckets` are
|
||||||
`buckets` are returned instead with `truncated: true`.
|
returned instead with `truncated: true` (check with `'frames' in response`, not null).
|
||||||
|
- Every frame entry carries the Wireshark packet-list columns — `src` / `dst` / `proto`
|
||||||
|
/ `info` — plus the **coloring-rule hints `bg` / `fg`** (Wireshark's own palette
|
||||||
|
decisions, so the UI can color rows exactly like Wireshark without shipping the
|
||||||
|
colorization engine). Columns are `null` only for a frame the engine could not describe.
|
||||||
- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating
|
- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating
|
||||||
tuple for the detail request.
|
tuple for the detail request, and the link association for timeline/topology rendering
|
||||||
|
(`link_id` joins the Web UI's own link objects).
|
||||||
|
|
||||||
### `frames` — point / window query
|
### Display filter
|
||||||
|
|
||||||
A time with no frames is a normal, successful answer — an empty array, no sentinel strings:
|
`?filter=<expression>` on both `range` and `frames` is a Wireshark display filter,
|
||||||
|
applied **before** counting and slicing — `start` / `end` / `frame_count` /
|
||||||
|
`frames` | `buckets` are all computed on the matching frames only. Filtered frames keep
|
||||||
|
their original pcap frame numbers. The filter travels as one argv-style element (never
|
||||||
|
through a shell) and is capped at 2000 characters. An invalid expression is a **400**
|
||||||
|
whose message carries sharkd's original error text — suitable for inline display in the
|
||||||
|
filter bar, and distinct from the 409 gate / 404 unknown-tag semantics.
|
||||||
|
|
||||||
|
### `frames` — point / window query (paging)
|
||||||
|
|
||||||
|
A time with no frames is a normal, successful answer — an empty array, no sentinel
|
||||||
|
strings:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
GET …/replay/frames?ts=1788196700.000&window_ms=500
|
GET …/replay/frames?ts=1788196700.000&window_ms=500
|
||||||
→ { "frames": [] }
|
→ { "frames": [] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Paging is deliberately **ts + window_ms only** (no offset/limit over the filtered set):
|
||||||
|
the merge spans multiple pcaps, so slicing happens server-side on the merged stream
|
||||||
|
either way, windows align with timeline semantics, and the gate freezes the data (the
|
||||||
|
window answer is deterministic).
|
||||||
|
|
||||||
### `frame/detail` — lazy single-frame decode
|
### `frame/detail` — lazy single-frame decode
|
||||||
|
|
||||||
Invoked only when the user opens a frame. The `ts` must be the **exact string received in
|
Invoked only when the user opens a frame. The `ts` must be the **exact string received
|
||||||
the timeline/frame list** (round-tripped verbatim — never re-serialized through a float);
|
in the timeline/frame list** (round-tripped verbatim — never re-serialized through a
|
||||||
`node_id + link_id + marker` identify the pcap. The server re-resolves the ts against the
|
float); `node_id + link_id + marker` identify the pcap. The server re-resolves the ts
|
||||||
file, guarding against a capture rebuilt between the timeline view and this click.
|
against the file, guarding against a capture rebuilt between the timeline view and this
|
||||||
|
click.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ts": "1788196663.226372",
|
"ts": "1788196663.226372",
|
||||||
"source": { "node_id": "b764c434…", "link_id": "316ef8fd…",
|
"source": { "node_id": "b764c434…", "link_id": "316ef8fd…",
|
||||||
"marker": "global-def-…", "frame_number": 1 },
|
"marker": "global-def-…", "frame_number": 1 },
|
||||||
"tshark_version": "TShark (Wireshark) 4.6.7 …",
|
|
||||||
"field_count": 85,
|
"field_count": 85,
|
||||||
"hex": "45000062…",
|
"hex": "00005e00010a…",
|
||||||
"tree": [
|
"tree": [
|
||||||
{ "element": "proto", "name": "ip",
|
{ "element": "proto", "label": "Internet Protocol Version 4, …", "children": [
|
||||||
"showname": "Internet Protocol Version 4, Src: 10.1.10.101, Dst: 203.0.113.1",
|
{ "element": "field", "name": "ip.ttl", "label": "Time to Live: 64",
|
||||||
"children": [
|
"filter_expr": "ip.ttl == 64", "pos": 22, "size": 1, "children": [] }
|
||||||
{ "element": "field", "name": "ip.ttl", "show": "64",
|
|
||||||
"showname": "Time to Live: 64", "value": "40", "size": "1",
|
|
||||||
"pos": "22", "children": [] }
|
|
||||||
] }
|
] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `tree` mirrors PDML **isomorphically**: every `<proto>`/`<field>` becomes a node, every
|
The tree is sharkd's protocol tree with **keys renamed into the REST contract** — a
|
||||||
XML attribute (`name`, `show`, `showname`, `value`, `size`, `pos`, `hide`, `mask`,
|
closed, protocol-independent key set (census-verified across ICMP / TCP / VLAN+OSPF
|
||||||
`unmaskedvalue`) becomes a JSON key, plus one structural key `element` (proto/field).
|
trees and pinned by a test), with values untouched:
|
||||||
Nothing is selected out, nothing interpreted, and **all values stay strings** — numeric
|
|
||||||
conversion is the client's business.
|
| sharkd | Contract key | Meaning |
|
||||||
- `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*.
|
| `t` | `element` | node type (`proto`, …) |
|
||||||
- `field_count` is the mapped node count — a client-side sanity check.
|
| `l` | `label` | display text |
|
||||||
|
| `fn` | `name` | field name (`ip.ttl`) |
|
||||||
|
| `f` | `filter_expr` | **ready-made display filter with the value baked in** — click-to-filter |
|
||||||
|
| `h` | `pos` + `size` | byte range — click field → highlight hex bytes |
|
||||||
|
| `s` | `expert` | expert severity name (`Chat` / `Warn` / …) |
|
||||||
|
| `g` | `generated` | generated-by-Wireshark flag |
|
||||||
|
| `n` | `children` | nested fields |
|
||||||
|
| `e` | *(dropped)* | Wireshark-internal hf id, unstable across versions |
|
||||||
|
|
||||||
|
Unknown keys from a newer Wireshark pass through verbatim (never silently dropped); a
|
||||||
|
census test flags new keys for naming. `hex` is the raw frame bytes read straight from
|
||||||
|
the pcap; `field_count` is the mapped node count (client-side sanity check).
|
||||||
|
|
||||||
## Ordering and timestamps
|
## Ordering and timestamps
|
||||||
|
|
||||||
@ -209,45 +239,33 @@ file, guarding against a capture rebuilt between the timeline view and this clic
|
|||||||
environment.
|
environment.
|
||||||
- The sort key is `(ts, source file, frame_number)` — ts alone is **not** unique (two
|
- 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
|
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
|
instead of a fictional one.
|
||||||
same-microsecond frames silently overwrite each other.
|
|
||||||
- The cross-link delta is the intermediate node's end-to-end forwarding latency
|
- 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
|
(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
|
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`,
|
propagation delay. A live capture pair confirmed it end-to-end: same `ip.id`,
|
||||||
TTL 64→63, 509 µs between two links.
|
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
|
## Error Responses
|
||||||
|
|
||||||
| Status | Description |
|
| Status | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
|
| 400 | Invalid display filter (message carries sharkd's original text) or filter longer than 2000 chars |
|
||||||
| 401 | Not authenticated |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 501 | sharkd not installed / unavailable — replay is unavailable, no degraded mode |
|
||||||
| 502 | tshark failed or timed out (10 s); truncated output never reaches the mapper |
|
| 502 | sharkd failed or timed out (10 s per RPC) |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Heterogeneous link types coexist.** Frames are never merged into a single pcap
|
- **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
|
(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.
|
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
|
Malformed packets are dissected like any other; sharkd marks them in the tree.
|
||||||
and carries on.
|
- **Session invalidation is cheap and total.** Every request stats the source pcap; a
|
||||||
- **Hardened tshark profiles.** openSUSE-style profiles (AppArmor &c.) can deny tshark
|
rewritten file (mtime/size change) respawns the session — a paused-but-restarted
|
||||||
access to the project directory and the user's home even though the server process can
|
capture can never serve stale dissect state.
|
||||||
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
|
- **Tag type.** REST and the `marker.match` WS event both carry `tag` as `int` (the
|
||||||
listener normalizes); replay keys on that int value.
|
listener normalizes); replay keys on that int value.
|
||||||
- **Follow-ups.** Remote-compute support via the existing capture-file proxy pattern;
|
- **Follow-ups.** Remote-compute support via the existing capture-file proxy pattern;
|
||||||
|
|||||||
@ -45,7 +45,7 @@ from gns3server.controller.controller_error import ControllerError, ControllerBa
|
|||||||
from gns3server.controller.import_project import import_project as import_controller_project
|
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.export_project import export_project as export_controller_project
|
||||||
from gns3server.controller import marker_replay
|
from gns3server.controller import marker_replay
|
||||||
from gns3server.controller.marker_replay import TsharkError, TsharkMissingError
|
from gns3server.controller.marker_replay import SharkdError, SharkdMissingError
|
||||||
from gns3server.utils.asyncio import aiozipstream
|
from gns3server.utils.asyncio import aiozipstream
|
||||||
from gns3server.utils.path import is_safe_path
|
from gns3server.utils.path import is_safe_path
|
||||||
from gns3server.db.repositories.templates import TemplatesRepository
|
from gns3server.db.repositories.templates import TemplatesRepository
|
||||||
@ -221,36 +221,62 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict:
|
|||||||
return project.markers
|
return project.markers
|
||||||
|
|
||||||
|
|
||||||
|
async def _replay_response(awaitable):
|
||||||
|
"""Shared engine-error mapping for the replay endpoints: 501 when sharkd
|
||||||
|
(the hard engine requirement) is unavailable, 502 when it fails. Data
|
||||||
|
state errors (409 gate / 404 unknown tag) and filter errors (400) map
|
||||||
|
through the global handlers before this."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await awaitable
|
||||||
|
except SharkdMissingError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e))
|
||||||
|
except SharkdError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/markers/tags/{tag}/replay/range",
|
"/{project_id}/markers/tags/{tag}/replay/range",
|
||||||
dependencies=[Depends(has_privilege("Project.Audit"))],
|
dependencies=[Depends(has_privilege("Project.Audit"))],
|
||||||
)
|
)
|
||||||
def replay_tag_range(tag: int, project: Project = Depends(dep_project)) -> dict:
|
async def replay_tag_range(
|
||||||
|
tag: int,
|
||||||
|
filter: Optional[str] = None,
|
||||||
|
project: Project = Depends(dep_project),
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Aggregate replay timeline for a tag: merges the pcap of every marker
|
Aggregate replay timeline for a tag: merges the pcap of every marker
|
||||||
carrying ``tag`` into one timestamp-ordered view (design reference:
|
carrying ``tag`` into one timestamp-ordered view. Every frame entry
|
||||||
``marker_replay`` module docstring).
|
carries Wireshark-style columns (``src`` / ``dst`` / ``proto`` / ``info``
|
||||||
|
plus coloring hints ``bg`` / ``fg``).
|
||||||
|
|
||||||
The tag gate applies: every marker under the tag must be paused
|
The tag gate applies: every marker under the tag must be paused
|
||||||
(``enabled: false``) — 409 otherwise. The response carries the timeline
|
(``enabled: false``) — 409 otherwise. The response carries the timeline
|
||||||
bounds, per-source stats, and the full merged frame list while under the
|
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.
|
frame cap (5000); above it the list is replaced by per-second buckets.
|
||||||
|
|
||||||
|
``filter`` is an optional Wireshark display filter applied **before**
|
||||||
|
counting and slicing — start / end / frame_count / frames | buckets are
|
||||||
|
all computed on the matching frames only. An invalid expression is a 400
|
||||||
|
carrying sharkd's original error text (for inline display in the UI
|
||||||
|
filter bar). Requires sharkd — 501 without it.
|
||||||
|
|
||||||
Required privilege: Project.Audit
|
Required privilege: Project.Audit
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return marker_replay.build_timeline(project, tag)
|
return await _replay_response(marker_replay.build_timeline(project, tag, filter_expr=filter))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/markers/tags/{tag}/replay/frames",
|
"/{project_id}/markers/tags/{tag}/replay/frames",
|
||||||
dependencies=[Depends(has_privilege("Project.Audit"))],
|
dependencies=[Depends(has_privilege("Project.Audit"))],
|
||||||
)
|
)
|
||||||
def replay_tag_frames(
|
async def replay_tag_frames(
|
||||||
tag: int,
|
tag: int,
|
||||||
ts: str,
|
ts: str,
|
||||||
window_ms: int = 100,
|
window_ms: int = 100,
|
||||||
limit: int = 1000,
|
limit: int = 1000,
|
||||||
|
filter: Optional[str] = None,
|
||||||
project: Project = Depends(dep_project),
|
project: Project = Depends(dep_project),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
@ -259,12 +285,16 @@ def replay_tag_frames(
|
|||||||
``{"frames": []}``. The tag gate applies (409 while any marker captures).
|
``{"frames": []}``. The tag gate applies (409 while any marker captures).
|
||||||
|
|
||||||
``ts`` must be the exact string returned by the range response — never
|
``ts`` must be the exact string returned by the range response — never
|
||||||
re-serialize it through a float.
|
re-serialize it through a float. ``filter`` (optional display filter) has
|
||||||
|
the same semantics as on the range endpoint. Requires sharkd — 501
|
||||||
|
without it.
|
||||||
|
|
||||||
Required privilege: Project.Audit
|
Required privilege: Project.Audit
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return marker_replay.query_frames(project, tag, ts, window_ms=window_ms, limit=limit)
|
return await _replay_response(
|
||||||
|
marker_replay.query_frames(project, tag, ts, window_ms=window_ms, limit=limit, filter_expr=filter)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -282,24 +312,21 @@ async def replay_tag_frame_detail(
|
|||||||
"""
|
"""
|
||||||
Decode exactly one frame (lazy — invoked when the user opens a frame,
|
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
|
never by the timeline itself): raw bytes for the hex view read straight
|
||||||
from the pcap, protocol tree from ``tshark -T pdml`` mapped isomorphically
|
from the pcap, protocol tree from the resident sharkd session with keys
|
||||||
to JSON (every PDML attribute survives, values stay strings).
|
renamed into the REST contract (``element`` / ``label`` / ``name`` /
|
||||||
|
``filter_expr`` / ``pos`` + ``size`` / ``expert`` / ``generated`` /
|
||||||
|
``children``) — values untouched.
|
||||||
|
|
||||||
``ts`` must be the exact string from the frame list; ``node_id`` +
|
``ts`` must be the exact string from the frame list; ``node_id`` +
|
||||||
``link_id`` + ``marker`` identify the source pcap. The tag gate applies.
|
``link_id`` + ``marker`` identify the source pcap. The tag gate applies.
|
||||||
|
Requires sharkd — 501 without it.
|
||||||
|
|
||||||
Required privilege: Project.Audit
|
Required privilege: Project.Audit
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
return await _replay_response(
|
||||||
return await marker_replay.decode_frame(project, tag, ts, node_id, link_id, marker)
|
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -16,37 +16,46 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Tag-keyed aggregate replay over paused markers' pcap files.
|
Tag-keyed aggregate replay over paused markers' pcaps, powered by sharkd.
|
||||||
|
|
||||||
Markers on different links sharing a ``tag`` form one distributed capture
|
Markers on different links sharing a ``tag`` form one distributed capture
|
||||||
session. This module merges their per-marker pcaps
|
session. This module merges their per-marker pcaps
|
||||||
(``<project>/project-files/markers/{node_id}_{link_id}_{name}.pcap``) into a
|
(``<project>/project-files/markers/{node_id}_{link_id}_{name}.pcap``) into a
|
||||||
single timestamp-ordered timeline and decodes individual frames on demand.
|
single timestamp-ordered timeline and decodes frames on demand.
|
||||||
|
|
||||||
Two deliberately separated performance regimes:
|
Engine: **sharkd is a hard requirement** (the Wireshark resident daemon,
|
||||||
|
driven over one-line JSON-RPC on stdin/stdout). Without it every replay
|
||||||
|
endpoint returns 501 — there is deliberately no degraded mode. Timeline
|
||||||
|
assembly (gate, pcap record-header scan, merge ordering, hex reads) is plain
|
||||||
|
Python, but it is an implementation detail, not an availability promise.
|
||||||
|
|
||||||
* the timeline path reads only the 16-byte pcap record headers — tshark is
|
Session model — sharkd loads one file at a time, so there is one resident
|
||||||
never invoked, so browsing works even where tshark is not installed;
|
session **per source pcap**: spawned lazily on first use, fed a ``/tmp``
|
||||||
* the detail path runs one ``tshark -T pdml`` per frame the caller asks about
|
scratch copy (hardened profiles deny sharkd the project directory; a real
|
||||||
(call count = user clicks) and maps the XML to JSON isomorphically — every
|
copy, not a symlink, since the profile resolves real paths) with a scratch
|
||||||
PDML attribute survives as a JSON key, values stay strings, nothing is
|
``HOME``, validated per request against the original's ``(mtime, size)`` —
|
||||||
selected out or interpreted.
|
a mismatch (e.g. the capture node restarted and uBridge truncated the pcap
|
||||||
|
while paused) kills and respawns the session. A per-pcap asyncio lock
|
||||||
|
serializes RPCs (sharkd serves one request at a time), each with a timeout.
|
||||||
|
An LRU cap bounds concurrent sessions.
|
||||||
|
|
||||||
Timestamps are uBridge's userspace ``gettimeofday`` at match time (µs, a
|
Timestamps are uBridge's userspace ``gettimeofday`` at match time (µs, a
|
||||||
value measured after the packet has crossed the kernel twice — the last
|
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
|
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
|
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
|
never use ts alone as a dict key, or same-microsecond frames silently
|
||||||
overwrite each other.
|
overwrite each other. The canonical ts strings travel to clients verbatim
|
||||||
|
and must be round-tripped verbatim.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
import tempfile
|
import tempfile
|
||||||
import xml.etree.ElementTree as ET
|
import time
|
||||||
|
|
||||||
from .controller_error import ControllerError, ControllerNotFoundError, ControllerBadRequestError
|
from .controller_error import ControllerError, ControllerNotFoundError, ControllerBadRequestError
|
||||||
|
|
||||||
@ -57,20 +66,45 @@ log = logging.getLogger(__name__)
|
|||||||
# client is never flooded by a high-traffic BPF.
|
# client is never flooded by a high-traffic BPF.
|
||||||
FRAME_LIST_CAP = 5000
|
FRAME_LIST_CAP = 5000
|
||||||
|
|
||||||
# One tshark decode per user click: a generous ceiling, not a rate limiter.
|
# One JSON-RPC per sharkd session, serialized by a per-session lock.
|
||||||
TSHARK_TIMEOUT_SECONDS = 10.0
|
RPC_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
# Resident sharkd sessions are bounded; least-recently-used evicted first.
|
||||||
|
SESSION_MAX = 8
|
||||||
|
|
||||||
|
# Display filters travel as one argv element (never through a shell) and are
|
||||||
|
# capped to keep absurd expressions off the command line.
|
||||||
|
FILTER_MAX_LENGTH = 2000
|
||||||
|
|
||||||
|
# Batch size when draining sharkd's `frames` RPC (columns / filter matches).
|
||||||
|
_FRAMES_PAGE = 1000
|
||||||
|
|
||||||
|
|
||||||
class TsharkMissingError(ControllerError):
|
class SharkdMissingError(ControllerError):
|
||||||
"""tshark is not installed (or not on PATH) — frame detail unavailable."""
|
"""sharkd is not installed (or not on PATH) — replay is unavailable (501)."""
|
||||||
|
|
||||||
|
|
||||||
class TsharkError(ControllerError):
|
class SharkdError(ControllerError):
|
||||||
"""tshark exited non-zero / timed out / produced unusable output."""
|
"""sharkd failed, timed out, or produced an unusable response (502)."""
|
||||||
|
|
||||||
|
|
||||||
|
class FilterError(ControllerBadRequestError):
|
||||||
|
"""sharkd rejected the display filter — its message is carried verbatim
|
||||||
|
so the UI can show it inline in the filter bar (400, distinct from the
|
||||||
|
409 gate / 404 unknown-tag semantics)."""
|
||||||
|
|
||||||
|
|
||||||
|
class _SharkdRpcError(Exception):
|
||||||
|
"""Internal: a JSON-RPC error object from sharkd (code + message)."""
|
||||||
|
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# pcap record-header scanning (timeline path — no tshark)
|
# pcap record-header scanning (timeline backbone — engine-free)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# magic → (byte order, timestamp unit). Both pcap families uBridge can write
|
# magic → (byte order, timestamp unit). Both pcap families uBridge can write
|
||||||
@ -140,8 +174,8 @@ def scan_pcap_frames(path):
|
|||||||
def read_frame_bytes(path, frame_number):
|
def read_frame_bytes(path, frame_number):
|
||||||
"""
|
"""
|
||||||
Read one frame's raw bytes (hex view) straight from the pcap — never via
|
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
|
the engine. ``frame_number`` is 1-based (the same number sharkd's frame
|
||||||
``frame.number`` filter uses).
|
RPC uses).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
frames = scan_pcap_frames(path)
|
frames = scan_pcap_frames(path)
|
||||||
@ -156,6 +190,273 @@ def read_frame_bytes(path, frame_number):
|
|||||||
return f.read(incl_len).hex()
|
return f.read(incl_len).hex()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sharkd: process environment and scratch copies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _scratch_copy(pcap):
|
||||||
|
"""
|
||||||
|
Copy the pcap to a scratch file under the system temp dir for sharkd to
|
||||||
|
load. Hardened 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 _engine_env():
|
||||||
|
"""Scratch HOME so sharkd never even tries to read the user's home."""
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["HOME"] = tempfile.gettempdir()
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sharkd: tree key renaming (the only transformation between sharkd and the
|
||||||
|
# REST contract — a closed, protocol-independent key set; values untouched)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Census-verified across ICMP / TCP / VLAN+OSPF trees: sharkd emits exactly
|
||||||
|
# these structural keys on every node regardless of protocol. Protocol
|
||||||
|
# semantics live in VALUES (field names, labels, filter expressions), which
|
||||||
|
# are never touched.
|
||||||
|
_KEY_RENAME = {
|
||||||
|
"t": "element", # node type ("proto", …)
|
||||||
|
"l": "label", # display text
|
||||||
|
"fn": "name", # field name (e.g. "ip.ttl")
|
||||||
|
"f": "filter_expr", # ready-made display filter with the value baked in
|
||||||
|
"s": "expert", # expert severity name ("Chat", "Warn", …)
|
||||||
|
"g": "generated", # generated-by-wireshark flag
|
||||||
|
"n": "children", # nested fields
|
||||||
|
}
|
||||||
|
# "h" → pos + size (byte range for hex highlighting) — handled specially.
|
||||||
|
# "e" is sharkd's internal header-field registry id — unstable across
|
||||||
|
# Wireshark versions and useless for rendering, so it is dropped.
|
||||||
|
_DROPPED_KEYS = {"e"}
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_value(value):
|
||||||
|
"""Recursive pass-through: rename known keys, drop none but 'e',
|
||||||
|
copy unknown keys verbatim (a future Wireshark adding a key never
|
||||||
|
silently loses data — the census test flags it for naming)."""
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
if key in _DROPPED_KEYS:
|
||||||
|
continue
|
||||||
|
if key == "h" and isinstance(item, list) and len(item) == 2:
|
||||||
|
out["pos"], out["size"] = item
|
||||||
|
else:
|
||||||
|
out[_KEY_RENAME.get(key, key)] = _rename_value(item)
|
||||||
|
return out
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_rename_value(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _count_tree_nodes(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return 1 + sum(_count_tree_nodes(item) for item in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return sum(_count_tree_nodes(item) for item in value)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sharkd sessions (one resident process per source pcap)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _SharkdSession:
|
||||||
|
"""A resident `sharkd -` process with one pcap loaded, addressed through
|
||||||
|
line-oriented JSON-RPC. Serialized by an asyncio lock (sharkd serves one
|
||||||
|
request at a time)."""
|
||||||
|
|
||||||
|
def __init__(self, pcap, scratch, proc, stat):
|
||||||
|
self.pcap = pcap
|
||||||
|
self.scratch = scratch
|
||||||
|
self.proc = proc
|
||||||
|
self.mtime_ns = stat.st_mtime_ns
|
||||||
|
self.size = stat.st_size
|
||||||
|
self.last_used = time.monotonic()
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self._next_id = 0
|
||||||
|
|
||||||
|
def matches(self, stat):
|
||||||
|
"""True while the source pcap is byte-identical to what was loaded —
|
||||||
|
a fresh (mtime, size) would serve a rebuilt/truncated capture."""
|
||||||
|
|
||||||
|
return stat.st_mtime_ns == self.mtime_ns and stat.st_size == self.size
|
||||||
|
|
||||||
|
def alive(self):
|
||||||
|
return self.proc.returncode is None
|
||||||
|
|
||||||
|
def touch(self):
|
||||||
|
self.last_used = time.monotonic()
|
||||||
|
|
||||||
|
async def rpc(self, method, params):
|
||||||
|
async with self.lock:
|
||||||
|
self._next_id += 1
|
||||||
|
request = {"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params}
|
||||||
|
try:
|
||||||
|
self.proc.stdin.write((json.dumps(request) + "\n").encode())
|
||||||
|
await self.proc.stdin.drain()
|
||||||
|
raw = await asyncio.wait_for(self.proc.stdout.readline(), RPC_TIMEOUT_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise SharkdError(f"sharkd timed out after {RPC_TIMEOUT_SECONDS:.0f}s on {method!r}")
|
||||||
|
except OSError as e:
|
||||||
|
raise SharkdError(f"sharkd session died on {method!r}: {e}")
|
||||||
|
if not raw:
|
||||||
|
raise SharkdError(f"sharkd closed the session during {method!r}")
|
||||||
|
try:
|
||||||
|
response = json.loads(raw)
|
||||||
|
except ValueError as e:
|
||||||
|
raise SharkdError(f"Malformed sharkd response: {e}")
|
||||||
|
if "error" in response:
|
||||||
|
error = response["error"]
|
||||||
|
raise _SharkdRpcError(error.get("code"), str(error.get("message", "")))
|
||||||
|
return response.get("result")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
try:
|
||||||
|
if self.proc.returncode is None:
|
||||||
|
self.proc.kill()
|
||||||
|
# Bounded wait: a kill that fails to reap (blocked signals,
|
||||||
|
# mocked os.kill in tests, a wedged process) must never hang
|
||||||
|
# the caller — leak the process with a log instead.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.proc.wait(), timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning("sharkd session for %s did not exit after kill", self.pcap)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.unlink(self.scratch)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _SharkdManager:
|
||||||
|
"""Resident sharkd sessions keyed by source pcap path, LRU-bounded."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions = {}
|
||||||
|
|
||||||
|
async def session_for(self, pcap):
|
||||||
|
if shutil.which("sharkd") is None:
|
||||||
|
raise SharkdMissingError(
|
||||||
|
"sharkd is not available on this server — marker replay requires sharkd "
|
||||||
|
"(part of the Wireshark package)"
|
||||||
|
)
|
||||||
|
stat = os.stat(pcap)
|
||||||
|
session = self._sessions.get(pcap)
|
||||||
|
if session is not None and session.matches(stat) and session.alive():
|
||||||
|
session.touch()
|
||||||
|
return session
|
||||||
|
if session is not None:
|
||||||
|
await session.close()
|
||||||
|
del self._sessions[pcap]
|
||||||
|
# Bound resident sessions: evict the least recently used.
|
||||||
|
while len(self._sessions) >= SESSION_MAX:
|
||||||
|
victim = min(self._sessions.values(), key=lambda s: s.last_used)
|
||||||
|
await victim.close()
|
||||||
|
self._sessions.pop(victim.pcap, None)
|
||||||
|
session = await self._spawn(pcap, stat)
|
||||||
|
self._sessions[pcap] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _spawn(self, pcap, stat):
|
||||||
|
scratch = _scratch_copy(pcap)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sharkd", "-",
|
||||||
|
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL, env=_engine_env(),
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
try:
|
||||||
|
os.unlink(scratch)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise SharkdError(f"Could not run sharkd: {e}")
|
||||||
|
session = _SharkdSession(pcap, scratch, proc, stat)
|
||||||
|
try:
|
||||||
|
await session.rpc("load", {"file": scratch})
|
||||||
|
except Exception as e:
|
||||||
|
await session.close()
|
||||||
|
raise SharkdError(f"sharkd failed to load {os.path.basename(pcap)}: {e}")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def close_all(self):
|
||||||
|
for session in list(self._sessions.values()):
|
||||||
|
await session.close()
|
||||||
|
self._sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_manager():
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = _SharkdManager()
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Columns + display filter (sharkd `frames` RPC)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _columns_for(pcap, filter_expr):
|
||||||
|
"""
|
||||||
|
One resident `frames` pass over the source pcap. Returns
|
||||||
|
``{frame_number: {src, dst, proto, info, bg, fg}}``; with a
|
||||||
|
``filter_expr`` the keys are exactly the matching frame numbers, so one
|
||||||
|
pass both filters and enriches the merge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session = await _get_manager().session_for(pcap)
|
||||||
|
columns = {}
|
||||||
|
skip = 0
|
||||||
|
while True:
|
||||||
|
# sharkd rejects skip=0 ("must be a positive integer") — only send it
|
||||||
|
# once there is actually something to skip.
|
||||||
|
params = {"limit": _FRAMES_PAGE}
|
||||||
|
if skip:
|
||||||
|
params["skip"] = skip
|
||||||
|
if filter_expr is not None:
|
||||||
|
params["filter"] = filter_expr
|
||||||
|
try:
|
||||||
|
rows = await session.rpc("frames", params)
|
||||||
|
except _SharkdRpcError as e:
|
||||||
|
if filter_expr is not None:
|
||||||
|
raise FilterError(f"Invalid display filter: {e.message}")
|
||||||
|
raise SharkdError(f"sharkd frames failed on {os.path.basename(pcap)}: {e.message}")
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
frame_number = int(row.get("num"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
cols = row.get("c") or []
|
||||||
|
columns[frame_number] = {
|
||||||
|
"src": cols[2] or None if len(cols) > 2 else None,
|
||||||
|
"dst": cols[3] or None if len(cols) > 3 else None,
|
||||||
|
"proto": cols[4] or None if len(cols) > 4 else None,
|
||||||
|
"info": cols[6] or None if len(cols) > 6 else None,
|
||||||
|
"bg": row.get("bg"),
|
||||||
|
"fg": row.get("fg"),
|
||||||
|
}
|
||||||
|
if len(rows) < _FRAMES_PAGE:
|
||||||
|
return columns
|
||||||
|
skip += len(rows)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tag gate + timeline assembly
|
# Tag gate + timeline assembly
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -199,12 +500,14 @@ def gate_tag(project, tag):
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _merged_frames(project, entries):
|
async def _merged_frames(project, entries, filter_expr=None):
|
||||||
"""
|
"""
|
||||||
Scan every source pcap and merge into one list sorted by
|
Scan every source pcap's record headers, ask sharkd for columns (and,
|
||||||
|
with a filter, the matching set), and merge into one list sorted by
|
||||||
``(ts, source file, frame number)`` — ts alone is not unique (two links
|
``(ts, source file, frame number)`` — ts alone is not unique (two links
|
||||||
can hit the same microsecond); the tiebreaker yields a stable, determined
|
can hit the same microsecond); the tiebreaker yields a stable, determined
|
||||||
order instead of a fictional one.
|
order instead of a fictional one. With a filter, only frames sharkd
|
||||||
|
matched survive, keeping their original pcap frame numbers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
markers_dir = project.markers_directory
|
markers_dir = project.markers_directory
|
||||||
@ -215,19 +518,35 @@ def _merged_frames(project, entries):
|
|||||||
markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap"
|
markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap"
|
||||||
)
|
)
|
||||||
frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else []
|
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")},
|
if frames:
|
||||||
"count": len(frames)})
|
columns = await _columns_for(pcap, filter_expr)
|
||||||
|
else:
|
||||||
|
columns = {}
|
||||||
|
source_key = f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}"
|
||||||
|
count = 0
|
||||||
for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1):
|
for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1):
|
||||||
|
if filter_expr is not None and frame_number not in columns:
|
||||||
|
continue
|
||||||
|
cols = columns.get(frame_number, {})
|
||||||
merged.append({
|
merged.append({
|
||||||
"ts": _format_ts(sec, usec),
|
"ts": _format_ts(sec, usec),
|
||||||
"ts_us": sec * 1_000_000 + usec,
|
"ts_us": sec * 1_000_000 + usec,
|
||||||
|
"_source": source_key,
|
||||||
"len": incl_len,
|
"len": incl_len,
|
||||||
"node_id": entry["node_id"],
|
"node_id": entry["node_id"],
|
||||||
"link_id": entry["link_id"],
|
"link_id": entry["link_id"],
|
||||||
"marker": entry["marker"],
|
"marker": entry["marker"],
|
||||||
"frame_number": frame_number,
|
"frame_number": frame_number,
|
||||||
"_source": f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}",
|
"src": cols.get("src"),
|
||||||
|
"dst": cols.get("dst"),
|
||||||
|
"proto": cols.get("proto"),
|
||||||
|
"info": cols.get("info"),
|
||||||
|
"bg": cols.get("bg"),
|
||||||
|
"fg": cols.get("fg"),
|
||||||
})
|
})
|
||||||
|
count += 1
|
||||||
|
sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
|
||||||
|
"count": count})
|
||||||
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
|
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
|
||||||
for frame in merged:
|
for frame in merged:
|
||||||
del frame["ts_us"]
|
del frame["ts_us"]
|
||||||
@ -235,15 +554,24 @@ def _merged_frames(project, entries):
|
|||||||
return merged, sources
|
return merged, sources
|
||||||
|
|
||||||
|
|
||||||
def build_timeline(project, tag, frame_cap=FRAME_LIST_CAP):
|
def _validate_filter(filter_expr):
|
||||||
|
if filter_expr is not None and len(filter_expr) > FILTER_MAX_LENGTH:
|
||||||
|
raise ControllerBadRequestError(
|
||||||
|
f"Display filter too long (max {FILTER_MAX_LENGTH} characters)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_timeline(project, tag, frame_cap=FRAME_LIST_CAP, filter_expr=None):
|
||||||
"""
|
"""
|
||||||
The ``range`` response: timeline bounds, per-source stats, and (under
|
The ``range`` response: timeline bounds, per-source stats, and (under
|
||||||
``frame_cap``) the full merged frame list for one-request timeline
|
``frame_cap``) the full merged frame list for one-request timeline
|
||||||
layout. Over the cap the list is replaced by per-second buckets.
|
layout. Over the cap the list is replaced by per-second buckets. With a
|
||||||
|
``filter_expr`` every figure is computed on the matching frames only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_validate_filter(filter_expr)
|
||||||
entries = gate_tag(project, tag)
|
entries = gate_tag(project, tag)
|
||||||
frames, sources = _merged_frames(project, entries)
|
frames, sources = await _merged_frames(project, entries, filter_expr)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
@ -267,14 +595,15 @@ def build_timeline(project, tag, frame_cap=FRAME_LIST_CAP):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def query_frames(project, tag, ts, window_ms=100, limit=1000):
|
async def query_frames(project, tag, ts, window_ms=100, limit=1000, filter_expr=None):
|
||||||
"""
|
"""
|
||||||
Frames with ts in ``[T, T+window_ms]`` merged across sources. A time with
|
Frames with ts in ``[T, T+window_ms]`` merged across sources. A time with
|
||||||
no frames is a normal, successful answer — ``{"frames": []}``.
|
no frames is a normal, successful answer — ``{"frames": []}``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_validate_filter(filter_expr)
|
||||||
entries = gate_tag(project, tag)
|
entries = gate_tag(project, tag)
|
||||||
frames, _sources = _merged_frames(project, entries)
|
frames, _sources = await _merged_frames(project, entries, filter_expr)
|
||||||
|
|
||||||
start_us = _parse_ts(ts)
|
start_us = _parse_ts(ts)
|
||||||
end_us = start_us + max(window_ms, 0) * 1000
|
end_us = start_us + max(window_ms, 0) * 1000
|
||||||
@ -283,70 +612,16 @@ def query_frames(project, tag, ts, window_ms=100, limit=1000):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frame detail (tshark path — lazy, one frame per call)
|
# Frame detail (lazy — one frame per call, via the resident session)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
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):
|
async def decode_frame(project, tag, ts, node_id, link_id, marker):
|
||||||
"""
|
"""
|
||||||
Decode exactly one frame: locate its pcap by source identity, verify the
|
Decode exactly one frame: locate its pcap by source identity, verify the
|
||||||
round-tripped ts still matches the file (guards a rebuild between 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
|
timeline view and this click), read the raw bytes for the hex view
|
||||||
map tshark's PDML of that single frame to JSON.
|
straight from the pcap, and rename the sharkd protocol tree into the
|
||||||
|
REST contract (closed key set, values untouched).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
entries = gate_tag(project, tag)
|
entries = gate_tag(project, tag)
|
||||||
@ -378,51 +653,22 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
raw_hex = read_frame_bytes(pcap, frame_number)
|
raw_hex = read_frame_bytes(pcap, frame_number)
|
||||||
|
session = await _get_manager().session_for(pcap)
|
||||||
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:
|
||||||
try:
|
result = await session.rpc("frame", {"frame": frame_number, "proto": True})
|
||||||
proc = await asyncio.create_subprocess_exec(
|
except _SharkdRpcError as e:
|
||||||
"tshark", "-r", scratch, "-T", "pdml", "-Y", f"frame.number == {frame_number}",
|
if e.code == -8003: # frame number out of range — file changed under us
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
raise ControllerNotFoundError(
|
||||||
env=_tshark_env(),
|
f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
|
||||||
)
|
)
|
||||||
stdout, stderr = await asyncio.wait_for(
|
raise SharkdError(f"sharkd frame failed: {e.message}")
|
||||||
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:
|
tree = _rename_value(result.get("tree", []))
|
||||||
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 {
|
return {
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
"source": {"node_id": node_id, "link_id": link_id, "marker": marker,
|
"source": {"node_id": node_id, "link_id": link_id, "marker": marker,
|
||||||
"frame_number": frame_number},
|
"frame_number": frame_number},
|
||||||
"tshark_version": version,
|
"field_count": _count_tree_nodes(tree),
|
||||||
"field_count": sum(_count_nodes(node) for node in tree),
|
|
||||||
"hex": raw_hex,
|
"hex": raw_hex,
|
||||||
"tree": tree,
|
"tree": tree,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,27 +16,39 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
HTTP-route tests for the tag replay endpoints: the tag gate (409 while any
|
HTTP-route tests for the tag replay endpoints (sharkd edition): the tag gate
|
||||||
marker captures, 404 unknown tag), the merged timeline, window queries
|
(409 while any marker captures, 404 unknown tag), the merged timeline with
|
||||||
(empty window = success), and the lazy frame detail (ts guard, isomorphic
|
columns, window queries (empty window = success), the display-filter
|
||||||
JSON, 501 when tshark is unavailable).
|
parameter (400 with sharkd's error text on a bad expression), the lazy frame
|
||||||
|
detail with the renamed sharkd tree, and 501 when sharkd is unavailable
|
||||||
|
(hard engine requirement, no degraded mode).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from gns3server.controller.project import Project
|
from gns3server.controller.project import Project
|
||||||
from gns3server.controller.udp_link import UDPLink
|
from gns3server.controller.udp_link import UDPLink
|
||||||
|
from gns3server.controller import marker_replay
|
||||||
|
|
||||||
from tests.controller.test_marker_replay import _write_pcap, _icmp_frame
|
from tests.controller.test_marker_replay import _write_pcap, _icmp_frame, _tcp_syn_frame, _cols
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed")
|
sharkd_present = pytest.mark.skipif(shutil.which("sharkd") is None, reason="sharkd not installed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def no_residual_sessions():
|
||||||
|
yield
|
||||||
|
manager = marker_replay._manager
|
||||||
|
if manager is not None:
|
||||||
|
await manager.close_all()
|
||||||
|
|
||||||
|
|
||||||
def _add_marker(project, tag, enabled, node_id, frames=None):
|
def _add_marker(project, tag, enabled, node_id, frames=None):
|
||||||
@ -77,18 +89,48 @@ class TestReplayRoutes:
|
|||||||
)
|
)
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
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:
|
async def test_range_501_without_sharkd(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||||
|
|
||||||
|
# A non-empty pcap: the engine must be consulted, and without sharkd
|
||||||
|
# the whole feature is unavailable (hard requirement, no degraded mode).
|
||||||
|
_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_range", project_id=project.id, tag=7)
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
|
||||||
|
# The app's HTTPException handler unifies the body as {"message": …}.
|
||||||
|
assert "sharkd" in response.json()["message"]
|
||||||
|
|
||||||
|
async def test_range_merges_sources_in_ts_order(
|
||||||
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# Columns injected hermetically — the merge/order/tiebreak contract
|
||||||
|
# must not depend on the engine being installed.
|
||||||
|
r1, r2 = UDPLink(project), UDPLink(project)
|
||||||
|
project._links.update({r1.id: r1, r2.id: r2})
|
||||||
|
|
||||||
|
def _wire(link, node_id, frames):
|
||||||
|
link._markers["icmp"] = {"bpf": "icmp", "tag": 7, "enabled": False, "color": None,
|
||||||
|
"highlight_duration": None, "capture_node_id": node_id,
|
||||||
|
"direction": None, "data_link_type": "DLT_EN10MB"}
|
||||||
|
_write_pcap(f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames)
|
||||||
|
|
||||||
# r1→r2 captures at t1 and t3; r2→r3 captures at t2 and t3 (same µs
|
# 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).
|
# as source A's t3 — the tiebreak must keep both frames).
|
||||||
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
_wire(r1, "n1", [(1693472000, 500000, b"a" * 60), (1693472002, 0, b"a" * 60)])
|
||||||
(1693472000, 500000, b"a" * 60),
|
_wire(r2, "n2", [(1693472001, 0, b"b" * 60), (1693472002, 0, b"b" * 60)])
|
||||||
(1693472002, 0, b"a" * 60),
|
|
||||||
])
|
async def fake_columns(pcap, filter_expr):
|
||||||
_add_marker(project, tag=7, enabled=False, node_id="n2", frames=[
|
name = pcap.rsplit("/", 1)[-1]
|
||||||
(1693472001, 0, b"b" * 60),
|
src = "10.0.0.1" if name.startswith("n1") else "10.0.0.2"
|
||||||
(1693472002, 0, b"b" * 60),
|
return {1: _cols(src=src), 2: _cols(src=src)}
|
||||||
])
|
|
||||||
|
monkeypatch.setattr(marker_replay, "_columns_for", fake_columns)
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
app.url_path_for("replay_tag_range", project_id=project.id, tag=7)
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7)
|
||||||
@ -105,16 +147,25 @@ class TestReplayRoutes:
|
|||||||
"1693472000.500000", "1693472001.000000",
|
"1693472000.500000", "1693472001.000000",
|
||||||
"1693472002.000000", "1693472002.000000",
|
"1693472002.000000", "1693472002.000000",
|
||||||
]
|
]
|
||||||
|
# Wireshark-style columns ride along on every frame entry.
|
||||||
|
assert body["frames"][0]["src"] == "10.0.0.1"
|
||||||
|
assert body["frames"][1]["src"] == "10.0.0.2"
|
||||||
|
assert body["frames"][0]["proto"] == "ICMP"
|
||||||
assert len(body["sources"]) == 2
|
assert len(body["sources"]) == 2
|
||||||
|
|
||||||
async def test_frames_window_miss_is_empty_success(
|
async def test_frames_window_miss_is_empty_success(
|
||||||
self, app: FastAPI, client: AsyncClient, project: Project
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
||||||
(1693472000, 0, b"a" * 60),
|
(1693472000, 0, b"a" * 60),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
async def fake_columns(pcap, filter_expr):
|
||||||
|
return {1: _cols()}
|
||||||
|
|
||||||
|
monkeypatch.setattr(marker_replay, "_columns_for", fake_columns)
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
||||||
params={"ts": "1693472001.000000", "window_ms": 100},
|
params={"ts": "1693472001.000000", "window_ms": 100},
|
||||||
@ -122,13 +173,20 @@ class TestReplayRoutes:
|
|||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
assert response.json() == {"frames": []}
|
assert response.json() == {"frames": []}
|
||||||
|
|
||||||
async def test_frames_window_hit(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
async def test_frames_window_hit(
|
||||||
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
|
||||||
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
||||||
(1693472000, 0, b"a" * 60),
|
(1693472000, 0, b"a" * 60),
|
||||||
(1693472000, 150000, b"a" * 60),
|
(1693472000, 150000, b"a" * 60),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
async def fake_columns(pcap, filter_expr):
|
||||||
|
return {1: _cols(), 2: _cols()}
|
||||||
|
|
||||||
|
monkeypatch.setattr(marker_replay, "_columns_for", fake_columns)
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
||||||
params={"ts": "1693472000.000000", "window_ms": 150},
|
params={"ts": "1693472000.000000", "window_ms": 150},
|
||||||
@ -151,22 +209,64 @@ class TestReplayRoutes:
|
|||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
assert "rebuilt" in response.json()["message"]
|
assert "rebuilt" in response.json()["message"]
|
||||||
|
|
||||||
async def test_detail_501_without_tshark(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
@sharkd_present
|
||||||
|
async def test_range_columns_and_filter_end_to_end(
|
||||||
|
self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions
|
||||||
|
) -> None:
|
||||||
|
|
||||||
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
||||||
|
(1693472000, 123456, _icmp_frame()),
|
||||||
|
(1693472001, 0, _tcp_syn_frame()),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Unfiltered: both frames with real engine columns.
|
||||||
|
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
|
||||||
|
frames = response.json()["frames"]
|
||||||
|
assert [f["proto"] for f in frames] == ["ICMP", "TCP"]
|
||||||
|
assert frames[0]["src"] == "10.0.0.1" and frames[0]["dst"] == "10.0.0.3"
|
||||||
|
assert "Echo" in frames[0]["info"]
|
||||||
|
assert frames[0]["frame_number"] == 1
|
||||||
|
|
||||||
|
# Display filter applied before counting: only the TCP frame survives.
|
||||||
|
response = await client.get(
|
||||||
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
||||||
|
params={"filter": "tcp"},
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
assert body["frame_count"] == 1
|
||||||
|
assert [f["frame_number"] for f in body["frames"]] == [2] # original pcap identity
|
||||||
|
assert body["start"] == "1693472001.000000"
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_range_invalid_filter_is_400_with_sharkd_text(
|
||||||
|
self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
||||||
(1693472000, 123456, _icmp_frame()),
|
(1693472000, 123456, _icmp_frame()),
|
||||||
])
|
])
|
||||||
|
|
||||||
with patch("gns3server.controller.marker_replay.shutil.which", return_value=None):
|
response = await client.get(
|
||||||
response = await client.get(
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
||||||
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
|
params={"filter": "this is (not valid"},
|
||||||
params={"ts": "1693472000.123456", "node_id": "n1",
|
)
|
||||||
"link_id": link.id, "marker": "icmp"},
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
)
|
assert "filter" in response.json()["message"].lower()
|
||||||
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
|
|
||||||
|
|
||||||
@tshark_present
|
# Oversized filters are rejected before reaching the engine.
|
||||||
async def test_detail_decodes_single_frame(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
response = await client.get(
|
||||||
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
||||||
|
params={"filter": "x" * (marker_replay.FILTER_MAX_LENGTH + 1)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_detail_decodes_single_frame(
|
||||||
|
self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions
|
||||||
|
) -> None:
|
||||||
|
|
||||||
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
||||||
(1693472000, 123456, _icmp_frame()),
|
(1693472000, 123456, _icmp_frame()),
|
||||||
@ -182,9 +282,35 @@ class TestReplayRoutes:
|
|||||||
assert body["source"]["frame_number"] == 1
|
assert body["source"]["frame_number"] == 1
|
||||||
assert body["hex"] == _icmp_frame().hex()
|
assert body["hex"] == _icmp_frame().hex()
|
||||||
assert body["field_count"] > 10
|
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")
|
def find(node, name):
|
||||||
ttl = next(f for f in ip["children"] if f.get("name") == "ip.ttl")
|
stack = node if isinstance(node, list) else [node]
|
||||||
# Values arrive as strings, exactly as tshark emitted them.
|
for child in stack:
|
||||||
assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64"
|
if child.get("name") == name:
|
||||||
|
return child
|
||||||
|
deep = find(child.get("children", []), name)
|
||||||
|
if deep is not None:
|
||||||
|
return deep
|
||||||
|
return None
|
||||||
|
|
||||||
|
ttl = find(body["tree"], "ip.ttl")
|
||||||
|
assert ttl["label"] == "Time to Live: 64"
|
||||||
|
assert ttl["filter_expr"] == "ip.ttl == 64"
|
||||||
|
assert ttl["pos"] == 22 and ttl["size"] == 1
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_detail_501_when_sharkd_disappears(
|
||||||
|
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
|
||||||
|
|||||||
@ -16,36 +16,47 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Unit tests for the tag-keyed aggregate replay module (controller layer):
|
Unit tests for the tag-keyed aggregate replay module (sharkd edition):
|
||||||
|
|
||||||
* pcap record-header scanning (ts extraction, truncated-tail tolerance,
|
* pcap record-header scanning (ts extraction, truncated-tail tolerance,
|
||||||
nanosecond-magic normalization) and raw-bytes reads for the hex view
|
nanosecond-magic normalization) and raw-bytes reads for the hex view
|
||||||
* the tag gate (404 unknown tag, 409 while any marker captures)
|
* the tag gate (404 unknown tag, 409 while any marker captures) — engine-free
|
||||||
* timeline assembly: cross-source merge ordered by (ts, source, frame number),
|
* timeline assembly with injected columns: cross-source merge ordered by
|
||||||
same-microsecond tiebreak, missing-pcap sources, frame-cap degradation to
|
(ts, source, frame number), same-microsecond tiebreak, frame-cap
|
||||||
per-second buckets
|
degradation to buckets, display-filter application before count/slice
|
||||||
* window queries (inclusive bounds, empty-window success)
|
* the tree key renaming (closed census key set, values untouched, unknown
|
||||||
* the lazy frame detail: round-tripped-ts guard, and — where tshark is
|
keys pass through verbatim, internal hf ids dropped)
|
||||||
installed — the isomorphic PDML → JSON mapping with a round-trip check
|
* the resident sharkd sessions — spawn/load/reuse, (mtime, size)
|
||||||
(element count and attribute coverage).
|
invalidation, LRU bound — against the real sharkd where installed, plus
|
||||||
|
the frame detail end-to-end (hex + renamed tree + filter expressions).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
|
from gns3server.controller.controller_error import (
|
||||||
|
ControllerBadRequestError,
|
||||||
|
ControllerError,
|
||||||
|
ControllerNotFoundError,
|
||||||
|
)
|
||||||
|
from gns3server.controller import marker_replay
|
||||||
from gns3server.controller.marker_replay import (
|
from gns3server.controller.marker_replay import (
|
||||||
|
FilterError,
|
||||||
|
SharkdMissingError,
|
||||||
build_timeline,
|
build_timeline,
|
||||||
decode_frame,
|
decode_frame,
|
||||||
query_frames,
|
query_frames,
|
||||||
read_frame_bytes,
|
read_frame_bytes,
|
||||||
scan_pcap_frames,
|
scan_pcap_frames,
|
||||||
|
_count_tree_nodes,
|
||||||
_format_ts,
|
_format_ts,
|
||||||
_parse_ts,
|
_parse_ts,
|
||||||
|
_rename_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@ -53,6 +64,8 @@ pytestmark = pytest.mark.asyncio
|
|||||||
PCAP_MAGIC_US = 0xA1B2C3D4
|
PCAP_MAGIC_US = 0xA1B2C3D4
|
||||||
PCAP_MAGIC_NS = 0xA1B23C4D
|
PCAP_MAGIC_NS = 0xA1B23C4D
|
||||||
|
|
||||||
|
sharkd_present = pytest.mark.skipif(shutil.which("sharkd") is None, reason="sharkd not installed")
|
||||||
|
|
||||||
|
|
||||||
def _write_pcap(path, frames, magic=PCAP_MAGIC_US, snaplen=65535):
|
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)."""
|
"""frames: list of (sec, frac, payload bytes); frac is µs (or ns for the ns magic)."""
|
||||||
@ -64,27 +77,39 @@ def _write_pcap(path, frames, magic=PCAP_MAGIC_US, snaplen=65535):
|
|||||||
f.write(payload)
|
f.write(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _cksum(data):
|
||||||
|
if len(data) % 2:
|
||||||
|
data = data + b"\x00" # RFC 1071 odd-length padding
|
||||||
|
s = 0
|
||||||
|
for i in range(0, len(data), 2):
|
||||||
|
s += (data[i] << 8) + data[i + 1]
|
||||||
|
while s >> 16:
|
||||||
|
s = (s & 0xFFFF) + (s >> 16)
|
||||||
|
return (~s) & 0xFFFF
|
||||||
|
|
||||||
|
|
||||||
def _icmp_frame():
|
def _icmp_frame():
|
||||||
"""A minimal well-formed ICMP echo request (10.0.0.1 → 10.0.0.3)."""
|
"""A minimal well-formed ICMP echo request (10.0.0.1 → 10.0.0.3)."""
|
||||||
|
|
||||||
def cksum(data):
|
|
||||||
if len(data) % 2:
|
|
||||||
data = data + b"\x00" # RFC 1071 odd-length padding
|
|
||||||
s = 0
|
|
||||||
for i in range(0, len(data), 2):
|
|
||||||
s += (data[i] << 8) + data[i + 1]
|
|
||||||
while s >> 16:
|
|
||||||
s = (s & 0xFFFF) + (s >> 16)
|
|
||||||
return (~s) & 0xFFFF
|
|
||||||
|
|
||||||
icmp = bytes([8, 0, 0, 0]) + struct.pack(">HHH", 1, 1, 0) + b"payload12"
|
icmp = bytes([8, 0, 0, 0]) + struct.pack(">HHH", 1, 1, 0) + b"payload12"
|
||||||
icmp = icmp[:2] + struct.pack(">H", cksum(icmp)) + icmp[4:]
|
icmp = icmp[:2] + struct.pack(">H", _cksum(icmp)) + icmp[4:]
|
||||||
ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0,
|
ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0,
|
||||||
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]))
|
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]))
|
||||||
ip = ip0[:10] + struct.pack(">H", cksum(ip0)) + ip0[12:]
|
ip = ip0[:10] + struct.pack(">H", _cksum(ip0)) + ip0[12:]
|
||||||
return bytes.fromhex("0200000000020200000000010800") + ip + icmp
|
return bytes.fromhex("0200000000020200000000010800") + ip + icmp
|
||||||
|
|
||||||
|
|
||||||
|
def _tcp_syn_frame():
|
||||||
|
"""A minimal TCP SYN (10.0.0.1:472 → 10.0.0.3:22)."""
|
||||||
|
|
||||||
|
tcp = struct.pack(">HHIIBBHHH", 472, 22, 0, 0, 0x50, 0x02, 64240, 0, 0)
|
||||||
|
ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(tcp), 2, 0, 64, 6, 0,
|
||||||
|
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3]))
|
||||||
|
ip = ip0[:10] + struct.pack(">H", _cksum(ip0)) + ip0[12:]
|
||||||
|
tcp = tcp[:16] + struct.pack(">H", _cksum(ip0[12:] + tcp)) + tcp[18:]
|
||||||
|
return bytes.fromhex("0200000000020200000000010800") + ip + tcp
|
||||||
|
|
||||||
|
|
||||||
def _fake_project(tmp_path, markers, markers_dir=None):
|
def _fake_project(tmp_path, markers, markers_dir=None):
|
||||||
"""markers: the flat project.markers shape ({'link/name': {..., node_id}})."""
|
"""markers: the flat project.markers shape ({'link/name': {..., node_id}})."""
|
||||||
|
|
||||||
@ -98,8 +123,26 @@ def _marker_entry(tag, enabled=True, node_id="node-1"):
|
|||||||
"node_id": node_id}
|
"node_id": node_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_columns(monkeypatch, mapping):
|
||||||
|
"""Inject canned sharkd columns: mapping pcap-basename → {frame#: cols or None}.
|
||||||
|
|
||||||
|
Frames absent from the dict get no columns; when the caller passes a
|
||||||
|
filter, the injected set IS the matching set (mirroring the real engine).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def fake_columns_for(pcap, filter_expr):
|
||||||
|
return mapping.get(os.path.basename(pcap), {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(marker_replay, "_columns_for", fake_columns_for)
|
||||||
|
|
||||||
|
|
||||||
|
def _cols(src="10.0.0.1", dst="10.0.0.3", proto="ICMP", info="Echo (ping) request"):
|
||||||
|
return {"src": src, "dst": dst, "proto": proto, "info": info,
|
||||||
|
"bg": "ffffff", "fg": "000000"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# pcap scanning
|
# pcap scanning (engine-free backbone)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestScanPcap:
|
class TestScanPcap:
|
||||||
@ -140,15 +183,15 @@ class TestScanPcap:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tag gate + timeline
|
# Tag gate (engine-free — raised before any sharkd work)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestGateAndTimeline:
|
class TestGate:
|
||||||
|
|
||||||
async def test_unknown_tag_404(self, tmp_path):
|
async def test_unknown_tag_404(self, tmp_path):
|
||||||
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=1)})
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=1)})
|
||||||
with pytest.raises(ControllerNotFoundError):
|
with pytest.raises(ControllerNotFoundError):
|
||||||
build_timeline(project, tag=7)
|
await build_timeline(project, tag=7)
|
||||||
|
|
||||||
async def test_gate_409_while_capturing(self, tmp_path):
|
async def test_gate_409_while_capturing(self, tmp_path):
|
||||||
project = _fake_project(tmp_path, {
|
project = _fake_project(tmp_path, {
|
||||||
@ -156,9 +199,22 @@ class TestGateAndTimeline:
|
|||||||
"linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"),
|
"linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"),
|
||||||
})
|
})
|
||||||
with pytest.raises(ControllerError, match="linkB"):
|
with pytest.raises(ControllerError, match="linkB"):
|
||||||
build_timeline(project, tag=7)
|
await build_timeline(project, tag=7)
|
||||||
|
|
||||||
async def test_merge_orders_by_ts_with_stable_tiebreak(self, tmp_path):
|
async def test_filter_length_capped(self, tmp_path, monkeypatch):
|
||||||
|
_fake_columns(monkeypatch, {})
|
||||||
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False)})
|
||||||
|
with pytest.raises(ControllerBadRequestError, match="too long"):
|
||||||
|
await build_timeline(project, tag=7, filter_expr="x" * (marker_replay.FILTER_MAX_LENGTH + 1))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeline assembly (columns injected — no engine needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTimeline:
|
||||||
|
|
||||||
|
async def test_merge_orders_by_ts_with_stable_tiebreak(self, tmp_path, monkeypatch):
|
||||||
# Two sources, deliberately interleaved in time, colliding on one µs.
|
# Two sources, deliberately interleaved in time, colliding on one µs.
|
||||||
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
||||||
(1693472000, 500000, b"a" * 60), # t1 sourceA
|
(1693472000, 500000, b"a" * 60), # t1 sourceA
|
||||||
@ -168,12 +224,16 @@ class TestGateAndTimeline:
|
|||||||
(1693472001, 000000, b"b" * 60), # t2 sourceB
|
(1693472001, 000000, b"b" * 60), # t2 sourceB
|
||||||
(1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA
|
(1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA
|
||||||
])
|
])
|
||||||
|
_fake_columns(monkeypatch, {
|
||||||
|
"n1_linkA_icmp.pcap": {1: _cols(), 2: _cols()},
|
||||||
|
"n2_linkB_icmp.pcap": {1: _cols(src="10.0.0.2"), 2: _cols(src="10.0.0.2")},
|
||||||
|
})
|
||||||
project = _fake_project(tmp_path, {
|
project = _fake_project(tmp_path, {
|
||||||
"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"),
|
"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"),
|
||||||
"linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"),
|
"linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"),
|
||||||
})
|
})
|
||||||
|
|
||||||
timeline = build_timeline(project, tag=7)
|
timeline = await build_timeline(project, tag=7)
|
||||||
assert timeline["frame_count"] == 4
|
assert timeline["frame_count"] == 4
|
||||||
assert timeline["start"] == "1693472000.500000"
|
assert timeline["start"] == "1693472000.500000"
|
||||||
assert timeline["end"] == "1693472002.000000"
|
assert timeline["end"] == "1693472002.000000"
|
||||||
@ -181,24 +241,42 @@ class TestGateAndTimeline:
|
|||||||
# Same-microsecond pair keeps both frames (a ts dict key would drop one).
|
# 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["ts"] for f in timeline["frames"]][2:] == ["1693472002.000000"] * 2
|
||||||
assert [f["frame_number"] for f in timeline["frames"]] == [1, 1, 2, 2]
|
assert [f["frame_number"] for f in timeline["frames"]] == [1, 1, 2, 2]
|
||||||
|
# Columns ride along verbatim.
|
||||||
|
assert timeline["frames"][0]["src"] == "10.0.0.1"
|
||||||
|
assert timeline["frames"][1]["src"] == "10.0.0.2"
|
||||||
|
assert timeline["frames"][0]["proto"] == "ICMP"
|
||||||
assert {s["count"] for s in timeline["sources"]} == {2}
|
assert {s["count"] for s in timeline["sources"]} == {2}
|
||||||
|
|
||||||
async def test_missing_pcap_is_zero_count_source(self, tmp_path):
|
async def test_columns_missing_for_a_frame_still_lists_it(self, tmp_path, monkeypatch):
|
||||||
|
# A frame the (injected) engine did not describe keeps its place with
|
||||||
|
# null columns — the timeline backbone never depends on the engine.
|
||||||
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [(1693472000, 0, b"a" * 60)])
|
||||||
|
_fake_columns(monkeypatch, {}) # engine describes nothing
|
||||||
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
|
||||||
|
|
||||||
|
timeline = await build_timeline(project, tag=7)
|
||||||
|
frame = timeline["frames"][0]
|
||||||
|
assert frame["ts"] == "1693472000.000000"
|
||||||
|
assert frame["src"] is None and frame["info"] is None
|
||||||
|
|
||||||
|
async def test_missing_pcap_is_zero_count_source(self, tmp_path, monkeypatch):
|
||||||
|
_fake_columns(monkeypatch, {})
|
||||||
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False)})
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False)})
|
||||||
timeline = build_timeline(project, tag=7)
|
timeline = await build_timeline(project, tag=7)
|
||||||
assert timeline["frame_count"] == 0
|
assert timeline["frame_count"] == 0
|
||||||
assert timeline["start"] is None and timeline["end"] is None
|
assert timeline["start"] is None and timeline["end"] is None
|
||||||
assert timeline["frames"] == []
|
assert timeline["frames"] == []
|
||||||
assert timeline["sources"][0]["count"] == 0
|
assert timeline["sources"][0]["count"] == 0
|
||||||
|
|
||||||
async def test_over_cap_degrades_to_buckets(self, tmp_path):
|
async def test_over_cap_degrades_to_buckets(self, tmp_path, monkeypatch):
|
||||||
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
||||||
(1693472000, 0, b"a" * 60), (1693472000, 500000, b"a" * 60),
|
(1693472000, 0, b"a" * 60), (1693472000, 500000, b"a" * 60),
|
||||||
(1693472001, 0, b"a" * 60),
|
(1693472001, 0, b"a" * 60),
|
||||||
])
|
])
|
||||||
|
_fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {1: _cols(), 2: _cols(), 3: _cols()}})
|
||||||
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
|
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)
|
timeline = await build_timeline(project, tag=7, frame_cap=2)
|
||||||
assert timeline["truncated"] is True
|
assert timeline["truncated"] is True
|
||||||
assert "frames" not in timeline
|
assert "frames" not in timeline
|
||||||
assert timeline["buckets"] == [
|
assert timeline["buckets"] == [
|
||||||
@ -206,40 +284,180 @@ class TestGateAndTimeline:
|
|||||||
{"ts": "1693472001.000000", "count": 1},
|
{"ts": "1693472001.000000", "count": 1},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def test_filter_applies_before_count_and_slice(self, tmp_path, monkeypatch):
|
||||||
|
# Three frames; the injected "matching set" (what a real engine would
|
||||||
|
# return for the filter) contains only frames 1 and 3.
|
||||||
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
||||||
|
(1693472000, 0, b"a" * 60),
|
||||||
|
(1693472001, 0, b"a" * 60),
|
||||||
|
(1693472002, 0, b"a" * 60),
|
||||||
|
])
|
||||||
|
_fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {1: _cols(), 3: _cols(proto="TCP")}})
|
||||||
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
|
||||||
|
|
||||||
|
timeline = await build_timeline(project, tag=7, filter_expr="tcp")
|
||||||
|
assert timeline["frame_count"] == 2
|
||||||
|
assert timeline["start"] == "1693472000.000000"
|
||||||
|
assert timeline["end"] == "1693472002.000000"
|
||||||
|
# frame numbers keep their ORIGINAL pcap identity through the filter.
|
||||||
|
assert [f["frame_number"] for f in timeline["frames"]] == [1, 3]
|
||||||
|
assert timeline["sources"][0]["count"] == 2
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Window query
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestQueryFrames:
|
class TestQueryFrames:
|
||||||
|
|
||||||
def _project(self, tmp_path):
|
def _project(self, tmp_path, monkeypatch):
|
||||||
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
||||||
(1693472000, 0, b"a" * 60),
|
(1693472000, 0, b"a" * 60),
|
||||||
(1693472000, 150000, b"a" * 60),
|
(1693472000, 150000, b"a" * 60),
|
||||||
(1693472005, 0, b"a" * 60),
|
(1693472005, 0, b"a" * 60),
|
||||||
])
|
])
|
||||||
|
_fake_columns(monkeypatch, {"n1_linkA_icmp.pcap": {i: _cols() for i in (1, 2, 3)}})
|
||||||
return _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
|
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):
|
async def test_window_inclusive_bounds(self, tmp_path, monkeypatch):
|
||||||
result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000", window_ms=150)
|
result = await query_frames(self._project(tmp_path, monkeypatch), tag=7,
|
||||||
|
ts="1693472000.000000", window_ms=150)
|
||||||
assert [f["ts"] for f in result["frames"]] == ["1693472000.000000", "1693472000.150000"]
|
assert [f["ts"] for f in result["frames"]] == ["1693472000.000000", "1693472000.150000"]
|
||||||
|
|
||||||
async def test_window_miss_is_empty_success(self, tmp_path):
|
async def test_window_miss_is_empty_success(self, tmp_path, monkeypatch):
|
||||||
result = query_frames(self._project(tmp_path), tag=7, ts="1693472001.000000", window_ms=100)
|
result = await query_frames(self._project(tmp_path, monkeypatch), tag=7,
|
||||||
|
ts="1693472001.000000", window_ms=100)
|
||||||
assert result == {"frames": []}
|
assert result == {"frames": []}
|
||||||
|
|
||||||
async def test_limit_applies(self, tmp_path):
|
async def test_limit_applies(self, tmp_path, monkeypatch):
|
||||||
result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000",
|
result = await query_frames(self._project(tmp_path, monkeypatch), tag=7,
|
||||||
window_ms=150, limit=1)
|
ts="1693472000.000000", window_ms=150, limit=1)
|
||||||
assert len(result["frames"]) == 1
|
assert len(result["frames"]) == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frame detail (tshark path)
|
# Tree key renaming
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed")
|
class TestRename:
|
||||||
|
|
||||||
|
async def test_renames_closed_key_set_and_drops_hf_id(self):
|
||||||
|
node = {
|
||||||
|
"t": "proto", "l": "Time to Live: 64", "fn": "ip.ttl",
|
||||||
|
"f": "ip.ttl == 64", "h": [22, 1], "s": None, "g": False,
|
||||||
|
"e": 8472,
|
||||||
|
"n": [{"l": "nested", "h": [23, 2], "n": []}],
|
||||||
|
}
|
||||||
|
renamed = _rename_value(node)
|
||||||
|
assert renamed == {
|
||||||
|
"element": "proto", "label": "Time to Live: 64", "name": "ip.ttl",
|
||||||
|
"filter_expr": "ip.ttl == 64", "pos": 22, "size": 1,
|
||||||
|
"expert": None, "generated": False,
|
||||||
|
"children": [{"label": "nested", "pos": 23, "size": 2, "children": []}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_unknown_keys_pass_through_verbatim(self):
|
||||||
|
# A future Wireshark adding a key must never silently lose data.
|
||||||
|
node = {"l": "x", "future_key": {"deep": [1, 2]}, "n": []}
|
||||||
|
renamed = _rename_value(node)
|
||||||
|
assert renamed["future_key"] == {"deep": [1, 2]}
|
||||||
|
|
||||||
|
async def test_count_tree_nodes_counts_dicts_only(self):
|
||||||
|
assert _count_tree_nodes({"a": [{"b": 1}, "str", 3]}) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sharkd sessions + engine-backed behaviour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSessions:
|
||||||
|
|
||||||
|
async def test_missing_sharkd_raises_501_error(self, tmp_path):
|
||||||
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [(1693472000, 0, b"a" * 60)])
|
||||||
|
project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")})
|
||||||
|
with patch("gns3server.controller.marker_replay.shutil.which", return_value=None):
|
||||||
|
with pytest.raises(SharkdMissingError):
|
||||||
|
await build_timeline(project, tag=7)
|
||||||
|
|
||||||
|
async def test_lru_bound_evicts_least_recently_used(self, tmp_path):
|
||||||
|
manager = marker_replay._SharkdManager()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self, pcap, used):
|
||||||
|
self.pcap, self.last_used = pcap, used
|
||||||
|
self.closed = False
|
||||||
|
self.mtime_ns, self.size = 0, 0
|
||||||
|
|
||||||
|
def matches(self, stat):
|
||||||
|
return False # always respawn → exercises the eviction path
|
||||||
|
|
||||||
|
def alive(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def touch(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
fake_by_pcap = {}
|
||||||
|
|
||||||
|
async def fake_spawn(pcap, stat):
|
||||||
|
session = FakeSession(pcap, 0)
|
||||||
|
fake_by_pcap[pcap] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
with patch.object(manager, "_spawn", side_effect=fake_spawn):
|
||||||
|
for i in range(marker_replay.SESSION_MAX + 2):
|
||||||
|
pcap = tmp_path / f"pcap{i}"
|
||||||
|
_write_pcap(pcap, [(1693472000, 0, b"a" * 60)])
|
||||||
|
await manager.session_for(str(pcap))
|
||||||
|
# Bounded to SESSION_MAX; the earliest (least recently used) got evicted.
|
||||||
|
assert len(manager._sessions) == marker_replay.SESSION_MAX
|
||||||
|
assert fake_by_pcap[str(tmp_path / "pcap0")].closed is True
|
||||||
|
assert fake_by_pcap[str(tmp_path / "pcap1")].closed is True
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_real_session_columns_and_filter(self, tmp_path):
|
||||||
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [
|
||||||
|
(1693472000, 123456, _icmp_frame()),
|
||||||
|
(1693472001, 0, _tcp_syn_frame()),
|
||||||
|
])
|
||||||
|
manager = marker_replay._get_manager()
|
||||||
|
try:
|
||||||
|
columns = await marker_replay._columns_for(str(tmp_path / "n1_linkA_icmp.pcap"), None)
|
||||||
|
assert columns[1]["src"] == "10.0.0.1"
|
||||||
|
assert columns[1]["proto"] == "ICMP"
|
||||||
|
assert "Echo" in columns[1]["info"]
|
||||||
|
assert columns[2]["proto"] == "TCP"
|
||||||
|
|
||||||
|
only_tcp = await marker_replay._columns_for(str(tmp_path / "n1_linkA_icmp.pcap"), "tcp")
|
||||||
|
assert set(only_tcp) == {2}
|
||||||
|
finally:
|
||||||
|
await manager.close_all()
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_real_session_invalid_filter_raises_filter_error(self, tmp_path):
|
||||||
|
_write_pcap(tmp_path / "n1_linkA_icmp.pcap", [(1693472000, 123456, _icmp_frame())])
|
||||||
|
manager = marker_replay._get_manager()
|
||||||
|
try:
|
||||||
|
with pytest.raises(FilterError):
|
||||||
|
await marker_replay._columns_for(str(tmp_path / "n1_linkA_icmp.pcap"), "this is (not valid")
|
||||||
|
finally:
|
||||||
|
await manager.close_all()
|
||||||
|
|
||||||
|
@sharkd_present
|
||||||
|
async def test_real_session_respawns_on_stat_change(self, tmp_path):
|
||||||
|
pcap = tmp_path / "n1_linkA_icmp.pcap"
|
||||||
|
_write_pcap(pcap, [(1693472000, 123456, _icmp_frame())])
|
||||||
|
manager = marker_replay._get_manager()
|
||||||
|
try:
|
||||||
|
first = await manager.session_for(str(pcap))
|
||||||
|
assert await manager.session_for(str(pcap)) is first # warm reuse
|
||||||
|
# Rewrite the file (simulating a truncated/rebuilt capture).
|
||||||
|
_write_pcap(pcap, [(1693473000, 0, _icmp_frame())])
|
||||||
|
second = await manager.session_for(str(pcap))
|
||||||
|
assert second is not first
|
||||||
|
columns = await marker_replay._columns_for(str(pcap), None)
|
||||||
|
assert set(columns) == {1}
|
||||||
|
finally:
|
||||||
|
await manager.close_all()
|
||||||
|
|
||||||
|
|
||||||
class TestDecodeFrame:
|
class TestDecodeFrame:
|
||||||
@ -264,83 +482,65 @@ class TestDecodeFrame:
|
|||||||
await decode_frame(project, tag=7, ts="1693472000.123456",
|
await decode_frame(project, tag=7, ts="1693472000.123456",
|
||||||
node_id="nobody", link_id="linkA", marker="icmp")
|
node_id="nobody", link_id="linkA", marker="icmp")
|
||||||
|
|
||||||
async def test_decode_feeds_tshark_a_scratch_copy(self, tmp_path):
|
@sharkd_present
|
||||||
"""Hardened tshark profiles deny the project dir — tshark must read a
|
async def test_decode_end_to_end(self, tmp_path):
|
||||||
/tmp copy (a real copy, not a symlink) that is unlinked afterwards."""
|
manager = marker_replay._get_manager()
|
||||||
|
try:
|
||||||
import tempfile
|
project = self._project(tmp_path)
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
|
|
||||||
observed = []
|
|
||||||
PDML = (b'<pdml><packet><proto name="frame" showname="Frame 1: 60 bytes">'
|
|
||||||
b'<field name="frame.len" show="60" showname="Frame Length: 60"/>'
|
|
||||||
b'</proto></packet></pdml>')
|
|
||||||
|
|
||||||
class FakeProc:
|
|
||||||
returncode = 0
|
|
||||||
|
|
||||||
async def communicate(self):
|
|
||||||
return PDML, b""
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
r_index = args.index("-r")
|
|
||||||
observed.append((args[r_index + 1], kwargs.get("env")))
|
|
||||||
return FakeProc()
|
|
||||||
|
|
||||||
project = self._project(tmp_path)
|
|
||||||
with patch("gns3server.controller.marker_replay.shutil.which", return_value="tshark"), \
|
|
||||||
patch("gns3server.controller.marker_replay._tshark_version", AsyncMock(return_value="tshark 4.6.7")), \
|
|
||||||
patch("gns3server.controller.marker_replay.asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
detail = await decode_frame(project, tag=7, ts="1693472000.123456",
|
detail = await decode_frame(project, tag=7, ts="1693472000.123456",
|
||||||
node_id="n1", link_id="linkA", marker="icmp")
|
node_id="n1", link_id="linkA", marker="icmp")
|
||||||
|
finally:
|
||||||
assert detail["field_count"] == 2 # proto + field from the canned PDML
|
await manager.close_all()
|
||||||
(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["source"]["frame_number"] == 1
|
||||||
assert detail["hex"] == _icmp_frame().hex()
|
assert detail["hex"] == _icmp_frame().hex()
|
||||||
assert detail["field_count"] > 0
|
assert detail["field_count"] > 10
|
||||||
assert "tshark" in detail["tshark_version"].lower()
|
|
||||||
|
|
||||||
# Round-trip fidelity: node count equals the PDML element count
|
# Renamed tree: every label/name present, filter expressions with
|
||||||
# (protos + fields, excluding the <packet> container itself)…
|
# values baked in, byte ranges for hex highlighting.
|
||||||
proc = await asyncio.create_subprocess_exec(
|
def find(node, name):
|
||||||
"tshark", "-r", str(tmp_path / "n1_linkA_icmp.pcap"), "-T", "pdml",
|
for child in node if isinstance(node, list) else node.get("children", []):
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
if child.get("name") == name:
|
||||||
)
|
return child
|
||||||
stdout, _ = await proc.communicate()
|
deep = find(child, name)
|
||||||
packet = ET.fromstring(stdout).find("./packet")
|
if deep is not None:
|
||||||
xml_elements = [e for e in packet.iter() if e is not packet]
|
return deep
|
||||||
assert detail["field_count"] == len(xml_elements)
|
return None
|
||||||
|
|
||||||
# …and every XML attribute survives verbatim as a JSON string key.
|
ttl = find(detail["tree"], "ip.ttl")
|
||||||
def walk(element, node):
|
assert ttl is not None
|
||||||
for key, value in element.attrib.items():
|
assert ttl["label"] == "Time to Live: 64"
|
||||||
assert node.get(key) == value
|
assert ttl["filter_expr"] == "ip.ttl == 64"
|
||||||
assert all(isinstance(v, str) for k, v in node.items() if k != "children")
|
assert ttl["pos"] == 22 and ttl["size"] == 1
|
||||||
for child, child_node in zip(element, node["children"]):
|
|
||||||
walk(child, child_node)
|
|
||||||
|
|
||||||
for element, node in zip(packet, detail["tree"]):
|
@sharkd_present
|
||||||
walk(element, node)
|
async def test_key_census_closed_set(self, tmp_path):
|
||||||
|
"""The rename correctness guarantee: across protocol-diverse real
|
||||||
|
trees, sharkd's raw key set stays within the census-known keys."""
|
||||||
|
|
||||||
# Values stay strings (no numeric re-typing).
|
pcap = tmp_path / "proto_mix.pcap"
|
||||||
ttl = next(
|
_write_pcap(pcap, [
|
||||||
f for p in detail["tree"] if p.get("name") == "ip"
|
(1693472000, 100000, _icmp_frame()),
|
||||||
for f in p["children"] if f.get("name") == "ip.ttl"
|
(1693472001, 0, _tcp_syn_frame()),
|
||||||
)
|
])
|
||||||
assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64"
|
manager = marker_replay._get_manager()
|
||||||
|
try:
|
||||||
|
session = await manager.session_for(str(pcap))
|
||||||
|
known = set(marker_replay._KEY_RENAME) | {"h", "e"}
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def walk(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
seen.update(value.keys())
|
||||||
|
for item in value.values():
|
||||||
|
walk(item)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
walk(item)
|
||||||
|
|
||||||
|
for frame_number in (1, 2):
|
||||||
|
result = await session.rpc("frame", {"frame": frame_number, "proto": True})
|
||||||
|
walk(result.get("tree", []))
|
||||||
|
assert seen <= known, f"unknown sharkd keys appeared: {seen - known}"
|
||||||
|
finally:
|
||||||
|
await manager.close_all()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user