mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge remote-tracking branch 'origin/3.1' into 3.1
This commit is contained in:
commit
0091f2c64a
@ -24,6 +24,7 @@
|
||||
|
||||
### uBridge Permission
|
||||
- **[uBridge Permission Issue](./gns3-ubridge-permission.md)** - Docker containers fail to start due to missing CAP_NET_ADMIN/CAP_NET_RAW capabilities on uBridge
|
||||
- **[Docker iptables FORWARD blocks bridge](./docker-iptables-forward-bridge.md)** - Docker sets FORWARD chain to DROP, blocking kernel bridge forwarding; `sudo iptables -P FORWARD ACCEPT` to fix
|
||||
|
||||
### Docker Container Stop Delay
|
||||
- **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark)
|
||||
|
||||
38
.claude/memory/docker-iptables-forward-bridge.md
Normal file
38
.claude/memory/docker-iptables-forward-bridge.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: docker-iptables-forward-bridge
|
||||
description: Docker iptables FORWARD DROP blocks kernel bridge forwarding, fix and symptoms
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
Docker sets the iptables `FORWARD` chain default policy to `DROP` when the
|
||||
Docker daemon starts. This blocks **all** forwarded traffic through Linux
|
||||
kernel bridges on the host — including `gns3br{N}` bridges created by the
|
||||
builtin Ethernet Switch (ubridge `brctl`).
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Nodes connected to the switch can send frames into the bridge (visible in
|
||||
`tcpdump -i gns3br{N}`) but never receive forwarded unicast frames.
|
||||
- `bridge fdb show` may fail to learn MAC addresses (frames dropped before
|
||||
the bridge learning path).
|
||||
- ARP and multicast/broadcast may appear to work because they flood, but
|
||||
unicast replies never reach the destination.
|
||||
- OSPF Hello / CDP visible on both sides but ICMP echo reply never returns.
|
||||
- `ubridge bridge get_stats` shows symmetric IN/OUT counts (relay is fine),
|
||||
`bridge fdb show` shows learned MACs, `bridge link show` shows `state forwarding`
|
||||
on all ports — yet unicast still doesn't work.
|
||||
|
||||
## Fix
|
||||
|
||||
Run once per host boot, or make persistent via iptables-persistent / firewall config:
|
||||
|
||||
```bash
|
||||
sudo iptables -P FORWARD ACCEPT
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [[ethernet-switch-ubridge-brctl-migration]] — the kernel bridge that hits this
|
||||
- [[gns3-server-linux-only]] — datapath constraint
|
||||
- [[gns3-ubridge-permission]] — another host-level prerequisite (CAP_NET_ADMIN)
|
||||
172
.claude/skills/gns3-api-testing/SKILL.md
Normal file
172
.claude/skills/gns3-api-testing/SKILL.md
Normal file
@ -0,0 +1,172 @@
|
||||
---
|
||||
name: gns3-api-testing
|
||||
description: Use this skill when testing GNS3 server REST API endpoints with curl — covers JWT auth, common patterns, and marker/link examples.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# GNS3 Server API Testing with curl
|
||||
|
||||
## Core Principle
|
||||
|
||||
Fixed routine for testing the GNS3 server API: **get a JWT token first, then send `Authorization: Bearer <token>` with every request.**
|
||||
Default address `http://127.0.0.1:3080`, API prefix `/v3`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication (always first)
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://127.0.0.1:3080/v3/access/users/authenticate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
```
|
||||
|
||||
Persist to a file for reuse (avoids re-logging in each time):
|
||||
|
||||
```bash
|
||||
echo "$TOKEN" > /tmp/gns3_token.txt
|
||||
TOKEN=$(cat /tmp/gns3_token.txt)
|
||||
```
|
||||
|
||||
Then attach to every request:
|
||||
```bash
|
||||
AUTH="Authorization: Bearer $TOKEN"
|
||||
curl -s -H "$AUTH" http://127.0.0.1:3080/v3/...
|
||||
```
|
||||
|
||||
> **Endpoint note**: login is `/v3/access/users/authenticate`, **not** `/v3/auth/login`.
|
||||
> OpenAPI spec is at `/openapi.json` (not `/v3/openapi.json`).
|
||||
|
||||
---
|
||||
|
||||
## Common Variables
|
||||
|
||||
```bash
|
||||
BASE="http://127.0.0.1:3080/v3"
|
||||
PID=<project_id>
|
||||
LID=<link_id>
|
||||
NID=<node_id>
|
||||
AUTH="Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Request Patterns
|
||||
|
||||
### GET (query)
|
||||
```bash
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/links | python3 -m json.tool
|
||||
```
|
||||
|
||||
### POST (create) — with JSON body
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"foo","bpf":"icmp"}' \
|
||||
$BASE/projects/$PID/links/$LID/markers
|
||||
```
|
||||
|
||||
### HTTP status code only (body not needed)
|
||||
```bash
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X DELETE -H "$AUTH" \
|
||||
$BASE/projects/$PID/links/$LID/markers/global-icmp
|
||||
```
|
||||
|
||||
### Extract a field from the response
|
||||
```bash
|
||||
LID=$(curl -s -H "$AUTH" -X POST ... | python3 -c "import sys,json; print(json.load(sys.stdin)['link_id'])")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status Code Reference
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 200 | GET/PUT succeeded |
|
||||
| 201 | POST created |
|
||||
| 204 | DELETE succeeded (no body) |
|
||||
| 401 | Not authenticated (token missing/expired) |
|
||||
| 404 | Resource not found |
|
||||
| 409 | Conflict (e.g. per-link edit of an inherited marker) |
|
||||
| 422 | Schema validation failed (e.g. marker name starting with `global`) |
|
||||
|
||||
---
|
||||
|
||||
## Marker Cheat Sheet
|
||||
|
||||
### Project-level global marker definitions (inheritance)
|
||||
```bash
|
||||
# Create a def → fans out to every link automatically
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"icmp","bpf":"icmp","tag":1,"color":"#ff5722"}' \
|
||||
$BASE/projects/$PID/marker-definitions
|
||||
|
||||
# List all defs + the link_ids each is bound to
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/marker-definitions
|
||||
|
||||
# Update a def → syncs to every link
|
||||
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"bpf":"icmp","tag":99}' \
|
||||
$BASE/projects/$PID/marker-definitions/icmp
|
||||
|
||||
# Delete a def → removes the inherited marker from every link
|
||||
curl -s -X DELETE -H "$AUTH" $BASE/projects/$PID/marker-definitions/icmp
|
||||
```
|
||||
|
||||
### Per-link markers
|
||||
```bash
|
||||
# List markers on a link
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/links/$LID/markers
|
||||
|
||||
# Create a private marker (name cannot start with "global")
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"bpf":"tcp port 80"}' \
|
||||
$BASE/projects/$PID/links/$LID/markers
|
||||
|
||||
# Delete (inherited markers return 409)
|
||||
curl -s -X DELETE -H "$AUTH" $BASE/projects/$PID/links/$LID/markers/<name>
|
||||
```
|
||||
|
||||
### Project-level aggregation query
|
||||
```bash
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/markers # all markers across links, flattened
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Link / Node Cheat Sheet
|
||||
|
||||
```bash
|
||||
# List all links in a project (includes the markers field)
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/links
|
||||
|
||||
# List nodes (check ports[].link_id to find free ports)
|
||||
curl -s -H "$AUTH" $BASE/projects/$PID/nodes
|
||||
|
||||
# Create a VPCS
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"t1","node_type":"vpcs","compute_id":"local"}' \
|
||||
$BASE/projects/$PID/nodes
|
||||
|
||||
# Start a node
|
||||
curl -s -o /dev/null -X POST -H "$AUTH" $BASE/projects/$PID/nodes/$NID/start
|
||||
|
||||
# Create a link (both ends: node + adapter/port)
|
||||
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"nodes\":[{\"node_id\":\"$N1\",\"adapter_number\":0,\"port_number\":0},{\"node_id\":\"$N2\",\"adapter_number\":0,\"port_number\":0}]}" \
|
||||
$BASE/projects/$PID/links
|
||||
```
|
||||
|
||||
> **Port occupancy**: VPCS has only one interface (port 0); once linked it cannot connect again.
|
||||
> Confirm `ports[].link_id` is empty before creating a link; `"Port is already used"` means the port is taken.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`POST /links` response may show `markers: []`** — the create response is serialized before the inheritance hook runs.
|
||||
The inherited marker is actually applied; check `GET /links/{lid}/markers` or refresh `GET /links` to see it.
|
||||
- **Restart gns3server after code changes** — the Python process does not hot-reload.
|
||||
- **Wrap JSON bodies in single quotes** in the shell (double quotes inside); to interpolate a shell variable use `\"$VAR\"`.
|
||||
- **Pipe long output through `python3 -m json.tool`** to pretty-print; extract fields with `python3 -c "import sys,json; ..."`.
|
||||
@ -72,6 +72,9 @@ Unified error response format across all GNS3 API endpoints. Documents HTTP stat
|
||||
### Web Wireshark (`features/web-wireshark-business-process.md`)
|
||||
Web-based packet capture analysis using Docker + xpra HTML5 client. Zero-install Wireshark experience directly in the browser, integrated with GNS3 topologies.
|
||||
|
||||
### Marker (Traffic Insight) (`features/marker-traffic-insight.md`)
|
||||
Real-time traffic insight via per-link BPF markers and project-level inherited definitions. A marker taps a link in uBridge, emitting match notifications and pcap capture on BPF hit; definitions fan out to every capable link automatically.
|
||||
|
||||
---
|
||||
|
||||
## GNS3 AI Copilot (`gns3-copilot/`)
|
||||
|
||||
262
docs/features/builtin-ethernet-switch-ubridge.md
Normal file
262
docs/features/builtin-ethernet-switch-ubridge.md
Normal file
@ -0,0 +1,262 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This documentation is AI-generated with reference to actual code and verified
|
||||
> against real-kernel testing (Linux 7.1.2-1-default). AI can make mistakes —
|
||||
> please verify against the source code when in doubt.
|
||||
|
||||
# Builtin Ethernet Switch — uBridge brctl Backend
|
||||
|
||||
## Overview
|
||||
|
||||
The historical GNS3 Ethernet Switch was an emulated L2 device inside Dynamips
|
||||
(`ethsw`). This implementation replaces it with a **real Linux kernel bridge**
|
||||
driven through uBridge's `brctl` module — one bridge per switch node. The
|
||||
migration makes the switch a first-class builtin node (no Dynamips dependency)
|
||||
and enables native-kernel-speed L2 switching with VLAN filtering and QinQ.
|
||||
|
||||
| | Old (Dynamips ethsw) | New (uBridge brctl) |
|
||||
|---|---|---|
|
||||
| Switching engine | Dynamips user-space emulation | Linux kernel bridge (netlink) |
|
||||
| VLAN model | ethsw ACL per port | Kernel VLAN filtering + PVID/untagged |
|
||||
| QinQ | 0x8100/0x88A8/0x9100/0x9200 | 0x8100 (802.1Q) / 0x88A8 (802.1ad) |
|
||||
| Data path | Node NIO ↔ ethsw NIO (Dynamips) | Node NIO ↔ uBridge relay ↔ TAP ↔ kernel bridge |
|
||||
| Console | Inactive (reserved TCP port) | None (console_type=none) |
|
||||
| Node type | `dynamips`-routed | `builtin` (always-on) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌───────────┐ ┌──────────────┐ ┌───────────────┐ ┌───────────┐
|
||||
│ Peer A │ │ uBridge │ │ Kernel │ │ Peer B │
|
||||
│ (Dynamips │◄───►│ per-port │◄───►│ Bridge │◄───►│ (IOU / │
|
||||
│ / IOU / │ UDP │ relay │ TAP │ gns3{id[:6]}│ TAP │ QEMU / …) │
|
||||
│ QEMU) │ │ nio_tap↔udp │ │ vlan_filter │ │ │
|
||||
└───────────┘ └──────────────┘ └──────┬────────┘ └───────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ ... more │
|
||||
│ ports │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
Each switch port is a **dual-role TAP** — uBridge holds the file descriptor as a
|
||||
`nio_tap` relay endpoint, and the same TAP is enslaved to the kernel bridge via
|
||||
`brctl addif`. This is the same pattern the Cloud node already uses for host
|
||||
bridges (`cloud.py:_add_linux_ethernet`). uBridge is **only** the per-port UDP
|
||||
transport; the kernel bridge performs the actual MAC learning, forwarding, and
|
||||
VLAN filtering.
|
||||
|
||||
### Component map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `compute/builtin/nodes/ethernet_switch.py` | Node implementation |
|
||||
| `api/routes/compute/ethernet_switch_nodes.py` | REST endpoints (repointed to Builtin) |
|
||||
| `schemas/compute/ethernet_switch_nodes.py` | Request/response models (unchanged) |
|
||||
| `controller/udp_link.py` | Link creation — pushes NIO to switch via standard adapter endpoint |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### `create()` → `start()`
|
||||
|
||||
1. `_start_ubridge(require_privileged_access=True)` — launch uBridge instance
|
||||
2. `_ensure_bridge()`:
|
||||
- Derive deterministic bridge name: `gns3` + first 6 hex chars of `self.id`
|
||||
- `brctl delete` (best-effort — crash recovery, cleans stale interfaces)
|
||||
- `brctl create`
|
||||
- `link set … up` (bridge is DOWN after create)
|
||||
- `brctl vlanfiltering … on`
|
||||
|
||||
### `add_nio(nio, port_number)`
|
||||
|
||||
Per port, one uBridge relay bridge `{node_id}-{port}` is wired:
|
||||
|
||||
```
|
||||
bridge create {node_id}-{port}
|
||||
bridge add_nio_tap {node_id}-{port} "{tap}" ← uBridge holds TAP fd
|
||||
brctl addif "{bridge}" "{tap}" ← enslave to kernel bridge
|
||||
brctl vlan_del/vlan_add … ← apply port VLAN mode
|
||||
bridge add_nio_udp {node_id}-{port} lport rhost rport
|
||||
bridge reset_packet_filters {node_id}-{port} ← from _ubridge_apply_filters
|
||||
bridge start {node_id}-{port}
|
||||
```
|
||||
|
||||
Captures and marker signals are applied via the existing `_ubridge_apply_filters`
|
||||
and `_ubridge_apply_markers` helpers from `BaseNode`.
|
||||
|
||||
### `remove_nio(port_number)`
|
||||
|
||||
```
|
||||
brctl delif "{bridge}" "{tap}"
|
||||
bridge delete {node_id}-{port}
|
||||
release_udp_port(nio.lport)
|
||||
```
|
||||
|
||||
### `close()`
|
||||
|
||||
```
|
||||
for each port: release UDP port
|
||||
brctl delete "{self._bridge_name}" ← kernel bridge teardown
|
||||
_stop_ubridge() ← destroys remaining TAPs
|
||||
```
|
||||
|
||||
**Cleanup paths:**
|
||||
|
||||
| Scenario | Bridge cleanup | TAP cleanup |
|
||||
|----------|---------------|-------------|
|
||||
| Normal project close | `close()` → `brctl delete` | uBridge stops → TAP fd closed → kernel destroys |
|
||||
| gns3server crash / kill | Next `_ensure_bridge()` → `brctl delete` before `create` | uBridge dies → TAP fd closed by kernel |
|
||||
| Manual project-file deletion after crash | Leaked (no GNS3 record of `gns3{id[:6]}`) | Leaked (same — but uBridge probably dead, TAPs gone with it) |
|
||||
|
||||
## Port mode → VLAN translation
|
||||
|
||||
All VLAN operations ride on the `brctl` hypervisor module (`../ubridge/doc/brctl.md`).
|
||||
The kernel bridge must have `vlan_filtering on` before any `vlan_*` call.
|
||||
|
||||
### access VLAN N
|
||||
|
||||
```
|
||||
brctl vlan_del {br} {tap} 1 ← remove default PVID 1
|
||||
brctl vlan_add {br} {tap} N pvid untagged
|
||||
```
|
||||
|
||||
### dot1q trunk (native VLAN V)
|
||||
|
||||
A dot1q trunk in ESW is "admit all VLANs tagged, native VLAN PVID + untagged":
|
||||
```
|
||||
brctl vlan_del {br} {tap} 1
|
||||
brctl vlan_add {br} {tap} 1 vid 4094 ← admit all VIDs tagged
|
||||
brctl vlan_add {br} {tap} V pvid untagged ← override native
|
||||
```
|
||||
|
||||
### qinq (outer VLAN O, ethertype 0x88A8)
|
||||
|
||||
Bridge-level (once):
|
||||
```
|
||||
brctl setvlanproto {br} 0x88a8 ← switch to 802.1ad (outer S-tag)
|
||||
```
|
||||
|
||||
Port-level:
|
||||
```
|
||||
brctl vlan_del {br} {tap} 1
|
||||
brctl vlan_add {br} {tap} O pvid untagged ← S-tag push for untagged ingress
|
||||
```
|
||||
|
||||
Ethertype 0x8100 qinq ports are treated as plain access ports (the bridge
|
||||
defaults to 0x8100; no `setvlanproto` needed). Ethertype 0x9100/0x9200 are
|
||||
not supported by the kernel bridge — see § Limitations.
|
||||
|
||||
### Runtime reconfiguration (`update_port_settings`)
|
||||
|
||||
On `ports_mapping` update, existing port VLANs are reset before re-apply:
|
||||
```
|
||||
brctl delif {br} {tap} ← release from bridge (clears VLAN state)
|
||||
brctl addif {br} {tap} ← re-enslave (resets to default PVID 1)
|
||||
brctl vlan_del/vlan_add … ← apply new mode
|
||||
```
|
||||
|
||||
This prevents stale VLAN membership from a previous mode leaking into the new
|
||||
configuration (e.g., access→trunk transition leaving old access VLAN behind).
|
||||
|
||||
## Bridge naming
|
||||
|
||||
Deterministic from the switch's UUID: `gns3` + first 6 hex chars (no dashes).
|
||||
|
||||
```
|
||||
gns3a1b2c3 ← bridge (10 chars, ≤ 15 IFNAMSIZ limit)
|
||||
gns3a1b2c3-0 ← tap for port 0 (12 chars)
|
||||
gns3a1b2c3-1 ← tap for port 1 (12 chars)
|
||||
```
|
||||
|
||||
- 6 hex = 48 bits of entropy — collision risk is astronomically low even with
|
||||
thousands of switches on the same host.
|
||||
- **Crash recovery**: `brctl delete` (best-effort, ignore if not found) then
|
||||
`brctl create` — stale interfaces from a previous abnormal shutdown are
|
||||
reclaimed automatically when the switch is re-created.
|
||||
|
||||
## Controller integration
|
||||
|
||||
No controller or API contract changes are required. The migration is entirely
|
||||
compute-internal:
|
||||
|
||||
- `node_types.BUILTIN_NODE_TYPES` already classified `ethernet_switch` as a
|
||||
builtin, always-running node.
|
||||
- `udp_link.create()` pushes the NIO to the switch via the standard
|
||||
`POST /adapters/0/ports/{p}/nio` endpoint (same as Dynamips).
|
||||
- The REST API paths, request/response schemas, and port model
|
||||
(`EthernetSwitchPort`: type/vlan/ethertype) are unchanged.
|
||||
- `/start`, `/stop`, `/suspend`, `/reload` return 405 (switch is always-on).
|
||||
|
||||
The sole observable difference: the `console` field in the response is now
|
||||
`null` (the switch has no console; `console_type="none"` makes `BaseNode`
|
||||
skip TCP port reservation). The old Dynamips ethsw returned an unused TCP
|
||||
port number. Both are valid under `Optional[int]`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Default PVID 1 must be deleted explicitly
|
||||
|
||||
A port freshly enslaved to a `vlan_filtering` bridge inherits default PVID 1
|
||||
(PVID + Egress Untagged). Access/trunk mode application must issue
|
||||
`vlan_del … 1` first — `vlan_add … pvid` moves the PVID but does not remove
|
||||
the old PVID's membership. This matches iproute2 semantics and is documented
|
||||
in `../ubridge/doc/brctl.md#limitations`.
|
||||
|
||||
### QinQ is outer-tag (S-VLAN) only
|
||||
|
||||
With `setvlanproto 0x88a8` the bridge filters on the outer S-tag; the inner
|
||||
C-tag passes through transparently. Selective QinQ (inner-VLAN classification
|
||||
or remapping) requires `IFLA_BRIDGE_VLAN_TUNNEL_INFO` which is not implemented.
|
||||
Documented in `../ubridge/doc/brctl.md#limitations`.
|
||||
|
||||
### Ethertype 0x9100 / 0x9200
|
||||
|
||||
The GNS3 schema allows legacy QinQ ethertypes `0x9100` and `0x9200`, but the
|
||||
Linux kernel bridge only supports `0x8100` (802.1Q) and `0x88a8` (802.1ad).
|
||||
Configuring these on a qinq port produces a `NodeError` at creation/update
|
||||
time. Handling policy (map to 0x88A8 + warn vs. reject with error) is
|
||||
pending per design discussion.
|
||||
|
||||
### No FDB read/write
|
||||
|
||||
The `brctl` module exposes no `fdb_show`/`fdb_flush`. The kernel bridge
|
||||
learns and ages MAC entries autonomously; uBridge has never exposed MAC-table
|
||||
access and gns3-server does not consume it. Consumers that need the FDB
|
||||
(e.g., a WebUI switch view) should read `/sys/class/net/<br>/brforward` or
|
||||
`bridge fdb show dev <br>` directly, without uBridge involvement.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker iptables: FORWARD chain DROP
|
||||
|
||||
Docker sets the iptables `FORWARD` chain default policy to `DROP` when the
|
||||
Docker daemon starts. This blocks **all** forwarded traffic through kernel
|
||||
bridges on the host, including `gns3*` bridges.
|
||||
|
||||
**Symptoms**: nodes can send frames into the bridge (visible in `tcpdump -i
|
||||
gns3*`) but never receive unicast replies. ARP and multicast may work
|
||||
because they flood, but unicast forwarding silently fails.
|
||||
|
||||
**Fix**:
|
||||
```bash
|
||||
sudo iptables -P FORWARD ACCEPT
|
||||
```
|
||||
|
||||
### Bridge left DOWN after creation
|
||||
|
||||
`brctl create` creates the bridge but leaves it administratively DOWN.
|
||||
The node now sends `link set … up` after `brctl create`. If forwarding
|
||||
is not working, verify:
|
||||
```bash
|
||||
ip -d link show gns3* | grep -E "state|vlan_filtering"
|
||||
```
|
||||
|
||||
### Kernel version differences
|
||||
|
||||
This implementation has been tested on Linux 7.1.2-1-default (x86_64) with
|
||||
uBridge installed via `make install` (cap_net_admin,cap_net_raw=ep). The
|
||||
ubridge `brctl` module has a 168-test suite covering kernel-side VLAN
|
||||
behaviour on this kernel.
|
||||
258
docs/features/marker-traffic-insight.md
Normal file
258
docs/features/marker-traffic-insight.md
Normal file
@ -0,0 +1,258 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
|
||||
|
||||
# Marker (Traffic Insight)
|
||||
|
||||
## Overview
|
||||
|
||||
A **marker** is a passive traffic-insight tap attached to a link. It runs a libpcap BPF
|
||||
expression inside uBridge; on every match uBridge emits a real-time `MARK` signal and
|
||||
appends the matching packet to a per-marker pcap file. Markers exist at two layers that
|
||||
coexist on the same link: **per-link private markers** and **project-level definitions**
|
||||
that are inherited by every capable link.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
UI["Web UI"]
|
||||
|
||||
subgraph Controller["Controller"]
|
||||
DEF["Project definitions<br/>(inheritance templates)"]
|
||||
LNK["Per-link markers"]
|
||||
end
|
||||
|
||||
Compute["Compute Node"]
|
||||
UB["uBridge<br/>mark filter"]
|
||||
PCAP[("pcap file")]
|
||||
LSTN["Marker listener<br/>(UDP, per compute)"]
|
||||
|
||||
UI -->|"REST + notifications ws"| Controller
|
||||
DEF -.->|"fan-out: global-{name}"| LNK
|
||||
LNK -->|"node.post /markers"| Compute
|
||||
Compute --> UB
|
||||
UB -->|"BPF match"| PCAP
|
||||
UB -->|"UDP MARK signal"| LSTN
|
||||
LSTN -->|"marker.match"| UI
|
||||
```
|
||||
|
||||
Inheritance is a controller-only fan-out: a definition CRUD loops over links and reuses the
|
||||
existing per-link marker operations, so the compute side sees an ordinary marker and is
|
||||
unchanged. Each compute process runs one UDP listener serving every uBridge on that host; the
|
||||
`node` and `link` fields in each signal together identify the source link (see
|
||||
[Per-link attribution](#per-link-attribution)).
|
||||
|
||||
## Business Process
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Web UI
|
||||
participant C as Controller
|
||||
participant L as Capable Link
|
||||
participant N as Compute / uBridge
|
||||
|
||||
UI->>C: POST /marker-definitions {name, bpf, ...}
|
||||
C->>C: store definition
|
||||
loop every capable link
|
||||
C->>L: start_marker("global-{name}")
|
||||
L->>N: install mark filter (BPF + pcap)
|
||||
end
|
||||
C-->>UI: 201 + link_ids
|
||||
|
||||
Note over N: later: a packet matches the BPF
|
||||
N->>N: emit MARK signal + append pcap
|
||||
N-->>UI: marker.match notification (per-project ws)
|
||||
```
|
||||
|
||||
Updating a definition syncs `bpf / tag / color / highlight_duration` to every inherited
|
||||
copy; deleting a definition removes every inherited copy. A newly created link inherits all
|
||||
existing definitions automatically.
|
||||
|
||||
## Per-link attribution
|
||||
|
||||
A uBridge `MARK` signal carries `node`, `filter`, `link`, `tag`, and `len` — but no bridge
|
||||
name. When one node is the capture side for several links — the common case for a project-level
|
||||
`global-{name}` marker on a multi-interface router — `node` + `filter` alone are identical
|
||||
across those links, so they cannot tell the signals (or pcap files) apart. The `link` field
|
||||
resolves this:
|
||||
|
||||
1. At install time the controller stamps each filter with its link id
|
||||
(`mark <bpf> [tag <id>] link <link_id> [pcap <path>]`).
|
||||
2. uBridge treats `link` as opaque and echoes it verbatim in the signal (`link=<link_id>`).
|
||||
3. The listener takes the signal's `link=` as the **authoritative** `link_id` of the
|
||||
`marker.match` event, falling back to its registry only for legacy signals that carry no
|
||||
`link=`.
|
||||
|
||||
This is also why the pcap path is keyed on link —
|
||||
`<project>/markers/<node_id>_<link_id>_<filter>.pcap`, not on `bridge`+`filter`: a single
|
||||
uBridge bridge can serve several links, and only the link id keeps their captures distinct.
|
||||
|
||||
### IOU: one bridge, many interfaces
|
||||
|
||||
IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+`filter` are
|
||||
identical across that node's links. uBridge keeps a separate filter list **per port
|
||||
(bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own
|
||||
pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other
|
||||
capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link`
|
||||
applies uniformly to all of them.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The
|
||||
`Auth` column lists the required privilege.
|
||||
|
||||
### Per-link markers
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/v3/projects/{pid}/links/{lid}/markers` | List markers on a link | Link.Audit |
|
||||
| POST | `/v3/projects/{pid}/links/{lid}/markers` | Attach a marker | Link.Modify |
|
||||
| PUT | `/v3/projects/{pid}/links/{lid}/markers/{name}` | Update a marker | Link.Modify |
|
||||
| DELETE | `/v3/projects/{pid}/links/{lid}/markers/{name}` | Remove a marker | Link.Modify |
|
||||
|
||||
### Project-level definitions
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/v3/projects/{pid}/marker-definitions` | List definitions + bound `link_ids` | Project.Audit |
|
||||
| POST | `/v3/projects/{pid}/marker-definitions` | Create definition (fans out to every link) | Project.Modify |
|
||||
| PUT | `/v3/projects/{pid}/marker-definitions/{name}` | Update definition (syncs all copies) | Project.Modify |
|
||||
| DELETE | `/v3/projects/{pid}/marker-definitions/{name}` | Delete definition (clears all copies) | Project.Modify |
|
||||
|
||||
### Aggregation
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/v3/projects/{pid}/markers` | All markers across links, flat | Project.Audit |
|
||||
|
||||
The link object returned by `GET /v3/projects/{pid}/links[/{lid}]` also carries a `markers`
|
||||
field (including inherited markers), so the Web UI can render a link's markers without an
|
||||
extra request.
|
||||
|
||||
## Request / Response
|
||||
|
||||
**Marker create body** (`MarkerCreate`, shared by per-link POST and PUT):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "icmp",
|
||||
"bpf": "icmp",
|
||||
"tag": 1,
|
||||
"color": "#ff5722",
|
||||
"highlight_duration": 800,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Definition create body** (`MarkerDefinitionCreate`, shared by POST and PUT):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "arp",
|
||||
"bpf": "arp",
|
||||
"tag": 5,
|
||||
"color": "#ff5722",
|
||||
"highlight_duration": 1200
|
||||
}
|
||||
```
|
||||
|
||||
**Marker entry** (returned by GET/POST/PUT, and the value of each link's `markers[name]`):
|
||||
|
||||
```json
|
||||
{
|
||||
"bpf": "icmp",
|
||||
"tag": 1,
|
||||
"enabled": true,
|
||||
"color": "#ff5722",
|
||||
"highlight_duration": 800,
|
||||
"capture_node_id": "a37e2235-e21f-46c9-a2ab-ba0f8c5465e6",
|
||||
"inherited_from": null
|
||||
}
|
||||
```
|
||||
|
||||
**Definition GET response** (adds `link_ids`):
|
||||
|
||||
```json
|
||||
{
|
||||
"arp": {
|
||||
"bpf": "arp",
|
||||
"tag": 5,
|
||||
"color": null,
|
||||
"highlight_duration": 1200,
|
||||
"link_ids": ["656ed826-...", "6bd9d156-..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
### Marker entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `bpf` | string | libpcap BPF expression (required) |
|
||||
| `tag` | int \| null | Correlation id echoed in `MARK` signals |
|
||||
| `enabled` | bool | Whether the marker is active |
|
||||
| `color` | string \| null | Hex color render hint, e.g. `#ff5722` |
|
||||
| `highlight_duration` | int \| null | UI highlight duration in ms after a match; `null` = UI default |
|
||||
| `capture_node_id` | string | Server-chosen node whose uBridge hosts the marker |
|
||||
| `inherited_from` | string | Source definition name — present on inherited markers only |
|
||||
|
||||
### Definition
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `bpf` | string | libpcap BPF expression (required) |
|
||||
| `tag` | int \| null | Correlation id |
|
||||
| `color` | string \| null | Hex color render hint |
|
||||
| `highlight_duration` | int \| null | UI highlight duration in ms; `null` = UI default |
|
||||
| `link_ids` | string[] | Links currently carrying an inherited copy (GET only) |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Event | Payload | Delivered to |
|
||||
|-------|---------|--------------|
|
||||
| `link.updated` | Link object (its `markers` field is the source of truth) | Project notification ws |
|
||||
| `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len` | Project notification ws only |
|
||||
|
||||
The `marker.match` `link_id` is taken from the signal's `link=` field (authoritative); see
|
||||
[Per-link attribution](#per-link-attribution).
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | Not authenticated |
|
||||
| 404 | Link / marker / definition not found |
|
||||
| 409 | Per-link edit or delete of an inherited marker; reserved (`global`) name or duplicate name on create |
|
||||
| 422 | Validation failure (name format, `highlight_duration < 1`, missing `bpf`) |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Marker name is immutable.** It is the identifier across the controller, the uBridge
|
||||
filter, the pcap filename, and `MARK` signal routing — so rename is a delete + recreate,
|
||||
not a field update. PUT ignores the body `name`; the `{name}` path parameter identifies
|
||||
the target, and only `bpf / tag / color / enabled / highlight_duration` are changeable.
|
||||
- **`global` prefix reserved.** User-chosen names may not start with `global`; inherited
|
||||
markers are stored as `global-{definition_name}` so the two namespaces cannot collide.
|
||||
Omitting `name` on create yields an auto-generated, prefix-free name.
|
||||
- **Inherited markers are read-only per-link.** PUT/DELETE on an inherited marker returns
|
||||
409 — edit them through the definitions API.
|
||||
- **Render hints are not enforced.** `color` and `highlight_duration` (milliseconds, `>= 1`)
|
||||
are stored on the link and never sent to uBridge; `null` lets the UI apply its own
|
||||
default. A partial PUT (e.g. changing only `bpf`) leaves them untouched.
|
||||
- **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`,
|
||||
`iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are
|
||||
silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but
|
||||
keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled
|
||||
(see [Per-link attribution](#per-link-attribution)).
|
||||
- **Shared capture-side node.** When one node hosts markers for several links (typical for
|
||||
`global-*` definitions on a router), each filter is stamped with its `link_id` so signals
|
||||
and pcap files stay link-distinct; the controller never collapses them to a single link.
|
||||
- **Persistence.** Definitions and private markers persist in the topology; inherited
|
||||
markers are re-created from definitions on project load, so reopening a project restores
|
||||
the same configuration and stale inherited copies cannot survive on disk.
|
||||
@ -127,7 +127,6 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
| `node_update` | Update node properties |
|
||||
| `node_start` | Start node(s) — `node_id` or `node_ids` array |
|
||||
| `node_stop` | Stop node(s) — `node_id` or `node_ids` array |
|
||||
| `node_reload` | Reload node(s) — `node_id` or `node_ids` array |
|
||||
| `node_suspend` | Suspend node(s) — `node_id` or `node_ids` array |
|
||||
| `node_console` | Get WebSocket console URL |
|
||||
| `node_file_list` | List files in node directory |
|
||||
@ -137,7 +136,6 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
| `node_start_all` | Start all nodes |
|
||||
| `node_stop_all` | Stop all nodes |
|
||||
| `node_suspend_all` | Suspend all nodes |
|
||||
| `node_reload_all` | Reload all nodes |
|
||||
| `node_duplicate` | Duplicate a node |
|
||||
| `node_isolate` | Isolate a node (suspend links) |
|
||||
| `node_unisolate` | Un-isolate a node (resume links) |
|
||||
@ -288,7 +286,8 @@ device_show_run(project_id, device_configs=[
|
||||
config = node_file_get(project_id, node_id, "startup-config.cfg")
|
||||
# Restore if config breaks
|
||||
node_file_write(project_id, node_id, "startup-config.cfg", config)
|
||||
node_reload(project_id, node_id)
|
||||
node_stop(project_id, node_id)
|
||||
node_start(project_id, node_id)
|
||||
```
|
||||
|
||||
### Device Config Workflow
|
||||
|
||||
@ -184,6 +184,8 @@ async def update_cloud_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
|
||||
nio.markers = getattr(nio_data, "markers", None) or {}
|
||||
await node.update_nio(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ from typing import Union
|
||||
from gns3server import schemas
|
||||
from gns3server.compute.docker import Docker
|
||||
from gns3server.compute.docker.docker_vm import DockerVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}}
|
||||
@ -293,6 +292,7 @@ async def update_docker_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -235,6 +235,7 @@ async def update_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.slot_update_nio_binding(adapter_number, port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
|
||||
"""
|
||||
API routes for Ethernet switch nodes.
|
||||
|
||||
The Ethernet switch is a builtin node backed by a Linux kernel bridge driven
|
||||
through uBridge's ``brctl`` module (see
|
||||
``gns3server.compute.builtin.nodes.ethernet_switch``).
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -25,8 +29,8 @@ from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from uuid import UUID
|
||||
|
||||
from gns3server.compute.dynamips import Dynamips
|
||||
from gns3server.compute.dynamips.nodes.ethernet_switch import EthernetSwitch
|
||||
from gns3server.compute.builtin import Builtin
|
||||
from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch
|
||||
from gns3server import schemas
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}}
|
||||
@ -39,8 +43,8 @@ def dep_node(project_id: UUID, node_id: UUID) -> EthernetSwitch:
|
||||
Dependency to retrieve a node.
|
||||
"""
|
||||
|
||||
dynamips_manager = Dynamips.instance()
|
||||
node = dynamips_manager.get_node(str(node_id), project_id=str(project_id))
|
||||
builtin_manager = Builtin.instance()
|
||||
node = builtin_manager.get_node(str(node_id), project_id=str(project_id))
|
||||
return node
|
||||
|
||||
|
||||
@ -55,10 +59,9 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
|
||||
Create a new Ethernet switch.
|
||||
"""
|
||||
|
||||
# Use the Dynamips Ethernet switch to simulate this node
|
||||
dynamips_manager = Dynamips.instance()
|
||||
builtin_manager = Builtin.instance()
|
||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
node = await dynamips_manager.create_node(
|
||||
node = await builtin_manager.create_node(
|
||||
node_data.pop("name"),
|
||||
str(project_id),
|
||||
node_data.get("node_id"),
|
||||
@ -67,7 +70,7 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
|
||||
node_type="ethernet_switch",
|
||||
ports=node_data.get("ports_mapping"),
|
||||
)
|
||||
|
||||
node.usage = node_data.get("usage", "")
|
||||
return node.asdict()
|
||||
|
||||
|
||||
@ -86,7 +89,7 @@ async def duplicate_ethernet_switch(
|
||||
Duplicate an Ethernet switch.
|
||||
"""
|
||||
|
||||
new_node = await Dynamips.instance().duplicate_node(node.id, str(destination_node_id))
|
||||
new_node = await Builtin.instance().duplicate_node(node.id, str(destination_node_id))
|
||||
return new_node.asdict()
|
||||
|
||||
|
||||
@ -101,7 +104,9 @@ async def update_ethernet_switch(
|
||||
|
||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
if "name" in node_data and node.name != node_data["name"]:
|
||||
await node.set_name(node_data["name"])
|
||||
node.name = node_data["name"]
|
||||
if "usage" in node_data:
|
||||
node.usage = node_data["usage"]
|
||||
if "ports_mapping" in node_data:
|
||||
node.ports_mapping = node_data["ports_mapping"]
|
||||
await node.update_port_settings()
|
||||
@ -117,7 +122,7 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No
|
||||
Delete an Ethernet switch.
|
||||
"""
|
||||
|
||||
await Dynamips.instance().delete_node(node.id)
|
||||
await Builtin.instance().delete_node(node.id)
|
||||
|
||||
|
||||
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@ -182,7 +187,7 @@ async def create_ethernet_switch_nio(
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> schemas.UDPNIO:
|
||||
|
||||
nio = await Dynamips.instance().create_nio(node, jsonable_encoder(nio_data, exclude_unset=True))
|
||||
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
|
||||
await node.add_nio(nio, port_number)
|
||||
return nio.asdict()
|
||||
|
||||
@ -199,8 +204,7 @@ async def delete_ethernet_switch_nio(
|
||||
The adapter number on the switch is always 0.
|
||||
"""
|
||||
|
||||
nio = await node.remove_nio(port_number)
|
||||
await nio.delete()
|
||||
await node.remove_nio(port_number)
|
||||
|
||||
|
||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||
@ -251,5 +255,5 @@ async def stream_pcap_file(
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
stream = Dynamips.instance().stream_pcap_file(nio, node.project.id)
|
||||
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
|
||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||
|
||||
@ -254,6 +254,8 @@ async def update_iou_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
|
||||
nio.markers = getattr(nio_data, "markers", None) or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ from gns3server import schemas
|
||||
from gns3server.compute import qemu
|
||||
from gns3server.compute.qemu import Qemu
|
||||
from gns3server.compute.qemu.qemu_vm import QemuVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
import logging
|
||||
@ -321,6 +320,7 @@ async def update_qemu_node_nio(
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.suspend = nio_data.suspend
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ from uuid import UUID
|
||||
from gns3server import schemas
|
||||
from gns3server.compute.vpcs import VPCS
|
||||
from gns3server.compute.vpcs.vpcs_vm import VPCSVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or VMware node"}}
|
||||
@ -240,6 +239,7 @@ async def update_vpcs_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.port_update_nio_binding(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@ -303,6 +303,7 @@ async def stop_vpcs_node_capture(
|
||||
await node.stop_capture(port_number)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
|
||||
@ -52,6 +52,10 @@ def has_privilege_on_websocket(
|
||||
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
|
||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||
):
|
||||
# Authentication may have failed and closed the socket inside the auth
|
||||
# dependency, returning None — bail out before touching the user object.
|
||||
if current_user is None:
|
||||
return None
|
||||
if not current_user.is_superadmin:
|
||||
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
|
||||
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
|
||||
|
||||
@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends, Request, status, WebSocket
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from typing import List, Union
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
@ -424,6 +424,97 @@ async def web_wireshark_websocket(
|
||||
pass
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{link_id}/markers",
|
||||
dependencies=[Depends(has_privilege("Link.Audit"))]
|
||||
)
|
||||
async def get_markers(link: Link = Depends(dep_link)) -> dict:
|
||||
"""
|
||||
Return all traffic-insight markers configured on this link.
|
||||
|
||||
Required privilege: Link.Audit
|
||||
"""
|
||||
|
||||
return link.markers
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{link_id}/markers",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def create_marker(
|
||||
marker_data: schemas.MarkerCreate,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> dict:
|
||||
"""
|
||||
Attach a traffic-insight marker to the link.
|
||||
On BPF match uBridge emits MARK signals and appends packets to a pcap.
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
# Auto-generate a link-unique name when the caller omits one. The short
|
||||
# uuid suffix avoids the collision that `marker-{link.id[:8]}` alone would
|
||||
# cause on the second anonymous marker on the same link (start_marker
|
||||
# rejects duplicate names).
|
||||
if marker_data.name and marker_data.name.lower().startswith("global"):
|
||||
raise ControllerError('Names starting with "global" are reserved for inherited markers')
|
||||
name = marker_data.name or f"marker-{link.id[:8]}-{uuid4().hex[:4]}"
|
||||
await link.start_marker(
|
||||
name=name,
|
||||
bpf=marker_data.bpf,
|
||||
tag=marker_data.tag,
|
||||
color=marker_data.color,
|
||||
highlight_duration=marker_data.highlight_duration,
|
||||
)
|
||||
return link.markers.get(name, {})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{link_id}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def delete_marker(
|
||||
marker_name: str,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> None:
|
||||
"""
|
||||
Remove a traffic-insight marker from the link.
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
await link.stop_marker(marker_name)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{link_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def update_marker(
|
||||
marker_name: str,
|
||||
marker_data: schemas.MarkerCreate,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> dict:
|
||||
"""
|
||||
Update a traffic-insight marker (change BPF, tag, or enabled).
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
await link.update_marker(
|
||||
name=marker_name,
|
||||
bpf=marker_data.bpf if marker_data.bpf else None,
|
||||
tag=marker_data.tag,
|
||||
color=marker_data.color,
|
||||
enabled=marker_data.enabled,
|
||||
highlight_duration=marker_data.highlight_duration,
|
||||
)
|
||||
return link.markers.get(marker_name, {})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{link_id}/iface",
|
||||
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
|
||||
|
||||
@ -243,14 +243,25 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
|
||||
raise
|
||||
|
||||
|
||||
# Node types that need live host interface data from compute
|
||||
_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"}
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
|
||||
def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
|
||||
async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
|
||||
"""
|
||||
Return a node from a given project.
|
||||
|
||||
Required privilege: Node.Audit
|
||||
"""
|
||||
|
||||
if node.node_type in _HOST_INTERFACE_NODE_TYPES:
|
||||
try:
|
||||
response = await node.get()
|
||||
await node.parse_node_response(response.json)
|
||||
except Exception:
|
||||
# If compute is unreachable, still return cached data
|
||||
log.warning(f"Could not refresh node {node.id} from compute, returning cached data")
|
||||
return node.asdict()
|
||||
|
||||
|
||||
|
||||
@ -203,6 +203,119 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
|
||||
return project.stats()
|
||||
|
||||
|
||||
@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
|
||||
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
|
||||
"""
|
||||
Return all traffic-insight markers across every link in the project.
|
||||
|
||||
Each entry is keyed ``"{link_id}/{marker_name}"`` and carries the
|
||||
marker's BPF, tag, color, enabled flag, plus its parent ``link_id``
|
||||
and capture-side ``node_id`` for frontend filtering / grouping.
|
||||
|
||||
Required privilege: Project.Audit
|
||||
"""
|
||||
|
||||
return project.markers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project-level marker definitions (global rules inherited by every link)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/marker-definitions",
|
||||
dependencies=[Depends(has_privilege("Project.Audit"))]
|
||||
)
|
||||
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
|
||||
"""
|
||||
Return all project-level marker definitions with their bound link IDs.
|
||||
|
||||
Required privilege: Project.Audit
|
||||
"""
|
||||
|
||||
result = {}
|
||||
for name, d in project.marker_definitions.items():
|
||||
# Collect which links currently carry an inherited copy.
|
||||
bound = [
|
||||
lid for lid, link in project.links.items()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
result[name] = {**d, "link_ids": bound}
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def create_marker_definition(
|
||||
def_data: schemas.MarkerDefinitionCreate,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> dict:
|
||||
"""
|
||||
Create a project-level marker definition and fan out to every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
if def_data.name and def_data.name.lower().startswith("global"):
|
||||
raise ControllerError('Names starting with "global" are reserved for inherited markers')
|
||||
name = def_data.name or f"def-{project.id[:8]}"
|
||||
await project.create_marker_definition(
|
||||
name=name,
|
||||
bpf=def_data.bpf,
|
||||
tag=def_data.tag,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
)
|
||||
return project.marker_definitions.get(name, {})
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}/marker-definitions/{def_name}",
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def update_marker_definition(
|
||||
def_name: str,
|
||||
def_data: schemas.MarkerDefinitionCreate,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> dict:
|
||||
"""
|
||||
Update a marker definition and sync all inherited copies on every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.update_marker_definition(
|
||||
name=def_name,
|
||||
bpf=def_data.bpf if def_data.bpf else None,
|
||||
tag=def_data.tag,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
)
|
||||
return project.marker_definitions.get(def_name, {})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/marker-definitions/{def_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def delete_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker definition and remove all inherited copies from every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.delete_marker_definition(def_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/close",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
|
||||
@ -86,13 +86,13 @@ from .device_config import (
|
||||
)
|
||||
from .nodes import (
|
||||
get_nodes_handler, get_node_handler, start_node_handler,
|
||||
stop_node_handler, reload_node_handler, suspend_node_handler,
|
||||
stop_node_handler, suspend_node_handler,
|
||||
create_node_handler, delete_node_handler, update_node_handler,
|
||||
get_node_console_info_handler,
|
||||
list_node_files_handler, get_node_file_handler,
|
||||
write_node_file_handler, delete_node_file_handler,
|
||||
start_all_nodes_handler, stop_all_nodes_handler,
|
||||
suspend_all_nodes_handler, reload_all_nodes_handler,
|
||||
suspend_all_nodes_handler,
|
||||
duplicate_node_handler, isolate_node_handler,
|
||||
unisolate_node_handler, get_node_links_handler,
|
||||
)
|
||||
@ -194,6 +194,12 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
||||
"mcp_jwt_username", default=None
|
||||
)
|
||||
# token_version extracted during token validation — short-lived JWTs minted for
|
||||
# download/console URLs must carry the same version, or the revocation check
|
||||
# (token_data.token_version != user.token_version) rejects them as "revoked".
|
||||
_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar(
|
||||
"mcp_jwt_token_version", default=0
|
||||
)
|
||||
|
||||
|
||||
# ── Token validation ──────────────────────────────────────────────────
|
||||
@ -208,8 +214,9 @@ async def _resolve_token(token: str) -> str | None:
|
||||
"""
|
||||
# Try JWT first
|
||||
try:
|
||||
username = auth_service.get_username_from_token(token)
|
||||
_jwt_username_var.set(username)
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_jwt_username_var.set(token_data.username)
|
||||
_jwt_token_version_var.set(token_data.token_version)
|
||||
return token
|
||||
except Exception:
|
||||
pass
|
||||
@ -233,7 +240,8 @@ async def _resolve_token(token: str) -> str | None:
|
||||
user = await user_repo.get_user(db_key.user_id)
|
||||
if user:
|
||||
_jwt_username_var.set(user.username)
|
||||
fresh_token = auth_service.create_access_token(user.username)
|
||||
_jwt_token_version_var.set(user.token_version)
|
||||
fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version)
|
||||
return fresh_token
|
||||
except Exception:
|
||||
pass
|
||||
@ -292,6 +300,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"server_url": _server_url(),
|
||||
"jwt_token": _jwt_token_var.get(),
|
||||
"jwt_username": _jwt_username_var.get(),
|
||||
"jwt_token_version": _jwt_token_version_var.get(),
|
||||
}
|
||||
result = handler(params, ctx)
|
||||
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
|
||||
@ -461,20 +470,6 @@ async def node_stop(
|
||||
params["node_id"] = node_id
|
||||
return await asyncio.to_thread(_run_handler_sync, stop_node_handler, params)
|
||||
|
||||
@mcp.tool()
|
||||
async def node_reload(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
|
||||
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reload multiple nodes in parallel")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reload (restart) one or more nodes. Provide node_id for single, or node_ids for batch."""
|
||||
params = {"project_id": project_id}
|
||||
if node_ids:
|
||||
params["node_ids"] = node_ids
|
||||
else:
|
||||
params["node_id"] = node_id
|
||||
return await asyncio.to_thread(_run_handler_sync, reload_node_handler, params)
|
||||
|
||||
@mcp.tool()
|
||||
async def node_suspend(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
@ -562,16 +557,18 @@ async def node_console(
|
||||
Complete workflow:
|
||||
1. Call this tool with project_id and node_id to get the WebSocket URL
|
||||
2. Connect to the returned URL using websocat in text mode (-t):
|
||||
> websocat -t "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
|
||||
> websocat -t --no-close "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
|
||||
3. Send device commands with \\r\\n line endings via heredoc:
|
||||
> websocat -t "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
|
||||
> websocat -t --no-close "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
|
||||
4. Receive response: websocat receives and displays device output
|
||||
Use 'timeout' to avoid connection hanging:
|
||||
> timeout 10 websocat -t "ws://..." <<< $'commands\\r\\n'
|
||||
> timeout 10 websocat -t --no-close "ws://..." <<< $'commands\\r\\n'
|
||||
|
||||
Key points:
|
||||
- Use \\r\\n (not \\n) to match console protocol line endings
|
||||
- Use $'...' format for escape sequences in bash
|
||||
- --no-close keeps the WebSocket open after stdin (heredoc) hits EOF, so
|
||||
device output is not cut off before it arrives
|
||||
- Set a timeout to prevent hanging connections
|
||||
"""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, {
|
||||
@ -873,16 +870,6 @@ async def node_suspend_all(
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def node_reload_all(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reload (restart) all nodes in a project."""
|
||||
return await asyncio.to_thread(_run_handler_sync, reload_all_nodes_handler, {
|
||||
"project_id": project_id,
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def node_duplicate(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
|
||||
@ -336,7 +336,7 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
|
||||
@ -159,24 +159,6 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
|
||||
return {"message": f"Node {node_id} stopped", "node_id": node_id}
|
||||
|
||||
|
||||
def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "reload", conn, "reloaded")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/reload")
|
||||
return {"message": f"Node {node_id} reloaded", "node_id": node_id}
|
||||
|
||||
|
||||
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
@ -316,7 +298,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
console_type = node.get("console_type", "unknown")
|
||||
# Short-lived JWT for the WebSocket URL (10 min)
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
ws_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
||||
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||
if ws_token:
|
||||
raw_url += f"?token={ws_token}"
|
||||
@ -328,7 +310,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
"node_name": node.get("name"),
|
||||
"console_type": console_type,
|
||||
"ws_url": ws_url,
|
||||
"command": f"websocat {ws_url}",
|
||||
"command": f"websocat -t --no-close {ws_url}",
|
||||
}
|
||||
if console_type in ("vnc",):
|
||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||
@ -452,15 +434,6 @@ def suspend_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
||||
return {"message": "All nodes suspended", "project_id": project_id}
|
||||
|
||||
|
||||
def reload_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/reload")
|
||||
return {"message": "All nodes reloaded", "project_id": project_id}
|
||||
|
||||
|
||||
def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
@ -556,19 +529,6 @@ NODE_TOOLS = [
|
||||
},
|
||||
"handler": stop_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "reload_node",
|
||||
"description": "Reload (restart) a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": reload_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "suspend_node",
|
||||
"description": "Suspend a node in a project",
|
||||
|
||||
@ -54,7 +54,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
|
||||
return {"error": "symbol_id is required"}
|
||||
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
result = {
|
||||
"symbol_id": symbol_id,
|
||||
"download_url": download_url,
|
||||
|
||||
@ -356,6 +356,7 @@ class BaseManager:
|
||||
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_tap":
|
||||
tap_device = nio_settings["tap_device"]
|
||||
|
||||
@ -935,9 +935,46 @@ class BaseNode:
|
||||
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
|
||||
)
|
||||
await self._ubridge_hypervisor.connect()
|
||||
# Tell this uBridge where to send MARK signals and which node id to
|
||||
# tag them with. Marker is opt-in and inert until a `mark` filter is
|
||||
# added, so this never disturbs the data plane.
|
||||
await self._ubridge_configure_marker_sink()
|
||||
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
|
||||
self._ubridge_require_privileged_access = require_privileged_access
|
||||
|
||||
async def _ubridge_configure_marker_sink(self):
|
||||
"""
|
||||
Point this node's uBridge at the compute's marker UDP sink and tag its
|
||||
signals with this node's id. Safe to call before any marker filter
|
||||
exists — uBridge stays inert until a ``mark`` filter is configured.
|
||||
|
||||
Old uBridge builds without the marker module are tolerated: the failure
|
||||
is downgraded to a warning so node start is not blocked by an opt-in
|
||||
observability feature.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
if not manager.running or not manager.host or not manager.port:
|
||||
return
|
||||
if self._ubridge_hypervisor is None:
|
||||
return
|
||||
try:
|
||||
# Talk to the hypervisor directly, NOT via _ubridge_send: this runs
|
||||
# inside _start_ubridge, which is reached THROUGH _ubridge_send when
|
||||
# uBridge starts lazily (e.g. linking a stopped node). _ubridge_send's
|
||||
# lock is non-reentrant, so calling it again here would deadlock on
|
||||
# the held ___ubridge_send_lock. uBridge is already running and
|
||||
# connected at this point, so the raw hypervisor send is safe.
|
||||
await self._ubridge_hypervisor.send(f"marker sink {manager.host} {manager.port}")
|
||||
await self._ubridge_hypervisor.send(f"marker node {self._id}")
|
||||
except UbridgeError:
|
||||
log.warning(
|
||||
"uBridge does not support the marker module; traffic insight disabled for node %r",
|
||||
self.name,
|
||||
)
|
||||
|
||||
async def _stop_ubridge(self):
|
||||
"""
|
||||
Stops uBridge.
|
||||
@ -983,10 +1020,12 @@ class BaseNode:
|
||||
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
|
||||
if destination_nio:
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def ubridge_delete_bridge(self, name):
|
||||
"""
|
||||
@ -1042,6 +1081,86 @@ class BaseNode:
|
||||
)
|
||||
i += 1
|
||||
|
||||
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None):
|
||||
"""
|
||||
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
|
||||
|
||||
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
|
||||
and (b) appends the packet to ``pcap_path``. Unlike the impairment
|
||||
filters, this is an observability tap: it never drops or alters traffic,
|
||||
and it is added/removed on its own (not via reset_packet_filters) so the
|
||||
pcap is not closed/reopened on unrelated filter changes.
|
||||
|
||||
:param bridge_name: uBridge bridge carrying the link's traffic
|
||||
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
|
||||
# name travels from the controller REST layer (MarkerCreate schema) but is
|
||||
# validated here too as defense-in-depth against hand-edited topology files.
|
||||
# Note: "global-*" names are legitimate here — they come from project-level
|
||||
# marker definitions (inherit_marker). The prefix is only forbidden at the
|
||||
# user-facing schema layer, not at the uBridge boundary.
|
||||
_MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
||||
if not _MARKER_NAME_RE.match(name):
|
||||
raise UbridgeError(f"Invalid marker name: {name!r}")
|
||||
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
|
||||
bridge=bridge_name, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# Per-link attribution (contract §3.2): when one ubridge bridge serves
|
||||
# several GNS3 links (e.g. IOU's per-node bridge), bridge+filter collide,
|
||||
# so the link id is the only way to tell signals — and pcap files — apart.
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
# Let BPF compile errors propagate — the marker is the user's intent, so a
|
||||
# bad expression must surface instead of being silently dropped.
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_apply_markers(self, bridge_name, nio):
|
||||
"""
|
||||
(Re-)apply every traffic-insight marker carried by *nio* to the uBridge
|
||||
bridge *bridge_name*. Called from ``add_ubridge_udp_connection`` (bridge
|
||||
creation / node restart) and ``update_ubridge_udp_connection`` (NIO update
|
||||
— the preceding ``_ubridge_apply_filters`` has already issued
|
||||
``reset_packet_filters``, so we must re-add markers to survive the reset).
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
for name, spec in markers.items():
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
link_id = spec.get("link_id", "")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
try:
|
||||
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id)
|
||||
except UbridgeError as e:
|
||||
# Swallow BPF compile errors (warn + skip) so a single bad
|
||||
# expression can't break link creation / node restart — mirrors
|
||||
# _ubridge_apply_filters, which does the same for packet filters.
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
|
||||
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
|
||||
"""
|
||||
Creates a connection with an Ethernet interface in uBridge.
|
||||
|
||||
@ -82,6 +82,9 @@ class Cloud(BaseNode):
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
|
||||
if interface["name"].lower().startswith("gns3"):
|
||||
continue
|
||||
host_interfaces.append(
|
||||
{
|
||||
"name": interface["name"],
|
||||
@ -312,6 +315,7 @@ class Cloud(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
if port_info["type"] in ("ethernet", "tap"):
|
||||
|
||||
if not self.manager.has_privileged_access(self.ubridge_path):
|
||||
@ -452,6 +456,7 @@ class Cloud(BaseNode):
|
||||
bridge_name = f"{self._id}-{port_number}"
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
|
||||
@ -14,14 +14,45 @@
|
||||
# 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 asyncio
|
||||
"""
|
||||
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
|
||||
``brctl`` module.
|
||||
|
||||
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
|
||||
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
|
||||
one bridge per switch node, managed over uBridge's hypervisor socket. Each
|
||||
switch port is a persistent TAP that plays two roles at once -- uBridge holds
|
||||
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
|
||||
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
|
||||
pattern the Cloud node already uses for host bridges (see
|
||||
``cloud.py::_add_linux_ethernet``).
|
||||
|
||||
Data path (UDP link mode)::
|
||||
|
||||
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
|
||||
|
||||
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
|
||||
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
|
||||
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
|
||||
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
|
||||
"""
|
||||
|
||||
from ...base_node import BaseNode
|
||||
from ...nios.nio_udp import NIOUDP
|
||||
from ...error import NodeError
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
|
||||
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
|
||||
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
|
||||
# configuring them on a qinq port is rejected.
|
||||
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
|
||||
_QINQ_ETHERTYPE = "0x88a8"
|
||||
|
||||
|
||||
class EthernetSwitch(BaseNode):
|
||||
|
||||
@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
|
||||
:param node_id: Node identifier
|
||||
:param project: Project instance
|
||||
:param manager: Parent VM Manager
|
||||
:param ports: initial switch ports
|
||||
"""
|
||||
|
||||
def __init__(self, name, node_id, project, manager):
|
||||
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
|
||||
|
||||
super().__init__(name, node_id, project, manager)
|
||||
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
|
||||
# The switch has no console; ``console_type="none"`` makes BaseNode skip
|
||||
# reserving a TCP console port entirely.
|
||||
self._ubridge_require_privileged_access = True
|
||||
|
||||
self._nios = {}
|
||||
self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge
|
||||
self._bridge_name = None # kernel bridge interface name (allocated on start)
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied
|
||||
# Idempotency flag for start(). Decoupled from ``status`` so the node can
|
||||
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
|
||||
# sees status "stopped" and refuses only genuinely running stateful nodes.
|
||||
self._started = False
|
||||
|
||||
if ports is None:
|
||||
# 8 access ports in VLAN 1 by default, matching the historical ESW.
|
||||
self._ports_mapping = []
|
||||
for port_number in range(0, 8):
|
||||
self._ports_mapping.append(
|
||||
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
|
||||
)
|
||||
else:
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ports(ports):
|
||||
"""Assign sequential port numbers/names like the Dynamips ESW did."""
|
||||
port_number = 0
|
||||
normalized = []
|
||||
for port in ports:
|
||||
port = dict(port)
|
||||
port["name"] = f"Ethernet{port_number}"
|
||||
port["port_number"] = port_number
|
||||
normalized.append(port)
|
||||
port_number += 1
|
||||
return normalized
|
||||
|
||||
def _ubridge_bridge_name(self, port_number):
|
||||
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
|
||||
return f"{self._id}-{port_number}"
|
||||
|
||||
def _tap_name(self, port_number):
|
||||
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
|
||||
return f"{self._bridge_name}-{port_number}"
|
||||
|
||||
def _port_settings(self, port_number):
|
||||
for port in self._ports_mapping:
|
||||
if port["port_number"] == port_number:
|
||||
return port
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# properties / serialisation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def nios(self):
|
||||
return self._nios
|
||||
|
||||
@property
|
||||
def ports_mapping(self):
|
||||
return self._ports_mapping
|
||||
|
||||
@ports_mapping.setter
|
||||
def ports_mapping(self, ports):
|
||||
if ports != self._ports_mapping:
|
||||
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
|
||||
raise NodeError("Cannot change the port count of a switch that is already connected.")
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
return self._console
|
||||
|
||||
@console.setter
|
||||
def console(self, console):
|
||||
self._console = console
|
||||
|
||||
@property
|
||||
def console_type(self):
|
||||
return self._console_type
|
||||
|
||||
@console_type.setter
|
||||
def console_type(self, console_type):
|
||||
self._console_type = console_type
|
||||
|
||||
def asdict(self):
|
||||
|
||||
@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
"node_id": self.id,
|
||||
"project_id": self.project.id
|
||||
"project_id": self.project.id,
|
||||
"ports_mapping": self._ports_mapping,
|
||||
"console": self.console,
|
||||
"console_type": self.console_type,
|
||||
# The switch is always-on once created (a kernel bridge), like the ESW.
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Creates this switch.
|
||||
"""
|
||||
|
||||
super().create()
|
||||
await self.start()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created')
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Starts this switch: bring up uBridge, create the kernel bridge, and
|
||||
re-wire any ports already bound before a restart.
|
||||
"""
|
||||
|
||||
if not self._started:
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._stop_ubridge()
|
||||
await self._start_ubridge(self._ubridge_require_privileged_access)
|
||||
await self._ensure_bridge()
|
||||
for port_number in self._nios:
|
||||
if self._nios[port_number]:
|
||||
try:
|
||||
await self._add_ubridge_connection(self._nios[port_number], port_number)
|
||||
except (UbridgeError, NodeError) as e:
|
||||
self._started = False
|
||||
raise e
|
||||
self._started = True
|
||||
|
||||
async def _ensure_bridge(self):
|
||||
"""
|
||||
Creates the per-node kernel bridge once and enables VLAN filtering.
|
||||
Applies the bridge-level QinQ ethertype if any port needs it.
|
||||
|
||||
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
|
||||
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
|
||||
bridge from a previous crash is deleted first so ``brctl create`` never
|
||||
hits EEXIST.
|
||||
"""
|
||||
|
||||
if self._bridge_created:
|
||||
return
|
||||
# deterministic short name — 10 chars, always fits the 15-char kernel cap
|
||||
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
|
||||
# crash recovery: best-effort delete any leftover bridge
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError:
|
||||
pass # not found = nothing to clean
|
||||
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
|
||||
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
|
||||
await self._ubridge_send(f'link set "{self._bridge_name}" up')
|
||||
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
|
||||
self._bridge_created = True
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
|
||||
async def _apply_bridge_proto_if_needed(self):
|
||||
"""
|
||||
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
|
||||
the whole bridge to that protocol. A Linux bridge has a single VLAN
|
||||
protocol, so mixed QinQ ethertypes within one switch are not supported.
|
||||
"""
|
||||
|
||||
proto = None
|
||||
for port in self._ports_mapping:
|
||||
if port.get("type") == "qinq":
|
||||
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
|
||||
# brctl setvlanproto wants lowercase hex
|
||||
ethertype = port.get("ethertype", "0x8100").lower()
|
||||
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
|
||||
raise NodeError(
|
||||
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
|
||||
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
|
||||
)
|
||||
if ethertype == _QINQ_ETHERTYPE:
|
||||
proto = _QINQ_ETHERTYPE
|
||||
if proto and not self._bridge_proto_set:
|
||||
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
|
||||
self._bridge_proto_set = True
|
||||
|
||||
async def delete(self):
|
||||
"""
|
||||
Deletes this switch.
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return await self.close()
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
|
||||
"""
|
||||
|
||||
if not (await super().close()):
|
||||
return False
|
||||
|
||||
for nio in self._nios.values():
|
||||
if nio and isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
self._nios.clear()
|
||||
self._tap_by_port.clear()
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
|
||||
try:
|
||||
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
|
||||
# them when it stops below.
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False
|
||||
self._bridge_name = None
|
||||
self._started = False
|
||||
|
||||
await self._stop_ubridge()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# per-port wiring
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def add_nio(self, nio, port_number):
|
||||
"""
|
||||
Adds a NIO as new port on this switch.
|
||||
Adds a NIO as a new port on this switch.
|
||||
|
||||
:param nio: NIO instance to add
|
||||
:param port_number: port to allocate for the NIO
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number in self._nios:
|
||||
raise NodeError(f"Port {port_number} isn't free")
|
||||
if not isinstance(nio, NIOUDP):
|
||||
raise NodeError("Ethernet switch ports only support UDP NIOs")
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
try:
|
||||
await self.start()
|
||||
await self._add_ubridge_connection(nio, port_number)
|
||||
self._nios[port_number] = nio
|
||||
except (NodeError, UbridgeError) as e:
|
||||
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
|
||||
await self._stop_ubridge()
|
||||
self.status = "stopped"
|
||||
self._nios[port_number] = nio
|
||||
self.project.emit("log.error", {"message": str(e)})
|
||||
|
||||
async def _add_ubridge_connection(self, nio, port_number):
|
||||
"""
|
||||
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
|
||||
is enslaved to the kernel bridge, with the port's VLAN mode applied.
|
||||
"""
|
||||
|
||||
port_settings = self._port_settings(port_number)
|
||||
if port_settings is None:
|
||||
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
tap = self._tap_name(port_number)
|
||||
|
||||
# per-port uBridge relay -- uBridge holds the TAP fd
|
||||
await self._ubridge_send(f"bridge create {ubridge_bridge}")
|
||||
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
|
||||
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
# VLAN membership for this port's access/trunk/qinq mode
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
# GNS3 link endpoint
|
||||
await self._ubridge_send(
|
||||
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
|
||||
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
if nio.capturing:
|
||||
await self._ubridge_send(
|
||||
'bridge start_capture {name} "{output_file}"'.format(
|
||||
name=ubridge_bridge, output_file=nio.pcap_output_file
|
||||
)
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {ubridge_bridge}")
|
||||
self._tap_by_port[port_number] = tap
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
Tears down one port's wiring: release the TAP from the bridge and delete
|
||||
the per-port uBridge relay.
|
||||
"""
|
||||
|
||||
tap = self._tap_by_port.pop(port_number, None)
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if tap is not None and self._bridge_created:
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
|
||||
try:
|
||||
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
|
||||
|
||||
async def remove_nio(self, port_number):
|
||||
"""
|
||||
Removes the specified NIO as member of this switch.
|
||||
Removes the specified NIO from this switch.
|
||||
|
||||
:param port_number: allocated port number
|
||||
|
||||
:returns: the NIO that was bound to the allocated port
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not allocated")
|
||||
|
||||
await self.stop_capture(port_number)
|
||||
nio = self._nios[port_number]
|
||||
if isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
del self._nios[port_number]
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._delete_ubridge_connection(port_number)
|
||||
return nio
|
||||
|
||||
def get_nio(self, port_number):
|
||||
"""
|
||||
Gets a port NIO binding.
|
||||
|
||||
:param port_number: port number
|
||||
:returns: NIO instance
|
||||
"""
|
||||
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not connected")
|
||||
return self._nios[port_number]
|
||||
|
||||
async def update_nio(self, port_number, nio):
|
||||
"""
|
||||
Re-applies uBridge filters/markers for a port (called when a link is updated).
|
||||
"""
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# VLAN mode translation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _reset_port_vlan(self, tap):
|
||||
"""
|
||||
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
|
||||
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
|
||||
from the previous mode do not leak.
|
||||
"""
|
||||
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
|
||||
async def _apply_port_vlan(self, port_settings, tap):
|
||||
"""
|
||||
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
|
||||
already be enslaved to the bridge and carry the default PVID 1.
|
||||
|
||||
- access VLAN N: drop default 1, add N as PVID + egress untagged.
|
||||
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
|
||||
the native VLAN PVID + untagged. (The ESW model declares only the native
|
||||
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
|
||||
- qinq (outer N): the bridge-level protocol is set separately; the port
|
||||
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
|
||||
"""
|
||||
|
||||
br = self._bridge_name
|
||||
port_type = port_settings["type"]
|
||||
vlan = int(port_settings["vlan"])
|
||||
|
||||
if port_type == "access":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "dot1q":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "qinq":
|
||||
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
else:
|
||||
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
|
||||
|
||||
async def update_port_settings(self):
|
||||
"""
|
||||
Re-applies port settings (called after ``ports_mapping`` is updated). For
|
||||
ports already wired, reset then re-apply so a mode/VLAN change fully
|
||||
replaces the previous VLAN membership.
|
||||
"""
|
||||
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
|
||||
return
|
||||
for port_settings in self._ports_mapping:
|
||||
port_number = port_settings["port_number"]
|
||||
tap = self._tap_by_port.get(port_number)
|
||||
if tap is None:
|
||||
continue
|
||||
await self._reset_port_vlan(tap)
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# capture
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
Starts a packet capture.
|
||||
Starts a packet capture on a port (uBridge captures on the per-port relay).
|
||||
|
||||
:param port_number: allocated port number
|
||||
:param output_file: PCAP destination file for the capture
|
||||
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if nio.capturing:
|
||||
raise NodeError(f"Packet capture is already activated on port {port_number}")
|
||||
nio.start_packet_capture(output_file)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_capture(self, port_number):
|
||||
"""
|
||||
Stops a packet capture.
|
||||
Stops a packet capture on a port.
|
||||
|
||||
:param port_number: allocated port number
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if not nio.capturing:
|
||||
return
|
||||
nio.stop_packet_capture()
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
@ -87,6 +87,23 @@ class Nat(Cloud):
|
||||
return True
|
||||
|
||||
def asdict(self):
|
||||
|
||||
nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else ""
|
||||
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
if interface["name"] == nat_interface:
|
||||
host_interfaces.append(
|
||||
{
|
||||
"name": interface["name"],
|
||||
"type": interface["type"],
|
||||
"special": interface["special"],
|
||||
"ip_addresses": interface.get("ip_addresses", []),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
@ -94,4 +111,5 @@ class Nat(Cloud):
|
||||
"project_id": self.project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": self.ports_mapping,
|
||||
"interfaces": host_interfaces,
|
||||
}
|
||||
|
||||
@ -1228,6 +1228,7 @@ class DockerVM(BaseNode):
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def adapter_add_nio_binding(self, adapter_number, nio):
|
||||
"""
|
||||
@ -1268,7 +1269,7 @@ class DockerVM(BaseNode):
|
||||
bridge_name = f"bridge{adapter_number}"
|
||||
if bridge_name in self._bridges:
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
async def adapter_remove_nio_binding(self, adapter_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
|
||||
@ -376,6 +376,7 @@ class Dynamips(BaseManager):
|
||||
raise DynamipsError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(node, lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_generic_ethernet":
|
||||
ethernet_device = nio_settings["ethernet_device"]
|
||||
|
||||
@ -40,6 +40,7 @@ class NIO:
|
||||
self._hypervisor = hypervisor
|
||||
self._name = name
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._suspended = False
|
||||
self._capturing = False
|
||||
self._pcap_output_file = ""
|
||||
@ -303,6 +304,26 @@ class NIO:
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the list of traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary)
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary)
|
||||
"""
|
||||
|
||||
self._markers = new_markers
|
||||
|
||||
@property
|
||||
def capturing(self):
|
||||
"""
|
||||
|
||||
@ -82,10 +82,12 @@ class NIOUDP(NIO):
|
||||
self._source_nio = nio_udp.NIOUDP(self._local_tunnel_rport, "127.0.0.1", self._local_tunnel_lport)
|
||||
self._destination_nio = nio_udp.NIOUDP(self._lport, self._rhost, self._rport)
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.add_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def update(self):
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.update_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def close(self):
|
||||
|
||||
@ -746,6 +746,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bay_id, unit_id, nio.filters)
|
||||
await self._ubridge_apply_markers(bay_id, unit_id, nio)
|
||||
unit_id += 1
|
||||
bay_id += 1
|
||||
|
||||
@ -1067,6 +1068,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def adapter_update_nio_binding(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
@ -1079,6 +1081,7 @@ class IOUVM(BaseNode):
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def _ubridge_apply_filters(self, adapter_number, port_number, filters):
|
||||
"""
|
||||
@ -1095,6 +1098,64 @@ class IOUVM(BaseNode):
|
||||
cmd = "iol_bridge add_packet_filter {} {}".format(location, filter)
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
(Re-)apply traffic-insight markers to the IOL bridge.
|
||||
|
||||
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
|
||||
command carries extra ``{bay} {unit}`` positional arguments between the
|
||||
bridge name and the filter name — this override mirrors the pattern in
|
||||
``_ubridge_apply_filters`` above.
|
||||
|
||||
:param adapter_number: bay id
|
||||
:param port_number: unit id
|
||||
:param nio: NIO instance carrying ``nio.markers``
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
|
||||
location = "{bridge_name} {bay} {unit}".format(
|
||||
bridge_name=bridge_name, bay=adapter_number, unit=port_number
|
||||
)
|
||||
for name, spec in markers.items():
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
link_id = spec.get("link_id", "")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
# Build the iol_bridge marker filter command:
|
||||
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
|
||||
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
|
||||
loc=location, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
|
||||
# are identical across this node's links — `link` is the only way the
|
||||
# controller can tell their signals apart (contract §3.2).
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
try:
|
||||
await self._ubridge_send(cmd)
|
||||
except UbridgeError as e:
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
|
||||
async def adapter_remove_nio_binding(self, adapter_number, port_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
|
||||
24
gns3server/compute/marker/__init__.py
Normal file
24
gns3server/compute/marker/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
#
|
||||
# Traffic-insight marker subsystem (compute side).
|
||||
#
|
||||
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
|
||||
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
|
||||
# This package owns the compute-side UDP sink: one listener per compute process
|
||||
# serves every ubridge on that host, disambiguated by ``node=<id>``.
|
||||
109
gns3server/compute/marker/marker_listener.py
Normal file
109
gns3server/compute/marker/marker_listener.py
Normal file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerListener(asyncio.DatagramProtocol):
|
||||
"""
|
||||
Receives ubridge ``MARK`` signal datagrams and turns each into a
|
||||
``marker.match`` notification.
|
||||
|
||||
Signal format (one datagram per match, ASCII)::
|
||||
|
||||
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
|
||||
|
||||
The signal carries metadata only (no packet bytes). The compute-side
|
||||
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
|
||||
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
|
||||
event can be emitted on the right project-scoped notification stream.
|
||||
"""
|
||||
|
||||
def __init__(self, manager):
|
||||
# MarkerManager owns this listener and the registry.
|
||||
self._manager = manager
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
try:
|
||||
self._handle(data)
|
||||
except Exception:
|
||||
# Never let a malformed datagram kill the listener.
|
||||
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
|
||||
|
||||
def _handle(self, data):
|
||||
line = data.decode("utf-8", errors="replace").strip()
|
||||
if not line.startswith("MARK"):
|
||||
return
|
||||
|
||||
parts = line.split()
|
||||
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
try:
|
||||
ts = float(parts[1])
|
||||
except ValueError:
|
||||
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
|
||||
return
|
||||
|
||||
kv = {}
|
||||
for token in parts[2:]:
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
kv[key] = value
|
||||
|
||||
node_id = kv.get("node")
|
||||
filter_name = kv.get("filter")
|
||||
if not node_id or not filter_name:
|
||||
return
|
||||
|
||||
# "-" means the field was unset on the ubridge side (see contract §3.3).
|
||||
link = kv.get("link")
|
||||
tag = kv.get("tag")
|
||||
length = kv.get("len")
|
||||
|
||||
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
|
||||
if project_id is None:
|
||||
log.warning(
|
||||
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
|
||||
)
|
||||
return
|
||||
|
||||
# `link=` is the authoritative per-link id (opaque, set by gns3server at
|
||||
# filter install time). It disambiguates signals that share a node+filter
|
||||
# across several links; fall back to the registry's link only for legacy
|
||||
# signals that carry no `link=`.
|
||||
signal_link = link if link and link != "-" else None
|
||||
|
||||
event = {
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"link_id": signal_link or link_id,
|
||||
"filter": filter_name,
|
||||
# Prefer the value carried in the signal; fall back to the one we registered.
|
||||
"tag": tag if tag and tag != "-" else registered_tag,
|
||||
"ts": ts,
|
||||
"len": int(length) if length and length.isdigit() else 0,
|
||||
}
|
||||
self._manager.emit_match(project_id, event)
|
||||
185
gns3server/compute/marker/marker_manager.py
Normal file
185
gns3server/compute/marker/marker_manager.py
Normal file
@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.compute.marker.marker_listener import MarkerListener
|
||||
from gns3server.compute.notification_manager import NotificationManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerManager:
|
||||
"""
|
||||
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
|
||||
the registry that maps each ``(node_id, filter_name)`` back to its
|
||||
``(project_id, link_id, tag)``.
|
||||
|
||||
The registry is populated when a marker is created on a link (the compute
|
||||
endpoint has project_id + node_id from its route path and link_id/name/tag
|
||||
from the request body) and cleared when the marker is deleted or the project
|
||||
closed. At signal time it is an O(1) lookup — no node-table scan, and the
|
||||
signal payload is untouched.
|
||||
|
||||
One listener per compute process serves every ubridge on that host; source
|
||||
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._listener = None
|
||||
self._transport = None
|
||||
self._host = None
|
||||
self._port = None
|
||||
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
|
||||
self._entries = {}
|
||||
# Reverse index for O(1) per-project teardown: project_id -> set of keys
|
||||
self._by_project = {}
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""The host the UDP sink is reachable on (for ``marker sink``)."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""The UDP port the sink is bound on (for ``marker sink``)."""
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self._transport is not None
|
||||
|
||||
async def start(self, host="127.0.0.1", port=0):
|
||||
"""
|
||||
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
|
||||
then read back and exposed via :attr:`port` for ``marker sink`` commands.
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._listener = MarkerListener(self)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, port)
|
||||
)
|
||||
except OSError:
|
||||
if port != 0:
|
||||
log.warning(
|
||||
"Marker listener: port %s unavailable, falling back to OS-assigned port", port
|
||||
)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, 0)
|
||||
)
|
||||
except OSError as e:
|
||||
log.error(
|
||||
"Marker listener startup failed: %s. Traffic insight signals are unavailable.", e
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
else:
|
||||
log.error(
|
||||
"Marker listener startup failed on OS-assigned port. Traffic insight signals are unavailable."
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
sock = self._transport.get_extra_info("socket")
|
||||
self._host = host
|
||||
self._port = sock.getsockname()[1] if sock else port
|
||||
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
|
||||
|
||||
async def stop(self):
|
||||
"""Close the UDP sink and drop the whole registry."""
|
||||
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._listener = None
|
||||
self._entries.clear()
|
||||
self._by_project.clear()
|
||||
self._host = None
|
||||
self._port = None
|
||||
|
||||
def register(self, project_id, node_id, filter_name, link_id, tag=None):
|
||||
"""
|
||||
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
|
||||
``link_id``. Called from the compute marker-start endpoint.
|
||||
|
||||
Re-registering the same key updates the stored tag (e.g. on re-add).
|
||||
"""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
|
||||
self._by_project.setdefault(project_id, set()).add(key)
|
||||
|
||||
def unregister(self, node_id, filter_name):
|
||||
"""Forget a single marker. Returns True if something was removed."""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is None:
|
||||
return False
|
||||
project_entries = self._by_project.get(entry["project_id"])
|
||||
if project_entries is not None:
|
||||
project_entries.discard(key)
|
||||
if not project_entries:
|
||||
self._by_project.pop(entry["project_id"], None)
|
||||
return True
|
||||
|
||||
def unregister_project(self, project_id):
|
||||
"""Drop every marker belonging to ``project_id`` (project close)."""
|
||||
|
||||
keys = self._by_project.pop(project_id, None)
|
||||
if not keys:
|
||||
return
|
||||
for key in keys:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def lookup(self, node_id, filter_name):
|
||||
"""
|
||||
O(1) resolution of an incoming signal to its project/link/tag.
|
||||
|
||||
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
|
||||
"""
|
||||
|
||||
entry = self._entries.get((node_id, filter_name))
|
||||
if entry is None:
|
||||
return None, None, None
|
||||
return entry["project_id"], entry["link_id"], entry["tag"]
|
||||
|
||||
def emit_match(self, project_id, event):
|
||||
"""
|
||||
Forward a parsed match as a project-scoped ``marker.match`` notification.
|
||||
Flows compute -> controller dispatch -> project_emit -> web UI WS.
|
||||
"""
|
||||
|
||||
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
|
||||
|
||||
_instance = None
|
||||
|
||||
@staticmethod
|
||||
def instance():
|
||||
if MarkerManager._instance is None:
|
||||
MarkerManager._instance = MarkerManager()
|
||||
return MarkerManager._instance
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
MarkerManager._instance = None
|
||||
@ -30,6 +30,7 @@ class NIO:
|
||||
self._capturing = False
|
||||
self._suspended = False
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._pcap_output_file = ""
|
||||
self._pcap_data_link_type = ""
|
||||
|
||||
@ -118,3 +119,24 @@ class NIO:
|
||||
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set the traffic-insight markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
assert isinstance(new_markers, dict)
|
||||
self._markers = new_markers
|
||||
|
||||
@ -80,5 +80,6 @@ class NIOUDP(NIO):
|
||||
"rport": self._rport,
|
||||
"rhost": self._rhost,
|
||||
"suspend": self._suspended,
|
||||
"filters": self._filters
|
||||
"filters": self._filters,
|
||||
"markers": self._markers
|
||||
}
|
||||
|
||||
@ -246,6 +246,22 @@ class Project:
|
||||
raise ComputeError(f"Could not create the capture working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def markers_working_directory(self):
|
||||
"""
|
||||
Returns the working directory where uBridge writes per-link marker pcaps
|
||||
(matched packets, kept for later replay).
|
||||
|
||||
:returns: path to the directory
|
||||
"""
|
||||
|
||||
workdir = os.path.join(self._path, "project-files", "markers")
|
||||
if not self._deleted:
|
||||
try:
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ComputeError(f"Could not create the markers working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def add_node(self, node):
|
||||
"""
|
||||
Adds a node to the project.
|
||||
|
||||
@ -92,6 +92,13 @@ udp_end_port_range = 30000
|
||||
; uBridge executable location, default: search in PATH
|
||||
;ubridge_path = ubridge
|
||||
|
||||
; Marker (traffic-insight) UDP sink: one listener per compute process that
|
||||
; receives uBridge MARK signals from every uBridge on this host.
|
||||
; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally.
|
||||
; marker_listen_port defaults to 3070 (set to 0 for OS-chosen).
|
||||
;marker_listen_host = 127.0.0.1
|
||||
;marker_listen_port = 3070
|
||||
|
||||
; Option to enable or disable compute HTTP authentication
|
||||
enable_http_auth = True
|
||||
|
||||
|
||||
@ -741,6 +741,9 @@ class Controller:
|
||||
topo_data.pop("version")
|
||||
topo_data.pop("revision")
|
||||
topo_data.pop("type")
|
||||
# marker_definitions is restored by Project.open() from the topology
|
||||
# file; it must not be passed to Project.__init__.
|
||||
topo_data.pop("marker_definitions", None)
|
||||
|
||||
if topo_data["project_id"] in self._projects:
|
||||
project = self._projects[topo_data["project_id"]]
|
||||
|
||||
@ -88,6 +88,7 @@ class Link:
|
||||
self._link_type = "ethernet"
|
||||
self._suspended = False
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._link_style = {}
|
||||
self._wireshark = False
|
||||
self._show_filters_icon = True
|
||||
@ -99,6 +100,40 @@ class Link:
|
||||
"""
|
||||
return self._filters
|
||||
|
||||
@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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
await self.start_marker(
|
||||
name=f"global-{def_name}",
|
||||
bpf=marker_def["bpf"],
|
||||
tag=marker_def.get("tag"),
|
||||
color=marker_def.get("color"),
|
||||
highlight_duration=marker_def.get("highlight_duration"),
|
||||
inherited_from=def_name,
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -298,6 +333,27 @@ class Link:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
async def start_marker(self, name, bpf, tag=None):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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
|
||||
@ -571,6 +627,7 @@ class Link:
|
||||
"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),
|
||||
@ -585,6 +642,7 @@ class Link:
|
||||
"capture_compute_id": self.capture_compute_id,
|
||||
"link_type": self._link_type,
|
||||
"filters": self._filters,
|
||||
"markers": self._markers,
|
||||
"suspend": self._suspended,
|
||||
"link_style": self._link_style,
|
||||
"wireshark": self._wireshark,
|
||||
|
||||
@ -639,6 +639,14 @@ class Node:
|
||||
except asyncio.TimeoutError:
|
||||
raise ControllerTimeoutError(f"Timeout when reset console {self._name}")
|
||||
|
||||
async def get(self, path="", **kwargs):
|
||||
"""
|
||||
HTTP get on the node
|
||||
"""
|
||||
return await self._compute.get(
|
||||
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
|
||||
)
|
||||
|
||||
async def post(self, path, data=None, **kwargs):
|
||||
"""
|
||||
HTTP post on the node
|
||||
|
||||
@ -41,6 +41,7 @@ from ..config import Config
|
||||
from ..utils.path import check_path_allowed, get_default_project_directory
|
||||
from ..utils.application_id import get_next_application_id
|
||||
from ..utils.asyncio.pool import Pool
|
||||
from ..utils.packet_filter_validation import validate_bpf_syntax
|
||||
from ..utils.asyncio import locking
|
||||
from ..utils.asyncio import aiozipstream
|
||||
from ..utils.asyncio import wait_run_in_executor
|
||||
@ -211,6 +212,7 @@ class Project:
|
||||
self._allocated_node_names = set()
|
||||
self._nodes = {}
|
||||
self._links = {}
|
||||
self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration}
|
||||
self._drawings = {}
|
||||
self._snapshots = {}
|
||||
self._computes = []
|
||||
@ -765,6 +767,31 @@ class Project:
|
||||
"Dropping invalid filters on link %s: %s",
|
||||
link_data.get("link_id"), e
|
||||
)
|
||||
# Restore traffic-insight markers directly into link state (mirrors how
|
||||
# filters are restored via update_filters). The capture_node_id persisted
|
||||
# last time is reused for NIO routing; no side resolution is possible here
|
||||
# because the link's nodes are added later. The marker is applied to
|
||||
# uBridge by _ubridge_apply_markers when create() runs. Invalid BPF is
|
||||
# dropped (like invalid filters).
|
||||
for name, marker in (link_data.get("markers") or {}).items():
|
||||
bpf = marker.get("bpf")
|
||||
if not bpf:
|
||||
log.warning("Dropping marker %s on link %s: missing bpf", name, link_data.get("link_id"))
|
||||
continue
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
log.warning(
|
||||
"Dropping marker %s on link %s: invalid BPF (%s)",
|
||||
name, link_data.get("link_id"), result.get("error")
|
||||
)
|
||||
continue
|
||||
link._markers[name] = {
|
||||
"bpf": bpf,
|
||||
"tag": marker.get("tag"),
|
||||
"enabled": marker.get("enabled", True),
|
||||
"color": marker.get("color"),
|
||||
"capture_node_id": marker.get("capture_node_id"),
|
||||
}
|
||||
if "link_style" in link_data:
|
||||
await link.update_link_style(link_data["link_style"])
|
||||
if "show_filters_icon" in link_data:
|
||||
@ -872,6 +899,146 @@ class Project:
|
||||
return self._get_closed_data("links", "link_id")
|
||||
return self._links
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Project-level read-only aggregation of all markers across every link.
|
||||
|
||||
Each entry is keyed ``"{link_id}/{marker_name}"`` so the flat dict is
|
||||
globally unique within the project. The value is a clone of the link's
|
||||
per-marker dict plus ``link_id`` and ``node_id`` (the capture-side node)
|
||||
for convenience — the frontend can filter/group by link or node without
|
||||
extra round-trips.
|
||||
|
||||
:returns: dict[str, dict] — keyed by "{link_id}/{marker_name}"
|
||||
"""
|
||||
result = {}
|
||||
for link_id, link in self._links.items():
|
||||
for name, info in link.markers.items():
|
||||
key = f"{link_id}/{name}"
|
||||
result[key] = {
|
||||
**info,
|
||||
"link_id": link_id,
|
||||
"node_id": info.get("capture_node_id"),
|
||||
}
|
||||
return result
|
||||
|
||||
@property
|
||||
def marker_definitions(self):
|
||||
"""
|
||||
:returns: dict of project-level marker definitions (name → {bpf, tag, color, highlight_duration})
|
||||
"""
|
||||
return self._marker_definitions
|
||||
|
||||
async def create_marker_definition(self, name, bpf, tag=None, color=None, highlight_duration=None):
|
||||
"""
|
||||
Create a project-level marker definition and fan out to every existing
|
||||
link that has a capable node. Links without a capable node are silently
|
||||
skipped.
|
||||
"""
|
||||
|
||||
if name in self._marker_definitions:
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}' already exists in this project"
|
||||
)
|
||||
|
||||
self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color, "highlight_duration": highlight_duration}
|
||||
await self._apply_def_to_all_links(name)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def update_marker_definition(self, name, bpf=None, tag=None, color=None, highlight_duration=None):
|
||||
"""
|
||||
Update a marker definition and sync every inherited copy on every link.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerNotFoundError(
|
||||
f"Marker definition '{name}' not found in this project"
|
||||
)
|
||||
|
||||
d = self._marker_definitions[name]
|
||||
if bpf is not None:
|
||||
d["bpf"] = bpf
|
||||
if tag is not None:
|
||||
d["tag"] = tag
|
||||
if color is not None:
|
||||
d["color"] = color
|
||||
if highlight_duration is not None:
|
||||
d["highlight_duration"] = highlight_duration
|
||||
|
||||
# Sync: update every inherited copy across all links.
|
||||
for link in list(self._links.values()):
|
||||
marker_name = f"global-{name}"
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name:
|
||||
await link.update_marker(
|
||||
marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"),
|
||||
highlight_duration=d.get("highlight_duration"), inherited=True
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def delete_marker_definition(self, name):
|
||||
"""
|
||||
Delete a marker definition and remove every inherited copy from every link.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerNotFoundError(
|
||||
f"Marker definition '{name}' not found in this project"
|
||||
)
|
||||
|
||||
del self._marker_definitions[name]
|
||||
|
||||
for link in list(self._links.values()):
|
||||
marker_name = f"global-{name}"
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name:
|
||||
try:
|
||||
await link.stop_marker(marker_name, inherited=True)
|
||||
except ControllerError:
|
||||
# A missing compute or broken link shouldn't block the delete.
|
||||
log.warning(
|
||||
"Failed to remove inherited marker %s from link %s",
|
||||
marker_name, link.id
|
||||
)
|
||||
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def _apply_def_to_all_links(self, def_name):
|
||||
"""
|
||||
Fan out a single marker definition to every existing link in the project.
|
||||
Links that have no capable node (``_MARKER_CAPABLE_TYPES``) are silently
|
||||
skipped — the marker can only live on a uBridge bridge.
|
||||
"""
|
||||
|
||||
d = self._marker_definitions[def_name]
|
||||
for link in list(self._links.values()):
|
||||
try:
|
||||
await link.inherit_marker(def_name, d)
|
||||
except ControllerError as e:
|
||||
# Per-link failures (e.g. no capable node) shouldn't block the
|
||||
# definition from serving the rest.
|
||||
log.warning(
|
||||
"Marker definition '%s' could not be applied to link %s: %s",
|
||||
def_name, link.id, e
|
||||
)
|
||||
|
||||
async def apply_defs_to_new_link(self, link):
|
||||
"""
|
||||
Apply every active marker definition to a newly created link so it
|
||||
inherits project-level rules automatically.
|
||||
"""
|
||||
|
||||
for def_name, d in self._marker_definitions.items():
|
||||
try:
|
||||
await link.inherit_marker(def_name, d)
|
||||
except ControllerError as e:
|
||||
log.warning(
|
||||
"Marker definition '%s' could not be applied to new link %s: %s",
|
||||
def_name, link.id, e
|
||||
)
|
||||
|
||||
@property
|
||||
def snapshots(self):
|
||||
"""
|
||||
@ -1262,6 +1429,12 @@ class Project:
|
||||
if val is not None:
|
||||
setattr(self, key, val)
|
||||
|
||||
# marker_definitions is loaded separately (it is not a __init__ kwarg
|
||||
# nor a simple attribute — it backs a read-only property).
|
||||
defs = project_data.get("marker_definitions")
|
||||
if isinstance(defs, dict):
|
||||
self._marker_definitions = defs
|
||||
|
||||
topology = project_data["topology"]
|
||||
for compute in topology.get("computes", []):
|
||||
compute_id = compute.get("compute_id")
|
||||
@ -1328,6 +1501,10 @@ class Project:
|
||||
for drawing_data in topology.get("drawings", []):
|
||||
await self.add_drawing(dump=False, **drawing_data)
|
||||
|
||||
# Note: project-level marker definitions are applied to each link
|
||||
# inside UDPLink.create() (the inheritance hook), so they are
|
||||
# already present once links are loaded — no separate fan-out here.
|
||||
|
||||
self.dump()
|
||||
# We catch all error to be able to roll back the .gns3 to the previous state
|
||||
except Exception as e:
|
||||
@ -1684,6 +1861,7 @@ class Project:
|
||||
"links": len(self._links),
|
||||
"drawings": len(self._drawings),
|
||||
"snapshots": len(self._snapshots),
|
||||
"markers": sum(len(link.markers) for link in self._links.values()),
|
||||
}
|
||||
|
||||
def asdict(self):
|
||||
@ -1708,6 +1886,7 @@ class Project:
|
||||
"supplier": self._supplier,
|
||||
"variables": self._variables,
|
||||
"created_by": self._created_by,
|
||||
"marker_definitions": self._marker_definitions,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@ -88,6 +88,7 @@ def project_to_topology(project):
|
||||
"variables": project.variables,
|
||||
"supplier": project.supplier,
|
||||
"created_by": project.created_by,
|
||||
"marker_definitions": project.marker_definitions,
|
||||
"topology": {"nodes": [], "links": [], "computes": [], "drawings": []},
|
||||
"type": "topology",
|
||||
"revision": GNS3_FILE_FORMAT_REVISION,
|
||||
|
||||
@ -19,6 +19,15 @@
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import Link
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
|
||||
|
||||
# Node types without a uBridge bridge — a marker filter has nothing to attach to.
|
||||
# Node types that can host a marker (have a uBridge bridge to attach the
|
||||
# `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat"
|
||||
# (which has no uBridge).
|
||||
_MARKER_CAPABLE_TYPES = frozenset({
|
||||
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud",
|
||||
})
|
||||
|
||||
|
||||
class UDPLink(Link):
|
||||
@ -37,7 +46,7 @@ class UDPLink(Link):
|
||||
def _get_node_filters(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
|
||||
|
||||
:returns: Tuple of (node1_filters, node2_filters)
|
||||
"""
|
||||
filter_node = self._get_filter_node()
|
||||
@ -46,6 +55,26 @@ class UDPLink(Link):
|
||||
self.get_active_filters() if filter_node == node2 else {},
|
||||
)
|
||||
|
||||
def _markers_for_node(self, node):
|
||||
"""
|
||||
Marker specs (name -> {bpf, tag, link_id}) for the markers whose capture
|
||||
side is ``node`` and that are enabled. Routed by capture_node_id so a
|
||||
marker only rides the NIO of the node whose uBridge will host it.
|
||||
"""
|
||||
return {
|
||||
name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id}
|
||||
for name, m in self._markers.items()
|
||||
if m.get("enabled", True) and m.get("capture_node_id") == node.id
|
||||
}
|
||||
|
||||
def _get_node_markers(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets which markers applied.
|
||||
|
||||
:returns: Tuple of (node1_markers, node2_markers)
|
||||
"""
|
||||
return self._markers_for_node(node1), self._markers_for_node(node2)
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Create the link on the nodes
|
||||
@ -80,6 +109,7 @@ class UDPLink(Link):
|
||||
self._node2_port = response.json["udp_port"]
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
# Create the tunnel on both side
|
||||
self._link_data.append(
|
||||
@ -89,6 +119,7 @@ class UDPLink(Link):
|
||||
"rport": self._node2_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@ -101,6 +132,7 @@ class UDPLink(Link):
|
||||
"rport": self._node1_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node2_filters,
|
||||
"markers": node2_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@ -113,6 +145,9 @@ class UDPLink(Link):
|
||||
await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
|
||||
raise e
|
||||
self._created = True
|
||||
# New links automatically inherit every active project-level marker
|
||||
# definition so the user doesn't have to reconfigure.
|
||||
await self._project.apply_defs_to_new_link(self)
|
||||
|
||||
async def update(self):
|
||||
"""
|
||||
@ -125,10 +160,12 @@ class UDPLink(Link):
|
||||
node2 = self._nodes[1]["node"]
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
adapter_number1 = self._nodes[0]["adapter_number"]
|
||||
port_number1 = self._nodes[0]["port_number"]
|
||||
self._link_data[0]["filters"] = node1_filters
|
||||
self._link_data[0]["markers"] = node1_markers
|
||||
self._link_data[0]["suspend"] = self._suspended
|
||||
if node1.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
await node1.put(
|
||||
@ -138,6 +175,7 @@ class UDPLink(Link):
|
||||
adapter_number2 = self._nodes[1]["adapter_number"]
|
||||
port_number2 = self._nodes[1]["port_number"]
|
||||
self._link_data[1]["filters"] = node2_filters
|
||||
self._link_data[1]["markers"] = node2_markers
|
||||
self._link_data[1]["suspend"] = self._suspended
|
||||
if node2.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
await node2.put(
|
||||
@ -245,9 +283,161 @@ class UDPLink(Link):
|
||||
|
||||
raise ControllerError("Cannot capture because there is no running device on this link")
|
||||
|
||||
def _choose_marker_side(self):
|
||||
"""
|
||||
Pick the node that will host the marker, mirroring ``_get_filter_node``
|
||||
in link.py. Only types with a uBridge bridge (``_MARKER_CAPABLE_TYPES``)
|
||||
are eligible. A running node is preferred, but a stopped one is
|
||||
accepted — like packet filters, the marker is stored on the NIO and
|
||||
applied when the node starts.
|
||||
"""
|
||||
|
||||
# Prefer started.
|
||||
for node in self._nodes:
|
||||
if (
|
||||
node["node"].node_type in _MARKER_CAPABLE_TYPES
|
||||
and node["node"].status == "started"
|
||||
):
|
||||
return node
|
||||
|
||||
# Accept stopped but capable (marker rides NIO, applied at start).
|
||||
for node in self._nodes:
|
||||
if node["node"].node_type in _MARKER_CAPABLE_TYPES:
|
||||
return node
|
||||
|
||||
raise ControllerError(
|
||||
"Cannot add marker because no device on this link supports "
|
||||
"traffic insight"
|
||||
)
|
||||
|
||||
async def node_updated(self, node):
|
||||
"""
|
||||
Called when a node member of the link is updated
|
||||
"""
|
||||
if self._capture_node and node == self._capture_node["node"] and node.status != "started":
|
||||
await self.stop_capture()
|
||||
# Marker clean-up is *not* done on node stop — markers are a persistent
|
||||
# link-scoped feature that recovers via NIO on restart (see
|
||||
# _ubridge_apply_markers in add_ubridge_udp_connection). The user
|
||||
# explicitly deletes a marker via the REST API, and a marker is torn
|
||||
# down automatically only when its link is deleted.
|
||||
|
||||
async def start_marker(self, name, bpf, tag=None, color=None, highlight_duration=None, inherited_from=None):
|
||||
"""
|
||||
Attach a traffic-insight marker to this link.
|
||||
|
||||
State-only model (mirrors ``update_filters``): record the marker in
|
||||
``_markers`` (with its capture-side node id for NIO routing), then push
|
||||
via ``self.update()`` so it rides the NIO and is applied by
|
||||
``_ubridge_apply_markers``. No dedicated uBridge round-trip — exactly
|
||||
how packet filters are applied.
|
||||
|
||||
:param name: stable filter name — echoed in MARK signals + pcap identity
|
||||
:param bpf: libpcap BPF expression
|
||||
:param tag: optional correlation id
|
||||
:param color: optional hex color for the Web UI (e.g. '#ff5722'); stored
|
||||
with the link and persisted in the topology, never sent to uBridge
|
||||
:param highlight_duration: optional UI-only hint (milliseconds) for how
|
||||
long a match keeps the marker highlighted; stored, never sent to uBridge
|
||||
:param inherited_from: def name when this marker is a project-level
|
||||
inheritance copy; set automatically, never exposed to REST callers
|
||||
"""
|
||||
|
||||
if name in self._markers:
|
||||
raise ControllerError(f"Marker '{name}' already exists on link {self._id}")
|
||||
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
|
||||
|
||||
marker_side = self._choose_marker_side()
|
||||
marker_entry = {
|
||||
"bpf": bpf,
|
||||
"tag": tag,
|
||||
"enabled": True,
|
||||
"color": color,
|
||||
"highlight_duration": highlight_duration,
|
||||
"capture_node_id": marker_side["node"].id,
|
||||
}
|
||||
if inherited_from:
|
||||
marker_entry["inherited_from"] = inherited_from
|
||||
self._markers[name] = marker_entry
|
||||
if self._created:
|
||||
await self.update()
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
self._project.dump()
|
||||
|
||||
async def stop_marker(self, name, inherited=False):
|
||||
"""
|
||||
Remove a traffic-insight marker from this link.
|
||||
|
||||
Drop it from ``_markers`` and push via ``self.update()``: the NIO
|
||||
reset+reapply in ``_ubridge_apply_filters``/``_ubridge_apply_markers``
|
||||
drops it from uBridge. Mirrors how deleting a packet filter works.
|
||||
|
||||
:param name: filter name to remove
|
||||
:param inherited: set by project-level def-delete to bypass the
|
||||
inheritance guard (the project layer is the legitimate remover)
|
||||
"""
|
||||
|
||||
if name not in self._markers:
|
||||
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
|
||||
|
||||
if self._markers[name].get("inherited_from") and not inherited:
|
||||
raise ControllerError(
|
||||
f"Marker '{name}' is inherited from the project-level "
|
||||
f"definition '{self._markers[name]['inherited_from']}'. "
|
||||
"Delete or update it via the marker-definitions API instead."
|
||||
)
|
||||
|
||||
del self._markers[name]
|
||||
if self._created:
|
||||
await self.update()
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
self._project.dump()
|
||||
|
||||
async def update_marker(self, name, bpf=None, tag=None, enabled=None, color=None, highlight_duration=None, inherited=False):
|
||||
"""
|
||||
Update an existing marker's BPF/tag/enabled/color. Any change pushes via
|
||||
``self.update()``; uBridge picks up the new params on the next NIO
|
||||
reset+reapply (same as packet filters).
|
||||
|
||||
:param name: filter name to update
|
||||
:param bpf: new BPF expression (None = keep existing)
|
||||
:param tag: new tag id (None = keep existing)
|
||||
:param enabled: toggle (None = keep existing)
|
||||
:param color: new hex color (None = keep existing)
|
||||
:param highlight_duration: new UI highlight duration in ms (None = keep existing)
|
||||
:param inherited: set by project-level sync to bypass the inheritance
|
||||
guard (the project layer is the legitimate editor)
|
||||
"""
|
||||
|
||||
marker_info = self._markers.get(name)
|
||||
if not marker_info:
|
||||
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
|
||||
|
||||
if marker_info.get("inherited_from") and not inherited:
|
||||
raise ControllerError(
|
||||
f"Marker '{name}' is inherited from the project-level "
|
||||
f"definition '{marker_info['inherited_from']}'. "
|
||||
"Update it via the marker-definitions API instead."
|
||||
)
|
||||
|
||||
if bpf is not None and bpf != marker_info["bpf"]:
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
|
||||
marker_info["bpf"] = bpf
|
||||
if tag is not None:
|
||||
marker_info["tag"] = tag
|
||||
if enabled is not None:
|
||||
marker_info["enabled"] = enabled
|
||||
if color is not None:
|
||||
marker_info["color"] = color
|
||||
if highlight_duration is not None:
|
||||
marker_info["highlight_duration"] = highlight_duration
|
||||
|
||||
if self._created:
|
||||
await self.update()
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
self._project.dump()
|
||||
|
||||
@ -24,6 +24,7 @@ from gns3server.controller import Controller
|
||||
from gns3server.config import Config
|
||||
from gns3server.compute import MODULES
|
||||
from gns3server.compute.port_manager import PortManager
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
from gns3server.db.tasks import connect_to_db, get_computes, disconnect_from_db, discover_images_on_filesystem
|
||||
|
||||
@ -84,6 +85,14 @@ async def startup(app: FastAPI) -> None:
|
||||
m = module.instance()
|
||||
m.port_manager = PortManager.instance()
|
||||
|
||||
# Start the marker (traffic-insight) UDP sink. One listener per compute
|
||||
# process receives ubridge MARK signals; ubridges are told its host/port at
|
||||
# startup (see BaseNode._start_ubridge).
|
||||
server_settings = Config.instance().settings.Server
|
||||
await MarkerManager.instance().start(
|
||||
host=server_settings.marker_listen_host, port=server_settings.marker_listen_port
|
||||
)
|
||||
|
||||
# Mark MCP server as ready to accept connections (if MCP is available)
|
||||
from gns3server.agent import MCP_AVAILABLE
|
||||
|
||||
@ -101,6 +110,7 @@ async def shutdown(app: FastAPI) -> None:
|
||||
if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled():
|
||||
auto_discover_images_task_handle.cancel()
|
||||
await HTTPClient.close_session()
|
||||
await MarkerManager.instance().stop()
|
||||
await Controller.instance().stop()
|
||||
|
||||
for module in MODULES:
|
||||
|
||||
@ -20,7 +20,7 @@ from .common import ErrorMessage
|
||||
from .version import Version
|
||||
|
||||
# Controller schemas
|
||||
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture
|
||||
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerDefinitionCreate
|
||||
from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute
|
||||
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
|
||||
from .controller.images import Image, ImageType
|
||||
|
||||
@ -36,6 +36,7 @@ class UDPNIO(BaseModel):
|
||||
rport: int = Field(..., gt=0, le=65535, description="Remote port")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters")
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
|
||||
|
||||
|
||||
class EthernetNIOType(str, Enum):
|
||||
|
||||
@ -153,6 +153,12 @@ class ServerSettings(BaseModel):
|
||||
udp_start_port_range: int = Field(10000, gt=0, le=65535)
|
||||
udp_end_port_range: int = Field(30000, gt=0, le=65535)
|
||||
ubridge_path: str = "ubridge"
|
||||
# Marker (traffic-insight) UDP sink: one listener per compute process that
|
||||
# receives ubridge MARK signals from every ubridge on this host. The host
|
||||
# defaults to loopback because ubridge runs on the same host as the compute.
|
||||
# port=0 lets the OS choose a free port (read back and handed to ubridge).
|
||||
marker_listen_host: str = "127.0.0.1"
|
||||
marker_listen_port: int = Field(3070, ge=0, le=65535)
|
||||
compute_username: str = "gns3"
|
||||
compute_password: SecretStr = SecretStr("")
|
||||
allowed_interfaces: List[str] = Field(default_factory=list)
|
||||
|
||||
@ -62,6 +62,10 @@ class LinkBase(BaseModel):
|
||||
suspend: Optional[bool] = None
|
||||
link_style: Optional[LinkStyle] = None
|
||||
filters: Optional[dict] = None
|
||||
markers: Optional[dict] = Field(
|
||||
None,
|
||||
description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
|
||||
)
|
||||
show_filters_icon: Optional[bool] = Field(
|
||||
True,
|
||||
description="Show filters icon in Web UI"
|
||||
@ -135,3 +139,73 @@ class LinkCapture(BaseModel):
|
||||
data_link_type: str = "DLT_EN10MB"
|
||||
capture_file_name: Optional[str] = None
|
||||
wireshark: bool = False
|
||||
|
||||
|
||||
class MarkerCreate(BaseModel):
|
||||
"""
|
||||
Body for attaching a traffic-insight marker to a link.
|
||||
|
||||
``name`` is optional at the controller REST layer (auto-generated when
|
||||
absent) but always set when the controller forwards to the compute.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
|
||||
max_length=128,
|
||||
description='Unique marker name on the link. Auto-generated when absent.',
|
||||
)
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
link_id: Optional[str] = None
|
||||
color: Optional[str] = Field(
|
||||
None,
|
||||
description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'",
|
||||
)
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description=(
|
||||
"How long (milliseconds) the Web UI keeps this marker highlighted "
|
||||
"after a match. Omitted = use the UI default. Pure render hint — "
|
||||
"stored on the link, never sent to uBridge."
|
||||
),
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Whether the marker is active. Defaults to true on creation.",
|
||||
)
|
||||
|
||||
|
||||
class MarkerDefinitionCreate(BaseModel):
|
||||
"""
|
||||
Body for creating / updating a project-level marker definition.
|
||||
|
||||
The definition is a template — when applied to a link the marker name is
|
||||
prefixed with ``global-`` (e.g. ``arp`` → ``global-arp``) so it can never
|
||||
collide with a per-link private marker.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
|
||||
max_length=128,
|
||||
description="Unique definition name. Auto-generated when absent.",
|
||||
)
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
color: Optional[str] = Field(
|
||||
None,
|
||||
description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'",
|
||||
)
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description=(
|
||||
"How long (milliseconds) the Web UI keeps this marker highlighted "
|
||||
"after a match. Omitted = use the UI default. Pure render hint — "
|
||||
"stored with the definition, never sent to uBridge."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -4,3 +4,7 @@ compute_password = gns3
|
||||
skills_repo_url = https://github.com/yueguobin/GNS3-Skills.git
|
||||
skills_repo_branch = main
|
||||
skills_auto_update = false
|
||||
|
||||
; Marker (traffic-insight) UDP sink port for uBridge MARK signals
|
||||
; Set to 0 for OS-chosen port
|
||||
marker_listen_port = 3070
|
||||
|
||||
@ -21,34 +21,48 @@ import pytest_asyncio
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
from unittest.mock import call
|
||||
from unittest.mock import call, MagicMock
|
||||
|
||||
from gns3server.compute.project import Project
|
||||
|
||||
# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of
|
||||
# the Dynamips hypervisor. These are the seams we stub so the routes can be
|
||||
# exercised without launching a real uBridge / creating kernel interfaces.
|
||||
_NODE = "gns3server.compute.builtin.nodes.ethernet_switch.EthernetSwitch"
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestEthernetSwitchNodesRoutes:
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def stub_ubridge(self):
|
||||
"""Keep uBridge from really starting and capture every command."""
|
||||
with asyncio_patch(f"{_NODE}._start_ubridge"), asyncio_patch(f"{_NODE}._stop_ubridge"), \
|
||||
asyncio_patch(f"{_NODE}._ubridge_send"):
|
||||
yield
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ethernet_switch(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> dict:
|
||||
|
||||
params = {"name": "Ethernet Switch"}
|
||||
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert mock.called
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
json_response = response.json()
|
||||
node = compute_project.get_node(json_response["node_id"])
|
||||
node._hypervisor = AsyncioMagicMock()
|
||||
node._hypervisor.send = AsyncioMagicMock()
|
||||
node._hypervisor.version = "0.2.16"
|
||||
# Pretend uBridge is up so the is_running() guards in remove/close pass.
|
||||
node._ubridge_hypervisor = MagicMock()
|
||||
node._ubridge_hypervisor.is_running.return_value = True
|
||||
node._ubridge_send.reset_mock()
|
||||
return json_response
|
||||
|
||||
@staticmethod
|
||||
def _udp_params() -> dict:
|
||||
return {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}
|
||||
|
||||
async def test_ethernet_switch_create(
|
||||
self, app: FastAPI,
|
||||
@ -57,16 +71,24 @@ class TestEthernetSwitchNodesRoutes:
|
||||
) -> None:
|
||||
|
||||
params = {"name": "Ethernet Switch 1"}
|
||||
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert mock.called
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["name"] == "Ethernet Switch 1"
|
||||
assert response.json()["project_id"] == compute_project.id
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["name"] == "Ethernet Switch 1"
|
||||
assert response.json()["project_id"] == compute_project.id
|
||||
assert response.json()["status"] == "started"
|
||||
|
||||
# creation stands up the kernel bridge with VLAN filtering
|
||||
node = compute_project.get_node(response.json()["node_id"])
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_has_calls([
|
||||
call(f'brctl delete "{br}"'),
|
||||
call(f'brctl create "{br}"'),
|
||||
call(f'link set "{br}" up'),
|
||||
call(f'brctl vlanfiltering "{br}" on'),
|
||||
])
|
||||
|
||||
async def test_ethernet_switch_get(
|
||||
self, app: FastAPI,
|
||||
@ -87,7 +109,6 @@ class TestEthernetSwitchNodesRoutes:
|
||||
assert response.json()["project_id"] == compute_project.id
|
||||
assert response.json()["status"] == "started"
|
||||
|
||||
|
||||
async def test_ethernet_switch_duplicate(
|
||||
self,
|
||||
app: FastAPI,
|
||||
@ -98,15 +119,11 @@ class TestEthernetSwitchNodesRoutes:
|
||||
|
||||
# create destination switch first
|
||||
params = {"name": "Ethernet Switch 2"}
|
||||
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock:
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:create_ethernet_switch",
|
||||
project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert mock.called
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id),
|
||||
json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
params = {"destination_node_id": response.json()["node_id"]}
|
||||
response = await compute_client.post(
|
||||
@ -117,7 +134,6 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
|
||||
async def test_ethernet_switch_update(
|
||||
self,
|
||||
app: FastAPI,
|
||||
@ -126,10 +142,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
params = {
|
||||
"name": "test",
|
||||
"console_type": "telnet"
|
||||
}
|
||||
params = {"name": "test", "console_type": "none"}
|
||||
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
@ -141,11 +154,12 @@ class TestEthernetSwitchNodesRoutes:
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["name"] == "test"
|
||||
# renaming a builtin switch does not touch uBridge (the kernel bridge is
|
||||
# name-independent); nothing should have been sent.
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._hypervisor.send.assert_called_with("ethsw rename \"Ethernet Switch\" \"test\"")
|
||||
node._ubridge_send.assert_not_called()
|
||||
|
||||
|
||||
async def test_ethernet_switch_update_ports(
|
||||
async def test_ethernet_switch_update_ports_qinq_proto(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
@ -153,33 +167,11 @@ class TestEthernetSwitchNodesRoutes:
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
# a QinQ port with the 802.1ad ethertype must switch the bridge protocol
|
||||
port_params = {
|
||||
"ports_mapping": [
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "qinq",
|
||||
"vlan": 1
|
||||
},
|
||||
{
|
||||
"name": "Ethernet1",
|
||||
"port_number": 1,
|
||||
"type": "qinq",
|
||||
"vlan": 2,
|
||||
"ethertype": "0x88A8"
|
||||
},
|
||||
{
|
||||
"name": "Ethernet2",
|
||||
"port_number": 2,
|
||||
"type": "dot1q",
|
||||
"vlan": 3,
|
||||
},
|
||||
{
|
||||
"name": "Ethernet3",
|
||||
"port_number": 3,
|
||||
"type": "access",
|
||||
"vlan": 4,
|
||||
}
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 2, "ethertype": "0x88A8"},
|
||||
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 4},
|
||||
],
|
||||
}
|
||||
|
||||
@ -192,90 +184,20 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
nio_params = {
|
||||
"type": "nio_udp",
|
||||
"lport": 4242,
|
||||
"rport": 4343,
|
||||
"rhost": "127.0.0.1"
|
||||
}
|
||||
|
||||
for port_mapping in port_params["ports_mapping"]:
|
||||
port_number = port_mapping["port_number"]
|
||||
vlan = port_mapping["vlan"]
|
||||
port_type = port_mapping["type"]
|
||||
ethertype = port_mapping.get("ethertype", "")
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number=f"{port_number}"
|
||||
)
|
||||
await compute_client.post(url, json=nio_params)
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
nio = node.get_nio(port_number)
|
||||
calls = [
|
||||
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
|
||||
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
|
||||
call.send(f'ethsw set_{port_type}_port "Ethernet Switch" {nio.name} {vlan} {ethertype}'.strip())
|
||||
]
|
||||
node._hypervisor.send.assert_has_calls(calls)
|
||||
node._hypervisor.send.reset_mock()
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.assert_any_call(f'brctl setvlanproto "{node._bridge_name}" 0x88a8')
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ports_settings",
|
||||
(
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "dot42q", # invalid port type
|
||||
"vlan": 1,
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "access", # missing vlan field
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "dot1q",
|
||||
"vlan": 1,
|
||||
"ethertype": "0x88A8" # EtherType is only for QinQ
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "qinq",
|
||||
"vlan": 1,
|
||||
"ethertype": "0x4242" # not a valid EtherType
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "access",
|
||||
"vlan": 0, # minimum vlan number is 1
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "Ethernet0",
|
||||
"port_number": 0,
|
||||
"type": "access",
|
||||
"vlan": 4242, # maximum vlan number is 4094
|
||||
}
|
||||
),
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 1,
|
||||
"ethertype": "0x88A8"}, # ethertype only for qinq
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1,
|
||||
"ethertype": "0x4242"}, # bad ethertype
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094
|
||||
)
|
||||
)
|
||||
async def test_ethernet_switch_update_ports_invalid(
|
||||
@ -286,20 +208,15 @@ class TestEthernetSwitchNodesRoutes:
|
||||
ports_settings: dict,
|
||||
) -> None:
|
||||
|
||||
port_params = {
|
||||
"ports_mapping": [ports_settings]
|
||||
}
|
||||
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"]),
|
||||
json=port_params
|
||||
json={"ports_mapping": [ports_settings]}
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
async def test_ethernet_switch_delete(
|
||||
self, app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
@ -315,12 +232,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
async def test_ethernet_switch_start(
|
||||
self, app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
async def test_ethernet_switch_start(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
@ -331,12 +243,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
|
||||
async def test_ethernet_switch_stop(
|
||||
self, app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
async def test_ethernet_switch_stop(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
@ -347,12 +254,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
|
||||
async def test_ethernet_switch_suspend(
|
||||
self, app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
async def test_ethernet_switch_suspend(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
@ -363,12 +265,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
|
||||
async def test_ethernet_switch_reload(
|
||||
self, app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
async def test_ethernet_switch_reload(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None:
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
@ -379,8 +276,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
|
||||
async def test_ethernet_switch_create_udp(
|
||||
async def test_ethernet_switch_create_udp_access(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
@ -388,13 +284,6 @@ class TestEthernetSwitchNodesRoutes:
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
params = {
|
||||
"type": "nio_udp",
|
||||
"lport": 4242,
|
||||
"rport": 4343,
|
||||
"rhost": "127.0.0.1"
|
||||
}
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
@ -402,19 +291,66 @@ class TestEthernetSwitchNodesRoutes:
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
response = await compute_client.post(url, json=params)
|
||||
response = await compute_client.post(url, json=self._udp_params())
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["type"] == "nio_udp"
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
nio = node.get_nio(0)
|
||||
calls = [
|
||||
call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'),
|
||||
call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'),
|
||||
call.send(f'ethsw set_access_port "Ethernet Switch" {nio.name} 1')
|
||||
]
|
||||
node._hypervisor.send.assert_has_calls(calls)
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
relay = f"{node.id}-0"
|
||||
# access VLAN 1 (default): drop default PVID 1, re-add 1 as PVID/untagged
|
||||
node._ubridge_send.assert_has_calls([
|
||||
call(f"bridge create {relay}"),
|
||||
call(f'bridge add_nio_tap {relay} "{tap}"'),
|
||||
call(f'brctl addif "{br}" "{tap}"'),
|
||||
call(f'brctl vlan_del "{br}" "{tap}" 1'),
|
||||
call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'),
|
||||
call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"),
|
||||
call(f"bridge reset_packet_filters {relay}"),
|
||||
call(f"bridge start {relay}"),
|
||||
])
|
||||
|
||||
async def test_ethernet_switch_create_udp_dot1q(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
# make port 0 a dot1q trunk with native VLAN 10
|
||||
await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"]),
|
||||
json={"ports_mapping": [
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10},
|
||||
]}
|
||||
)
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
response = await compute_client.post(url, json=self._udp_params())
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
# trunk: drop default 1, admit all VIDs tagged, mark native 10 PVID/untagged
|
||||
node._ubridge_send.assert_has_calls([
|
||||
call(f'brctl vlan_del "{br}" "{tap}" 1'),
|
||||
call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'),
|
||||
call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'),
|
||||
])
|
||||
|
||||
async def test_ethernet_switch_delete_nio(
|
||||
self,
|
||||
@ -424,13 +360,6 @@ class TestEthernetSwitchNodesRoutes:
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
params = {
|
||||
"type": "nio_udp",
|
||||
"lport": 4242,
|
||||
"rport": 4343,
|
||||
"rhost": "127.0.0.1"
|
||||
}
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
@ -438,11 +367,10 @@ class TestEthernetSwitchNodesRoutes:
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
await compute_client.post(url, json=params)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._hypervisor.send.reset_mock()
|
||||
nio = node.get_nio(0)
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:delete_ethernet_switch_nio",
|
||||
@ -454,52 +382,86 @@ class TestEthernetSwitchNodesRoutes:
|
||||
response = await compute_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
calls = [
|
||||
call(f'ethsw remove_nio "Ethernet Switch" {nio.name}'),
|
||||
call(f'nio delete {nio.name}')
|
||||
]
|
||||
node._hypervisor.send.assert_has_calls(calls)
|
||||
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
relay = f"{node.id}-0"
|
||||
node._ubridge_send.assert_has_calls([
|
||||
call(f'brctl delif "{br}" "{tap}"'),
|
||||
call(f"bridge delete {relay}"),
|
||||
])
|
||||
|
||||
async def test_ethernet_switch_start_capture(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
params = {
|
||||
"capture_file_name": "test.pcap",
|
||||
"data_link_type": "DLT_EN10MB"
|
||||
}
|
||||
# capture needs a wired port
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
|
||||
url = app.url_path_for("compute:start_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0")
|
||||
|
||||
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.start_capture") as mock:
|
||||
response = await compute_client.post(url, json=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert mock.called
|
||||
assert "test.pcap" in response.json()["pcap_file_path"]
|
||||
|
||||
response = await compute_client.post(url, json=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "test.pcap" in response.json()["pcap_file_path"]
|
||||
relay = f"{node.id}-0"
|
||||
node._ubridge_send.assert_any_call(f'bridge start_capture {relay} "{node.get_nio(0).pcap_output_file}"')
|
||||
|
||||
async def test_ethernet_switch_stop_capture(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
url = app.url_path_for("compute:stop_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0")
|
||||
# start a capture first
|
||||
await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
),
|
||||
json=self._udp_params()
|
||||
)
|
||||
await compute_client.post(
|
||||
app.url_path_for("compute:start_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"),
|
||||
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"}
|
||||
)
|
||||
|
||||
with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.stop_capture") as mock:
|
||||
response = await compute_client.post(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert mock.called
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
relay = f"{node.id}-0"
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:stop_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0")
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
|
||||
|
||||
256
tests/api/routes/controller/test_markers.py
Normal file
256
tests/api/routes/controller/test_markers.py
Normal file
@ -0,0 +1,256 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# 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 traffic-insight marker endpoints: per-link markers,
|
||||
project-level definitions, and the project-wide aggregation view.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.utils import asyncio_patch
|
||||
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.controller.udp_link import UDPLink
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _inherited(link, name="arp"):
|
||||
"""Inject an inherited marker so the controller's inheritance guard can fire."""
|
||||
link._markers[f"global-{name}"] = {
|
||||
"bpf": name, "tag": None, "enabled": True, "color": None,
|
||||
"highlight_duration": None, "capture_node_id": "node-id",
|
||||
"inherited_from": name,
|
||||
}
|
||||
|
||||
|
||||
class TestMarkerRoutes:
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Per-link markers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def test_create_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mock.assert_called_once()
|
||||
_, kwargs = mock.call_args
|
||||
assert kwargs["bpf"] == "icmp"
|
||||
assert kwargs["tag"] == 3
|
||||
assert kwargs["color"] == "#ff5722"
|
||||
assert kwargs["highlight_duration"] == 800
|
||||
assert kwargs["name"].startswith("marker-")
|
||||
|
||||
async def test_create_marker_with_explicit_name(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||
json={"name": "web", "bpf": "tcp port 80"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
_, kwargs = mock.call_args
|
||||
assert kwargs["name"] == "web"
|
||||
|
||||
async def test_create_marker_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||
json={"name": "global-x", "bpf": "icmp"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert not mock.called # rejected before reaching the controller
|
||||
|
||||
async def test_create_marker_bad_format_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
|
||||
json={"name": "bad name!", "bpf": "icmp"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
async def test_get_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
link._markers["web"] = {"bpf": "tcp port 80", "tag": None, "enabled": True,
|
||||
"color": None, "highlight_duration": 800, "capture_node_id": "n1"}
|
||||
project._links = {link.id: link}
|
||||
|
||||
response = await client.get(
|
||||
app.url_path_for("get_markers", project_id=project.id, link_id=link.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["web"]["highlight_duration"] == 800
|
||||
|
||||
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
|
||||
response = await client.put(
|
||||
app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="web"),
|
||||
json={"bpf": "udp port 53", "highlight_duration": 1500},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
_, kwargs = mock.call_args
|
||||
assert kwargs["bpf"] == "udp port 53"
|
||||
assert kwargs["highlight_duration"] == 1500
|
||||
|
||||
async def test_update_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
_inherited(link, "arp")
|
||||
project._links = {link.id: link}
|
||||
|
||||
response = await client.put(
|
||||
app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="global-arp"),
|
||||
json={"name": "global-arp", "bpf": "arp"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "inherited" in response.json()["message"]
|
||||
|
||||
async def test_delete_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
project._links = {link.id: link}
|
||||
|
||||
with asyncio_patch("gns3server.controller.udp_link.UDPLink.stop_marker") as mock:
|
||||
response = await client.delete(
|
||||
app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="web")
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock.assert_called_once_with("web")
|
||||
|
||||
async def test_delete_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
_inherited(link, "arp")
|
||||
project._links = {link.id: link}
|
||||
|
||||
response = await client.delete(
|
||||
app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="global-arp")
|
||||
)
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Project-level marker definitions
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker_definition", project_id=project.id),
|
||||
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
_, kwargs = mock.call_args
|
||||
assert kwargs["name"] == "arp"
|
||||
assert kwargs["bpf"] == "arp"
|
||||
assert kwargs["highlight_duration"] == 1200
|
||||
|
||||
async def test_create_marker_definition_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
|
||||
response = await client.post(
|
||||
app.url_path_for("create_marker_definition", project_id=project.id),
|
||||
json={"name": "global-x", "bpf": "arp"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert not mock.called
|
||||
|
||||
async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
|
||||
response = await client.put(
|
||||
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
|
||||
json={"bpf": "arp or rarp", "highlight_duration": 900},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
_, kwargs = mock.call_args
|
||||
assert kwargs["bpf"] == "arp or rarp"
|
||||
assert kwargs["highlight_duration"] == 900
|
||||
|
||||
async def test_delete_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.delete_marker_definition") as mock:
|
||||
response = await client.delete(
|
||||
app.url_path_for("delete_marker_definition", project_id=project.id, def_name="arp")
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock.assert_called_once_with("arp")
|
||||
|
||||
async def test_get_marker_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
project._marker_definitions = {
|
||||
"arp": {"bpf": "arp", "tag": 5, "color": None, "highlight_duration": 1200},
|
||||
}
|
||||
project._links = {}
|
||||
|
||||
response = await client.get(
|
||||
app.url_path_for("get_marker_definitions", project_id=project.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
body = response.json()
|
||||
assert body["arp"]["bpf"] == "arp"
|
||||
assert body["arp"]["highlight_duration"] == 1200
|
||||
assert body["arp"]["link_ids"] == []
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Aggregation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def test_get_project_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = UDPLink(project)
|
||||
link._markers["icmp"] = {"bpf": "icmp", "tag": 1, "enabled": True, "color": "#ff5722",
|
||||
"highlight_duration": 800, "capture_node_id": "node-1"}
|
||||
project._links = {link.id: link}
|
||||
|
||||
response = await client.get(
|
||||
app.url_path_for("get_project_markers", project_id=project.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
body = response.json()
|
||||
key = f"{link.id}/icmp"
|
||||
assert key in body
|
||||
assert body[key]["highlight_duration"] == 800
|
||||
assert body[key]["link_id"] == link.id
|
||||
assert body[key]["node_id"] == "node-1"
|
||||
@ -200,13 +200,6 @@ class TestNode:
|
||||
result = suspend_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_reload_batch(self, ctx):
|
||||
from gns3server.api.routes.mcp.nodes import reload_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"status": "started"})
|
||||
result = reload_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_console(self, ctx):
|
||||
from gns3server.api.routes.mcp.nodes import get_node_console_info_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
|
||||
@ -38,7 +38,6 @@ HANDLER_FILES = {
|
||||
"get_node_handler": "nodes.py",
|
||||
"start_node_handler": "nodes.py",
|
||||
"stop_node_handler": "nodes.py",
|
||||
"reload_node_handler": "nodes.py",
|
||||
"suspend_node_handler": "nodes.py",
|
||||
"create_node_handler": "nodes.py",
|
||||
"delete_node_handler": "nodes.py",
|
||||
@ -51,7 +50,6 @@ HANDLER_FILES = {
|
||||
"start_all_nodes_handler": "nodes.py",
|
||||
"stop_all_nodes_handler": "nodes.py",
|
||||
"suspend_all_nodes_handler": "nodes.py",
|
||||
"reload_all_nodes_handler": "nodes.py",
|
||||
"duplicate_node_handler": "nodes.py",
|
||||
"isolate_node_handler": "nodes.py",
|
||||
"unisolate_node_handler": "nodes.py",
|
||||
|
||||
@ -37,7 +37,15 @@ def test_json_gns3vm(on_gns3vm, compute_project):
|
||||
"port_number": 0,
|
||||
"type": "ethernet"
|
||||
}
|
||||
]
|
||||
],
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "virbr0",
|
||||
"type": "ethernet",
|
||||
"special": True,
|
||||
"ip_addresses": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -47,39 +55,55 @@ def test_json_darwin(darwin_platform, compute_project):
|
||||
{"name": "eth0", "special": False, "type": "ethernet"},
|
||||
{"name": "vmnet8", "special": True, "type": "ethernet"}]):
|
||||
nat = Nat("nat1", str(uuid.uuid4()), compute_project, MagicMock())
|
||||
assert nat.asdict() == {
|
||||
"name": "nat1",
|
||||
"usage": "",
|
||||
"node_id": nat.id,
|
||||
"project_id": compute_project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": [
|
||||
{
|
||||
"interface": "vmnet8",
|
||||
"name": "nat0",
|
||||
"port_number": 0,
|
||||
"type": "ethernet"
|
||||
}
|
||||
]
|
||||
}
|
||||
assert nat.asdict() == {
|
||||
"name": "nat1",
|
||||
"usage": "",
|
||||
"node_id": nat.id,
|
||||
"project_id": compute_project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": [
|
||||
{
|
||||
"interface": "vmnet8",
|
||||
"name": "nat0",
|
||||
"port_number": 0,
|
||||
"type": "ethernet"
|
||||
}
|
||||
],
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "vmnet8",
|
||||
"type": "ethernet",
|
||||
"special": True,
|
||||
"ip_addresses": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_json_windows_with_full_name_of_interface(windows_platform, project):
|
||||
with patch("gns3server.utils.interfaces.interfaces", return_value=[
|
||||
{"name": "VMware Network Adapter VMnet8", "special": True, "type": "ethernet"}]):
|
||||
nat = Nat("nat1", str(uuid.uuid4()), project, MagicMock())
|
||||
assert nat.asdict() == {
|
||||
"name": "nat1",
|
||||
"usage": "",
|
||||
"node_id": nat.id,
|
||||
"project_id": project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": [
|
||||
{
|
||||
"interface": "VMware Network Adapter VMnet8",
|
||||
"name": "nat0",
|
||||
"port_number": 0,
|
||||
"type": "ethernet"
|
||||
}
|
||||
]
|
||||
}
|
||||
assert nat.asdict() == {
|
||||
"name": "nat1",
|
||||
"usage": "",
|
||||
"node_id": nat.id,
|
||||
"project_id": project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": [
|
||||
{
|
||||
"interface": "VMware Network Adapter VMnet8",
|
||||
"name": "nat0",
|
||||
"port_number": 0,
|
||||
"type": "ethernet"
|
||||
}
|
||||
],
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "VMware Network Adapter VMnet8",
|
||||
"type": "ethernet",
|
||||
"special": True,
|
||||
"ip_addresses": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
0
tests/compute/marker/__init__.py
Normal file
0
tests/compute/marker/__init__.py
Normal file
240
tests/compute/marker/test_marker_manager.py
Normal file
240
tests/compute/marker/test_marker_manager.py
Normal file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
from gns3server.compute.marker.marker_listener import MarkerListener
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMarkerRegistry:
|
||||
|
||||
def test_register_and_lookup(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
mgr.register("proj1", "node1", "filter1", "link1", tag=5)
|
||||
pid, lid, tag = mgr.lookup("node1", "filter1")
|
||||
assert pid == "proj1"
|
||||
assert lid == "link1"
|
||||
assert tag == 5
|
||||
|
||||
def test_miss_returns_none(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
pid, lid, tag = mgr.lookup("no-such-node", "no-such-filter")
|
||||
assert pid is None
|
||||
|
||||
def test_reregister_updates(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
mgr.register("p", "n", "f", "l", tag=1)
|
||||
mgr.register("p", "n", "f", "l", tag=99)
|
||||
_, _, tag = mgr.lookup("n", "f")
|
||||
assert tag == 99
|
||||
|
||||
def test_unregister(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
mgr.register("p", "n", "f", "l")
|
||||
assert mgr.unregister("n", "f") is True
|
||||
pid, _, _ = mgr.lookup("n", "f")
|
||||
assert pid is None
|
||||
assert mgr.unregister("n", "f") is False
|
||||
|
||||
def test_unregister_project(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
mgr.register("p1", "n1", "f1", "l1")
|
||||
mgr.register("p1", "n2", "f2", "l2")
|
||||
mgr.register("p2", "n3", "f3", "l3")
|
||||
mgr.unregister_project("p1")
|
||||
assert mgr.lookup("n1", "f1") == (None, None, None)
|
||||
assert mgr.lookup("n2", "f2") == (None, None, None)
|
||||
assert mgr.lookup("n3", "f3")[0] == "p2"
|
||||
|
||||
def test_re_add_after_project_clear(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
mgr.register("p", "n", "f", "l")
|
||||
mgr.unregister_project("p")
|
||||
mgr.register("p", "n", "f", "l2", tag=42)
|
||||
pid, lid, tag = mgr.lookup("n", "f")
|
||||
assert pid == "p" and lid == "l2" and tag == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MarkerListener parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeMarkerManager:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
self._entries = {}
|
||||
|
||||
def lookup(self, node_id, filter_name):
|
||||
e = self._entries.get((node_id, filter_name))
|
||||
if e is None:
|
||||
return None, None, None
|
||||
return e["project_id"], e["link_id"], e["tag"]
|
||||
|
||||
def emit_match(self, project_id, event):
|
||||
self.events.append((project_id, event))
|
||||
|
||||
def register(self, project_id, node_id, filter_name, link_id, tag):
|
||||
self._entries[(node_id, filter_name)] = {
|
||||
"project_id": project_id, "link_id": link_id, "tag": tag
|
||||
}
|
||||
|
||||
|
||||
class TestMarkerListener:
|
||||
|
||||
def test_parses_valid_mark_datagram(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
fmgr.register("p1", "n1", "f1", "l1", tag=7)
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(
|
||||
b"MARK 1700000000.123456 node=n1 filter=f1 tag=7 len=98\n",
|
||||
("127.0.0.1", 9999),
|
||||
)
|
||||
assert len(fmgr.events) == 1
|
||||
_, ev = fmgr.events[0]
|
||||
assert ev["node_id"] == "n1"
|
||||
assert ev["link_id"] == "l1"
|
||||
assert ev["filter"] == "f1"
|
||||
assert ev["tag"] == "7"
|
||||
assert ev["ts"] == pytest.approx(1700000000.123456)
|
||||
assert ev["len"] == 98
|
||||
|
||||
def test_unknown_node_dropped(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"MARK 1.0 node=bad filter=bad len=10\n", None)
|
||||
assert fmgr.events == []
|
||||
|
||||
def test_bad_timestamp_ignored(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"MARK badts node=n filter=f len=1\n", None)
|
||||
assert fmgr.events == []
|
||||
|
||||
def test_not_mark_line_ignored(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"HELLO world\n", None)
|
||||
assert fmgr.events == []
|
||||
|
||||
def test_missing_node_ignored(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"MARK 1.0 filter=f len=1\n", None)
|
||||
assert fmgr.events == []
|
||||
|
||||
def test_tag_dash_falls_back_to_registered(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
fmgr.register("p", "n", "f", "l", tag=42)
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None)
|
||||
assert fmgr.events[0][1]["tag"] == 42
|
||||
|
||||
def test_link_in_signal_overrides_registry_link(self):
|
||||
# Per-link attribution (contract §3.2/§3.3): the signal's `link=` is
|
||||
# authoritative and must disambiguate links sharing a node+filter —
|
||||
# e.g. several links on one IOU node under the same global marker name.
|
||||
fmgr = FakeMarkerManager()
|
||||
fmgr.register("p", "n", "f", "registry-link", tag=1)
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(
|
||||
b"MARK 3.0 node=n filter=f link=signal-link tag=1 len=42\n", None
|
||||
)
|
||||
assert fmgr.events[0][1]["link_id"] == "signal-link"
|
||||
|
||||
def test_link_dash_falls_back_to_registry_link(self):
|
||||
# Legacy signals that carry no link fall back to the registry's link_id.
|
||||
fmgr = FakeMarkerManager()
|
||||
fmgr.register("p", "n", "f", "registry-link", tag=1)
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
lis.datagram_received(b"MARK 3.0 node=n filter=f link=- tag=1 len=42\n", None)
|
||||
assert fmgr.events[0][1]["link_id"] == "registry-link"
|
||||
|
||||
def test_exception_does_not_kill_listener(self):
|
||||
fmgr = FakeMarkerManager()
|
||||
lis = MarkerListener(fmgr)
|
||||
lis.connection_made(None)
|
||||
# Non-decodable bytes
|
||||
lis.datagram_received(b"\xff\xfe\xfd", None)
|
||||
# The listener swallows exceptions; reaching here proves it survived.
|
||||
assert True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UDP round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMarkerManagerUDP:
|
||||
|
||||
async def test_listener_receives_and_dispatches(self):
|
||||
MarkerManager.reset()
|
||||
mgr = MarkerManager.instance()
|
||||
|
||||
captured = []
|
||||
original_emit = mgr.emit_match
|
||||
mgr.emit_match = lambda pid, ev: captured.append((pid, ev))
|
||||
|
||||
await mgr.start("127.0.0.1", 0)
|
||||
assert mgr.running
|
||||
assert mgr.port is not None
|
||||
|
||||
mgr.register("proj-rt", "node-rt", "filt-rt", "link-rt", tag=10)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
class SendProto(asyncio.DatagramProtocol):
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
sp = SendProto()
|
||||
transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: sp, remote_addr=("127.0.0.1", mgr.port)
|
||||
)
|
||||
transport.sendto(
|
||||
b"MARK 123.456 node=node-rt filter=filt-rt tag=10 len=88\n"
|
||||
)
|
||||
await asyncio.sleep(0.15)
|
||||
transport.close()
|
||||
|
||||
mgr.emit_match = original_emit
|
||||
await mgr.stop()
|
||||
|
||||
assert len(captured) == 1
|
||||
pid, ev = captured[0]
|
||||
assert pid == "proj-rt"
|
||||
assert ev["link_id"] == "link-rt"
|
||||
assert ev["len"] == 88
|
||||
@ -221,6 +221,7 @@ async def test_json(project, compute):
|
||||
}
|
||||
],
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"show_filters_icon": True,
|
||||
"link_style": {},
|
||||
"suspend": False,
|
||||
@ -255,6 +256,7 @@ async def test_json(project, compute):
|
||||
],
|
||||
"link_style": {},
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"show_filters_icon": True,
|
||||
"suspend": False
|
||||
}
|
||||
|
||||
346
tests/controller/test_marker.py
Normal file
346
tests/controller/test_marker.py
Normal file
@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2025 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Controller-layer tests for the traffic-insight marker feature:
|
||||
|
||||
* UDPLink.start_marker / stop_marker / update_marker — storage, guards,
|
||||
inheritance bypass, and partial-update preservation of render hints.
|
||||
* Project.create/update/delete_marker_definition — fan-out, sync, cleanup.
|
||||
* Project.apply_defs_to_new_link and the markers aggregation property.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.utils import AsyncioMagicMock
|
||||
|
||||
from gns3server.controller.udp_link import UDPLink
|
||||
from gns3server.controller.ports.ethernet_port import EthernetPort
|
||||
from gns3server.controller.node import Node
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
|
||||
|
||||
|
||||
def _valid_bpf():
|
||||
"""Bypass tcpdump-based BPF validation so tests don't depend on tcpdump."""
|
||||
return patch(
|
||||
"gns3server.controller.udp_link.validate_bpf_syntax",
|
||||
return_value={"valid": True, "error": None},
|
||||
)
|
||||
|
||||
|
||||
async def _make_link(project):
|
||||
"""Build a created UDPLink between two VPCS nodes on a mocked compute."""
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "local"
|
||||
compute.host = "example.com"
|
||||
|
||||
node1 = Node(project, compute, "n1", node_type="vpcs")
|
||||
node1._ports = [EthernetPort("E0", 0, 0, 0)]
|
||||
node2 = Node(project, compute, "n2", node_type="vpcs")
|
||||
node2._ports = [EthernetPort("E0", 0, 0, 1)]
|
||||
|
||||
async def subnet(_other):
|
||||
return ("192.168.1.1", "192.168.1.2")
|
||||
|
||||
async def udp_cb(path, data={}, **kwargs):
|
||||
response = MagicMock()
|
||||
response.json = {"udp_port": 1234}
|
||||
return response
|
||||
|
||||
compute.get_ip_on_same_subnet.side_effect = subnet
|
||||
compute.post.side_effect = udp_cb
|
||||
# start_marker / update_marker push via node.put -> compute.put; make it awaitable.
|
||||
compute.put = AsyncioMagicMock()
|
||||
compute.delete = AsyncioMagicMock()
|
||||
|
||||
link = UDPLink(project)
|
||||
await link.add_node(node1, 0, 0)
|
||||
await link.add_node(node2, 0, 1)
|
||||
# Register with the project so definition fan-out (which iterates _links) reaches it.
|
||||
project._links[link.id] = link
|
||||
return link
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UDPLink.start_marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_marker_stores_entry(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("icmp", "icmp", tag=7, color="#ff5722", highlight_duration=800)
|
||||
|
||||
entry = link.markers["icmp"]
|
||||
assert entry["bpf"] == "icmp"
|
||||
assert entry["tag"] == 7
|
||||
assert entry["color"] == "#ff5722"
|
||||
assert entry["highlight_duration"] == 800
|
||||
assert entry["enabled"] is True
|
||||
assert entry["capture_node_id"] in {n["node"].id for n in link._nodes}
|
||||
assert "inherited_from" not in entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_marker_rejects_duplicate(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("icmp", "icmp")
|
||||
with pytest.raises(ControllerError):
|
||||
await link.start_marker("icmp", "tcp")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_marker_rejects_invalid_bpf(project):
|
||||
|
||||
link = await _make_link(project)
|
||||
with patch("gns3server.controller.udp_link.validate_bpf_syntax",
|
||||
return_value={"valid": False, "error": "bad expression"}):
|
||||
with pytest.raises(ControllerError):
|
||||
await link.start_marker("bad", "not a real bpf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UDPLink.stop_marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marker_removes(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("icmp", "icmp")
|
||||
assert "icmp" in link.markers
|
||||
await link.stop_marker("icmp")
|
||||
assert "icmp" not in link.markers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marker_rejects_inherited(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.inherit_marker("arp", {"bpf": "arp"})
|
||||
# Per-link delete of an inherited marker must be refused (use the def API).
|
||||
with pytest.raises(ControllerError):
|
||||
await link.stop_marker("global-arp")
|
||||
assert "global-arp" in link.markers # still present
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marker_inherited_bypass(project):
|
||||
"""The def-delete path passes inherited=True to remove inherited copies."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.inherit_marker("arp", {"bpf": "arp"})
|
||||
await link.stop_marker("global-arp", inherited=True)
|
||||
assert "global-arp" not in link.markers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marker_unknown_raises(project):
|
||||
|
||||
link = await _make_link(project)
|
||||
with pytest.raises(ControllerNotFoundError):
|
||||
await link.stop_marker("nope")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UDPLink.update_marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_marker_preserves_render_hints(project):
|
||||
"""A partial update (bpf only) must not reset color/highlight_duration/tag."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("m", "icmp", tag=1, color="#ff5722", highlight_duration=800)
|
||||
await link.update_marker("m", bpf="tcp port 80")
|
||||
|
||||
entry = link.markers["m"]
|
||||
assert entry["bpf"] == "tcp port 80"
|
||||
assert entry["color"] == "#ff5722" # preserved
|
||||
assert entry["highlight_duration"] == 800 # preserved
|
||||
assert entry["tag"] == 1 # preserved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_marker_changes_fields(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("m", "icmp", highlight_duration=800)
|
||||
await link.update_marker("m", highlight_duration=1500, enabled=False, tag=9)
|
||||
|
||||
entry = link.markers["m"]
|
||||
assert entry["highlight_duration"] == 1500
|
||||
assert entry["enabled"] is False
|
||||
assert entry["tag"] == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_marker_rejects_inherited(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.inherit_marker("arp", {"bpf": "arp"})
|
||||
with pytest.raises(ControllerError):
|
||||
await link.update_marker("global-arp", bpf="tcp")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_marker_inherited_bypass(project):
|
||||
"""The def-sync path passes inherited=True to update inherited copies."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.inherit_marker("arp", {"bpf": "arp", "highlight_duration": 500})
|
||||
await link.update_marker("global-arp", highlight_duration=1200, inherited=True)
|
||||
assert link.markers["global-arp"]["highlight_duration"] == 1200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Link.inherit_marker + persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherit_marker_creates_global_copy(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.inherit_marker("arp", {"bpf": "arp", "tag": 3, "color": "#111", "highlight_duration": 400})
|
||||
|
||||
entry = link.markers["global-arp"]
|
||||
assert entry["bpf"] == "arp"
|
||||
assert entry["tag"] == 3
|
||||
assert entry["color"] == "#111"
|
||||
assert entry["highlight_duration"] == 400
|
||||
assert entry["inherited_from"] == "arp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_markers_excludes_inherited(project):
|
||||
"""Inherited markers are re-created from definitions on load, never persisted."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("private", "icmp", highlight_duration=800)
|
||||
await link.inherit_marker("arp", {"bpf": "arp"})
|
||||
|
||||
persisted = link._persist_markers()
|
||||
assert set(persisted.keys()) == {"private"}
|
||||
assert "global-arp" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asdict_markers_runtime_vs_dump(project):
|
||||
"""Runtime asdict exposes all markers; topology dump drops inherited ones."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("private", "icmp")
|
||||
await link.inherit_marker("arp", {"bpf": "arp"})
|
||||
|
||||
runtime = link.asdict()
|
||||
assert set(runtime["markers"].keys()) == {"private", "global-arp"}
|
||||
dumped = link.asdict(topology_dump=True)
|
||||
assert set(dumped["markers"].keys()) == {"private"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project-level marker definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_marker_definition_fans_out(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link1 = await _make_link(project)
|
||||
link2 = await _make_link(project)
|
||||
await project.create_marker_definition("arp", "arp", tag=5, highlight_duration=1200)
|
||||
|
||||
for link in (link1, link2):
|
||||
entry = link.markers["global-arp"]
|
||||
assert entry["inherited_from"] == "arp"
|
||||
assert entry["bpf"] == "arp"
|
||||
assert entry["highlight_duration"] == 1200
|
||||
assert project.marker_definitions["arp"]["highlight_duration"] == 1200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_marker_definition_syncs(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link1 = await _make_link(project)
|
||||
link2 = await _make_link(project)
|
||||
await project.create_marker_definition("arp", "arp", highlight_duration=500)
|
||||
await project.update_marker_definition("arp", highlight_duration=1500, bpf="arp or rarp")
|
||||
|
||||
for link in (link1, link2):
|
||||
assert link.markers["global-arp"]["highlight_duration"] == 1500
|
||||
assert link.markers["global-arp"]["bpf"] == "arp or rarp"
|
||||
assert project.marker_definitions["arp"]["highlight_duration"] == 1500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_marker_definition_clears(project):
|
||||
"""Regression: deleting a def must remove inherited copies from every link."""
|
||||
|
||||
with _valid_bpf():
|
||||
link1 = await _make_link(project)
|
||||
link2 = await _make_link(project)
|
||||
await project.create_marker_definition("arp", "arp")
|
||||
assert "global-arp" in link1.markers
|
||||
await project.delete_marker_definition("arp")
|
||||
|
||||
assert "global-arp" not in link1.markers
|
||||
assert "global-arp" not in link2.markers
|
||||
assert "arp" not in project.marker_definitions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_defs_to_new_link(project):
|
||||
"""A link created after a definition exists inherits it automatically."""
|
||||
|
||||
with _valid_bpf():
|
||||
await project.create_marker_definition("arp", "arp")
|
||||
new_link = await _make_link(project)
|
||||
|
||||
assert "global-arp" in new_link.markers
|
||||
assert new_link.markers["global-arp"]["inherited_from"] == "arp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_markers_aggregation(project):
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project)
|
||||
await link.start_marker("icmp", "icmp", highlight_duration=800)
|
||||
|
||||
agg = project.markers
|
||||
key = f"{link.id}/icmp"
|
||||
assert key in agg
|
||||
assert agg[key]["bpf"] == "icmp"
|
||||
assert agg[key]["highlight_duration"] == 800
|
||||
assert agg[key]["link_id"] == link.id
|
||||
assert agg[key]["node_id"] == agg[key]["capture_node_id"]
|
||||
@ -82,6 +82,7 @@ async def test_json():
|
||||
"drawing_grid_size": 25,
|
||||
"supplier": None,
|
||||
"variables": None,
|
||||
"marker_definitions": {},
|
||||
"created_by": None
|
||||
}
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@ async def test_project_to_topology_empty(tmpdir):
|
||||
"supplier": None,
|
||||
"variables": None,
|
||||
"version": __version__,
|
||||
"marker_definitions": {},
|
||||
"created_by": None
|
||||
}
|
||||
|
||||
|
||||
@ -78,6 +78,7 @@ async def test_create(project):
|
||||
"rport": 2048,
|
||||
"type": "nio_udp",
|
||||
"filters": {"delay": [10, 0]},
|
||||
"markers": {},
|
||||
"suspend": False,
|
||||
}, timeout=120)
|
||||
|
||||
@ -87,6 +88,7 @@ async def test_create(project):
|
||||
"rport": 1024,
|
||||
"type": "nio_udp",
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"suspend": False,
|
||||
}, timeout=120)
|
||||
|
||||
@ -146,6 +148,7 @@ async def test_create_one_side_failure(project):
|
||||
"rport": 2048,
|
||||
"type": "nio_udp",
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"suspend": False,
|
||||
}, timeout=120)
|
||||
|
||||
@ -155,6 +158,7 @@ async def test_create_one_side_failure(project):
|
||||
"rport": 1024,
|
||||
"type": "nio_udp",
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"suspend": False,
|
||||
}, timeout=120)
|
||||
# The link creation has failed we rollback the nio
|
||||
@ -345,6 +349,7 @@ async def test_update(project):
|
||||
"rport": 2048,
|
||||
"type": "nio_udp",
|
||||
"suspend": False,
|
||||
"markers": {},
|
||||
"filters": {"delay": [10, 0]}
|
||||
}, timeout=120)
|
||||
|
||||
@ -354,6 +359,7 @@ async def test_update(project):
|
||||
"rport": 1024,
|
||||
"type": "nio_udp",
|
||||
"suspend": False,
|
||||
"markers": {},
|
||||
"filters": {}
|
||||
}, timeout=120)
|
||||
|
||||
@ -365,6 +371,7 @@ async def test_update(project):
|
||||
"rport": 2048,
|
||||
"type": "nio_udp",
|
||||
"suspend": False,
|
||||
"markers": {},
|
||||
"filters": {
|
||||
"frequency_drop": [5],
|
||||
"bpf": ["icmp[icmptype] == 8"]
|
||||
@ -425,6 +432,7 @@ async def test_update_suspend(project):
|
||||
"rport": 2048,
|
||||
"type": "nio_udp",
|
||||
"filters": {"frequency_drop": [-1]},
|
||||
"markers": {},
|
||||
"suspend": True
|
||||
}, timeout=120)
|
||||
|
||||
@ -434,5 +442,6 @@ async def test_update_suspend(project):
|
||||
"rport": 1024,
|
||||
"type": "nio_udp",
|
||||
"filters": {},
|
||||
"markers": {},
|
||||
"suspend": True
|
||||
}, timeout=120)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user