mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-14 05:59:43 +03:00
Review-driven session/transport fixes (each reproduced live against sharkd 4.6.7 before fixing): - raise the RPC stream limit to 16 MB: a full 1000-row frames page measures ~190 KB against the 64 KB StreamReader default, which failed the request with a 500 and desynchronized the resident session; a line-over-limit ValueError is now treated as a transport failure - verify JSON-RPC reply ids: a timed-out request's late reply was served as the next request's answer; timeouts, dead pipes, malformed and stale replies now kill the session for good instead - make check-spawn atomic under one manager lock: concurrent requests for the same pcap double-spawned sharkd and leaked the loser (process plus /tmp scratch copy) forever - refcount sessions and evict idle only (LRU, cap raised 8 -> 16): a tag with more sources than the cap respawned every source on every request, and concurrent requests could get their session killed mid-RPC (spurious 502) - map FilterError to sharkd's filter rejection (-13002) only; other engine failures with a filter set are 502, not a client 400 - detail: accept an optional frame_number to disambiguate same-microsecond frames (ts is not unique within a pcap); drop the -8003 -> 404 mapping (the range is validated locally, engine errors are real faults); a failed hex read is a 404 instead of "hex": null - a pcap deleted mid-request is a 404, not a 500; the pcap-sized scratch copy runs off the event loop; server shutdown kills every resident session and drops its scratch directory - pin the packet-list layout through scratch-HOME Wireshark preferences: the column indexes are a contract the server owns (protocol-level column negotiation is rejected by sharkd 4.6.x) Range contract change (WebUI moved to an always-flat list): the merged frame list is returned in full, deliberately uncapped - truncated and per-second buckets are removed, frame_count always equals len(frames), and rendering cost is the client's concern (the window endpoint remains the incremental path).
387 lines
16 KiB
Python
387 lines
16 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (C) 2025 GNS3 Technologies Inc.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
HTTP-route tests for the tag replay endpoints (sharkd edition): the tag gate
|
|
(409 while any marker captures, 404 unknown tag), the merged timeline with
|
|
columns, window queries (empty window = success), the display-filter
|
|
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
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI, status
|
|
from httpx import AsyncClient
|
|
|
|
from gns3server.controller.project import Project
|
|
from gns3server.controller.udp_link import UDPLink
|
|
from gns3server.controller import marker_replay
|
|
|
|
from tests.controller.test_marker_replay import _write_pcap, _icmp_frame, _tcp_syn_frame, _cols
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
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):
|
|
"""Create a paused/capturing link+marker and optionally its pcap."""
|
|
|
|
link = UDPLink(project)
|
|
link._markers["icmp"] = {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None,
|
|
"highlight_duration": None, "capture_node_id": node_id,
|
|
"direction": None, "data_link_type": "DLT_EN10MB"}
|
|
project._links[link.id] = link
|
|
if frames is not None:
|
|
_write_pcap(
|
|
f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames
|
|
)
|
|
return link
|
|
|
|
|
|
class TestReplayRoutes:
|
|
|
|
async def test_range_409_while_capturing(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
|
|
|
_add_marker(project, tag=7, enabled=False, node_id="n1")
|
|
running = _add_marker(project, tag=7, enabled=True, node_id="n2")
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7)
|
|
)
|
|
assert response.status_code == status.HTTP_409_CONFLICT
|
|
assert "icmp" in response.json()["message"]
|
|
assert running.id in response.json()["message"]
|
|
|
|
async def test_range_404_unknown_tag(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
|
|
|
_add_marker(project, tag=7, enabled=False, node_id="n1")
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=99)
|
|
)
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
|
|
async def test_range_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
|
|
# as source A's t3 — the tiebreak must keep both frames).
|
|
_wire(r1, "n1", [(1693472000, 500000, b"a" * 60), (1693472002, 0, b"a" * 60)])
|
|
_wire(r2, "n2", [(1693472001, 0, b"b" * 60), (1693472002, 0, b"b" * 60)])
|
|
|
|
async def fake_columns(pcap, filter_expr):
|
|
name = pcap.rsplit("/", 1)[-1]
|
|
src = "10.0.0.1" if name.startswith("n1") else "10.0.0.2"
|
|
return {1: _cols(src=src), 2: _cols(src=src)}
|
|
|
|
monkeypatch.setattr(marker_replay, "_columns_for", fake_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
|
|
body = response.json()
|
|
assert body["tag"] == 7
|
|
assert body["frame_count"] == 4
|
|
assert body["start"] == "1693472000.500000"
|
|
assert body["end"] == "1693472002.000000"
|
|
assert [f["node_id"] for f in body["frames"]] == ["n1", "n2", "n1", "n2"]
|
|
assert [f["ts"] for f in body["frames"]] == [
|
|
"1693472000.500000", "1693472001.000000",
|
|
"1693472002.000000", "1693472002.000000",
|
|
]
|
|
# 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
|
|
|
|
async def test_frames_window_miss_is_empty_success(
|
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
|
) -> None:
|
|
|
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
|
(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(
|
|
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
|
params={"ts": "1693472001.000000", "window_ms": 100},
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert response.json() == {"frames": []}
|
|
|
|
async def test_frames_window_hit(
|
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
|
) -> None:
|
|
|
|
_add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
|
(1693472000, 0, 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(
|
|
app.url_path_for("replay_tag_frames", project_id=project.id, tag=7),
|
|
params={"ts": "1693472000.000000", "window_ms": 150},
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert [f["ts"] for f in response.json()["frames"]] == [
|
|
"1693472000.000000", "1693472000.150000"
|
|
]
|
|
|
|
async def test_detail_404_on_ts_mismatch(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
|
|
|
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
|
(1693472000, 123456, _icmp_frame()),
|
|
])
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
|
|
params={"ts": "1.000000", "node_id": "n1", "link_id": link.id, "marker": "icmp"},
|
|
)
|
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
|
assert "rebuilt" in response.json()["message"]
|
|
|
|
async def test_range_link_param_narrows_and_keeps_sources(
|
|
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
|
|
) -> None:
|
|
|
|
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)
|
|
|
|
_wire(r1, "n1", [(1693472000, 0, b"a" * 60), (1693472002, 0, b"a" * 60)])
|
|
_wire(r2, "n2", [(1693472001, 0, b"b" * 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(
|
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
|
params={"link": r1.id},
|
|
)
|
|
body = response.json()
|
|
assert body["frame_count"] == 2
|
|
assert {f["link_id"] for f in body["frames"]} == {r1.id}
|
|
# The source dropdown keeps the whole tag inventory.
|
|
assert sorted(s["count"] for s in body["sources"]) == [1, 2]
|
|
|
|
# Unknown link: 200 with an empty timeline, same shape as a zero-match
|
|
# filter — not a 404.
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
|
params={"link": "00000000-0000-0000-0000-000000000000"},
|
|
)
|
|
body = response.json()
|
|
assert body["frame_count"] == 0 and body["frames"] == [] and body["start"] is None
|
|
assert len(body["sources"]) == 2
|
|
|
|
@sharkd_present
|
|
async def test_range_columns_and_filter_end_to_end(
|
|
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()),
|
|
(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()),
|
|
])
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_range", project_id=project.id, tag=7),
|
|
params={"filter": "this is (not valid"},
|
|
)
|
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
assert "filter" in response.json()["message"].lower()
|
|
|
|
# Oversized filters are rejected before reaching the engine.
|
|
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=[
|
|
(1693472000, 123456, _icmp_frame()),
|
|
])
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
|
|
params={"ts": "1693472000.123456", "node_id": "n1",
|
|
"link_id": link.id, "marker": "icmp"},
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
body = response.json()
|
|
assert body["source"]["frame_number"] == 1
|
|
assert body["hex"] == _icmp_frame().hex()
|
|
assert body["field_count"] > 10
|
|
|
|
def find(node, name):
|
|
stack = node if isinstance(node, list) else [node]
|
|
for child in stack:
|
|
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_frame_number_disambiguates_same_ts(
|
|
self, app: FastAPI, client: AsyncClient, project: Project, no_residual_sessions
|
|
) -> None:
|
|
|
|
# Two frames in the same microsecond: only the explicit frame number
|
|
# (from the frame list entry) tells them apart.
|
|
link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[
|
|
(1693472000, 123456, _icmp_frame()),
|
|
(1693472000, 123456, _tcp_syn_frame()),
|
|
])
|
|
common = {"ts": "1693472000.123456", "node_id": "n1",
|
|
"link_id": link.id, "marker": "icmp"}
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
|
|
params=common,
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert response.json()["source"]["frame_number"] == 1
|
|
assert response.json()["hex"] == _icmp_frame().hex()
|
|
|
|
response = await client.get(
|
|
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
|
|
params={**common, "frame_number": 2},
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert response.json()["source"]["frame_number"] == 2
|
|
assert response.json()["hex"] == _tcp_syn_frame().hex()
|
|
|
|
@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
|