mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
Switch-to-switch links were the last shape left on the relay: the switch fast path works by absorbing the *peer's* anchor, and a switch has none — its ports are its own bridge, and a kernel interface belongs to exactly one bridge, so two bridges cannot be joined by enslaving anything. Join them with a veth pair instead: one end per bridge, each side enslaving its own end and applying its own port mode to it — an access port on one side and a trunk on the other behaves like the real cable it models. uBridge's docker create_veth/delete_veth (the primitive every Docker adapter is already born from) creates and destroys the pair; the names derive from the link id (utils.kernel_anchor.kernel_cascade_names), so each side computes its own end and the names survive a restart. The pair belongs to the link, and the two ends are wired by two independent uBridge processes — the project-open batch drives different nodes concurrently — so the protocol is order-free: both sides create the pair when their own end is missing (one create wins; the loser's failure is re-checked against the kernel and "the pair is there" is not an error), and either side's teardown destroys it (deleting one veth end takes the other with it, so both converge on the same gone pair and the second delif answers ENOENT). A pre-existing end — this switch restarted, or a leftover from a previous life of the link id — is reused rather than recreated, because the other end may still be a live port of the peer switch; a stale tc qdisc is all a reused end can carry. No new wire format was needed: a cascade is an anchor NIO on both ends, the absorbed-anchor case plus the peer end's name. That also corrects the filter routing — the single-sided rule exists because an absorbed anchor is one interface, while a cascade has one per switch, so impairments go to both ends like any other kernel link. Tests: the wire format, the eligibility flip and the both-ends re-push on node start (controller); the naming contract (utils); the switch-side lifecycle — create, reuse, lost create race, delete destroys the pair (compute routes). Drive-by: the relay filter test's compute mock carried no awaitable `put`, so tests/controller/test_kernel_datapath_link.py failed when run alone while an argument-less full run passed; callers now get one.
880 lines
32 KiB
Python
880 lines
32 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (C) 2016 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/>.
|
|
|
|
import html
|
|
import logging
|
|
import os
|
|
import re
|
|
import uuid
|
|
|
|
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
|
from gns3server.config import Config
|
|
from gns3server.utils.packet_filter_validation import (
|
|
FilterValidationError,
|
|
filter_inactive_filters,
|
|
kernel_only_features,
|
|
validate_all_filters,
|
|
)
|
|
|
|
from .controller_error import ControllerError, ControllerNotFoundError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# Sentinel for "argument not passed". Distinct from None so marker/definition
|
|
# updaters can tell "caller omitted direction" (keep current value) from
|
|
# "caller passed direction=None" (clear it back to both directions). See
|
|
# UDPLink.update_marker and Project.update_marker_definition.
|
|
_UNSET = object()
|
|
|
|
|
|
FILTERS = [
|
|
{
|
|
"type": "frequency_drop",
|
|
"name": "Frequency drop",
|
|
"description": "It will drop everything with a -1 frequency, drop every Nth packet with a positive "
|
|
"frequency, or drop nothing. On kernel-datapath links this is exact (eBPF every-Nth counter)",
|
|
"parameters": [{"name": "Frequency", "minimum": -1, "maximum": 32767, "type": "int", "unit": "th packet"}],
|
|
},
|
|
{
|
|
"type": "packet_loss",
|
|
"name": "Packet loss",
|
|
"description": "The percentage represents the chance for a packet to be lost. The optional correlation "
|
|
"(kernel-datapath links only) makes consecutive losses dependent, like a bursty real network",
|
|
"parameters": [
|
|
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
],
|
|
},
|
|
{
|
|
"type": "delay",
|
|
"name": "Delay",
|
|
"description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay, and "
|
|
"(kernel-datapath links only) shape the jitter with a distribution",
|
|
"parameters": [
|
|
{"name": "Latency", "minimum": 1, "maximum": 32767, "unit": "ms", "type": "int"},
|
|
{"name": "Jitter (-/+)", "minimum": 0, "maximum": 32767, "unit": "ms", "type": "int"},
|
|
{"name": "Distribution", "type": "str", "unit": "uniform|normal|pareto|paretonormal"},
|
|
],
|
|
},
|
|
{
|
|
"type": "corrupt",
|
|
"name": "Corrupt",
|
|
"description": "The percentage represents the chance for a packet to be corrupted",
|
|
"parameters": [{"name": "Chance", "minimum": 0, "maximum": 100, "unit": "%", "type": "int"}],
|
|
},
|
|
{
|
|
"type": "bpf",
|
|
"name": "Berkeley Packet Filter (BPF)",
|
|
"description": "This filter will drop any packet matching a BPF expression. Put one expression per line",
|
|
"parameters": [{"name": "Filters", "type": "text"}],
|
|
},
|
|
# Everything below runs on kernel-datapath links only (tc netem on the
|
|
# veth host end; the uBridge relay has no equivalent).
|
|
{
|
|
"type": "rate",
|
|
"name": "Bandwidth limit",
|
|
"description": "Shape the link to a maximum bandwidth, tc-style value (kernel-datapath links only)",
|
|
"parameters": [{"name": "Rate", "type": "str", "unit": "e.g. 512kbit, 10mbit"}],
|
|
},
|
|
{
|
|
"type": "reorder",
|
|
"name": "Reorder",
|
|
"description": "The percentage represents the chance for a packet to be reordered (held until a later "
|
|
"packet passes it). Requires the delay filter; kernel-datapath links only",
|
|
"parameters": [
|
|
{"name": "Reorder", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "Gap", "minimum": 1, "maximum": 1000, "type": "int", "unit": "packets"},
|
|
],
|
|
},
|
|
{
|
|
"type": "gemodel",
|
|
"name": "Gilbert-Elliot loss",
|
|
"description": "Bursty loss model: p is the chance of moving from the good to the bad state, r the chance "
|
|
"of moving back, and 1-h the loss chance while in the bad state (the good state loses nothing) — the "
|
|
"steady-state mean loss is p/(p+r) x (1-h). Mutually exclusive with packet loss; kernel-datapath links "
|
|
"only",
|
|
"parameters": [
|
|
{"name": "p (good-to-bad)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "r (bad-to-good)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "1-h (bad-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
],
|
|
},
|
|
{
|
|
"type": "duplicate",
|
|
"name": "Duplicate",
|
|
"description": "The percentage represents the chance for a packet to be duplicated; the optional "
|
|
"correlation makes consecutive duplicates dependent (kernel-datapath links only)",
|
|
"parameters": [
|
|
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "Correlation", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
],
|
|
},
|
|
{
|
|
"type": "seed",
|
|
"name": "Random seed",
|
|
"description": "Make the netem random draws (loss, duplication, jitter) reproducible for repeated "
|
|
"experiments (kernel-datapath links only)",
|
|
"parameters": [{"name": "Seed", "minimum": 0, "maximum": 4294967295, "type": "int", "unit": ""}],
|
|
},
|
|
{
|
|
"type": "limit",
|
|
"name": "Queue limit",
|
|
"description": "Queue depth of the impairment qdisc in packets — raise it above the 1000 default when "
|
|
"combining a low rate with a long delay (kernel-datapath links only)",
|
|
"parameters": [{"name": "Limit", "minimum": 1, "maximum": 1000000, "type": "int", "unit": "packets"}],
|
|
},
|
|
{
|
|
"type": "quota",
|
|
"name": "Byte quota",
|
|
"description": "After the byte quota is consumed, each further packet drops with the given chance "
|
|
"(100 = hard cutoff) — a data cap, like a mobile plan (kernel-datapath links only)",
|
|
"parameters": [
|
|
{"name": "Quota", "minimum": 1, "maximum": 1000000000000000, "type": "int", "unit": "bytes"},
|
|
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
],
|
|
},
|
|
{
|
|
"type": "window_drop",
|
|
"name": "Time window drop",
|
|
"description": "Drop packets with the given chance inside a time window starting Start ms from the "
|
|
"moment the filter is applied — a single outage (traffic passes before and after), or with a "
|
|
"period recurring flaps whose per-cycle timing a jitter randomizes (kernel-datapath links only)",
|
|
"parameters": [
|
|
{"name": "Start", "minimum": 0, "maximum": 1000000000000, "type": "int", "unit": "ms"},
|
|
{"name": "Outage", "minimum": 1, "maximum": 1000000000000, "type": "int", "unit": "ms"},
|
|
{"name": "Chance", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"},
|
|
{"name": "Period", "minimum": 1, "maximum": 1000000000000, "type": "int", "unit": "ms"},
|
|
{"name": "Jitter", "minimum": 0, "maximum": 1000000000, "type": "int", "unit": "ms"},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
class Link:
|
|
"""
|
|
Base class for links.
|
|
"""
|
|
|
|
def __init__(self, project, link_id=None):
|
|
|
|
if link_id:
|
|
self._id = link_id
|
|
else:
|
|
self._id = str(uuid.uuid4())
|
|
self._nodes = []
|
|
self._project = project
|
|
self._capturing = False
|
|
self._capture_node = None
|
|
self._capture_file_name = None
|
|
self._streaming_pcap = None
|
|
self._created = False
|
|
self._link_type = "ethernet"
|
|
self._suspended = False
|
|
self._filters = {}
|
|
self._markers = {}
|
|
self._link_style = {}
|
|
self._wireshark = False
|
|
self._show_filters_icon = True
|
|
|
|
@property
|
|
def filters(self):
|
|
"""
|
|
Get an array of filters
|
|
"""
|
|
return self._filters
|
|
|
|
@property
|
|
def kernel_datapath(self):
|
|
"""
|
|
Whether this link is wired on the kernel datapath (no uBridge relay
|
|
in the forwarding path): either both endpoints enslave their
|
|
anchors into a per-link Linux bridge (``nio_bridge``), or an
|
|
Ethernet switch absorbs the peer's anchor into its own kernel
|
|
bridge (``nio_anchor`` on the switch end, ``nio_bridge`` with no
|
|
bridge on the peer end), or two switches are cascaded through a
|
|
veth pair (``nio_anchor`` on both ends). Impairment filters run as
|
|
tc netem on the anchors; markers and capture are served by
|
|
uBridge's AF_PACKET module.
|
|
"""
|
|
return any(d.get("type") in ("nio_bridge", "nio_anchor") for d in (getattr(self, "_link_data", None) or []))
|
|
|
|
@property
|
|
def markers(self):
|
|
"""
|
|
Get the traffic insight markers dict: name → {bpf, tag, enabled}
|
|
"""
|
|
return self._markers
|
|
|
|
async def inherit_marker(self, def_name, marker_def, dump=True, memory_only=False):
|
|
"""
|
|
Apply a project-level marker definition to this link.
|
|
|
|
The marker is stored under ``global-{def_name}`` so it cannot collide
|
|
with a per-link private marker of the same name. It carries an
|
|
``inherited_from`` back-reference that (a) guards against per-link
|
|
edits and (b) lets the project sync changes to every copy at once.
|
|
|
|
The pcap link-layer follows the link type: Ethernet is always EN10MB.
|
|
A serial link needs the definition's WAN encapsulation (HDLC / PPP /
|
|
Frame Relay); if none was chosen the serial link is skipped — an EN10MB
|
|
pcap on a serial link is undecodable.
|
|
"""
|
|
|
|
def_data_link_type = marker_def.get("data_link_type", "DLT_EN10MB")
|
|
if self._link_type == "serial":
|
|
if def_data_link_type.upper() == "DLT_EN10MB":
|
|
return # definition is Ethernet-only; skip this serial link
|
|
data_link_type = def_data_link_type
|
|
else:
|
|
data_link_type = "DLT_EN10MB"
|
|
|
|
await self.start_marker(
|
|
name=f"global-{def_name}",
|
|
bpf=marker_def["bpf"],
|
|
tag=marker_def.get("tag"),
|
|
direction=marker_def.get("direction"),
|
|
data_link_type=data_link_type,
|
|
color=marker_def.get("color"),
|
|
highlight_duration=marker_def.get("highlight_duration"),
|
|
enabled=not marker_def.get("paused", False),
|
|
inherited_from=def_name,
|
|
dump=dump,
|
|
memory_only=memory_only,
|
|
)
|
|
|
|
def _persist_markers(self):
|
|
"""
|
|
Return only the per-link (non-inherited) markers suitable for
|
|
persistence in a topology dump. Inherited markers are re-created from
|
|
``project._marker_definitions`` on load so they do not need to be saved.
|
|
"""
|
|
return {k: v for k, v in self._markers.items() if not v.get("inherited_from")}
|
|
|
|
@property
|
|
def show_filters_icon(self):
|
|
"""
|
|
Get whether to show filters icon in Web UI
|
|
"""
|
|
return getattr(self, "_show_filters_icon", True)
|
|
|
|
@property
|
|
def project(self):
|
|
"""
|
|
Get the project this link belongs to.
|
|
|
|
:return: Project instance.
|
|
"""
|
|
return self._project
|
|
|
|
@property
|
|
def capture_node(self):
|
|
"""
|
|
Get the capturing node
|
|
|
|
:return: Node instance.
|
|
"""
|
|
return self._capture_node
|
|
|
|
@property
|
|
def compute(self):
|
|
"""
|
|
Get the capturing node
|
|
|
|
:return: Node instance.
|
|
"""
|
|
assert self.capture_node
|
|
return self.capture_node["node"].compute
|
|
|
|
def get_active_filters(self):
|
|
"""
|
|
Return the active filters.
|
|
Filters are overridden if the link is suspended.
|
|
"""
|
|
if self._suspended:
|
|
# this is to allow all node types to support suspend link
|
|
return {"frequency_drop": [-1]}
|
|
return self._filters
|
|
|
|
async def update_filters(self, filters):
|
|
"""
|
|
Modify the filters list.
|
|
|
|
Filters with value 0 will be filtered out as inactive, with special
|
|
handling for delay filter to distinguish between "disabled" and "invalid config".
|
|
"""
|
|
# Filter out inactive filters using the utility function
|
|
new_filters = filter_inactive_filters(filters)
|
|
|
|
# Validate filter parameters before applying
|
|
try:
|
|
validate_all_filters(new_filters)
|
|
except FilterValidationError as e:
|
|
raise ControllerError(f"Invalid packet filter parameters: {e!s}")
|
|
|
|
if self._created and not self.kernel_datapath:
|
|
# Kernel-only filters (the netem extensions, the eBPF quota and
|
|
# window modes)
|
|
# have no relay equivalent. Only enforced on created links — while
|
|
# loading a project the datapath is not decided yet (a link that
|
|
# will be wired on the kernel datapath must accept them), and the
|
|
# relay prepare path drops what it cannot run with a warning.
|
|
conflicts = kernel_only_features(new_filters)
|
|
if conflicts:
|
|
raise ControllerError(
|
|
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
|
|
"veth host end); this link is wired on the uBridge relay — delete and "
|
|
"recreate the link to switch it to the kernel datapath".format(", ".join(sorted(conflicts)))
|
|
)
|
|
|
|
# An unchanged filters dict is normally a no-op skip, but a PUT that
|
|
# still carries window_drop must reconcile: the outage schedule is
|
|
# measured from the apply, so re-sending the same window has to
|
|
# re-arm the one-shot outage (the documented contract is that any
|
|
# filter update restarts the schedule).
|
|
if new_filters != self.filters or "window_drop" in new_filters:
|
|
self._filters = new_filters
|
|
if self._created:
|
|
await self.update()
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
self._project.dump()
|
|
|
|
async def update_suspend(self, value):
|
|
if value != self._suspended:
|
|
self._suspended = value
|
|
await self.update()
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
self._project.dump()
|
|
|
|
async def update_show_filters_icon(self, value):
|
|
"""
|
|
Update the show_filters_icon property.
|
|
|
|
:param value: Boolean indicating whether to show filters icon in Web UI
|
|
"""
|
|
if value != self._show_filters_icon:
|
|
self._show_filters_icon = value
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
self._project.dump()
|
|
|
|
async def update_link_style(self, link_style):
|
|
if link_style != self._link_style:
|
|
self._link_style = link_style
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
self._project.dump()
|
|
|
|
@property
|
|
def created(self):
|
|
"""
|
|
:returns: True the link has been created on the computes
|
|
"""
|
|
return self._created
|
|
|
|
async def add_node(self, node, adapter_number, port_number, label=None, dump=True, batch=False):
|
|
"""
|
|
Add a node to the link
|
|
|
|
:param dump: Dump project on disk
|
|
:param batch: When True, do not create the link on the computes once
|
|
both nodes are attached — the caller drives creation via the
|
|
project-open bulk path. Used to avoid one HTTP round-trip per link.
|
|
"""
|
|
|
|
port = node.get_port(adapter_number, port_number)
|
|
if port is None:
|
|
raise ControllerNotFoundError(f"Port {adapter_number}/{port_number} for {node.name} not found")
|
|
if port.link is not None:
|
|
raise ControllerError("Port is already used")
|
|
|
|
self._link_type = port.link_type
|
|
|
|
for other_node in self._nodes:
|
|
if other_node["node"] == node:
|
|
raise ControllerError("Cannot connect to itself")
|
|
|
|
if node.node_type in ["nat", "cloud"]:
|
|
if other_node["node"].node_type in ["nat", "cloud"]:
|
|
raise ControllerError(
|
|
"Connecting a {} to a {} is not allowed".format(other_node["node"].node_type, node.node_type)
|
|
)
|
|
|
|
# Check if user is not connecting serial => ethernet
|
|
other_port = other_node["node"].get_port(other_node["adapter_number"], other_node["port_number"])
|
|
if other_port is None:
|
|
raise ControllerNotFoundError(
|
|
"Port {}/{} for {} not found".format(
|
|
other_node["adapter_number"], other_node["port_number"], other_node["node"].name
|
|
)
|
|
)
|
|
if port.link_type != other_port.link_type:
|
|
raise ControllerError(
|
|
f"Connecting a {other_port.link_type} interface to a {port.link_type} interface is not allowed"
|
|
)
|
|
|
|
if label is None:
|
|
label = {
|
|
"text": html.escape(f"{adapter_number}/{port_number}"),
|
|
"style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;",
|
|
}
|
|
|
|
self._nodes.append(
|
|
{"node": node, "adapter_number": adapter_number, "port_number": port_number, "port": port, "label": label}
|
|
)
|
|
|
|
if len(self._nodes) == 2 and not batch:
|
|
await self.create()
|
|
for n in self._nodes:
|
|
n["node"].add_link(self)
|
|
n["port"].link = self
|
|
self._created = True
|
|
self._project.emit_notification("link.created", self.asdict())
|
|
|
|
if dump:
|
|
self._project.dump()
|
|
|
|
async def update_nodes(self, nodes):
|
|
for node_data in nodes:
|
|
node = self._project.get_node(node_data["node_id"])
|
|
for port in self._nodes:
|
|
if port["node"] == node:
|
|
label = node_data.get("label")
|
|
if label:
|
|
port["label"] = label
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
self._project.dump()
|
|
|
|
async def create(self):
|
|
"""
|
|
Create the link
|
|
"""
|
|
|
|
raise NotImplementedError
|
|
|
|
async def update(self):
|
|
"""
|
|
Update a link
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def delete(self):
|
|
"""
|
|
Delete the link
|
|
"""
|
|
for n in self._nodes:
|
|
# It could be different of self if we rollback an already existing link
|
|
if n["port"].link == self:
|
|
n["port"].link = None
|
|
n["node"].remove_link(self)
|
|
|
|
async def reset(self):
|
|
"""
|
|
Reset a link
|
|
"""
|
|
|
|
raise NotImplementedError
|
|
|
|
async def start_marker(
|
|
self,
|
|
name,
|
|
bpf,
|
|
tag=None,
|
|
direction=None,
|
|
data_link_type="DLT_EN10MB",
|
|
capture_node_id=None,
|
|
color=None,
|
|
highlight_duration=None,
|
|
enabled=True,
|
|
inherited_from=None,
|
|
dump=True,
|
|
memory_only=False,
|
|
):
|
|
"""
|
|
Attach a traffic-insight marker to this link (base — UDPLink overrides).
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def stop_marker(self, name):
|
|
"""
|
|
Remove a traffic-insight marker from this link (base — UDPLink overrides).
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def update_marker(
|
|
self,
|
|
name,
|
|
bpf=None,
|
|
tag=None,
|
|
enabled=None,
|
|
direction=_UNSET,
|
|
color=None,
|
|
highlight_duration=None,
|
|
inherited=False,
|
|
dump=True,
|
|
memory_only=False,
|
|
):
|
|
"""
|
|
Update an existing marker's BPF, tag, or enabled flag.
|
|
|
|
A BPF change is a delete+re-add on the ubridge side so the pcap is
|
|
flushed and the new filter takes effect.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
|
|
"""
|
|
Start capture on the link
|
|
|
|
:param data_link_type: PCAP data link type
|
|
:param capture_file_name: PCAP capture file name
|
|
:param wireshark: Enable Web Wireshark
|
|
:param jwt_token: JWT token for authentication
|
|
:returns: Capture object
|
|
"""
|
|
|
|
self._capturing = True
|
|
self._capture_file_name = capture_file_name
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
|
|
# Start Web Wireshark if requested
|
|
if wireshark:
|
|
await self._start_web_wireshark(jwt_token)
|
|
|
|
async def stop_capture(self):
|
|
"""
|
|
Stop capture on the link
|
|
"""
|
|
|
|
# Stop Web Wireshark
|
|
if self._capturing:
|
|
await self._stop_web_wireshark()
|
|
|
|
self._capturing = False
|
|
self._project.emit_notification("link.updated", self.asdict())
|
|
|
|
async def _start_web_wireshark(self, jwt_token: str):
|
|
"""Start Web Wireshark.
|
|
|
|
Args:
|
|
jwt_token: JWT authentication token
|
|
|
|
Raises:
|
|
ControllerError: If startup fails
|
|
"""
|
|
if not jwt_token:
|
|
raise ControllerError("JWT token is required for Web Wireshark")
|
|
|
|
# Load WebWireshark configuration from config file
|
|
webwireshark_config = Config.instance().settings.WebWireshark
|
|
|
|
manager = WebWiresharkManager()
|
|
try:
|
|
log.info(f"Starting Web Wireshark for link {self.id}")
|
|
|
|
result = await manager.start_wireshark_session(
|
|
project_id=self._project.id,
|
|
link_id=self.id,
|
|
jwt_token=jwt_token,
|
|
memory=webwireshark_config.memory,
|
|
cpus=webwireshark_config.cpus,
|
|
pids_limit=webwireshark_config.pids_limit,
|
|
)
|
|
|
|
# Send notification
|
|
self._project.emit_notification(
|
|
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
|
|
)
|
|
|
|
log.info(f"Web Wireshark started for link {self.id}: {result.get('ws_url')}")
|
|
self._wireshark = True
|
|
|
|
except ControllerError:
|
|
# Re-raise ControllerError to return error to client
|
|
raise
|
|
except Exception as e:
|
|
error_msg = f"Error starting Web Wireshark: {e!s}"
|
|
log.error(error_msg)
|
|
raise ControllerError(error_msg)
|
|
finally:
|
|
await manager.close()
|
|
|
|
async def _stop_web_wireshark(self):
|
|
"""Stop Web Wireshark"""
|
|
manager = WebWiresharkManager()
|
|
try:
|
|
log.info(f"Stopping Web Wireshark for link {self.id}")
|
|
|
|
await manager.stop_wireshark_session(project_id=self._project.id, link_id=self.id)
|
|
|
|
log.info(f"Web Wireshark stopped for link {self.id}")
|
|
self._wireshark = False
|
|
|
|
except Exception as e:
|
|
log.error(f"Error stopping Web Wireshark: {e}")
|
|
finally:
|
|
await manager.close()
|
|
|
|
async def _restart_web_wireshark(self, jwt_token: str):
|
|
"""Restart Web Wireshark after window was closed.
|
|
|
|
Args:
|
|
jwt_token: JWT authentication token
|
|
|
|
Raises:
|
|
ControllerError: If restart fails
|
|
"""
|
|
# Get capture stream URL from compute
|
|
manager = WebWiresharkManager()
|
|
try:
|
|
log.info(f"Restarting Web Wireshark for link {self.id}")
|
|
|
|
result = await manager.restart_wireshark_session(
|
|
project_id=self._project.id, link_id=self.id, jwt_token=jwt_token
|
|
)
|
|
|
|
self._project.emit_notification(
|
|
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
|
|
)
|
|
|
|
log.info(f"Web Wireshark restarted for link {self.id}: {result.get('ws_url')}")
|
|
|
|
except ControllerError:
|
|
raise
|
|
except Exception as e:
|
|
error_msg = f"Error restarting Web Wireshark: {e!s}"
|
|
log.error(error_msg)
|
|
raise ControllerError(error_msg)
|
|
finally:
|
|
await manager.close()
|
|
|
|
def pcap_streaming_url(self):
|
|
"""
|
|
Get the PCAP streaming URL on compute
|
|
|
|
:returns: URL
|
|
"""
|
|
|
|
assert self.capture_node
|
|
compute = self.capture_node["node"].compute
|
|
node_type = self.capture_node["node"].node_type
|
|
node_id = self.capture_node["node"].id
|
|
adapter_number = self.capture_node["adapter_number"]
|
|
port_number = self.capture_node["port_number"]
|
|
url = (
|
|
f"/projects/{self.project.id}/{node_type}/nodes/{node_id}/adapters/{adapter_number}/"
|
|
f"ports/{port_number}/capture/stream"
|
|
)
|
|
|
|
return compute._getUrl(url)
|
|
|
|
async def node_updated(self, node):
|
|
"""
|
|
Called when a node member of the link is updated
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def node_started(self, node):
|
|
"""
|
|
Called after a node member of the link reached the started state.
|
|
The base link has nothing to re-synchronise; UDPLink uses it for
|
|
the Ethernet-switch fast path (the switch must re-join a peer anchor
|
|
that only comes into existence when the peer starts).
|
|
"""
|
|
|
|
def default_capture_file_name(self):
|
|
"""
|
|
:returns: File name for a capture on this link
|
|
"""
|
|
|
|
capture_file_name = "{}_{}-{}_to_{}_{}-{}".format(
|
|
self._nodes[0]["node"].name,
|
|
self._nodes[0]["adapter_number"],
|
|
self._nodes[0]["port_number"],
|
|
self._nodes[1]["node"].name,
|
|
self._nodes[1]["adapter_number"],
|
|
self._nodes[1]["port_number"],
|
|
)
|
|
return re.sub(r"[^0-9A-Za-z_-]", "", capture_file_name) + ".pcap"
|
|
|
|
@property
|
|
def id(self):
|
|
return self._id
|
|
|
|
@property
|
|
def nodes(self):
|
|
return [node["node"] for node in self._nodes]
|
|
|
|
@property
|
|
def capturing(self):
|
|
return self._capturing
|
|
|
|
@property
|
|
def capture_file_path(self):
|
|
"""
|
|
Get the path of the capture
|
|
"""
|
|
|
|
if self._capture_file_name:
|
|
return os.path.join(self._project.captures_directory, self._capture_file_name)
|
|
else:
|
|
return None
|
|
|
|
@property
|
|
def capture_compute_id(self):
|
|
"""
|
|
Get the capture compute ID.
|
|
"""
|
|
|
|
if self._capture_node:
|
|
return self.capture_node["node"].compute.id
|
|
else:
|
|
return None
|
|
|
|
def available_filters(self):
|
|
"""
|
|
Return the list of filters compatible with this link
|
|
|
|
:returns: Array of filters
|
|
"""
|
|
filter_node = self._get_filter_node()
|
|
if filter_node:
|
|
if self.kernel_datapath:
|
|
# Kernel-datapath links serve every filter type in principle:
|
|
# netem for delay/loss/corrupt and the netem extensions,
|
|
# cls_bpf for bpf, the eBPF classifier for frequency_drop,
|
|
# quota and window_drop. What is offered is gated on the
|
|
# endpoints' computes reporting a uBridge that can run each
|
|
# type; computes that report nothing (older servers, failed
|
|
# probe) keep the full list — update_filters still rejects
|
|
# with 409 on apply.
|
|
return [f for f in FILTERS if self._filter_supported_by_computes(f["type"])]
|
|
# Relay links: hide the kernel-only types the relay cannot run
|
|
# (they would be rejected with 409 on update).
|
|
from gns3server.utils.packet_filter_validation import KERNEL_ONLY_FILTERS
|
|
|
|
return [f for f in FILTERS if f["type"] not in KERNEL_ONLY_FILTERS]
|
|
return []
|
|
|
|
def _filter_supported_by_computes(self, filter_type):
|
|
"""
|
|
Whether every endpoint's compute reports a uBridge able to run
|
|
*filter_type* on a kernel-datapath link (both ends apply their own
|
|
filters, so one incapable compute is enough to hide the type).
|
|
Computes without a uBridge tc report impose no constraint.
|
|
"""
|
|
|
|
from gns3server.utils.tc_capabilities import FILTER_EBPF_MODES, FILTER_NETEM_KEYWORDS
|
|
|
|
reported = []
|
|
for side in self._nodes:
|
|
capabilities = side["node"].compute.capabilities
|
|
if isinstance(capabilities, dict) and capabilities.get("ubridge_tc"):
|
|
reported.append(capabilities["ubridge_tc"])
|
|
if not reported:
|
|
return True
|
|
for caps in reported:
|
|
if filter_type in FILTER_EBPF_MODES:
|
|
if FILTER_EBPF_MODES[filter_type] not in caps["ebpf_modes"]:
|
|
return False
|
|
elif filter_type == "bpf":
|
|
if not caps["cbpf"]:
|
|
return False
|
|
elif filter_type in FILTER_NETEM_KEYWORDS:
|
|
if FILTER_NETEM_KEYWORDS[filter_type] not in caps["netem"]:
|
|
return False
|
|
# the remaining types (delay, packet_loss, corrupt) run on any
|
|
# build that reports tc capabilities at all
|
|
return True
|
|
|
|
def _get_filter_node(self):
|
|
"""
|
|
Return the node where the filter will run
|
|
|
|
:returns: None if no node support filtering else the node
|
|
"""
|
|
for node in self._nodes:
|
|
if node["node"].node_type in (
|
|
"vpcs",
|
|
"vmware",
|
|
"dynamips",
|
|
"qemu",
|
|
"iou",
|
|
"cloud",
|
|
"nat",
|
|
"virtualbox",
|
|
"docker",
|
|
# the brctl Ethernet switch applies filters on its per-port uBridge relays
|
|
"ethernet_switch",
|
|
):
|
|
return node["node"]
|
|
return None
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, Link):
|
|
return False
|
|
return self.id == other.id
|
|
|
|
def __hash__(self):
|
|
return hash(self._id)
|
|
|
|
def asdict(self, topology_dump=False):
|
|
"""
|
|
:param topology_dump: Filter to keep only properties require for saving on disk
|
|
"""
|
|
res = []
|
|
for side in self._nodes:
|
|
res.append(
|
|
{
|
|
"node_id": side["node"].id,
|
|
"adapter_number": side["adapter_number"],
|
|
"port_number": side["port_number"],
|
|
"label": side["label"],
|
|
}
|
|
)
|
|
if topology_dump:
|
|
return {
|
|
"nodes": res,
|
|
"link_id": self._id,
|
|
"filters": self._filters,
|
|
"markers": self._persist_markers(),
|
|
"link_style": self._link_style,
|
|
"suspend": self._suspended,
|
|
"show_filters_icon": getattr(self, "_show_filters_icon", True),
|
|
}
|
|
result = {
|
|
"nodes": res,
|
|
"link_id": self._id,
|
|
"project_id": self._project.id,
|
|
"capturing": self._capturing,
|
|
"capture_file_name": self._capture_file_name,
|
|
"capture_file_path": self.capture_file_path,
|
|
"capture_compute_id": self.capture_compute_id,
|
|
"link_type": self._link_type,
|
|
# runtime fact, not persisted: recomputed from the NIO wiring on
|
|
# every load (tells clients which datapath impairs this link —
|
|
# kernel-only filter types absent from available_filters are
|
|
# then distinguishable from capability gaps)
|
|
"kernel_datapath": self.kernel_datapath,
|
|
"filters": self._filters,
|
|
"markers": self._markers,
|
|
"suspend": self._suspended,
|
|
"link_style": self._link_style,
|
|
"wireshark": self._wireshark,
|
|
"show_filters_icon": getattr(self, "_show_filters_icon", True),
|
|
}
|
|
return result
|