mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 20:40:13 +03:00
Merge branch '3.1' into update-dependencies
This commit is contained in:
commit
3689e2bb87
@ -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; ..."`.
|
||||
57
.dockerignore
Normal file
57
.dockerignore
Normal file
@ -0,0 +1,57 @@
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# CI / GitHub / Docker
|
||||
.github
|
||||
.whitesource
|
||||
.dockerignore
|
||||
|
||||
# Editor / IDE
|
||||
.idea
|
||||
.vscode
|
||||
.settings
|
||||
.project
|
||||
.pydevproject
|
||||
.mr.developer.cfg
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
build/
|
||||
dist/
|
||||
eggs/
|
||||
parts/
|
||||
var/
|
||||
sdist/
|
||||
develop-eggs/
|
||||
.installed.cfg
|
||||
lib/
|
||||
lib64/
|
||||
.ropeproject
|
||||
|
||||
# Test & coverage
|
||||
tests/
|
||||
pytest.ini
|
||||
.coveragerc
|
||||
.coverage
|
||||
.coverage*
|
||||
.tox
|
||||
.cache
|
||||
.pytest_cache
|
||||
nosetests.xml
|
||||
|
||||
# Virtualenv
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# Editor backup files
|
||||
*~
|
||||
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
name: Bug report
|
||||
description: Report a bug so we can fix it.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear description of the bug.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we reproduce this? Numbered steps if possible.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen instead?
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / commit
|
||||
description: Which version or commit hash are you on?
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, runtime version, anything else that might be relevant.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste any relevant log output. This is automatically rendered as code.
|
||||
render: shell
|
||||
48
.github/workflows/docker-build.yml
vendored
48
.github/workflows/docker-build.yml
vendored
@ -10,12 +10,37 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DOCKERHUB_ORG: ${{ vars.DOCKERHUB_ORG || 'gns3' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Check for stable release
|
||||
id: ver
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
if echo "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "stable=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "stable=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set lowercase image name vars
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
id: names
|
||||
run: |
|
||||
echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@ -23,18 +48,21 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push to GitHub Container Registry
|
||||
run: |
|
||||
docker build -t ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):latest .
|
||||
docker push ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):latest
|
||||
|
||||
- name: Build and push to Docker Hub
|
||||
run: |
|
||||
docker build -t gns3/${{ github.event.repository.name }}:latest .
|
||||
docker push gns3/${{ github.event.repository.name }}:latest
|
||||
- name: Build and push
|
||||
if: steps.ver.outputs.stable == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
|
||||
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest
|
||||
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:${{ steps.ver.outputs.tag }}
|
||||
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:latest
|
||||
|
||||
160
CHANGELOG
160
CHANGELOG
@ -1,5 +1,165 @@
|
||||
# Change Log
|
||||
|
||||
## 2.2.61 30/07/2026
|
||||
|
||||
* Sync appliances
|
||||
* fix(import): unvalidated symlink creation in import_project
|
||||
* fix(qemu): fix addition of QEMU RNG device causes interface names to change
|
||||
* fix(qemu): remove trailing space from RNG object argument
|
||||
* fix: do not start nodes when deleting a project
|
||||
* fix: correct always-true state check in DockerVM.stop()
|
||||
|
||||
## 2.2.60 15/07/2026
|
||||
|
||||
* Sync appliances
|
||||
* Only set IOU images to be executable when importing images
|
||||
* fix(import): project import does not move images symlinks into place
|
||||
* Search for system UEFI that is compatible with Python < 3.12
|
||||
* Add OVMF firmware directory configuration
|
||||
* Automatically add a Random Number Generator (RNG) device when using uefi option is enabled
|
||||
* fix(docker): handle container name conflict automatically
|
||||
* fix: gns3-server crashes on startup if "Open this project in the background" is active but there is a problem with that project
|
||||
* Handle HTTPNotFound exception when retrieving compute status
|
||||
* Fix: Check compute connectivity before open() during project deletion
|
||||
* API endpoints to manage base configuration files for templates
|
||||
|
||||
## 3.1.0a4 09/07/2026
|
||||
|
||||
* Bundle web-ui v3.1.0a4
|
||||
* Add jwt_refresh_token_expire_minutes to sample configuration
|
||||
* Remove deleted web-ui files from git
|
||||
* .dockerignore ignore .dockerignore
|
||||
* typo made me reconsider and move out to env
|
||||
* defaulted DOCKERHUB_ORG repository variable
|
||||
* Remove API docs from 2.2 after merging
|
||||
* Update GitHub Actions workflows
|
||||
* Fix web-wireshark docker build broken by xpra 6.5 release
|
||||
* Update CI to install [ai-features,dev] instead of [ai-copilot,dev]
|
||||
* Make AI features (AI Copilot + MCP) optional via [ai-features] extra
|
||||
* Add refresh token mechanism documentation under docs/features/
|
||||
* Add /refresh to allowed public endpoints list in route auth test
|
||||
* Add stateless JWT refresh token mechanism
|
||||
* Add project/node/link handler tests: 39 total, covering list/get/create/delete/start/stop/suspend/reload/console/update/fields
|
||||
* Add MCP tool parameter consistency tests
|
||||
* Fix appliance_install: add version parameter
|
||||
* Add coordinate system note to docs
|
||||
* Document canvas coordinate system in node_create x/y params
|
||||
* Update docstring: batch concurrency from 10 to 100
|
||||
* Remove unused import time
|
||||
* Add list type check before nodes[0] access in _normalize_link_nodes
|
||||
* Add fields type validation in create handlers
|
||||
* Use pop() instead of pop(0) for O(1) port removal
|
||||
* Fix review issues: key_prefix length, count validation, WAL log, timeout comment, pointless temp var
|
||||
* Fix: pass name from TemplateUsage to add_node_from_template
|
||||
* Remove unused imports (logging, log, select)
|
||||
* Add warning for unconsumed pre-allocated UDP ports after link creation
|
||||
* Remove FIXME comment about middleware in server.py
|
||||
* Rename device_command_run_handler → device_show_run_handler to match tool name
|
||||
* Rename device_command_run → device_show_run for clarity
|
||||
* Fix device_command_run KeyError('commands'): tool desc said show_commands but backend expects commands
|
||||
* Fix template_list return type annotation to match _run_handler_sync envelope
|
||||
* Add performance optimization documentation
|
||||
* Update MCP service docs: API key format, auth flow, tool parameters, concurrency
|
||||
* Increase HTTP connection pool to 500/1000
|
||||
* Increase BATCH_MAX_WORKERS and Pool concurrency from 20 to 100
|
||||
* Remove final timing artifact in projects.py
|
||||
* Remove remaining dead timing variables and imports
|
||||
* Remove database warmup (proven ineffective - real bottleneck was bcrypt blocking event loop)
|
||||
* Clean up all timing/debug logs
|
||||
* Add memory: import validation best practice
|
||||
* Fix: add missing UUID imports
|
||||
* Generate fresh JWT on API key auth instead of returning raw key
|
||||
* Optimize API key auth: O(1) lookup via UUID-embedded key format
|
||||
* Fix: offload bcrypt.checkpw to thread pool to prevent blocking event loop
|
||||
* Add timing to API key auth path and log api_keys count
|
||||
* Replace SELECT 1 warmup with full database file read to warm OS page cache
|
||||
* Add timing logs to auth dependency chain to identify 6s pre-handler delay
|
||||
* Add granular timing to get_template: separate execute vs fetch time
|
||||
* Warm up database connection pool on startup to avoid 8s cold-start penalty on first API request
|
||||
* Fix: register WAL PRAGMA on sync_engine instead of Engine class for async compat
|
||||
* Add timing logs to get_template to identify DB query bottleneck
|
||||
* Add timing middleware to log slow requests (>1s) with [CTRL-TIMING] prefix
|
||||
* Fix: _time → time in compute.py timing log
|
||||
* Add [CTRL-TIMING] logs to controller create_node flow
|
||||
* Fix: pass template_id to batch mode handler so top-level template_id works as default
|
||||
* Add detailed timing logs to MCP node creation and HTTP client
|
||||
* Add fields filter to template_list tool with description for AI
|
||||
* Fix: add missing _filter_link_response function
|
||||
* Pass name parameter through to controller API when creating node from template
|
||||
* Add validation to compact link format with clear error messages
|
||||
* Add compact array format for link node entries to reduce token usage
|
||||
* Add fields filter to link_create tool
|
||||
* Reduce md5sum cache write failure log level from error to warning
|
||||
* Add fields filter to node_create tool description for AI
|
||||
* Optimize MCP create_node: support inherited template_id and default fields filter
|
||||
* Increase MCP HTTP client timeout from 10s to 30s
|
||||
* Enable SQLite WAL mode to fix 'database is locked' errors under concurrent API requests
|
||||
* Cache IOU image default values per image path to avoid redundant subprocess calls
|
||||
* Increase node and link creation concurrency from 5 to 20
|
||||
* Fix: revert IOU lock optimization, serialize IOU node creation for correct application_id assignment
|
||||
* Performance: accelerate project opening with parallel link creation and batch UDP port allocation
|
||||
* feat: Add batch link_ids to link_delete/link_reset, fields filter to link_list
|
||||
* feat: Add fields filter to link_list
|
||||
* feat: Add batch node_ids to node_delete
|
||||
* fix: Convert http to ws scheme in node_console WebSocket URL
|
||||
* feat: Add batch link_ids to link_capture_download
|
||||
* feat: Add batch link_ids to link_capture_start/stop
|
||||
* fix: Store username in gns3_ctx during auth, use for short-lived download JWTs
|
||||
* fix: Generate independent short-lived JWT for pcap download
|
||||
* revert: Remove _configs_map changes in tools_v2 (handled by template renderer now)
|
||||
* fix: Merge commands for duplicate device_names in _configs_map
|
||||
* fix: Actually pass template param to device_config/command handlers
|
||||
* fix: Correct Jinja2 template commands_field per tool type
|
||||
* feat: Jinja2 template support in device_command_run
|
||||
* feat: Jinja2 template support in device_config_send
|
||||
* feat: Add batch node_ids support to node_start/stop/reload/suspend
|
||||
* feat: Add fields filter to appliance_list
|
||||
* feat: Add fields filter to node_list
|
||||
* feat: node_get fields filter — match controller Node schema fields
|
||||
* fix: Set auto_close=False on project_create so projects stay open when clients disconnect
|
||||
* feat: Add batch mode to node_create and link_create (parallel, max 10 workers)
|
||||
* fix: Pass API key directly instead of generating short-lived JWT
|
||||
* feat: API key lifecycle — revoke/restore/delete
|
||||
* fix: Rename revoke_api_key → delete_api_key
|
||||
* fix: Hard-delete API keys instead of soft delete (revoked flag)
|
||||
* feat: Support API keys in REST API authentication (reuse gns3_ prefix keys)
|
||||
* fix: Lazily access db engine for API key validation
|
||||
* fix: Add missing updated_at column to api_keys table
|
||||
* fix: Export ApiKeyCreate from schemas package
|
||||
* feat: Add API Key support for MCP authentication
|
||||
* fix: Validate JWT token exp claim — was silently ignored after migration to joserfc
|
||||
* fix: Add image field to template_create, document type-specific params in description
|
||||
* fix: Remove .json() calls on 204 responses for prune/install images
|
||||
* fix: Skip always-running nodes in start_all/stop_all
|
||||
* fix: Add missing rotation parameter to drawing_update MCP tool
|
||||
* fix: Map MCP device_command_run parameter to tool's expected field name
|
||||
* fix: Update link_reset description to match actual behavior (delete + recreate)
|
||||
* fix: Remove unsupported description param from project_create
|
||||
* fix: Fix symbol_get/upload/delete handlers for correct API paths
|
||||
* feat: Log registered MCP tools at startup
|
||||
* fix: Type compute_id as uuid.UUID to reject non-UUID values at MCP input layer
|
||||
* fix: Require UUID for compute_get/images, remove 'local' string default
|
||||
* feat: Add device configuration MCP tools (config_send, command_run, vpcs_config_set)
|
||||
* refactor: unify MCP tool naming to <module>_<action> convention
|
||||
* feat: Add symbol upload/delete, project load, and locked check MCP tools
|
||||
* feat: Add image management MCP tools
|
||||
* feat: Add symbol and appliance MCP tools
|
||||
* feat: Add node bulk ops, project lock, and server info MCP tools
|
||||
* feat: Add snapshot and drawing MCP tools
|
||||
* refactor: unify MCP handlers to use http_call directly, relocate node file ops to Node class
|
||||
* feat: Add node file operations as MCP tools (list, get, write, delete)
|
||||
* Add comment about rootful Docker permissions at container start
|
||||
* Fix _fix_permissions test: set process.returncode=0 and update assertion
|
||||
* Fix list_node_files PermissionError on os.scandir
|
||||
* Fix _fix_permissions error handling and list_node_files PermissionError
|
||||
* Add async_iterable_to_stream utility to avoid aiohttp compatibility issues
|
||||
* Add descriptive detail to 403 errors in compute file endpoints
|
||||
* Fix silent file write failure in write_compute_project_file
|
||||
* feat: Node file streaming, recursive listing, file type detection, and file delete
|
||||
* Update README tool descriptions: .txt → .md
|
||||
* Update README tool descriptions to mention Markdown format
|
||||
* Add MCP project tools: update, duplicate, and README operations
|
||||
|
||||
## 3.1.0a3 06/06/2026
|
||||
|
||||
* Bundle web-ui v3.1.0a3
|
||||
|
||||
@ -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.
|
||||
378
docs/features/marker-traffic-insight.md
Normal file
378
docs/features/marker-traffic-insight.md
Normal file
@ -0,0 +1,378 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
## Direction
|
||||
|
||||
A `MARK` signal optionally carries `dir=<tx|rx>` — the matched packet's travel direction
|
||||
**relative to the capture node** (the `node=<id>` in the same signal, i.e. the node whose
|
||||
uBridge hosts the marker):
|
||||
|
||||
| `dir` | Ingress NIO | Meaning |
|
||||
|-------|-------------|---------|
|
||||
| `tx` | device side (`source_nio` on a generic bridge; the IOL instance on an IOU `IOL-BRIDGE`) | capture node is **sending** |
|
||||
| `rx` | link side (`destination_nio` on a generic bridge; the NIO side on an IOU `IOL-BRIDGE`) | capture node is **receiving** |
|
||||
|
||||
A marker is single-sided: only the chosen capture node's uBridge installs the `mark` filter,
|
||||
yet both directions of the link transit that one bridge (it carries exactly two NIOs — the
|
||||
device side and the link side), so that single uBridge observes and classifies both
|
||||
directions. The `marker.match` event forwards `dir` through unchanged; the Web UI combines it
|
||||
with the link's two endpoints and the capture `node_id` to draw an arrow:
|
||||
|
||||
- `dir=tx` → `capture_node → far_node`
|
||||
- `dir=rx` → `far_node → capture_node`
|
||||
- `dir` absent (older uBridge) → undirected highlight (current behaviour)
|
||||
|
||||
Because the listener ignores unknown keys, `dir` is **additive**: an older server silently
|
||||
drops it and an older uBridge simply omits it — either way the system falls back to
|
||||
undirected rendering with no error.
|
||||
|
||||
### Choosing the capture node
|
||||
|
||||
Since `dir` is relative to the capture node, *which* endpoint is the observer decides what
|
||||
`tx`/`rx` mean. By default the server auto-picks (first started marker-capable endpoint, in
|
||||
link-endpoint order). To pin it — e.g. so `dir=tx` unambiguously means "vpcs1 is sending" —
|
||||
pass `capture_node_id` on marker **create**:
|
||||
|
||||
```json
|
||||
{ "bpf": "icmp", "direction": "tx", "capture_node_id": "<vpcs1 node uuid>" }
|
||||
```
|
||||
|
||||
The value must be one of the link's two endpoints and a marker-capable type (`vpcs`, `qemu`,
|
||||
`docker`, `iou`, `dynamips`, `cloud`); any other id is rejected with `409`. Omit it to keep
|
||||
the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in
|
||||
each `MARK` signal's `node=<id>`, so the Web UI always knows the observer regardless of who
|
||||
picked it.
|
||||
|
||||
`capture_node_id` is **create-only**: it is fixed once the marker exists (changing the
|
||||
observer would silently flip the meaning of stored `direction`, so recreate the marker
|
||||
instead). It is not accepted on project-level definitions — a definition is link-agnostic and
|
||||
has no endpoints to choose from, so inherited markers always auto-pick per link.
|
||||
|
||||
For the same reason, a definition **rejects `direction: tx|rx`** (HTTP 409): each inherited
|
||||
copy auto-picks its capture node, so a fixed tx/rx would denote different session directions
|
||||
on different links. A definition is `both` only; encode the direction you want in the BPF
|
||||
instead — e.g. `icmp and icmp[icmptype]==8` for echo requests, a packet-intrinsic property
|
||||
that is consistent on every link regardless of capture node. tx/rx remains available on
|
||||
per-link markers, where the capture node is fixed.
|
||||
|
||||
## Pause & resume
|
||||
|
||||
Two levels of silencing, both instant (no NIO rebuild, no pcap flush):
|
||||
|
||||
- **Per-marker (private)** — `PUT /v3/projects/{pid}/links/{lid}/markers/{name}`
|
||||
with `{"enabled": false}` flips that one filter off in place (uBridge
|
||||
`enable_packet_filter … off`): no signal, no pcap, but traffic still relays —
|
||||
a paused `mark` is a no-op tap, not a drop. `{"enabled": true}` flips it back.
|
||||
A change to `enabled` alone is a single command (the pcap identity and emitted
|
||||
counter are preserved). Changing `bpf`, `tag`, or `direction` rebuilds just that
|
||||
one filter (`delete_packet_filter` + add) — only that marker's own pcap reopens
|
||||
(a new capture session for the new BPF); changing `color`/`highlight_duration`
|
||||
is UI-only, nothing is pushed to uBridge.
|
||||
- **Per-definition (inherited)** — `POST /v3/projects/{pid}/marker-definitions/{name}/pause`
|
||||
and `/resume` toggle **every** inherited `global-{name}` copy across all links
|
||||
at once (same `enable_packet_filter on|off`, fanned out per copy). Use to
|
||||
pause or resume a whole rule independently of the others. The definition's
|
||||
`paused` flag is persisted to the `.gns3` and echoed on the definition object,
|
||||
so links created later inherit it already paused, and the Web UI renders the
|
||||
per-rule button from server truth.
|
||||
|
||||
| Action | signal | pcap | sink |
|
||||
|--------|--------|------|------|
|
||||
| per-marker `enabled: false` | stop | stop | n/a |
|
||||
| per-def `pause` (all `global-{name}` copies) | stop | stop | n/a |
|
||||
| per-def `resume` | resume | resume | n/a |
|
||||
|
||||
## Capture files
|
||||
|
||||
Each marker appends matches to `<project>/project-files/markers/<node_id>_<link_id>_<filter>.pcap`.
|
||||
Removing a marker — per-link `DELETE .../markers/{name}` or deleting a definition (which
|
||||
removes every inherited copy) — deletes that marker's pcap too, even with the capture node
|
||||
stopped (the filter is removed with `delete_packet_filter`, the file is unlinked). uBridge's
|
||||
`reset_packet_filters` (run on NIO/filter changes) preserves mark filters, so unrelated
|
||||
changes no longer close/reopen any marker's pcap.
|
||||
|
||||
## 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 |
|
||||
| POST | `/v3/projects/{pid}/marker-definitions/{name}/pause` | Pause every inherited copy (instant, persisted) | Project.Modify |
|
||||
| POST | `/v3/projects/{pid}/marker-definitions/{name}/resume` | Resume every inherited copy | 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,
|
||||
"direction": "tx",
|
||||
"capture_node_id": "a37e2235-e21f-46c9-a2ab-ba0f8c5465e6",
|
||||
"color": "#ff5722",
|
||||
"highlight_duration": 800,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
`direction` and `capture_node_id` are both optional and create-only (see
|
||||
[Direction](#direction)).
|
||||
|
||||
**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,
|
||||
"direction": null,
|
||||
"paused": false,
|
||||
"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. Toggle is instant: `false` flips the uBridge filter off in place (no signal/pcap), `true` back on — no NIO rebuild (see [Pause & resume](#pause--resume)) |
|
||||
| `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 |
|
||||
| `direction` | string \| null | `tx` / `rx` filter relative to the capture node; `null` = both |
|
||||
| `capture_node_id` | string | Node whose uBridge hosts the marker — caller-set on create, else auto-picked |
|
||||
| `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 |
|
||||
| `direction` | string \| null | `tx` / `rx` filter relative to the capture node; `null` = both |
|
||||
| `paused` | bool | Per-definition mute flag — `true` mutes every inherited copy (persisted) |
|
||||
| `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`, `dir` | 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). The `dir` field is the matched packet's travel
|
||||
direction relative to the capture node; see [Direction](#direction).
|
||||
|
||||
## 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.
|
||||
Names are 1–32 chars (`[A-Za-z0-9][A-Za-z0-9_.-]*`); inherited copies carry a `global-`
|
||||
prefix, so their filter names reach ~39.
|
||||
- **`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.
|
||||
- **BPF is validated once per source.** A private per-link marker validates its BPF inline
|
||||
on create/update. A definition validates its BPF once at create/update (and once per
|
||||
definition on project load, dropping any whose BPF has gone invalid); the inherited
|
||||
fan-out to every link then skips re-validation, so creating a definition over *N* links
|
||||
runs one `tcpdump -d` rather than *N*. (uBridge still runs `pcap_compile` itself at
|
||||
install time, so an invalid expression can never slip through.)
|
||||
- **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.
|
||||
- **Log interpretation across node types.** Each node type logs its startup and link
|
||||
operations differently — do not mistake sparse logs from one type for inactivity.
|
||||
QEMU prints `set_link gns3-<N> on` via its QEMU monitor, which is the most visible
|
||||
startup log among all types. VPCS, Docker, IOU, Dynamips, and Cloud each have their own
|
||||
startup paths (fork + ubridge, container veth, iouyap, Dynamips hypervisor, and TAP
|
||||
device respectively) and none of them emit QEMU-monitor-style logs. To verify marker
|
||||
operations (toggle, pause, resume) on non-QEMU types, either inspect uBridge's
|
||||
own log for `enable_packet_filter` / `marker pause` / `marker resume` commands, or
|
||||
watch the gns3server log for the corresponding compute-route calls at INFO level.
|
||||
@ -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()
|
||||
|
||||
@ -253,3 +255,83 @@ async def stream_pcap_file(
|
||||
nio = node.get_nio(port_number)
|
||||
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
|
||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}"
|
||||
)
|
||||
async def toggle_cloud_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_cloud_marker_capture(
|
||||
*,
|
||||
marker_name: str,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
||||
async def rebuild_cloud_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -20,7 +20,7 @@ API routes for Docker nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, status
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from uuid import UUID
|
||||
@ -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()
|
||||
|
||||
@ -408,3 +408,89 @@ async def vnc_console_ws(
|
||||
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_docker_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_docker_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_docker_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -20,7 +20,7 @@ API routes for Dynamips nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Body, Depends, status
|
||||
from fastapi import APIRouter, WebSocket, Body, Depends, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Union
|
||||
@ -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()
|
||||
|
||||
@ -366,3 +367,89 @@ async def console_ws(
|
||||
async def reset_console(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_dynamips_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: Router = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_dynamips_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: Router = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number, port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_dynamips_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: Router = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -21,7 +21,7 @@ API routes for images.
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import APIRouter, Request, status, Response, HTTPException
|
||||
from fastapi import APIRouter, Body, Request, status, Response, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List
|
||||
|
||||
@ -43,6 +43,16 @@ async def get_docker_images() -> List[dict]:
|
||||
return await docker_manager.list_images()
|
||||
|
||||
|
||||
@router.post("/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")) -> None:
|
||||
"""
|
||||
Pull or update a Docker image.
|
||||
"""
|
||||
|
||||
docker_manager = Docker.instance()
|
||||
await docker_manager.pull_image(image, force=True)
|
||||
|
||||
|
||||
@router.get("/dynamips/images")
|
||||
async def get_dynamips_images() -> List[dict]:
|
||||
"""
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -286,7 +288,7 @@ async def start_iou_node_capture(
|
||||
"""
|
||||
|
||||
pcap_file_path = os.path.join(node.project.capture_working_directory(), node_capture_data.capture_file_name)
|
||||
await node.start_capture(adapter_number, port_number, pcap_file_path)
|
||||
await node.start_capture(adapter_number, port_number, pcap_file_path, node_capture_data.data_link_type)
|
||||
return {"pcap_file_path": str(pcap_file_path)}
|
||||
|
||||
|
||||
@ -344,3 +346,89 @@ async def console_ws(
|
||||
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_iou_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_iou_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number, port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_iou_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -20,7 +20,7 @@ API routes for Qemu nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, Path, status
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, Path, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Union
|
||||
@ -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()
|
||||
|
||||
@ -438,3 +438,89 @@ async def vnc_console_ws(
|
||||
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_qemu_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_qemu_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int = Path(..., ge=0, le=0),
|
||||
link_id: str = "",
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_qemu_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -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)]
|
||||
@ -344,3 +345,90 @@ async def console_ws(
|
||||
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_vpcs_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_vpcs_marker_capture(
|
||||
*,
|
||||
marker_name: str,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_vpcs_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
API routes for computes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Body, Depends, status
|
||||
from typing import Any, List, Union, Optional
|
||||
from uuid import UUID
|
||||
|
||||
@ -165,6 +165,25 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{compute_id}/docker/images/pull",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Compute.Modify"))]
|
||||
)
|
||||
async def docker_pull_image(
|
||||
compute_id: Union[str, UUID],
|
||||
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")
|
||||
) -> None:
|
||||
"""
|
||||
Pull or update a Docker image on a compute.
|
||||
|
||||
Required privilege: Compute.Modify
|
||||
"""
|
||||
|
||||
compute = Controller.instance().get_compute(str(compute_id))
|
||||
await compute.forward("POST", "docker", "images/pull", data={"image": image})
|
||||
|
||||
|
||||
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
|
||||
async def virtualbox_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVirtualBoxVM]:
|
||||
"""
|
||||
|
||||
@ -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,12 +27,12 @@ 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
|
||||
from gns3server.db.repositories.rbac import RbacRepository
|
||||
from gns3server.controller.link import Link
|
||||
from gns3server.controller.link import Link, _UNSET
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
from gns3server.utils.port_allocator import link_id_to_port
|
||||
from gns3server.utils.websocket_to_websocket import websocket_proxy
|
||||
@ -424,6 +424,101 @@ 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,
|
||||
direction=marker_data.direction,
|
||||
capture_node_id=marker_data.capture_node_id,
|
||||
color=marker_data.color,
|
||||
highlight_duration=marker_data.highlight_duration,
|
||||
data_link_type=marker_data.data_link_type,
|
||||
)
|
||||
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.MarkerUpdate,
|
||||
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,
|
||||
direction=marker_data.direction if "direction" in marker_data.model_fields_set else _UNSET,
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ from uuid import UUID
|
||||
from gns3server import schemas
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.controller.link import _UNSET
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
|
||||
from gns3server.controller.import_project import import_project as import_controller_project
|
||||
from gns3server.controller.export_project import export_project as export_controller_project
|
||||
@ -203,6 +204,161 @@ 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,
|
||||
direction=def_data.direction,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
data_link_type=def_data.data_link_type,
|
||||
)
|
||||
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,
|
||||
direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET,
|
||||
)
|
||||
return project.marker_definitions.get(def_name, {})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions/{def_name}/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def pause_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""
|
||||
Pause a definition: toggle off every inherited ``global-{def_name}`` copy
|
||||
on every link (uBridge ``enable_packet_filter off``, instant — no NIO
|
||||
rebuild). The definition's ``paused`` flag is persisted, so links created
|
||||
later inherit it already paused.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.pause_marker_definition(def_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions/{def_name}/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def resume_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""Resume a paused definition (toggle on every inherited copy).
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.resume_marker_definition(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,
|
||||
|
||||
@ -35,7 +35,7 @@ from gns3server.db.repositories.templates import TemplatesRepository
|
||||
from gns3server.services.templates import TemplatesService
|
||||
from gns3server.db.repositories.rbac import RbacRepository
|
||||
from gns3server.db.repositories.images import ImagesRepository
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
|
||||
from gns3server.utils.images import get_builtin_disks
|
||||
|
||||
from .dependencies.authentication import get_current_active_user
|
||||
@ -230,3 +230,60 @@ async def duplicate_template(
|
||||
|
||||
template = await TemplatesService(templates_repo).duplicate_template(template_id)
|
||||
return template
|
||||
|
||||
@router.get(
|
||||
"/{template_id}/base-config/{filename}",
|
||||
dependencies=[Depends(has_privilege("Template.Audit"))]
|
||||
)
|
||||
async def get_base_config(
|
||||
template_id: UUID,
|
||||
filename: str,
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||
):
|
||||
|
||||
service = TemplatesService(templates_repo)
|
||||
await service.get_template(template_id)
|
||||
content = service.get_file(str(template_id), filename)
|
||||
|
||||
return {
|
||||
"template_id": str(template_id),
|
||||
"filename": os.path.basename(filename),
|
||||
"content": content
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{template_id}/base-config/{filename}",
|
||||
dependencies=[Depends(has_privilege("Template.Modify"))]
|
||||
)
|
||||
async def update_base_config(
|
||||
template_id: UUID,
|
||||
filename: str,
|
||||
body: dict,
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||
):
|
||||
if not body or "content" not in body:
|
||||
raise ControllerBadRequestError("Missing 'content' field")
|
||||
|
||||
service = TemplatesService(templates_repo)
|
||||
await service.get_template(template_id)
|
||||
service.update_file(str(template_id), filename, body["content"])
|
||||
|
||||
return {
|
||||
"template_id": str(template_id),
|
||||
"filename": os.path.basename(filename),
|
||||
"content": body["content"]
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{template_id}/base-configs",
|
||||
dependencies=[Depends(has_privilege("Template.Audit"))]
|
||||
)
|
||||
async def list_base_configs(
|
||||
template_id: UUID,
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||
):
|
||||
service = TemplatesService(templates_repo)
|
||||
await service.get_template(template_id)
|
||||
return service.list_files(str(template_id))
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -101,6 +101,7 @@ from .links import (
|
||||
delete_link_handler, update_link_handler,
|
||||
reset_link_handler, start_capture_handler, stop_capture_handler,
|
||||
download_capture_file_handler,
|
||||
link_marker_handler, marker_definition_handler,
|
||||
)
|
||||
from .templates import (
|
||||
list_templates_handler, get_template_handler, create_template_handler,
|
||||
@ -194,6 +195,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 +215,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 +241,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 +301,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 +471,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 +558,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, {
|
||||
@ -663,6 +661,14 @@ async def link_update(
|
||||
{"filters": {"delay": [100, 10]}}
|
||||
{"filters": {"packet_loss": [5]}}
|
||||
{"filters": {"delay": [50, 5], "packet_loss": [2]}}
|
||||
|
||||
To clear all filters: {"filters": {}}
|
||||
|
||||
Filters are applied **bidirectionally** — a packet crossing the link twice
|
||||
(e.g. ping round-trip) is filtered in both directions independently.
|
||||
For example, packet_loss: [50] gives ~75% observed loss (1 - 0.5²), not 50%.
|
||||
ARP frames also pass through filters; at high loss/corrupt rates, pre-set
|
||||
static ARP entries to avoid false "Destination Host Unreachable" errors.
|
||||
"""
|
||||
params = {"project_id": project_id, "link_id": link_id, **kwargs}
|
||||
return await asyncio.to_thread(_run_handler_sync, update_link_handler, params)
|
||||
@ -873,16 +879,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")],
|
||||
@ -946,7 +942,9 @@ async def link_reset(
|
||||
- Force filter state (delay, packet loss, etc.) to restart fresh
|
||||
- Recover a stuck or abnormal link state
|
||||
|
||||
Filters are preserved but their internal application state resets.
|
||||
This restarts the filter state machines (e.g. frequency_drop counters)
|
||||
while keeping the filter configuration intact. Filters are preserved but
|
||||
their internal application state resets.
|
||||
"""
|
||||
params = {"project_id": project_id}
|
||||
if link_ids:
|
||||
@ -1004,6 +1002,85 @@ async def link_capture_download(
|
||||
return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, params)
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) tools ─────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def link_marker(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
link_id: Annotated[str, Field(description="UUID of the link")],
|
||||
action: Annotated[str, Field(description="Action: create, update, or delete")],
|
||||
bpf: Annotated[str | None, Field(description="BPF expression, e.g. 'arp', 'icmp', 'tcp port 80' (required for create)")] = None,
|
||||
marker_name: Annotated[str | None, Field(description="Marker name (required for update/delete actions)")] = None,
|
||||
name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None,
|
||||
tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None,
|
||||
enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None,
|
||||
direction: Annotated[str | None, Field(description="Direction filter: 'tx' (capture node sending only), 'rx' (receiving only), or 'both' (no filter — on update this clears a previously set direction). Omit to leave unchanged on update.")] = None,
|
||||
capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None,
|
||||
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
|
||||
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Manage traffic-insight markers on a link.
|
||||
|
||||
A marker highlights packets matching a BPF expression as they cross the link.
|
||||
Set action='create' to add a marker, 'update' to modify it, 'delete' to remove.
|
||||
|
||||
Create requires: project_id, link_id, action='create', bpf
|
||||
Update requires: project_id, link_id, action='update', marker_name, and at least one of (bpf, tag, enabled, direction, color, highlight_duration)
|
||||
Delete requires: project_id, link_id, action='delete', marker_name
|
||||
|
||||
To read current markers, use link_get — the response includes a 'markers' dict.
|
||||
|
||||
NOTE: Markers named 'global-*' are inherited from project-level marker definitions
|
||||
and cannot be modified or deleted via this tool.
|
||||
"""
|
||||
params = {"project_id": project_id, "link_id": link_id, "action": action}
|
||||
for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration"):
|
||||
val = locals().get(opt)
|
||||
if val is not None:
|
||||
params[opt] = val
|
||||
return await asyncio.to_thread(_run_handler_sync, link_marker_handler, params)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def marker_definition(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
action: Annotated[str, Field(description="Action: create, update, delete, or list")],
|
||||
bpf: Annotated[str | None, Field(description="BPF expression, e.g. 'arp', 'ospf', 'tcp port 22' (required for create)")] = None,
|
||||
def_name: Annotated[str | None, Field(description="Definition name (required for update/delete actions)")] = None,
|
||||
name: Annotated[str | None, Field(description="Custom definition name for create action (auto-generated if omitted)")] = None,
|
||||
tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None,
|
||||
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
|
||||
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
|
||||
data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483). Omit = Ethernet-only (serial links skipped); setting it also covers serial links with that encapsulation")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Manage project-level marker definitions — traffic-insight rules that apply to ALL links.
|
||||
|
||||
A marker definition is a global BPF rule. On create, it auto-fans out to every
|
||||
link in the project as 'global-{name}'. Updates sync to all inherited copies.
|
||||
On delete, 'global-{name}' is removed from every link.
|
||||
|
||||
Create requires: project_id, action='create', bpf
|
||||
Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration, data_link_type)
|
||||
Delete requires: project_id, action='delete', def_name
|
||||
List requires: project_id, action='list'
|
||||
|
||||
A definition has NO direction (tx/rx): it fans out to every link and auto-selects
|
||||
its capture node on each, so a fixed direction has no consistent meaning. Encode
|
||||
the direction you want in the BPF instead (e.g. 'icmp and icmp[icmptype]==8' for
|
||||
echo requests only). For a capture-node-relative direction on a single link, use
|
||||
the per-link `link_marker` tool.
|
||||
|
||||
Common BPF examples: 'arp', 'icmp', 'ospf', 'tcp port 22', 'udp port 53'
|
||||
"""
|
||||
params = {"project_id": project_id, "action": action}
|
||||
for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
val = locals().get(opt)
|
||||
if val is not None:
|
||||
params[opt] = val
|
||||
return await asyncio.to_thread(_run_handler_sync, marker_definition_handler, params)
|
||||
|
||||
|
||||
# ── Snapshot tools ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -1402,7 +1479,13 @@ async def device_show_run(
|
||||
|
||||
Use this to inspect device status, view configurations, or verify changes.
|
||||
For configuration changes use device_config_send instead.
|
||||
Devices must be started first.
|
||||
|
||||
Prerequisites:
|
||||
- Devices must be started first (use node_start or node_start_all).
|
||||
- Each node must have a device_type:<type> tag set in GNS3
|
||||
(e.g. device_type:cisco_ios_telnet, device_type:gns3_huawei_telnet_ce).
|
||||
Nodes without this tag will fail with "device_type tag not found".
|
||||
Docker/Linux nodes are not supported (use node_console instead).
|
||||
"""
|
||||
params = {"project_id": project_id, "device_configs": device_configs}
|
||||
if template is not None:
|
||||
|
||||
@ -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:
|
||||
@ -367,6 +367,124 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
return result
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) handlers ──────────────────────────────────
|
||||
|
||||
|
||||
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage traffic-insight markers on a specific link.
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/links/{lid}/markers
|
||||
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
|
||||
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, link_id, action]):
|
||||
return {"error": "project_id, link_id and action are required"}
|
||||
if action not in ("create", "update", "delete"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
|
||||
if params.get("direction") in ("tx", "rx"):
|
||||
body["direction"] = params["direction"]
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
marker_name = params.get("marker_name")
|
||||
if not marker_name:
|
||||
return {"error": "marker_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{marker_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
|
||||
direction = params.get("direction")
|
||||
if direction == "both":
|
||||
body["direction"] = None
|
||||
elif direction in ("tx", "rx"):
|
||||
body["direction"] = direction
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
|
||||
|
||||
|
||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage project-level marker definitions (auto-fanout to all links).
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/marker-definitions → fans out global-{name} to every link
|
||||
- update: PUT /projects/{pid}/marker-definitions/{name}
|
||||
- delete: DELETE /projects/{pid}/marker-definitions/{name}
|
||||
- list: GET /projects/{pid}/marker-definitions
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, action]):
|
||||
return {"error": "project_id and action are required"}
|
||||
if action not in ("create", "update", "delete", "list"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
|
||||
|
||||
if action == "list":
|
||||
return conn.http_call("get", base).json()
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# No direction: a definition fans out to every link and auto-selects its
|
||||
# capture node on each, so tx/rx (which is relative to that node) has no
|
||||
# consistent meaning. Encode direction in the BPF instead.
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
def_name = params.get("def_name")
|
||||
if not def_name:
|
||||
return {"error": "def_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{def_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
LINK_TOOLS = [
|
||||
|
||||
@ -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,
|
||||
|
||||
59
gns3server/appliances/armbian.gns3a
Normal file
59
gns3server/appliances/armbian.gns3a
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"appliance_id": "b3b90fde-143a-4129-8031-ccbba73c5e02",
|
||||
"name": "armbian",
|
||||
"category": "guest",
|
||||
"description": "A highly optimized base operating system specialized for single board computers (SBCs) and its extensive build framework.",
|
||||
"vendor_name": "The Armbian team",
|
||||
"vendor_url": "https://armbian.com/",
|
||||
"documentation_url": "https://docs.armbian.com/",
|
||||
"product_name": "Armbian UEFI x86",
|
||||
"product_url": "https://armbian.com/boards/uefi-x86",
|
||||
"registry_version": 4,
|
||||
"status": "stable",
|
||||
"maintainer": "GNS3 Team",
|
||||
"maintainer_email": "developers@gns3.net",
|
||||
"usage": "By first login you create root password and new sudo user.\n\nBoot disk from UEFI shell, type: FS0:EFI\\BOOT\\BOOTX64 and press <Enter>",
|
||||
"port_name_format": "Ethernet{0}",
|
||||
"qemu": {
|
||||
"adapter_type": "virtio-net-pci",
|
||||
"adapters": 2,
|
||||
"ram": 256,
|
||||
"hda_disk_interface": "virtio",
|
||||
"arch": "x86_64",
|
||||
"console_type": "spice+agent",
|
||||
"uefi": false,
|
||||
"boot_priority": "c",
|
||||
"kvm": "require",
|
||||
"options": "-nographic"
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"filename": "OVMF-edk2-stable202305.fd",
|
||||
"version": "stable202305",
|
||||
"md5sum": "6c4cf1519fec4a4b95525d9ae562963a",
|
||||
"filesize": 4194304,
|
||||
"download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/",
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download",
|
||||
"compression": "zip"
|
||||
},
|
||||
{
|
||||
"filename": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2",
|
||||
"version": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"md5sum": "7f4c915668718d6135406de5a6c4fc30",
|
||||
"filesize": 877920512,
|
||||
"download_url": "https://armbian.com/boards/uefi-x86",
|
||||
"direct_download_url": "https://armbian.atomonetworks.com/dl/uefi-x86/archive/Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2.xz",
|
||||
"compression": "xz"
|
||||
}
|
||||
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -51,250 +51,12 @@
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.3-build2487-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.3",
|
||||
"md5sum": "b01d9f86aa27c538407d518df1326863",
|
||||
"filesize": 346107904,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.2-build2397-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.2",
|
||||
"md5sum": "36371fbf06210ded57c00b2ff290f2c5",
|
||||
"filesize": 322514944,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.1-build2308-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.1",
|
||||
"md5sum": "e542cc8f2d8f46e9c32b783bf31bef39",
|
||||
"filesize": 309387264,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.5-build1574-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.5",
|
||||
"md5sum": "754326845096afd909ec45d98f8d5a83",
|
||||
"filesize": 278401024,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.4-build1460-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.4",
|
||||
"md5sum": "98fa9830d9ecb5911a703d03b80026b6",
|
||||
"filesize": 261992448,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.2-build1334-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.2",
|
||||
"md5sum": "2ff1298257321cd485d2cad91d6ce510",
|
||||
"filesize": 246083584,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.1-build1215-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.1",
|
||||
"md5sum": "1a3eeff1204fa8f4243773f7521e12b5",
|
||||
"filesize": 242814976,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.12-build0623-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.12",
|
||||
"md5sum": "5b6f6a2b8bc00e56337aa7023a9025cf",
|
||||
"filesize": 249520128,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.11-build0595-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.11",
|
||||
"md5sum": "7b166222136e26190159f37cccbaab6e",
|
||||
"filesize": 249360384,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.9-build0489-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.9",
|
||||
"md5sum": "dbeb6a79b6e421000573dbbbdb50b8b5",
|
||||
"filesize": 247955456,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.6-build0372-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.6",
|
||||
"md5sum": "dfa4df9e976ed87e73cb9601a8a70323",
|
||||
"filesize": 239190016,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.5-build0365-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.5",
|
||||
"md5sum": "e8b9c992784cea766b52a427a5fe0279",
|
||||
"filesize": 237535232,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6.4.14-build2660-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.14",
|
||||
"md5sum": "0fe56e363b166c07b710bde795e36049",
|
||||
"filesize": 219430912,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6.4.12-build2610-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.12",
|
||||
"md5sum": "36c0dc531d921e5f1e1e09b030f7c813",
|
||||
"filesize": 219455488,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build2288-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.5",
|
||||
"md5sum": "bd2791984b03f55a6825297e83c6576a",
|
||||
"filesize": 117014528,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build2253-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.4",
|
||||
"md5sum": "3554a47fde2dc91d17eec16fd0dc10a3",
|
||||
"filesize": 116621312,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build1183-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.2.2",
|
||||
"md5sum": "f5051a8fe49d916bb554b9bae32a1eb4",
|
||||
"filesize": 139145216,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build1050-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.2.0",
|
||||
"md5sum": "c19d2527f91ad1bbafbde5bf08487867",
|
||||
"filesize": 126894080,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0349-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.6",
|
||||
"md5sum": "d03f024c948ba6e2bb9e66c11ca8f34c",
|
||||
"filesize": 112553984,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0255-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.3",
|
||||
"md5sum": "5f34d52d9289b0be2a4c04943446ea39",
|
||||
"filesize": 115703808,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0205-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.2",
|
||||
"md5sum": "8f748649c537d9b5466b24c5b4e62017",
|
||||
"filesize": 116981760,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0092-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.0",
|
||||
"md5sum": "73bfe1bc70124521a524d857646b9c2e",
|
||||
"filesize": 119066624,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1631-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.2",
|
||||
"md5sum": "c81cc247e8eb03249b475fe0e847653e",
|
||||
"filesize": 106946560,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1619-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.1",
|
||||
"md5sum": "8cc553842564d232af295d6a0c784c1f",
|
||||
"filesize": 106831872,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1557-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.0",
|
||||
"md5sum": "f8bd600796f894f4ca1ea2d6b4066d3d",
|
||||
"filesize": 108363776,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1225-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.4",
|
||||
"md5sum": "53bc6e320fe7bde5d2b636bde95a910c",
|
||||
"filesize": 89911296,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1187-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.3",
|
||||
"md5sum": "53602c776d215d98e32163a10804fc49",
|
||||
"filesize": 87425024,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1151-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.2",
|
||||
"md5sum": "8e131ad40009c740f3efdee6dc3a0ac3",
|
||||
"filesize": 86437888,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1082-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.1",
|
||||
"md5sum": "fc1815410f3f0536e2e3a9c1c5c07f41",
|
||||
"filesize": 83124224,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1019-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.0",
|
||||
"md5sum": "1cfb22671cb372d8bf3e47b9c3c55ded",
|
||||
"filesize": 77541376,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0786-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.10",
|
||||
"md5sum": "377fe38bf07bc2435608e5b65f780f07",
|
||||
"filesize": 64962560,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0780-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.9",
|
||||
"md5sum": "04268e779d3d5e6c928c6fd638423c52",
|
||||
"filesize": 65007616,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0777-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.8",
|
||||
"md5sum": "6dbf148ace9bf309ad383757afd75fad",
|
||||
"filesize": 65011712,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0757-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.7",
|
||||
"md5sum": "d37dbaa49d7522324681eeba19f7699b",
|
||||
"filesize": 65056768,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "empty30G.qcow2",
|
||||
"filename": "empty500G.qcow2",
|
||||
"version": "1.0",
|
||||
"md5sum": "3411a599e822f2ac6be560a26405821a",
|
||||
"filesize": 197120,
|
||||
"md5sum": "658c825441b9b3080ba00f9eec002eaa",
|
||||
"filesize": 204608,
|
||||
"download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/",
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/empty30G.qcow2/download"
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/empty500G.qcow2/download"
|
||||
}
|
||||
],
|
||||
"versions": [
|
||||
@ -302,259 +64,21 @@
|
||||
"name": "7.4.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.6.M-build2588-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.5.M-build2553-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.4.F-build2550-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.3-build2487-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.2-build2397-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.1-build2308-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.5-build1574-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.4-build1460-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.2-build1334-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.1-build1215-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.12",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.12-build0623-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.11",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.11-build0595-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.9",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.9-build0489-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.6-build0372-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.5-build0365-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.14",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6.4.14-build2660-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.12",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6.4.12-build2610-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build2288-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build2253-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.2.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build1183-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.2.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build1050-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0349-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0255-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0205-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0092-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1631-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1619-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1557-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1225-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1187-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1151-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1082-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1019-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.10",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0786-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.9",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0780-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.8",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0777-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.7",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0757-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -139,9 +139,23 @@
|
||||
"md5sum": "60f7c36c389c33ab108acc021f41ccd5",
|
||||
"version": "26.05.0",
|
||||
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.05.0/infix-x86_64-v26.05.0.qcow2"
|
||||
},
|
||||
{
|
||||
"filename": "infix-x86_64-v26.06.0.qcow2",
|
||||
"filesize": 363593728,
|
||||
"md5sum": "79ca8bd8534bbaa1af0ab874a49d6f4c",
|
||||
"version": "26.06.0",
|
||||
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.06.0/infix-x86_64-v26.06.0.qcow2"
|
||||
}
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "26.06.0",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "infix-x86_64-v26.06.0.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "26.05.0",
|
||||
"images": {
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"symbol": "linux_guest.svg",
|
||||
"docker": {
|
||||
"adapters": 1,
|
||||
"image": "gns3/ubuntu:noble",
|
||||
"image": "gns3/ubuntu:resolute",
|
||||
"console_type": "telnet"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -100,6 +100,9 @@ class BaseNode:
|
||||
self._internal_aux_port = None
|
||||
self._custom_adapters = []
|
||||
self._ubridge_require_privileged_access = False
|
||||
# marker filter name -> uBridge bridge_name (recorded at apply time so
|
||||
# _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild).
|
||||
self._marker_filter_bridges = {}
|
||||
|
||||
if self._console is not None:
|
||||
# use a previously allocated console port
|
||||
@ -926,27 +929,71 @@ class BaseNode:
|
||||
raise NodeError("uBridge requires root access or the capability to interact with network adapters")
|
||||
|
||||
server_host = self._manager.config.settings.Server.host
|
||||
transport = self._manager.config.settings.Server.ubridge_control_transport
|
||||
if not self.ubridge:
|
||||
self._ubridge_hypervisor = Hypervisor(self._project, self.ubridge_path, self.working_dir, server_host)
|
||||
log.info(f"Starting new uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}")
|
||||
self._ubridge_hypervisor = Hypervisor(
|
||||
self._project, self.ubridge_path, self.working_dir, transport, server_host, self.id
|
||||
)
|
||||
log.info(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}")
|
||||
await self._ubridge_hypervisor.start()
|
||||
if self._ubridge_hypervisor:
|
||||
log.info(
|
||||
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
|
||||
f"Hypervisor at {self._ubridge_hypervisor.endpoint} 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.
|
||||
"""
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
log.info(f"Stopping uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}")
|
||||
log.info(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}")
|
||||
await self._ubridge_hypervisor.stop()
|
||||
self._ubridge_hypervisor = None
|
||||
# uBridge is gone, so every marker filter (and its in-bridge state) is
|
||||
# gone too — clear the map so the next apply re-installs them all rather
|
||||
# than skipping them as "already installed".
|
||||
self._marker_filter_bridges.clear()
|
||||
|
||||
async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
|
||||
"""
|
||||
@ -983,10 +1030,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 +1091,242 @@ class BaseNode:
|
||||
)
|
||||
i += 1
|
||||
|
||||
@staticmethod
|
||||
def _marker_linktype(data_link_type):
|
||||
"""
|
||||
Normalize a GNS3 pcap data-link type (e.g. ``DLT_C_HDLC``) to the bare
|
||||
uBridge ``linktype`` token (``C_HDLC``) by stripping the ``DLT_`` prefix.
|
||||
Returns ``None`` for Ethernet (``DLT_EN10MB`` / unset) so the ``linktype``
|
||||
keyword is omitted and uBridge defaults to EN10MB. Values come straight
|
||||
from ``SerialPort.data_link_types`` (the single source of truth); uBridge
|
||||
resolves them with ``pcap_datalink_name_to_val``, which is case-sensitive
|
||||
and expects the canonical uppercase form.
|
||||
"""
|
||||
if not data_link_type:
|
||||
return None
|
||||
dlt = data_link_type.upper()
|
||||
if dlt.startswith("DLT_"):
|
||||
dlt = dlt[4:]
|
||||
return None if dlt == "EN10MB" else dlt
|
||||
|
||||
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=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_.-]*$")
|
||||
# Defense-in-depth vs hand-edited topology: the user-facing name is capped
|
||||
# at 32 by the schema; inherited copies carry a ``global-`` prefix (≤ 39),
|
||||
# so allow up to 48 here.
|
||||
if not _MARKER_NAME_RE.match(name) or len(name) > 48:
|
||||
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}"
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(data_link_type)
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
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 delete_marker_capture(self, name, link_id, nio=None):
|
||||
"""
|
||||
Remove a marker from uBridge (fine-grained ``delete_packet_filter`` — NOT
|
||||
reset_packet_filters, so sibling markers' pcaps aren't closed/reopened)
|
||||
and delete its capture pcap. Called by the controller when a marker is
|
||||
removed; safe with the node stopped (filter removal is skipped, the file
|
||||
is still unlinked). IOU overrides ``_ubridge_delete_marker_filter`` for
|
||||
its ``iol_bridge`` command shape.
|
||||
|
||||
``nio`` is the port NIO whose cached ``nio.markers`` carries this marker
|
||||
spec; it is dropped here so a later node start / NIO reapply
|
||||
(``_ubridge_apply_markers``) does not reinstall the marker. Without this,
|
||||
deleting a marker while the node is stopped left the spec in
|
||||
``nio.markers``, and starting the node recreated an empty pcap.
|
||||
"""
|
||||
if nio is not None and getattr(nio, "markers", None):
|
||||
nio.markers.pop(name, None)
|
||||
bridge_name = self._marker_filter_bridges.pop((name, link_id), None)
|
||||
if bridge_name is not None:
|
||||
await self._ubridge_delete_marker_filter(bridge_name, name)
|
||||
try:
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")
|
||||
os.remove(pcap_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
"""
|
||||
Remove a single marker filter from uBridge with ``delete_packet_filter``
|
||||
(not a bridge-wide reset) so other markers keep their pcaps open. A no-op
|
||||
when uBridge isn't running — the pcap cleanup in the caller still proceeds.
|
||||
"""
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove marker filter '%s' from %s: %s", name, bridge_name, e)
|
||||
|
||||
async def rebuild_marker_filter(self, name, link_id, bpf, tag=None, direction=None, enabled=True):
|
||||
"""
|
||||
Re-install a single marker filter with new params (delete + add), without
|
||||
a bridge-wide reset — so sibling markers keep their pcaps open. uBridge
|
||||
reopens the marker's own pcap on re-add (a new capture session for the
|
||||
new BPF), which is expected. No-op if the marker isn't installed (node
|
||||
stopped) — the next NIO reapply picks up the updated ``_markers``.
|
||||
|
||||
IOU needs no override: this calls ``_ubridge_delete_marker_filter`` /
|
||||
``_ubridge_add_marker_filter`` / ``_ubridge_set_marker_filter_state``,
|
||||
all of which IOU already overrides for ``iol_bridge``.
|
||||
"""
|
||||
bridge_name = self._marker_filter_bridges.get((name, link_id))
|
||||
if bridge_name is None:
|
||||
return
|
||||
await self._ubridge_delete_marker_filter(bridge_name, name)
|
||||
pcap_path = os.path.join(self.project.markers_working_directory(), f"{self._id}_{link_id}_{name}.pcap")
|
||||
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, direction=direction)
|
||||
if not enabled:
|
||||
await self._ubridge_set_marker_filter_state(name, enabled=False)
|
||||
|
||||
async def _ubridge_apply_markers(self, bridge_name, nio):
|
||||
"""
|
||||
Install the traffic-insight markers carried by *nio* onto bridge
|
||||
*bridge_name* that aren't already there. uBridge's ``reset_packet_filters``
|
||||
preserves mark filters (contract), so on an NIO update we add only the new
|
||||
ones — re-adding an existing marker would either duplicate it or
|
||||
close/reopen its pcap. Called from ``add_ubridge_udp_connection`` (fresh
|
||||
bridge, empty map → installs all) and ``update_ubridge_udp_connection``
|
||||
(incremental).
|
||||
"""
|
||||
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():
|
||||
link_id = spec.get("link_id", "")
|
||||
# Incremental: skip markers already on this bridge. uBridge keeps mark
|
||||
# filters across reset_packet_filters, so re-adding would duplicate (or
|
||||
# reopen the pcap). A fresh bridge has an empty map → installs all.
|
||||
if (name, link_id) in self._marker_filter_bridges:
|
||||
continue
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
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,
|
||||
direction=spec.get("direction"),
|
||||
data_link_type=spec.get("data_link_type"))
|
||||
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
|
||||
# A disabled marker is installed but turned off (a paused tap), not
|
||||
# dropped — so the UI can flip it back on instantly with
|
||||
# enable_packet_filter, no NIO rebuild (ubridge contract §3.2).
|
||||
if not spec.get("enabled", True):
|
||||
try:
|
||||
await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off")
|
||||
except UbridgeError as e:
|
||||
# Old ubridge without enable_packet_filter: leave it installed
|
||||
# (on) rather than fail the whole link/marker apply.
|
||||
log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}")
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
# Remember which bridge hosts this filter so an instant on/off toggle
|
||||
# (no NIO rebuild) can resolve it by name alone.
|
||||
# keyed (name, link_id) so a node that hosts markers for several links
|
||||
# (e.g. IOU with one IOL-BRIDGE and many bays/units) records each
|
||||
# copy independently — toggle below iterates all matching entries.
|
||||
self._marker_filter_bridges[name, link_id] = bridge_name
|
||||
|
||||
async def _ubridge_set_marker_filter_state(self, name, enabled):
|
||||
"""
|
||||
Toggle an installed marker filter on/off with a single uBridge command
|
||||
(``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the
|
||||
pcap identity and emitted counter are preserved (ubridge contract §3.2).
|
||||
The bridge is resolved from the (name, link_id)→bridge map populated at
|
||||
apply time; entries are iterated so a node that hosts the same marker name
|
||||
on several links (e.g. IOU with one IOL-BRIDGE per node) toggles every
|
||||
copy. IOU overrides this for its ``iol_bridge`` command shape.
|
||||
|
||||
:param name: marker filter name
|
||||
:param enabled: True = on (signal+pcap), False = off (paused tap)
|
||||
"""
|
||||
|
||||
state = "on" if enabled else "off"
|
||||
for (n, lid), bridge_name in list(self._marker_filter_bridges.items()):
|
||||
if n == name:
|
||||
await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}")
|
||||
|
||||
async def _ubridge_marker_pause(self):
|
||||
"""
|
||||
Pause all marker signal+pcap emission on this node's uBridge
|
||||
(``marker pause``). Keeps the sink open so ``resume`` is instant. Safe
|
||||
on old ubridge builds (the error is downgraded to a warning). Called by
|
||||
the project-level pause fan-out.
|
||||
"""
|
||||
|
||||
if self._ubridge_hypervisor:
|
||||
try:
|
||||
await self._ubridge_hypervisor.send("marker pause")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not pause markers on node {self._id}: {e}")
|
||||
|
||||
async def _ubridge_marker_resume(self):
|
||||
"""Resume marker signal+pcap emission (``marker resume``)."""
|
||||
|
||||
if self._ubridge_hypervisor:
|
||||
try:
|
||||
await self._ubridge_hypervisor.send("marker resume")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not resume markers on node {self._id}: {e}")
|
||||
|
||||
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,8 +82,20 @@ 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"], "type": interface["type"], "special": interface["special"]}
|
||||
{
|
||||
"name": interface["name"],
|
||||
"type": interface["type"],
|
||||
"special": interface["special"],
|
||||
"ip_addresses": interface.get("ip_addresses", []),
|
||||
"status": interface.get("status", "down"),
|
||||
"speed": interface.get("speed", 0),
|
||||
"mtu": interface.get("mtu", 0),
|
||||
"flags": interface.get("flags", []),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
@ -303,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):
|
||||
@ -443,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,
|
||||
}
|
||||
|
||||
@ -260,19 +260,21 @@ class Docker(BaseManager):
|
||||
return connection
|
||||
|
||||
@locking
|
||||
async def pull_image(self, image, progress_callback=None):
|
||||
async def pull_image(self, image, progress_callback=None, force=False):
|
||||
"""
|
||||
Pulls an image from the Docker repository
|
||||
|
||||
:params image: Image name
|
||||
:params progress_callback: A function that receive a log message about image download progress
|
||||
:params force: Pull the image even if it is already available locally
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.query("GET", f"images/{image}/json")
|
||||
return # We already have the image skip the download
|
||||
except DockerHttp404Error:
|
||||
pass
|
||||
if not force:
|
||||
try:
|
||||
await self.query("GET", f"images/{image}/json")
|
||||
return # We already have the image skip the download
|
||||
except DockerHttp404Error:
|
||||
pass
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"Pulling '{image}' from Docker repository")
|
||||
@ -285,29 +287,45 @@ class Docker(BaseManager):
|
||||
)
|
||||
# The pull api will stream status via an HTTP JSON stream
|
||||
content = ""
|
||||
while True:
|
||||
try:
|
||||
chunk = await response.content.read(CHUNK_SIZE)
|
||||
except aiohttp.ServerDisconnectedError:
|
||||
log.error(f"Disconnected from server while pulling Docker image '{image}' from Docker repository")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Timeout while pulling Docker image '{}' from Docker repository".format(image))
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
content += chunk.decode("utf-8")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await response.content.read(CHUNK_SIZE)
|
||||
except aiohttp.ServerDisconnectedError as e:
|
||||
raise DockerError(
|
||||
f"Disconnected while pulling Docker image '{image}' from Docker repository"
|
||||
) from e
|
||||
except asyncio.TimeoutError as e:
|
||||
raise DockerError(
|
||||
f"Timeout while pulling Docker image '{image}' from Docker repository"
|
||||
) from e
|
||||
if not chunk:
|
||||
break
|
||||
content += chunk.decode("utf-8")
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = content.lstrip(" \r\n\t")
|
||||
answer, index = json.JSONDecoder().raw_decode(content)
|
||||
if not isinstance(answer, dict):
|
||||
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
|
||||
error_detail = answer.get("errorDetail")
|
||||
error = answer.get("error")
|
||||
if not error and isinstance(error_detail, dict):
|
||||
error = error_detail.get("message")
|
||||
if error:
|
||||
raise DockerError(error)
|
||||
if "progress" in answer and progress_callback:
|
||||
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
|
||||
content = content[index:]
|
||||
except ValueError: # Partial JSON
|
||||
pass
|
||||
|
||||
if content.strip():
|
||||
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = content.lstrip(" \r\n\t")
|
||||
answer, index = json.JSONDecoder().raw_decode(content)
|
||||
if "progress" in answer and progress_callback:
|
||||
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
|
||||
content = content[index:]
|
||||
except ValueError: # Partial JSON
|
||||
pass
|
||||
response.close()
|
||||
if progress_callback:
|
||||
progress_callback(f"Success pulling image {image}")
|
||||
|
||||
|
||||
@ -1048,7 +1048,7 @@ class DockerVM(BaseNode):
|
||||
await self._fix_permissions()
|
||||
|
||||
state = await self._get_container_state()
|
||||
if state != "stopped" or state != "exited":
|
||||
if state != "stopped" and state != "exited":
|
||||
# t=5 number of seconds to wait before killing the container
|
||||
try:
|
||||
await self.manager.query("POST", f"containers/{self._cid}/stop", params={"t": 5})
|
||||
@ -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):
|
||||
|
||||
@ -54,6 +54,85 @@ import sys
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOUL1KeepaliveProtocol(asyncio.DatagramProtocol):
|
||||
"""Handle IOU/IOL Layer 1 keepalives for connected interfaces."""
|
||||
|
||||
_header = struct.Struct("!HHBBBB")
|
||||
_message_type = 3
|
||||
|
||||
def __init__(self, vm):
|
||||
self._vm = vm
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def encode_interface(adapter_number, port_number):
|
||||
"""Encode an IOU bay/unit for the L1 keepalive protocol."""
|
||||
|
||||
# IOU stores the zero-based unit in the high nibble and the
|
||||
# zero-based bay in the low nibble.
|
||||
return (port_number << 4) | adapter_number
|
||||
|
||||
@staticmethod
|
||||
def decode_interface(interface):
|
||||
"""Decode an L1 keepalive interface into an IOU bay/unit."""
|
||||
|
||||
return interface & 0x0F, interface >> 4
|
||||
|
||||
def datagram_received(self, data, address):
|
||||
if len(data) != self._header.size:
|
||||
log.debug('IOU "%s": ignored malformed L1 keepalive of %d bytes', self._vm.name, len(data))
|
||||
return
|
||||
|
||||
destination, source, destination_interface, source_interface, message_type, channel = self._header.unpack(data)
|
||||
if (
|
||||
destination != self._vm.l1_bridge_id
|
||||
or source != self._vm.application_id
|
||||
or message_type != self._message_type
|
||||
or not self._vm.has_nio_for_iou_interface(source_interface)
|
||||
):
|
||||
return
|
||||
|
||||
response = self._header.pack(
|
||||
source,
|
||||
destination,
|
||||
source_interface,
|
||||
destination_interface,
|
||||
message_type,
|
||||
channel,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(response, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# IOU creates its endpoint during startup and removes it on stop.
|
||||
# Dropping a keepalive during either transition is harmless.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive response: %s', self._vm.name, e)
|
||||
|
||||
def send_keepalives(self):
|
||||
"""Tell IOU that every interface with an attached NIO has Layer 1 connectivity."""
|
||||
|
||||
for adapter_number, adapter in enumerate(self._vm.adapters):
|
||||
for port_number, nio in adapter.ports.items():
|
||||
if nio is None:
|
||||
continue
|
||||
interface = self.encode_interface(adapter_number, port_number)
|
||||
keepalive = self._header.pack(
|
||||
self._vm.application_id,
|
||||
self._vm.l1_bridge_id,
|
||||
interface,
|
||||
interface,
|
||||
self._message_type,
|
||||
0,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(keepalive, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# The IOU endpoint does not exist until the image has started.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive: %s', self._vm.name, e)
|
||||
|
||||
|
||||
class IOUVM(BaseNode):
|
||||
module_name = "iou"
|
||||
|
||||
@ -98,6 +177,8 @@ class IOUVM(BaseNode):
|
||||
self._lib_base = self.manager.get_images_directory()
|
||||
self._loader = None
|
||||
self._license_check = True
|
||||
self._l1_keepalive_transport = None
|
||||
self._l1_keepalive_task = None
|
||||
|
||||
# IOU settings
|
||||
self._ethernet_adapters = []
|
||||
@ -110,7 +191,7 @@ class IOUVM(BaseNode):
|
||||
self._private_config = ""
|
||||
self._ram = 1024 # Megabytes
|
||||
self._application_id = application_id
|
||||
self._l1_keepalives = False # used to overcome the always-up Ethernet interfaces (not supported by all IOSes).
|
||||
self._l1_keepalives = False
|
||||
|
||||
def _nvram_changed(self, path):
|
||||
"""
|
||||
@ -637,6 +718,10 @@ class IOUVM(BaseNode):
|
||||
raise IOUError(f"Could not create symbolic link: {e}")
|
||||
|
||||
command = await self._build_command()
|
||||
# Only start the responder when the capability probe actually
|
||||
# enabled IOU's L1 protocol on the command line.
|
||||
if "-l" in command:
|
||||
await self._start_l1_keepalive_responder()
|
||||
try:
|
||||
if self._loader:
|
||||
log.info(f"Starting IOU: {command} with loader {self._loader}")
|
||||
@ -657,8 +742,10 @@ class IOUVM(BaseNode):
|
||||
callback = functools.partial(self._termination_callback, "IOU")
|
||||
gns3server.utils.asyncio.monitor_process(self._iou_process, callback)
|
||||
except FileNotFoundError as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
iou_stdout = self.read_iou_stdout()
|
||||
log.error(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
raise IOUError(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
@ -746,6 +833,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
|
||||
|
||||
@ -759,6 +847,7 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
self._terminate_process_iou()
|
||||
self._stop_l1_keepalive_responder()
|
||||
if returncode != 0:
|
||||
if returncode == -11:
|
||||
message = 'IOU VM "{}" process has stopped with return code: {} (segfault). This could be an issue with the IOU image, using a different image may fix this.\n{}'.format(
|
||||
@ -791,6 +880,7 @@ class IOUVM(BaseNode):
|
||||
Stops the IOU process.
|
||||
"""
|
||||
|
||||
self._stop_l1_keepalive_responder()
|
||||
await self._stop_ubridge()
|
||||
if self._nvram_watcher:
|
||||
self._nvram_watcher.close()
|
||||
@ -893,6 +983,83 @@ class IOUVM(BaseNode):
|
||||
except OSError as e:
|
||||
raise IOUError(f"Could not create {netmap_path}: {e}")
|
||||
|
||||
@property
|
||||
def l1_bridge_id(self):
|
||||
return self.application_id + 512
|
||||
|
||||
@property
|
||||
def l1_socket_directory(self):
|
||||
# IOU hard-codes this directory independently from TMPDIR.
|
||||
return os.path.join("/tmp", f"netl1{os.geteuid()}")
|
||||
|
||||
@property
|
||||
def l1_bridge_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.l1_bridge_id}")
|
||||
|
||||
@property
|
||||
def l1_iou_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.application_id}")
|
||||
|
||||
def has_nio_for_iou_interface(self, interface):
|
||||
"""Return whether the IOU bay/unit encoded in one byte is connected."""
|
||||
|
||||
adapter_number, port_number = IOUL1KeepaliveProtocol.decode_interface(interface)
|
||||
if adapter_number >= len(self._adapters):
|
||||
return False
|
||||
adapter = self._adapters[adapter_number]
|
||||
return adapter.port_exists(port_number) and adapter.get_nio(port_number) is not None
|
||||
|
||||
async def _start_l1_keepalive_responder(self):
|
||||
"""Create the bridge-side UNIX datagram endpoint used by IOU's ``-l`` option."""
|
||||
|
||||
if self._l1_keepalive_transport is not None:
|
||||
return
|
||||
|
||||
socket_directory = self.l1_socket_directory
|
||||
try:
|
||||
os.makedirs(socket_directory, mode=0o755, exist_ok=True)
|
||||
if os.path.islink(socket_directory) or os.stat(socket_directory).st_uid != os.geteuid():
|
||||
raise IOUError(f"Unsafe IOU L1 keepalive directory '{socket_directory}'")
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: IOUL1KeepaliveProtocol(self),
|
||||
local_addr=self.l1_bridge_socket_path,
|
||||
family=socket.AF_UNIX,
|
||||
)
|
||||
self._l1_keepalive_transport = transport
|
||||
self._l1_keepalive_task = asyncio.create_task(self._send_l1_keepalives(protocol))
|
||||
log.info(
|
||||
'IOU "%s" [%s]: L1 keepalive responder listening on %s',
|
||||
self._name,
|
||||
self._id,
|
||||
self.l1_bridge_socket_path,
|
||||
)
|
||||
except (OSError, RuntimeError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU L1 keepalive responder: {e}")
|
||||
|
||||
async def _send_l1_keepalives(self, protocol):
|
||||
while self._l1_keepalive_transport is not None:
|
||||
protocol.send_keepalives()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def _stop_l1_keepalive_responder(self):
|
||||
"""Stop the L1 endpoint and remove its bridge-side socket."""
|
||||
|
||||
if self._l1_keepalive_task is not None:
|
||||
self._l1_keepalive_task.cancel()
|
||||
self._l1_keepalive_task = None
|
||||
if self._l1_keepalive_transport is not None:
|
||||
self._l1_keepalive_transport.close()
|
||||
self._l1_keepalive_transport = None
|
||||
try:
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
except OSError as e:
|
||||
log.warning('Could not remove IOU L1 keepalive socket "%s": %s', self.l1_bridge_socket_path, e)
|
||||
|
||||
async def _build_command(self):
|
||||
"""
|
||||
Command to start the IOU process.
|
||||
@ -1067,6 +1234,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 +1247,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 +1264,105 @@ 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():
|
||||
link_id = spec.get("link_id", "")
|
||||
# Incremental: skip markers already installed on this port. A NIO
|
||||
# update carries EVERY marker on the port (e.g. an inherited
|
||||
# global-* copy plus a newly added private one); uBridge's
|
||||
# add_packet_filter rejects a duplicate filter name (packet_filter.c),
|
||||
# so we must not re-add one already here — mirrors the generic
|
||||
# _ubridge_apply_markers guard. A fresh bridge has an empty map
|
||||
# (cleared on _stop_ubridge) so all are installed.
|
||||
if (name, link_id) in self._marker_filter_bridges:
|
||||
continue
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
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}"
|
||||
direction = spec.get("direction")
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(spec.get("data_link_type"))
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
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
|
||||
if not spec.get("enabled", True):
|
||||
try:
|
||||
await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} off")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not turn marker '{name}' off on {location}: {e}")
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
# Record name -> location (bridge bay unit) for instant toggle.
|
||||
self._marker_filter_bridges[name, link_id] = location
|
||||
|
||||
async def _ubridge_set_marker_filter_state(self, name, enabled):
|
||||
"""IOU override: toggle every (name, link_id) entry via ``iol_bridge``."""
|
||||
|
||||
state = "on" if enabled else "off"
|
||||
for (n, lid), location in list(self._marker_filter_bridges.items()):
|
||||
if n == name:
|
||||
await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}")
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, location, name):
|
||||
"""IOU override: remove a single marker filter via ``iol_bridge``
|
||||
(location = ``{bridge} {bay} {unit}``), not a bridge-wide reset."""
|
||||
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"iol_bridge delete_packet_filter {location} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove marker filter '%s' from %s: %s", name, location, e)
|
||||
|
||||
async def adapter_remove_nio_binding(self, adapter_number, port_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
@ -1207,8 +1475,9 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
env = os.environ.copy()
|
||||
if "IOURC" not in os.environ:
|
||||
env["IOURC"] = self.iourc_path
|
||||
iourc_path = self.iourc_path
|
||||
if "IOURC" not in os.environ and iourc_path:
|
||||
env["IOURC"] = iourc_path
|
||||
try:
|
||||
output = await gns3server.utils.asyncio.subprocess_check_output(
|
||||
*self._loader, self._path, "-h", cwd=self.working_dir, env=env, stderr=True
|
||||
|
||||
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>``.
|
||||
126
gns3server/compute/marker/marker_listener.py
Normal file
126
gns3server/compute/marker/marker_listener.py
Normal file
@ -0,0 +1,126 @@
|
||||
#!/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> [dir=<tx|rx>]\\n
|
||||
|
||||
The signal carries metadata only (no packet bytes). ``dir`` is optional and
|
||||
additive: uBridge stamps it from the ingress NIO of the matched packet to
|
||||
indicate travel direction relative to the capture node (the ``node=<id>``
|
||||
above) — ``tx`` = the capture node is sending (ingressed on the device-side
|
||||
NIO), ``rx`` = it is receiving (ingressed on the link-side NIO). Older
|
||||
uBridge builds omit it, so the listener leaves ``dir`` unset and consumers
|
||||
fall back to undirected rendering. Unknown keys are always ignored, so the
|
||||
field ships safely with no version coupling.
|
||||
|
||||
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")
|
||||
# Travel direction relative to the capture node (the node=<id> above):
|
||||
# "tx" = capture node is sending (matched packet ingressed on the
|
||||
# device-side NIO), "rx" = it is receiving (link-side NIO). Older
|
||||
# uBridge builds omit dir; None here lets consumers render undirected.
|
||||
direction = kv.get("dir")
|
||||
|
||||
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,
|
||||
# Travel direction relative to the capture node (node_id above);
|
||||
# None when the signal carries none (older uBridge) — undirected.
|
||||
"dir": direction,
|
||||
}
|
||||
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.
|
||||
|
||||
@ -34,6 +34,7 @@ import json
|
||||
import shlex
|
||||
import psutil
|
||||
|
||||
from pathlib import Path
|
||||
from gns3server.utils import parse_version
|
||||
from gns3server.utils.asyncio import subprocess_check_output, cancellable_wait_run_in_executor
|
||||
from .qemu_error import QemuError
|
||||
@ -1337,7 +1338,7 @@ class QemuVM(BaseNode):
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
log.debug(
|
||||
f"Connected to QEMU monitor on {self._monitor_host}:{self._monitor} after {time.time() - begin:.4f} seconds"
|
||||
)
|
||||
return reader, writer
|
||||
@ -1354,7 +1355,7 @@ class QemuVM(BaseNode):
|
||||
|
||||
result = None
|
||||
if self.is_running() and self._monitor:
|
||||
log.info(f"Execute QEMU monitor command: {command}")
|
||||
log.debug(f"Execute QEMU monitor command: {command}")
|
||||
reader, writer = await self._open_qemu_monitor_connection_vm()
|
||||
if reader is None and writer is None:
|
||||
return result
|
||||
@ -1404,7 +1405,7 @@ class QemuVM(BaseNode):
|
||||
return
|
||||
|
||||
for command in commands:
|
||||
log.info(f"Execute QEMU monitor command: {command}")
|
||||
log.debug(f"Execute QEMU monitor command: {command}")
|
||||
try:
|
||||
cmd_byte = command.encode("ascii")
|
||||
writer.write(cmd_byte + b"\n")
|
||||
@ -2292,15 +2293,22 @@ class QemuVM(BaseNode):
|
||||
options.extend(["-bios", self._bios_image.replace(",", ",,")])
|
||||
|
||||
elif self._uefi:
|
||||
|
||||
system_ovmf_firmware_dir = Path(self.manager.config.settings.Qemu.ovmf_firmware_dir)
|
||||
log.info("Using OVMF firmware directory: {}".format(system_ovmf_firmware_dir))
|
||||
old_ovmf_vars_path = os.path.join(self.working_dir, "OVMF_VARS.fd")
|
||||
if os.path.exists(old_ovmf_vars_path):
|
||||
# the node has its own UEFI variables store already, we must also use the old UEFI firmware
|
||||
ovmf_firmware_path = self.manager.get_abs_image_path("OVMF_CODE.fd")
|
||||
else:
|
||||
system_ovmf_firmware_path = "/usr/share/OVMF/OVMF_CODE_4M.fd"
|
||||
if os.path.exists(system_ovmf_firmware_path):
|
||||
ovmf_firmware_path = system_ovmf_firmware_path
|
||||
# Use a manual case-insensitive search instead
|
||||
try:
|
||||
system_ovmf_firmware_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd")
|
||||
if f.name.lower() == "ovmf_code_4m.fd"), None)
|
||||
except (FileNotFoundError, StopIteration):
|
||||
system_ovmf_firmware_path = None
|
||||
|
||||
if system_ovmf_firmware_path:
|
||||
ovmf_firmware_path = str(system_ovmf_firmware_path)
|
||||
else:
|
||||
# otherwise, get the UEFI firmware from the images directory
|
||||
ovmf_firmware_path = self.manager.get_abs_image_path("OVMF_CODE_4M.fd")
|
||||
@ -2309,9 +2317,13 @@ class QemuVM(BaseNode):
|
||||
options.extend(["-drive", "if=pflash,format=raw,readonly,file={}".format(ovmf_firmware_path)])
|
||||
|
||||
# try to use the UEFI variables store from the system first
|
||||
system_ovmf_vars_path = "/usr/share/OVMF/OVMF_VARS_4M.fd"
|
||||
if os.path.exists(system_ovmf_vars_path):
|
||||
ovmf_vars_path = system_ovmf_vars_path
|
||||
try:
|
||||
system_ovmf_vars_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd")
|
||||
if f.name.lower() == "ovmf_vars_4m.fd"), None)
|
||||
except (FileNotFoundError, StopIteration):
|
||||
system_ovmf_vars_path = None
|
||||
if system_ovmf_vars_path:
|
||||
ovmf_vars_path = str(system_ovmf_vars_path)
|
||||
else:
|
||||
# otherwise, get the UEFI variables store from the images directory
|
||||
ovmf_vars_path = self.manager.get_abs_image_path("OVMF_VARS_4M.fd")
|
||||
@ -2327,6 +2339,10 @@ class QemuVM(BaseNode):
|
||||
except OSError as e:
|
||||
raise QemuError("Cannot copy OVMF_VARS_4M.fd file to the node working directory: {}".format(e))
|
||||
options.extend(["-drive", "if=pflash,format=raw,file={}".format(ovmf_vars_node_path)])
|
||||
|
||||
# edk2 firmware requires a Random Number Generator (RNG) device in order to turn network adapters on
|
||||
options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0"])
|
||||
options.extend(["-device", "virtio-rng-pci,rng=rng0"])
|
||||
return options
|
||||
|
||||
def _linux_boot_options(self):
|
||||
@ -2649,7 +2665,6 @@ class QemuVM(BaseNode):
|
||||
elif sys.platform.startswith("darwin"):
|
||||
command.extend(["-enable-hax"])
|
||||
command.extend(["-boot", f"order={self._boot_priority}"])
|
||||
command.extend(self._bios_option())
|
||||
command.extend(self._cdrom_option())
|
||||
command.extend(await self._disk_options())
|
||||
command.extend(self._linux_boot_options())
|
||||
@ -2659,6 +2674,8 @@ class QemuVM(BaseNode):
|
||||
command.extend(self._aux_options())
|
||||
command.extend(self._monitor_options())
|
||||
command.extend(await self._network_options())
|
||||
# bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838
|
||||
command.extend(self._bios_option())
|
||||
if self.on_close != "save_vm_state":
|
||||
await self._clear_save_vm_stated()
|
||||
else:
|
||||
|
||||
@ -18,11 +18,11 @@
|
||||
Represents a uBridge hypervisor and starts/stops the associated uBridge process.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import asyncio
|
||||
import socket
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
from gns3server.utils import parse_version
|
||||
@ -44,17 +44,42 @@ class Hypervisor(UBridgeHypervisor):
|
||||
:param project: Project instance
|
||||
:param path: path to uBridge executable
|
||||
:param working_dir: working directory
|
||||
:param host: host/address for this hypervisor
|
||||
:param port: port for this hypervisor
|
||||
:param transport: control channel transport — "unix" (-U) or "tcp" (-H)
|
||||
:param host: host/address for the TCP transport (unused for "unix")
|
||||
:param node_id: node id used to name the AF_UNIX socket (unix transport)
|
||||
"""
|
||||
|
||||
_instance_count = 1
|
||||
_instance_count = 0
|
||||
|
||||
def __init__(self, project, path, working_dir, host, port=None):
|
||||
def __init__(self, project, path, working_dir, transport, host=None, node_id=None):
|
||||
|
||||
if port is None:
|
||||
self._project = project
|
||||
self._path = path
|
||||
self._working_dir = working_dir
|
||||
|
||||
if transport == "unix":
|
||||
# AF_UNIX control socket (-U). Name it after the node so the socket
|
||||
# is self-describing (one ubridge per node => node_id is unique).
|
||||
# sun_path is capped at 107 bytes; a single UUID fits comfortably
|
||||
# (~69 bytes with this prefix), so no project_id is needed.
|
||||
if node_id:
|
||||
socket_name = f"ubridge-{node_id}.sock"
|
||||
else:
|
||||
Hypervisor._instance_count += 1
|
||||
socket_name = f"ubridge-{Hypervisor._instance_count}.sock"
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
|
||||
socket_dir = os.path.join(runtime_dir, "gns3")
|
||||
try:
|
||||
os.makedirs(socket_dir, mode=0o700, exist_ok=True)
|
||||
os.chmod(socket_dir, 0o700)
|
||||
except OSError as e:
|
||||
raise UbridgeError(f"Could not create uBridge socket directory {socket_dir}: {e}")
|
||||
socket_path = os.path.join(socket_dir, socket_name)
|
||||
super().__init__(socket_path=socket_path)
|
||||
else:
|
||||
# TCP control channel (-H): let the OS find an unused local port.
|
||||
port = None
|
||||
try:
|
||||
port = None
|
||||
info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
|
||||
if not info:
|
||||
raise UbridgeError(f"getaddrinfo returns an empty list on {host}")
|
||||
@ -68,11 +93,8 @@ class Hypervisor(UBridgeHypervisor):
|
||||
break
|
||||
except OSError as e:
|
||||
raise UbridgeError(f"Could not find free port for the uBridge hypervisor: {e}")
|
||||
super().__init__(host=host, port=port)
|
||||
|
||||
super().__init__(host, port)
|
||||
self._project = project
|
||||
self._path = path
|
||||
self._working_dir = working_dir
|
||||
self._command = []
|
||||
self._process = None
|
||||
self._stdout_file = ""
|
||||
@ -131,19 +153,17 @@ class Hypervisor(UBridgeHypervisor):
|
||||
|
||||
async def _check_ubridge_version(self, env=None):
|
||||
"""
|
||||
Checks if the ubridge executable version
|
||||
Checks if the ubridge executable version meets the minimum required.
|
||||
"""
|
||||
try:
|
||||
output = await subprocess_check_output(self._path, "-v", cwd=self._working_dir, env=env)
|
||||
match = re.search(r"ubridge version ([0-9a-z\.]+)", output)
|
||||
if match:
|
||||
self._version = match.group(1)
|
||||
if sys.platform.startswith("darwin"):
|
||||
minimum_required_version = "0.9.12"
|
||||
else:
|
||||
# uBridge version 0.9.14 is required for packet filters
|
||||
# to work for IOU nodes.
|
||||
minimum_required_version = "0.9.14"
|
||||
# uBridge >= 1.2.0 is required for features this server now
|
||||
# relies on: the AF_UNIX control channel (-U), the marker
|
||||
# (mark) filter, and the brctl-backed builtin Ethernet Switch.
|
||||
minimum_required_version = "1.2.0"
|
||||
if parse_version(self._version) < parse_version(minimum_required_version):
|
||||
raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}")
|
||||
else:
|
||||
@ -169,6 +189,17 @@ class Hypervisor(UBridgeHypervisor):
|
||||
)
|
||||
|
||||
log.info(f"ubridge started PID={self._process.pid}")
|
||||
# An unsupported flag (e.g. -U on an old ubridge build) makes ubridge exit
|
||||
# immediately with a non-zero code. Detect that here and surface the real
|
||||
# reason from ubridge.log instead of waiting for connect() to time out with
|
||||
# a confusing "couldn't connect" error.
|
||||
await asyncio.sleep(0.3)
|
||||
if self._process.returncode is not None:
|
||||
raise UbridgeError(
|
||||
f"uBridge exited immediately (code {self._process.returncode}); if "
|
||||
f"ubridge_control_transport is 'unix', the installed ubridge may not "
|
||||
f"support -U.\n{self.read_stdout()}"
|
||||
)
|
||||
# recv: Bad address is received by uBridge when a docker image stops by itself
|
||||
# see https://github.com/GNS3/gns3-gui/issues/2957
|
||||
# monitor_process(self._process, self._termination_callback)
|
||||
@ -214,6 +245,16 @@ class Hypervisor(UBridgeHypervisor):
|
||||
os.remove(self._stdout_file)
|
||||
except OSError as e:
|
||||
log.warning(f"could not delete temporary uBridge log file: {e}")
|
||||
|
||||
# ubridge unlinks its AF_UNIX control socket on a clean exit; for the
|
||||
# unix transport remove it here too so a killed process leaves no stale
|
||||
# socket behind. The TCP transport has no socket_path.
|
||||
if self._socket_path:
|
||||
try:
|
||||
os.unlink(self._socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self._process = None
|
||||
self._started = False
|
||||
|
||||
@ -250,7 +291,10 @@ class Hypervisor(UBridgeHypervisor):
|
||||
"""
|
||||
|
||||
command = [self._path]
|
||||
command.extend(["-H", f"{self._host}:{self._port}"])
|
||||
if self._socket_path:
|
||||
command.extend(["-U", self._socket_path])
|
||||
else:
|
||||
command.extend(["-H", f"{self._host}:{self._port}"])
|
||||
if log.getEffectiveLevel() == logging.DEBUG:
|
||||
command.extend(["-d", "1"])
|
||||
return command
|
||||
|
||||
@ -28,20 +28,29 @@ log = logging.getLogger(__name__)
|
||||
class UBridgeHypervisor:
|
||||
|
||||
"""
|
||||
Creates a new connection to uBridge hypervisor.
|
||||
Creates a new connection to a uBridge hypervisor control channel.
|
||||
|
||||
:param host: the hostname or ip address string of the uBridge hypervisor
|
||||
:param port: the tcp port integer
|
||||
Two transports, selected by which argument is set:
|
||||
* ``socket_path`` -> AF_UNIX (``-U``), authenticated in-kernel via
|
||||
SO_PEERCRED (ubridge accepts only its own UID; the compute process that
|
||||
spawned it shares that UID). Recommended on Linux.
|
||||
* ``host``/``port`` -> TCP (``-H``), retained for backward compatibility.
|
||||
|
||||
:param socket_path: path to the uBridge AF_UNIX control socket (None for TCP)
|
||||
:param host: TCP hostname/IP (None for AF_UNIX)
|
||||
:param port: TCP port
|
||||
:param timeout: timeout integer for how long to wait for a response to commands sent to the
|
||||
hypervisor (defaults to 30 seconds)
|
||||
hypervisor (defaults to 30 seconds)
|
||||
"""
|
||||
|
||||
# Used to parse Ubridge response codes
|
||||
error_re = re.compile(r"""^2[0-9]{2}-""")
|
||||
success_re = re.compile(r"""^1[0-9]{2}\s{1}""")
|
||||
|
||||
def __init__(self, host, port, timeout=30.0):
|
||||
def __init__(self, socket_path=None, host=None, port=None, timeout=30.0):
|
||||
|
||||
# Exactly one transport is active: socket_path (AF_UNIX) or host/port (TCP).
|
||||
self._socket_path = socket_path
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._version = "N/A"
|
||||
@ -54,22 +63,23 @@ class UBridgeHypervisor:
|
||||
Connects to the hypervisor.
|
||||
"""
|
||||
|
||||
# connect to a local address by default
|
||||
# if listening to all addresses (IPv4 or IPv6)
|
||||
if self._host == "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
elif self._host == "::":
|
||||
host = "::1"
|
||||
else:
|
||||
host = self._host
|
||||
|
||||
begin = time.time()
|
||||
connection_success = False
|
||||
last_exception = None
|
||||
while time.time() - begin < timeout:
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.open_connection(host, self._port)
|
||||
if self._socket_path:
|
||||
self._reader, self._writer = await asyncio.open_unix_connection(self._socket_path)
|
||||
else:
|
||||
# connect to a local address by default if listening on all addresses
|
||||
if self._host == "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
elif self._host == "::":
|
||||
host = "::1"
|
||||
else:
|
||||
host = self._host
|
||||
self._reader, self._writer = await asyncio.open_connection(host, self._port)
|
||||
except OSError as e:
|
||||
last_exception = e
|
||||
continue
|
||||
@ -77,9 +87,9 @@ class UBridgeHypervisor:
|
||||
break
|
||||
|
||||
if not connection_success:
|
||||
raise UbridgeError(f"Couldn't connect to hypervisor on {host}:{self._port} :{last_exception}")
|
||||
raise UbridgeError(f"Couldn't connect to hypervisor on {self.endpoint} :{last_exception}")
|
||||
else:
|
||||
log.info(f"Connected to uBridge hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds")
|
||||
log.info(f"Connected to uBridge hypervisor on {self.endpoint} after {time.time() - begin:.4f} seconds")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(0.1)
|
||||
@ -122,7 +132,7 @@ class UBridgeHypervisor:
|
||||
await self._writer.drain()
|
||||
self._writer.close()
|
||||
except OSError as e:
|
||||
log.debug(f"Stopping hypervisor {self._host}:{self._port} {e}")
|
||||
log.debug(f"Stopping hypervisor {self.endpoint} {e}")
|
||||
self._reader = self._writer = None
|
||||
|
||||
async def reset(self):
|
||||
@ -133,44 +143,17 @@ class UBridgeHypervisor:
|
||||
await self.send("hypervisor reset")
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
def endpoint(self):
|
||||
"""
|
||||
Returns the port used to start the hypervisor.
|
||||
Returns a human-readable control endpoint: the AF_UNIX socket path when
|
||||
using -U, or host:port when using -H. Used for logging and errors.
|
||||
|
||||
:returns: port number (integer)
|
||||
:returns: endpoint (string)
|
||||
"""
|
||||
|
||||
return self._port
|
||||
|
||||
@port.setter
|
||||
def port(self, port):
|
||||
"""
|
||||
Sets the port used to start the hypervisor.
|
||||
|
||||
:param port: port number (integer)
|
||||
"""
|
||||
|
||||
self._port = port
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""
|
||||
Returns the host (binding) used to start the hypervisor.
|
||||
|
||||
:returns: host/address (string)
|
||||
"""
|
||||
|
||||
return self._host
|
||||
|
||||
@host.setter
|
||||
def host(self, host):
|
||||
"""
|
||||
Sets the host (binding) used to start the hypervisor.
|
||||
|
||||
:param host: host/address (string)
|
||||
"""
|
||||
|
||||
self._host = host
|
||||
if self._socket_path:
|
||||
return self._socket_path
|
||||
return f"{self._host}:{self._port}"
|
||||
|
||||
@locking
|
||||
async def send(self, command):
|
||||
@ -205,8 +188,8 @@ class UBridgeHypervisor:
|
||||
await self._writer.drain()
|
||||
except OSError as e:
|
||||
raise UbridgeError(
|
||||
"Lost communication with {host}:{port} when sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, error=e, run=self.is_running()
|
||||
"Lost communication with {endpoint} when sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, error=e, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
@ -232,8 +215,8 @@ class UBridgeHypervisor:
|
||||
if not chunk:
|
||||
if retries > max_retries:
|
||||
raise UbridgeError(
|
||||
"No data returned from {host}:{port} after sending command '{command}', uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, run=self.is_running()
|
||||
"No data returned from {endpoint} after sending command '{command}', uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, run=self.is_running()
|
||||
)
|
||||
)
|
||||
else:
|
||||
@ -244,8 +227,8 @@ class UBridgeHypervisor:
|
||||
buf += chunk.decode("utf-8")
|
||||
except OSError as e:
|
||||
raise UbridgeError(
|
||||
"Lost communication with {host}:{port} after sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, error=e, run=self.is_running()
|
||||
"Lost communication with {endpoint} after sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, error=e, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
@ -255,8 +238,8 @@ class UBridgeHypervisor:
|
||||
continue
|
||||
except IndexError:
|
||||
raise UbridgeError(
|
||||
"Could not communicate with {host}:{port} after sending command '{command}', uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, run=self.is_running()
|
||||
"Could not communicate with {endpoint} after sending command '{command}', uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
jwt_secret_key = efd08eccec3bd0a1be2e086670e5efa90969c68d07e072d7354a76cea5e33d4e
|
||||
jwt_algorithm = HS256
|
||||
jwt_access_token_expire_minutes = 1440
|
||||
jwt_refresh_token_expire_minutes = 43200
|
||||
|
||||
; Initial default super admin username
|
||||
; It cannot be changed once the controller has started once
|
||||
@ -91,6 +92,19 @@ udp_end_port_range = 30000
|
||||
; uBridge executable location, default: search in PATH
|
||||
;ubridge_path = ubridge
|
||||
|
||||
; uBridge control channel transport: "unix" (-U socket_path; AF_UNIX +
|
||||
; SO_PEERCRED, default — recommended on Linux for kernel-level peer
|
||||
; authentication) or "tcp" (-H host:port; retained for backward compatibility,
|
||||
; binds loopback).
|
||||
;ubridge_control_transport = unix
|
||||
|
||||
; 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
|
||||
|
||||
@ -169,6 +183,8 @@ enable_hardware_acceleration = True
|
||||
require_hardware_acceleration = False
|
||||
; Allow unsafe additional command line options
|
||||
allow_unsafe_options = False
|
||||
; Path to the OVMF firmware directory
|
||||
ovmf_firmware_dir = "/usr/share/OVMF"
|
||||
|
||||
[WebWireshark]
|
||||
; Enable Web Wireshark feature (container-based Wireshark in browser)
|
||||
|
||||
@ -29,7 +29,7 @@ try:
|
||||
except ImportError:
|
||||
from importlib import resources as importlib_resources
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.events import FileSystemEventHandler, DirDeletedEvent, FileDeletedEvent
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from ..config import Config
|
||||
@ -73,6 +73,9 @@ class _ProjectsDirectoryEventHandler(FileSystemEventHandler):
|
||||
def on_moved(self, event):
|
||||
self._handle_event(event)
|
||||
|
||||
def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
|
||||
self._handle_event(event)
|
||||
|
||||
def _handle_event(self, event):
|
||||
if event.is_directory:
|
||||
# Only react to direct child directories of the projects path
|
||||
@ -446,6 +449,12 @@ class Controller:
|
||||
return # Monitor was stopped, skip the scan
|
||||
try:
|
||||
await self.load_projects()
|
||||
# Remove stale projects that no longer exist on disk
|
||||
for project_id in list(self._projects):
|
||||
project = self._projects[project_id]
|
||||
if not os.path.exists(project.path):
|
||||
log.info(f"Removing stale project '{project.name}' ('{project.path}' no longer exists)")
|
||||
del self._projects[project.id]
|
||||
except Exception as e:
|
||||
log.warning(f"Projects directory rescan failed: {e}")
|
||||
|
||||
@ -741,6 +750,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"]]
|
||||
|
||||
@ -182,24 +182,46 @@ async def import_project(
|
||||
project = await controller.load_project(dot_gns3_path, load=False)
|
||||
return project
|
||||
|
||||
|
||||
def _create_symbolic_links(zip_file, path):
|
||||
"""
|
||||
Manually create symbolic links (if any) because ZipFile does not support it.
|
||||
Refuse any target that escapes `path`.
|
||||
|
||||
:param zip_file: ZipFile instance
|
||||
:param path: project location
|
||||
"""
|
||||
|
||||
path_root = os.path.realpath(path) + os.sep
|
||||
for zip_info in zip_file.infolist():
|
||||
if stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
try:
|
||||
# remove the regular file and replace it by a symbolic link
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Cannot create symbolic link: {e}")
|
||||
if not stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
continue
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
|
||||
# 1. Reject absolute targets outright.
|
||||
if os.path.isabs(symlink_target):
|
||||
raise ControllerError(f"Symlink {zip_info.filename!r} has absolute target {symlink_target!r}, refusing")
|
||||
|
||||
# 2. Reject paths where the entry name itself escapes (defence in depth;
|
||||
# extractall normally would already have caught this).
|
||||
member_abs = os.path.realpath(symlink_path)
|
||||
if not (member_abs + os.sep).startswith(path_root) and member_abs + os.sep != path_root:
|
||||
raise ControllerError(f"Symlink entry {zip_info.filename!r} escapes project dir, refusing")
|
||||
|
||||
# 3. Resolve the symlink target relative to the entry's own parent
|
||||
# directory and verify the resolved real path stays inside `path`.
|
||||
link_dir = os.path.realpath(os.path.dirname(symlink_path))
|
||||
resolved_target = os.path.realpath(os.path.join(link_dir, symlink_target))
|
||||
if not (resolved_target + os.sep).startswith(path_root) and resolved_target + os.sep != path_root:
|
||||
raise ControllerError("Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing")
|
||||
|
||||
try:
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Cannot create symbolic link: {e}")
|
||||
|
||||
|
||||
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
|
||||
"""
|
||||
@ -295,14 +317,19 @@ async def _import_images(controller, images_path):
|
||||
for (dirpath, dirnames, filenames) in os.walk(root, followlinks=False):
|
||||
for filename in filenames:
|
||||
path = os.path.join(dirpath, filename)
|
||||
if os.path.islink(path):
|
||||
continue
|
||||
dst = os.path.join(image_dir, os.path.relpath(path, root))
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if not os.path.exists(dst):
|
||||
await wait_run_in_executor(shutil.move, path, dst)
|
||||
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
|
||||
try:
|
||||
with open(dst, "rb") as f:
|
||||
# read the first 7 bytes of the file.
|
||||
elf_header_start = f.read(7)
|
||||
# IOU images must start with the ELF magic number, be 32-bit or 64-bit, little endian and have an ELF version of 1
|
||||
if elf_header_start == b'\x7fELF\x01\x01\x01' or elf_header_start == b'\x7fELF\x02\x01\x01':
|
||||
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
except OSError as e:
|
||||
continue
|
||||
|
||||
async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True):
|
||||
"""
|
||||
|
||||
@ -30,6 +30,13 @@ import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Sentinel for "argument not passed". Distinct from None so marker/definition
|
||||
# updaters can tell "caller omitted direction" (keep current value) from
|
||||
# "caller passed direction=None" (clear it back to both directions). See
|
||||
# UDPLink.update_marker and Project.update_marker_definition.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
FILTERS = [
|
||||
{
|
||||
"type": "frequency_drop",
|
||||
@ -88,6 +95,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 +107,57 @@ 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, dump=True):
|
||||
"""
|
||||
Apply a project-level marker definition to this link.
|
||||
|
||||
The marker is stored under ``global-{def_name}`` so it cannot collide
|
||||
with a per-link private marker of the same name. It carries an
|
||||
``inherited_from`` back-reference that (a) guards against per-link
|
||||
edits and (b) lets the project sync changes to every copy at once.
|
||||
|
||||
The pcap link-layer follows the link type: Ethernet is always EN10MB.
|
||||
A serial link needs the definition's WAN encapsulation (HDLC / PPP /
|
||||
Frame Relay); if none was chosen the serial link is skipped — an EN10MB
|
||||
pcap on a serial link is undecodable.
|
||||
"""
|
||||
|
||||
def_data_link_type = marker_def.get("data_link_type", "DLT_EN10MB")
|
||||
if self._link_type == "serial":
|
||||
if def_data_link_type.upper() == "DLT_EN10MB":
|
||||
return # definition is Ethernet-only; skip this serial link
|
||||
data_link_type = def_data_link_type
|
||||
else:
|
||||
data_link_type = "DLT_EN10MB"
|
||||
|
||||
await self.start_marker(
|
||||
name=f"global-{def_name}",
|
||||
bpf=marker_def["bpf"],
|
||||
tag=marker_def.get("tag"),
|
||||
direction=marker_def.get("direction"),
|
||||
data_link_type=data_link_type,
|
||||
color=marker_def.get("color"),
|
||||
highlight_duration=marker_def.get("highlight_duration"),
|
||||
enabled=not marker_def.get("paused", False),
|
||||
inherited_from=def_name,
|
||||
dump=dump,
|
||||
)
|
||||
|
||||
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 +357,27 @@ class Link:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, enabled=True):
|
||||
"""
|
||||
Attach a traffic-insight marker to this link (base — UDPLink overrides).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def stop_marker(self, name):
|
||||
"""
|
||||
Remove a traffic-insight marker from this link (base — UDPLink overrides).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET):
|
||||
"""
|
||||
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 +651,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 +666,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
|
||||
|
||||
@ -37,10 +37,12 @@ from .snapshot import Snapshot
|
||||
from .drawing import Drawing
|
||||
from .topology import project_to_topology, load_topology
|
||||
from .udp_link import UDPLink
|
||||
from .link import _UNSET
|
||||
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 +213,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 +768,33 @@ 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"),
|
||||
"highlight_duration": marker.get("highlight_duration"),
|
||||
"capture_node_id": marker.get("capture_node_id"),
|
||||
"direction": marker.get("direction"),
|
||||
}
|
||||
if "link_style" in link_data:
|
||||
await link.update_link_style(link_data["link_style"])
|
||||
if "show_filters_icon" in link_data:
|
||||
@ -872,6 +902,285 @@ 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
|
||||
|
||||
async def pause_marker_definition(self, name):
|
||||
"""
|
||||
Pause every inherited copy of a definition (``global-{name}``) on every
|
||||
link: toggle each filter off in place via ``update_marker(enabled=False)``
|
||||
— uBridge ``enable_packet_filter off``, no NIO rebuild, pcap/emitted
|
||||
preserved. The definition's ``paused`` flag is persisted, so links
|
||||
created later inherit the marker already paused.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerError(f"Marker definition '{name}' not found")
|
||||
self._marker_definitions[name]["paused"] = True
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(marker_name, enabled=False, inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to pause marker {marker_name} on link {link.id}: {e}",
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def resume_marker_definition(self, name):
|
||||
"""Resume every inherited copy of a definition (toggle on)."""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerError(f"Marker definition '{name}' not found")
|
||||
self._marker_definitions[name]["paused"] = False
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(marker_name, enabled=True, inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to resume marker {marker_name} on link {link.id}: {e}",
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
@property
|
||||
def marker_definitions(self):
|
||||
"""
|
||||
:returns: dict of project-level marker definitions (name → {bpf, tag, color, highlight_duration})
|
||||
"""
|
||||
return self._marker_definitions
|
||||
|
||||
def _validate_marker_definition_bpf(self, name, bpf):
|
||||
"""
|
||||
Validate a marker definition's BPF once, here, so the fan-out to every
|
||||
link (``_apply_def_to_all_links`` → ``inherit_marker`` → ``start_marker``)
|
||||
and the per-link sync (``update_marker_definition`` → ``update_marker``)
|
||||
can skip re-validation for the inherited copies — otherwise one
|
||||
``tcpdump -d`` subprocess runs per link for the same expression. A
|
||||
private per-link marker still validates in ``start_marker``/``update_marker``.
|
||||
"""
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}"
|
||||
)
|
||||
|
||||
def _validate_marker_definition_direction(self, name, direction):
|
||||
"""
|
||||
Reject tx/rx on a marker definition: a definition fans out to every link
|
||||
and auto-selects its capture node on each (``_choose_marker_side``),
|
||||
while tx/rx is relative to that node, so a fixed direction has no
|
||||
consistent meaning across links. Only 'both' (the default, = ``None``)
|
||||
is allowed — encode the direction in the BPF instead (e.g.
|
||||
``icmp[icmptype]==8`` for echo requests), or use a per-link marker whose
|
||||
capture node is pinned.
|
||||
"""
|
||||
if direction in ("tx", "rx"):
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}': direction '{direction}' is not allowed. "
|
||||
"A definition fans out to every link and auto-selects its capture node on each, "
|
||||
"but tx/rx is relative to that node, so a fixed direction has no consistent "
|
||||
"meaning across links. Keep 'both' (the default) and encode the direction in "
|
||||
"the BPF instead, e.g. 'icmp and icmp[icmptype]==8' for echo requests only. "
|
||||
"For a capture-node-relative direction on a single link, use a per-link marker."
|
||||
)
|
||||
|
||||
async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
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._validate_marker_definition_bpf(name, bpf)
|
||||
self._validate_marker_definition_direction(name, direction)
|
||||
self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False}
|
||||
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, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET):
|
||||
"""
|
||||
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:
|
||||
self._validate_marker_definition_bpf(name, bpf)
|
||||
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
|
||||
if direction is not _UNSET:
|
||||
self._validate_marker_definition_direction(name, direction)
|
||||
d["direction"] = direction # None = clear back to both directions
|
||||
if data_link_type is not _UNSET:
|
||||
d["data_link_type"] = data_link_type
|
||||
|
||||
# Links that currently carry an inherited copy of this definition.
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
|
||||
if data_link_type is not _UNSET:
|
||||
# data_link_type decides which links host an inherited copy (serial
|
||||
# links are skipped unless a WAN encapsulation is chosen), so a change
|
||||
# needs a full re-fan-out: drop every copy, then re-apply.
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.stop_marker(f"global-{name}", inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}",
|
||||
)
|
||||
await self._apply_def_to_all_links(name)
|
||||
else:
|
||||
# Sync: update every inherited copy across all links.
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(
|
||||
f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"),
|
||||
color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True,
|
||||
dump=False
|
||||
),
|
||||
lambda link, e: f"Failed to sync marker global-{name} on link {link.id}: {e}",
|
||||
)
|
||||
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]
|
||||
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.stop_marker(f"global-{name}", inherited=True),
|
||||
# A missing compute or broken link shouldn't block the delete.
|
||||
lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}",
|
||||
)
|
||||
|
||||
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]
|
||||
# dump=False: per-link topology writes are the dominant cost on large
|
||||
# projects — the callers (create/update_marker_definition) dump once
|
||||
# after the fan-out.
|
||||
await self._marker_apply_concurrently(
|
||||
list(self._links.values()),
|
||||
lambda link: link.inherit_marker(def_name, d, dump=False),
|
||||
lambda link, e: f"Marker definition '{def_name}' could not be applied to link {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.
|
||||
|
||||
Deliberately serial: all definitions share the same link, and each
|
||||
``inherit_marker`` pushes the link's full marker set — concurrent
|
||||
pushes would race (a later push overwriting an earlier one's spec and
|
||||
losing markers).
|
||||
"""
|
||||
|
||||
for def_name, d in self._marker_definitions.items():
|
||||
try:
|
||||
# dump=False: the caller (link create / project open) dumps once
|
||||
# after; per-def dumps here would be N full topology writes.
|
||||
await link.inherit_marker(def_name, d, dump=False)
|
||||
except ControllerError as e:
|
||||
log.warning(
|
||||
"Marker definition '%s' could not be applied to new link %s: %s",
|
||||
def_name, link.id, e
|
||||
)
|
||||
|
||||
async def _marker_apply_concurrently(self, links, operation, fail_msg):
|
||||
"""
|
||||
Run an async per-link marker operation across *links* with bounded
|
||||
concurrency. A serial loop takes N sequential compute round-trips — a
|
||||
definition over 1000 links would take minutes on remote computes — so
|
||||
fan out in parallel batches. Links are independent (own ``_markers`` /
|
||||
``_link_data``), so this is race-free; per-link ``ControllerError`` is
|
||||
logged and skipped, preserving the serial loop's isolation semantics.
|
||||
``Project.dump`` is synchronous and writes atomically (tmp + rename),
|
||||
so concurrent dumps from the fan-out cannot corrupt the topology file.
|
||||
|
||||
:param links: iterable of links to operate on
|
||||
:param operation: async callable ``(link) -> coroutine``
|
||||
:param fail_msg: callable ``(link, error) -> log message``
|
||||
"""
|
||||
|
||||
sem = asyncio.Semaphore(32)
|
||||
|
||||
async def guarded(link):
|
||||
async with sem:
|
||||
try:
|
||||
await operation(link)
|
||||
except ControllerError as e:
|
||||
log.warning(fail_msg(link, e))
|
||||
|
||||
await asyncio.gather(*(guarded(link) for link in links))
|
||||
|
||||
@property
|
||||
def snapshots(self):
|
||||
"""
|
||||
@ -1125,7 +1434,7 @@ class Project:
|
||||
|
||||
if self._status != "opened":
|
||||
try:
|
||||
await self.open()
|
||||
await self.open(auto_start=False)
|
||||
except ControllerError as e:
|
||||
# ignore missing images or other conflicts when deleting a project
|
||||
log.warning(f"Conflict while deleting project: {e}")
|
||||
@ -1215,9 +1524,12 @@ class Project:
|
||||
return os.path.join(self.path, self._filename)
|
||||
|
||||
@locking
|
||||
async def open(self):
|
||||
async def open(self, auto_start=True):
|
||||
"""
|
||||
Load topology elements
|
||||
|
||||
:param auto_start: whether the nodes may be started when the project
|
||||
has auto start enabled
|
||||
"""
|
||||
|
||||
if self._closing is True:
|
||||
@ -1262,6 +1574,29 @@ 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). Each BPF
|
||||
# is validated once here so the inherited fan-out (start_marker) can
|
||||
# skip re-validation; an invalid definition is dropped with a warning
|
||||
# rather than failing the open — it could not fan out anyway.
|
||||
defs = project_data.get("marker_definitions")
|
||||
if isinstance(defs, dict):
|
||||
clean_defs = {}
|
||||
for def_name, d in defs.items():
|
||||
bpf = d.get("bpf")
|
||||
if not bpf:
|
||||
log.warning("Dropping marker definition '%s' on load: missing bpf", def_name)
|
||||
continue
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
log.warning(
|
||||
"Dropping marker definition '%s' on load: invalid BPF (%s)",
|
||||
def_name, result.get("error")
|
||||
)
|
||||
continue
|
||||
clean_defs[def_name] = d
|
||||
self._marker_definitions = clean_defs
|
||||
|
||||
topology = project_data["topology"]
|
||||
for compute in topology.get("computes", []):
|
||||
compute_id = compute.get("compute_id")
|
||||
@ -1328,6 +1663,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:
|
||||
@ -1356,7 +1695,7 @@ class Project:
|
||||
self._loading = False
|
||||
self.emit_controller_notification("project.opened", self.asdict())
|
||||
# Should we start the nodes when project is open
|
||||
if self._auto_start:
|
||||
if self._auto_start and auto_start:
|
||||
# Start all in the background without waiting for completion
|
||||
# we ignore errors because we want to let the user open
|
||||
# their project and fix it
|
||||
@ -1684,6 +2023,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 +2048,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,
|
||||
|
||||
@ -17,8 +17,17 @@
|
||||
|
||||
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import Link
|
||||
from .link import Link, _UNSET
|
||||
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,32 @@ 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, direction, data_link_type,
|
||||
enabled}) for the markers whose capture side is ``node``. Routed by
|
||||
capture_node_id so a marker only rides the NIO of the node whose uBridge
|
||||
will host it. A disabled marker is included (installed then turned
|
||||
``off`` at uBridge, not dropped) so the UI can toggle it instantly
|
||||
without an NIO rebuild.
|
||||
"""
|
||||
return {
|
||||
name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id,
|
||||
"direction": m.get("direction"),
|
||||
"data_link_type": m.get("data_link_type", "DLT_EN10MB"),
|
||||
"enabled": m.get("enabled", True)}
|
||||
for name, m in self._markers.items()
|
||||
if 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 +115,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 +125,7 @@ class UDPLink(Link):
|
||||
"rport": self._node2_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@ -101,6 +138,7 @@ class UDPLink(Link):
|
||||
"rport": self._node1_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node2_filters,
|
||||
"markers": node2_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@ -113,6 +151,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 +166,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 +181,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 +289,256 @@ 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"
|
||||
)
|
||||
|
||||
def _node_by_id(self, node_id):
|
||||
"""
|
||||
Resolve a caller-chosen capture node by id, validating it is an
|
||||
endpoint of this link and marker-capable. Used when the caller
|
||||
(REST/MCP) explicitly pins the observer side instead of letting
|
||||
``_choose_marker_side`` auto-pick.
|
||||
|
||||
:param node_id: node id (UUID or str) the caller requested
|
||||
:returns: a ``self._nodes`` entry (node/adapter_number/port_number)
|
||||
"""
|
||||
|
||||
target = str(node_id)
|
||||
for node in self._nodes:
|
||||
if str(node["node"].id) != target:
|
||||
continue
|
||||
if node["node"].node_type not in _MARKER_CAPABLE_TYPES:
|
||||
raise ControllerError(
|
||||
f"Node {node_id} ({node['node'].node_type}) cannot host a "
|
||||
f"marker — no uBridge bridge to attach the filter to"
|
||||
)
|
||||
return node
|
||||
raise ControllerNotFoundError(
|
||||
f"Node {node_id} is not an endpoint of link {self._id}"
|
||||
)
|
||||
|
||||
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, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None, dump=True):
|
||||
"""
|
||||
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 capture_node_id: optional explicit observer node. When set the
|
||||
marker is pinned to that endpoint's uBridge (and ``direction`` is
|
||||
interpreted from its perspective); validated by ``_node_by_id``.
|
||||
Omitted = auto-pick via ``_choose_marker_side``. Ignored for
|
||||
inherited markers (project defs are link-agnostic → always auto).
|
||||
: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}")
|
||||
|
||||
# Validate the BPF only for private per-link markers. An inherited copy
|
||||
# (``inherited_from`` set) fans out from a definition whose BPF was
|
||||
# already validated once at create/update (and on project load), so
|
||||
# re-validating per link would spawn one ``tcpdump -d`` per link for the
|
||||
# same expression.
|
||||
if not inherited_from:
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
|
||||
|
||||
if capture_node_id and not inherited_from:
|
||||
marker_side = self._node_by_id(capture_node_id)
|
||||
else:
|
||||
marker_side = self._choose_marker_side()
|
||||
marker_entry = {
|
||||
"bpf": bpf,
|
||||
"tag": tag,
|
||||
"enabled": enabled,
|
||||
"color": color,
|
||||
"highlight_duration": highlight_duration,
|
||||
"capture_node_id": marker_side["node"].id,
|
||||
"direction": direction,
|
||||
"data_link_type": data_link_type,
|
||||
}
|
||||
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())
|
||||
# Bulk fan-out passes dump=False: N per-link topology writes on a
|
||||
# 500-link project are the dominant cost — the caller dumps once after.
|
||||
if dump:
|
||||
self._project.dump()
|
||||
|
||||
async def stop_marker(self, name, inherited=False, dump=True):
|
||||
"""
|
||||
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."
|
||||
)
|
||||
|
||||
capture_node_id = self._markers[name].get("capture_node_id")
|
||||
del self._markers[name]
|
||||
# Remove the marker filter + its pcap on the capture node directly — NOT a
|
||||
# full NIO reapply (which would reset_packet_filters and close/reopen every
|
||||
# sibling marker's pcap). delete_packet_filter removes just this filter;
|
||||
# the marker is already gone from _markers, so any later reapply (filter
|
||||
# change, node restart) won't re-add it either.
|
||||
if capture_node_id is not None:
|
||||
side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None)
|
||||
if side is not None:
|
||||
try:
|
||||
await side["node"].delete(
|
||||
f"/adapters/{side['adapter_number']}/ports/{side['port_number']}/markers/{name}",
|
||||
params={"link_id": self._id},
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort: old compute without the route leaves the file
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
if dump:
|
||||
self._project.dump()
|
||||
|
||||
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False, dump=True):
|
||||
"""
|
||||
Update an existing marker's fields and push to uBridge fine-grained — no
|
||||
full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction
|
||||
rebuild just this filter (delete + add); enabled is an instant toggle;
|
||||
color/highlight_duration are UI-only (stored, never pushed).
|
||||
|
||||
: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."
|
||||
)
|
||||
|
||||
# Merge every changed field into the marker state first.
|
||||
if bpf is not None and bpf != marker_info["bpf"]:
|
||||
# An inherited marker is synced from a definition whose BPF was
|
||||
# already validated at create/update (or load); re-validating per
|
||||
# link is redundant. Private markers validate here as before.
|
||||
if not inherited:
|
||||
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 direction is not _UNSET:
|
||||
marker_info["direction"] = direction # None = clear back to both directions
|
||||
|
||||
# Push to uBridge fine-grained — NO full NIO reapply (which would
|
||||
# reset_packet_filters and close/reopen every sibling marker's pcap):
|
||||
# * bpf/tag/direction changed → rebuild just this filter (delete + add),
|
||||
# reopening only this marker's pcap (expected, new BPF)
|
||||
# * only enabled changed → instant toggle (enable_packet_filter)
|
||||
# * only UI fields changed → nothing to push to uBridge
|
||||
if self._created:
|
||||
ubridge_rebuild = (bpf is not None) or (tag is not None) or (direction is not _UNSET)
|
||||
capture_node_id = marker_info.get("capture_node_id")
|
||||
side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None)
|
||||
if side is not None:
|
||||
try:
|
||||
if ubridge_rebuild:
|
||||
await side["node"].put(
|
||||
f"/markers/{name}/rebuild",
|
||||
data={
|
||||
"bpf": marker_info["bpf"],
|
||||
"tag": marker_info.get("tag"),
|
||||
"direction": marker_info.get("direction"),
|
||||
"enabled": marker_info.get("enabled", True),
|
||||
"link_id": self._id,
|
||||
},
|
||||
)
|
||||
elif enabled is not None:
|
||||
await side["node"].put(f"/markers/{name}", data={"enabled": enabled})
|
||||
except Exception:
|
||||
# Old compute without the route / node down: state is already
|
||||
# correct in _markers; the next NIO reapply converges uBridge.
|
||||
pass
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
if dump:
|
||||
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:
|
||||
|
||||
@ -58,7 +58,7 @@ class CrashReport:
|
||||
Report crash to a third party service
|
||||
"""
|
||||
|
||||
DSN = "https://5a97c548f3a1cdc23c9b6b82f7c64744@o19455.ingest.us.sentry.io/38482"
|
||||
DSN = "https://7b5d8f1189b61e674dc6ee41b0ec9da8@o19455.ingest.us.sentry.io/38482"
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@ -52,6 +52,9 @@ class TemplatesRepository(BaseRepository):
|
||||
|
||||
super().__init__(db_session)
|
||||
|
||||
def configs_path(self) -> str:
|
||||
return os.path.join(os.getcwd(), "configs")
|
||||
|
||||
async def get_template(self, template_id: UUID) -> Union[None, models.Template]:
|
||||
|
||||
query = select(models.Template).\
|
||||
|
||||
@ -31,6 +31,8 @@ import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
import resource
|
||||
|
||||
|
||||
def daemonize():
|
||||
@ -97,6 +99,34 @@ def parse_arguments(argv):
|
||||
return parser, args
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _raise_open_files_limit(target=65535):
|
||||
"""
|
||||
Raise RLIMIT_NOFILE at startup so large topologies don't hit EMFILE.
|
||||
Every started node holds ~3 file descriptors in the server's table
|
||||
(pidfd + stdout/stderr pipes per child process), so a few hundred nodes
|
||||
exhaust the default 1024 limit. Best-effort: the hard limit caps what we
|
||||
can request; failures are logged but never fatal. Runs before daemonize()
|
||||
so the daemon inherits the raised limit.
|
||||
"""
|
||||
try:
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
if soft >= target:
|
||||
return
|
||||
new_soft = min(target, hard)
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
|
||||
if new_soft < target:
|
||||
log.warning(
|
||||
f"Open-files limit raised to {new_soft} (hard limit), below the requested {target}"
|
||||
)
|
||||
else:
|
||||
log.info(f"Open-files limit raised from {soft} to {new_soft}")
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning(f"Could not raise the open-files limit: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Entry point for GNS3 server
|
||||
@ -104,6 +134,7 @@ def main():
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
raise SystemExit("Windows is not a supported platform to run the GNS3 server")
|
||||
_raise_open_files_limit()
|
||||
if "--daemon" in sys.argv:
|
||||
daemonize()
|
||||
|
||||
|
||||
@ -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, MarkerUpdate, 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
|
||||
@ -91,7 +91,7 @@ from .controller.templates.dynamips_templates import (
|
||||
)
|
||||
|
||||
# Compute schemas
|
||||
from .compute.nios import UDPNIO, TAPNIO, EthernetNIO
|
||||
from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle, MarkerRebuild
|
||||
from .compute.atm_switch_nodes import ATMSwitchCreate, ATMSwitchUpdate, ATMSwitch
|
||||
from .compute.cloud_nodes import CloudCreate, CloudUpdate, Cloud
|
||||
from .compute.docker_nodes import DockerCreate, DockerUpdate, Docker
|
||||
|
||||
@ -28,6 +28,28 @@ class HostInterfaceType(str, Enum):
|
||||
tap = "tap"
|
||||
|
||||
|
||||
class IPAddressFamily(str, Enum):
|
||||
|
||||
ipv4 = "ipv4"
|
||||
ipv6 = "ipv6"
|
||||
|
||||
|
||||
class InterfaceStatus(str, Enum):
|
||||
|
||||
up = "up"
|
||||
down = "down"
|
||||
|
||||
|
||||
class HostInterfaceIPAddress(BaseModel):
|
||||
"""
|
||||
An IP address (with optional netmask) bound to a host interface.
|
||||
"""
|
||||
|
||||
family: IPAddressFamily = Field(..., description="Address family (ipv4 or ipv6)")
|
||||
address: str = Field(..., description="IP address")
|
||||
netmask: Optional[str] = Field(None, description="Network mask, if available")
|
||||
|
||||
|
||||
class HostInterface(BaseModel):
|
||||
"""
|
||||
Interface on this host.
|
||||
@ -36,6 +58,13 @@ class HostInterface(BaseModel):
|
||||
name: str = Field(..., description="Interface name")
|
||||
type: HostInterfaceType = Field(..., description="Interface type")
|
||||
special: bool = Field(..., description="Whether the interface is non standard")
|
||||
ip_addresses: List[HostInterfaceIPAddress] = Field(
|
||||
default_factory=list, description="All IPv4 and IPv6 addresses on this interface"
|
||||
)
|
||||
status: InterfaceStatus = Field(InterfaceStatus.down, description="Interface status (up or down)")
|
||||
speed: int = Field(0, description="Interface speed in Mbit/s (0 if unknown)")
|
||||
mtu: int = Field(0, description="Interface MTU")
|
||||
flags: List[str] = Field(default_factory=list, description="Interface flags")
|
||||
|
||||
|
||||
class EthernetType(str, Enum):
|
||||
|
||||
@ -38,7 +38,10 @@ class IOUBase(BaseModel):
|
||||
ethernet_adapters: Optional[int] = Field(None, description="How many Ethernet adapters are connected to IOU")
|
||||
ram: Optional[int] = Field(None, gt=0, description="Amount of RAM in MB")
|
||||
nvram: Optional[int] = Field(None, gt=0, description="Amount of NVRAM in KB")
|
||||
l1_keepalives: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
None,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
use_default_iou_values: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
startup_config_content: Optional[str] = Field(None, description="Content of IOU startup configuration file")
|
||||
private_config_content: Optional[str] = Field(None, description="Content of IOU private configuration file")
|
||||
|
||||
@ -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):
|
||||
@ -64,3 +65,29 @@ class TAPNIO(BaseModel):
|
||||
|
||||
type: TAPNIOType
|
||||
tap_device: str = Field(..., description="TAP device name e.g. tap0")
|
||||
|
||||
|
||||
class MarkerToggle(BaseModel):
|
||||
"""
|
||||
Body for the per-marker enable/disable toggle endpoint: flips a running
|
||||
uBridge marker filter with ``enable_packet_filter on|off`` (no NIO rebuild,
|
||||
so the pcap identity and emitted counter are preserved).
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MarkerRebuild(BaseModel):
|
||||
"""
|
||||
Body for the per-marker rebuild endpoint: re-install a single uBridge marker
|
||||
filter with new BPF/tag/direction via ``delete_packet_filter`` + add (NOT a
|
||||
bridge-wide reset), so sibling markers keep their pcaps open. The marker's
|
||||
own pcap is reopened by uBridge on re-add (new capture session for the new
|
||||
BPF), which is expected.
|
||||
"""
|
||||
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
direction: Optional[str] = None
|
||||
enabled: bool = True
|
||||
link_id: str = ""
|
||||
|
||||
@ -71,6 +71,7 @@ class QemuSettings(BaseModel):
|
||||
enable_hardware_acceleration: bool = True
|
||||
require_hardware_acceleration: bool = False
|
||||
allow_unsafe_options: bool = False
|
||||
ovmf_firmware_dir: str = "/usr/share/OVMF"
|
||||
model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
@ -112,6 +113,16 @@ class ServerProtocol(str, Enum):
|
||||
https = "https"
|
||||
|
||||
|
||||
class UbridgeControlTransport(str, Enum):
|
||||
|
||||
# TCP control channel: -H host:port. ubridge now binds loopback by default,
|
||||
# so this is reachable only locally. Retained for backward compatibility.
|
||||
tcp = "tcp"
|
||||
# AF_UNIX control channel: -U socket_path, authenticated in-kernel via
|
||||
# SO_PEERCRED (ubridge accepts only its own UID). Recommended on Linux.
|
||||
unix = "unix"
|
||||
|
||||
|
||||
class BuiltinSymbolTheme(str, Enum):
|
||||
|
||||
classic = "Classic"
|
||||
@ -153,6 +164,17 @@ 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"
|
||||
# Transport for the uBridge hypervisor control channel. "unix" (-U,
|
||||
# AF_UNIX + SO_PEERCRED) is the default — recommended on Linux for
|
||||
# kernel-level peer authentication. "tcp" (-H) is retained for backward
|
||||
# compatibility.
|
||||
ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.unix
|
||||
# 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)
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
@ -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,154 @@ 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=32,
|
||||
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.",
|
||||
)
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
|
||||
)
|
||||
capture_node_id: Optional[UUID] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Which endpoint's uBridge hosts this marker (the 'observer'). "
|
||||
"tx/rx in `direction` are interpreted from this node's perspective. "
|
||||
"Must be one of the link's two endpoints and a marker-capable type. "
|
||||
"Omitted = server auto-picks (first started marker-capable endpoint)."
|
||||
),
|
||||
)
|
||||
data_link_type: str = Field(
|
||||
"DLT_EN10MB",
|
||||
description=(
|
||||
"pcap link-layer type the marker's BPF compiles against and its "
|
||||
"capture file is written with (a uBridge `linktype` token). Defaults "
|
||||
"to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. "
|
||||
"Only meaningful for serial links: set it to the matching serial DLT "
|
||||
"from the port's data_link_types — DLT_C_HDLC / DLT_PPP_SERIAL / "
|
||||
"DLT_FRELAY / DLT_ATM_RFC1483 — so the BPF offsets and pcap decode "
|
||||
"match the encapsulation configured in IOS. Create-only (changing it "
|
||||
"would invalidate the pcap)."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
class MarkerUpdate(BaseModel):
|
||||
"""
|
||||
Body for updating a marker — partial update, every field optional.
|
||||
|
||||
``bpf`` is optional here (it is required on create). ``capture_node_id`` and
|
||||
``name`` are create-only / path-driven and intentionally absent; an explicit
|
||||
``direction: null`` clears the direction back to both (omitting keeps it).
|
||||
"""
|
||||
|
||||
bpf: Optional[str] = None
|
||||
tag: Optional[int] = None
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter; 'both' or an explicit null clears it to both. Omit to keep.",
|
||||
)
|
||||
color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'")
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None, ge=1, description="UI highlight duration in ms; null = UI default"
|
||||
)
|
||||
enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).")
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
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=32,
|
||||
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."
|
||||
),
|
||||
)
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
|
||||
)
|
||||
data_link_type: str = Field(
|
||||
"DLT_EN10MB",
|
||||
description=(
|
||||
"pcap link-layer type for inherited markers on serial links (uBridge "
|
||||
"`linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then "
|
||||
"applies only to Ethernet links and serial links are skipped. Set a "
|
||||
"serial DLT — DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / "
|
||||
"DLT_ATM_RFC1483 — to also cover serial links with that encapsulation; "
|
||||
"Ethernet links stay EN10MB regardless. Changing it re-fans-out."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
|
||||
@ -35,7 +35,10 @@ class IOUTemplate(TemplateBase):
|
||||
use_default_iou_values: Optional[bool] = Field(False, description="Use default IOU values")
|
||||
startup_config: Optional[str] = Field("iou_l3_base_startup-config.txt", description="Startup-config of IOU")
|
||||
private_config: Optional[str] = Field("", description="Private-config of IOU")
|
||||
l1_keepalives: Optional[bool] = Field(False, description="Always keep up Ethernet interface (does not always work)")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
False,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
console_type: Optional[ConsoleType] = Field(ConsoleType.telnet, description="Console type")
|
||||
console_auto_start: Optional[bool] = Field(
|
||||
False, description="Automatically start the console when the node has started"
|
||||
|
||||
@ -175,6 +175,9 @@ class TemplatesService:
|
||||
if builtin_template["template_id"] == template_id:
|
||||
return jsonable_encoder(builtin_template)
|
||||
|
||||
def _base_path(self):
|
||||
return self._templates_repo.configs_path()
|
||||
|
||||
async def get_templates(self) -> List[dict]:
|
||||
|
||||
templates = []
|
||||
@ -342,3 +345,45 @@ class TemplatesService:
|
||||
self._controller.notification.controller_emit("template.deleted", {"template_id": str(template_id)})
|
||||
else:
|
||||
raise ControllerNotFoundError(f"Template '{template_id}' not found")
|
||||
|
||||
def _template_path(self, template_id: str) -> str:
|
||||
return os.path.join(self._base_path(), str(template_id))
|
||||
|
||||
def list_files(self, template_id: str):
|
||||
path = self._template_path(template_id)
|
||||
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
|
||||
return [
|
||||
{"filename": f}
|
||||
for f in sorted(os.listdir(path))
|
||||
if os.path.isfile(os.path.join(path, f))
|
||||
]
|
||||
|
||||
def get_file(self, template_id: str, filename: str):
|
||||
safe_filename = os.path.basename(filename)
|
||||
path = os.path.join(self._template_path(template_id), safe_filename)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
raise ControllerNotFoundError(f"File '{safe_filename}' not found")
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
except OSError as e:
|
||||
raise ControllerError(str(e))
|
||||
|
||||
def update_file(self, template_id: str, filename: str, content: str):
|
||||
safe_filename = os.path.basename(filename)
|
||||
|
||||
dir_path = self._template_path(template_id)
|
||||
path = os.path.join(dir_path, safe_filename)
|
||||
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
raise ControllerError(str(e))
|
||||
File diff suppressed because one or more lines are too long
15
gns3server/static/web-ui/chunk-72DGZVTL.js
Normal file
15
gns3server/static/web-ui/chunk-72DGZVTL.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
import{$ as a}from"./chunk-EI7RU2ND.js";import"./chunk-6QUQX5EO.js";export{a as TopologySummaryComponent};
|
||||
File diff suppressed because one or more lines are too long
1
gns3server/static/web-ui/chunk-HJ5XTV4X.js
Normal file
1
gns3server/static/web-ui/chunk-HJ5XTV4X.js
Normal file
@ -0,0 +1 @@
|
||||
import{$ as a}from"./chunk-NJCA2RVJ.js";import"./chunk-72DGZVTL.js";export{a as TopologySummaryComponent};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -198,6 +198,7 @@ def interfaces():
|
||||
results = []
|
||||
allowed_interfaces = Config.instance().settings.Server.allowed_interfaces
|
||||
net_if_addrs = psutil.net_if_addrs()
|
||||
net_if_stats = psutil.net_if_stats()
|
||||
for interface in sorted(net_if_addrs.keys()):
|
||||
if allowed_interfaces and interface not in allowed_interfaces and not interface.startswith("gns3tap"):
|
||||
log.warning(f"Interface '{interface}' is not allowed to be used on this server")
|
||||
@ -206,16 +207,48 @@ def interfaces():
|
||||
mac_address = ""
|
||||
netmask = ""
|
||||
interface_type = "ethernet"
|
||||
# Collect every IPv4 and IPv6 address on this interface. An interface may
|
||||
# carry several addresses of each family (or none at all), so we keep a
|
||||
# list in addition to the legacy single IPv4 value retained for backward
|
||||
# compatibility with existing callers (compute link detection, GNS3 VM,
|
||||
# VMware, has_netmask(), ...).
|
||||
ip_addresses = []
|
||||
for addr in net_if_addrs[interface]:
|
||||
# get the first available IPv4 address only
|
||||
if addr.family == socket.AF_INET:
|
||||
# legacy single-value behavior (keeps the last IPv4 seen)
|
||||
ip_address = addr.address
|
||||
netmask = addr.netmask
|
||||
ip_addresses.append(
|
||||
{"family": "ipv4", "address": addr.address, "netmask": addr.netmask or None}
|
||||
)
|
||||
elif addr.family == socket.AF_INET6:
|
||||
ip_addresses.append(
|
||||
{"family": "ipv6", "address": addr.address, "netmask": addr.netmask or None}
|
||||
)
|
||||
if addr.family == psutil.AF_LINK:
|
||||
mac_address = addr.address
|
||||
if interface.startswith("tap"):
|
||||
# found no way to reliably detect a TAP interface
|
||||
interface_type = "tap"
|
||||
# Operational state and link attributes (speed/mtu/flags) come from
|
||||
# psutil.net_if_stats(). An interface present in net_if_addrs is normally
|
||||
# also present here; fall back to neutral defaults when it is not.
|
||||
status = "down"
|
||||
speed = 0
|
||||
mtu = 0
|
||||
flags = []
|
||||
stats = net_if_stats.get(interface)
|
||||
if stats is not None:
|
||||
status = "up" if stats.isup else "down"
|
||||
speed = stats.speed
|
||||
mtu = stats.mtu
|
||||
# psutil returns flags either as a comma-separated string (>= 6.0) or
|
||||
# as a list (older versions); normalize to a list for a stable shape.
|
||||
f = stats.flags
|
||||
if isinstance(f, str):
|
||||
flags = [flag for flag in f.split(",") if flag]
|
||||
else:
|
||||
flags = f
|
||||
results.append(
|
||||
{
|
||||
"id": interface,
|
||||
@ -224,6 +257,11 @@ def interfaces():
|
||||
"netmask": netmask,
|
||||
"mac_address": mac_address,
|
||||
"type": interface_type,
|
||||
"ip_addresses": ip_addresses,
|
||||
"status": status,
|
||||
"speed": speed,
|
||||
"mtu": mtu,
|
||||
"flags": flags,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
# or negative for a release candidate or beta (after the base version
|
||||
# number has been incremented)
|
||||
|
||||
__version__ = "3.1.0.dev4"
|
||||
__version__ = "3.1.0.dev5"
|
||||
__version_info__ = (3, 1, 0, 99)
|
||||
|
||||
if "dev" in __version__:
|
||||
|
||||
@ -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
|
||||
|
||||
85
scripts/tag.py
Executable file
85
scripts/tag.py
Executable file
@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Read gns3server/version.py, extract __version__, and create an annotated git tag."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show the tag that would be created without creating it",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run_git(args: list[str], repo_root: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def get_version(repo_root: Path) -> str:
|
||||
version_file = repo_root / "gns3server" / "version.py"
|
||||
content = version_file.read_text(encoding="utf-8")
|
||||
match = re.search(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", content, re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError("Unable to find __version__ in gns3server/version.py")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
inside_repo = run_git(["rev-parse", "--is-inside-work-tree"], repo_root)
|
||||
if inside_repo.returncode != 0:
|
||||
print(f"Error: {repo_root} is not a git repository.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
tag = 'v' + get_version(repo_root)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
existing_tag = run_git(["rev-parse", tag], repo_root)
|
||||
if existing_tag.returncode == 0:
|
||||
print(f"Error: tag '{tag}' already exists.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
print(f"Dry run: would create tag '{tag}' from gns3server/version.py")
|
||||
return 0
|
||||
|
||||
create_tag = run_git(["tag", "-a", tag, "-m", f"Release {tag}"], repo_root)
|
||||
if create_tag.returncode != 0:
|
||||
print(create_tag.stderr.strip() or "Error: failed to create tag", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Created tag '{tag}'")
|
||||
|
||||
push_tag = run_git(["push", "origin", tag], repo_root)
|
||||
if push_tag.returncode != 0:
|
||||
print(push_tag.stderr.strip() or "Error: failed to push tag", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Tag '{tag}' has been pushed to the remote repository.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@ -82,6 +82,7 @@ fi
|
||||
echo "Removing: $GNS3SERVER_DIR/gns3server/static/web-ui/*"
|
||||
|
||||
rm -rf $GNS3SERVER_DIR/gns3server/static/web-ui/*
|
||||
git rm -rf gns3server/static/web-ui/*
|
||||
|
||||
echo "Re-create: $GNS3SERVER_DIR/gns3server/static/web-ui"
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
49
tests/api/routes/compute/test_images.py
Normal file
49
tests/api/routes/compute/test_images.py
Normal file
@ -0,0 +1,49 @@
|
||||
#
|
||||
# Copyright (C) 2026 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 pytest
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
from tests.utils import asyncio_patch
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestImagesRoutes:
|
||||
|
||||
async def test_pull_docker_image(self, app: FastAPI, compute_client: AsyncClient) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:pull_docker_image"),
|
||||
json={"image": "nginx:latest"}
|
||||
)
|
||||
mock.assert_called_once_with("nginx:latest", force=True)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
@pytest.mark.parametrize("image", ["", " ", "nginx latest"])
|
||||
async def test_pull_docker_image_rejects_invalid_name(
|
||||
self, app: FastAPI, compute_client: AsyncClient, image: str
|
||||
) -> None:
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.pull_image") as mock:
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:pull_docker_image"),
|
||||
json={"image": image}
|
||||
)
|
||||
mock.assert_not_called()
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
@ -25,8 +26,7 @@ from gns3server.schemas.controller.computes import Compute
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
import unittest
|
||||
from tests.utils import asyncio_patch
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
|
||||
|
||||
class TestComputeRoutes:
|
||||
@ -128,6 +128,21 @@ class TestComputeFeatures:
|
||||
mock.assert_called_with("GET", "docker", "images")
|
||||
assert response.json() == [{"image": "docker1"}, {"image": "docker2"}]
|
||||
|
||||
async def test_compute_pull_docker_image(
|
||||
self, app: FastAPI, client: AsyncClient, test_compute: Compute
|
||||
) -> None:
|
||||
|
||||
compute = MagicMock()
|
||||
compute.forward = AsyncioMagicMock(return_value={})
|
||||
with patch("gns3server.api.routes.controller.computes.Controller.instance") as controller:
|
||||
controller.return_value.get_compute.return_value = compute
|
||||
response = await client.post(
|
||||
app.url_path_for("docker_pull_image", compute_id=test_compute.compute_id),
|
||||
json={"image": "nginx:latest"}
|
||||
)
|
||||
compute.forward.assert_called_with("POST", "docker", "images/pull", data={"image": "nginx:latest"})
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
async def test_compute_list_virtualbox_vms(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
params = {
|
||||
|
||||
267
tests/api/routes/controller/test_markers.py
Normal file
267
tests/api/routes/controller/test_markers.py
Normal file
@ -0,0 +1,267 @@
|
||||
# -*- 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_create_marker_name_too_long_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": "x" * 33, "bpf": "icmp"}, # max_length is 32
|
||||
)
|
||||
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"
|
||||
@ -33,6 +33,8 @@ from gns3server.db.repositories.templates import TemplatesRepository
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller import Config
|
||||
from gns3server.services.templates import BUILTIN_TEMPLATES
|
||||
from gns3server.api.routes.controller.dependencies.authentication import get_current_active_user
|
||||
from gns3server import schemas
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@ -239,6 +241,151 @@ class TestTemplateRoutes:
|
||||
# mock.assert_called_with(id, x=42, y=12, compute_id=None)
|
||||
# assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
async def test_get_base_config(self, app: FastAPI, client: AsyncClient):
|
||||
|
||||
async def mock_get_current_active_user():
|
||||
return schemas.User(
|
||||
username="admin",
|
||||
user_id=uuid.uuid4(),
|
||||
is_superadmin=True,
|
||||
is_active=True
|
||||
)
|
||||
app.dependency_overrides[get_current_active_user] = mock_get_current_active_user
|
||||
try:
|
||||
create_resp = await client.post(app.url_path_for("create_template"), json={
|
||||
"name": "TEST",
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs"
|
||||
})
|
||||
|
||||
assert create_resp.status_code == 201
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
await client.put(
|
||||
app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"),
|
||||
json={"content": "hello"}
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
app.url_path_for(
|
||||
"get_base_config",
|
||||
template_id=template_id,
|
||||
filename="test.txt"
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == "hello"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_active_user, None)
|
||||
|
||||
async def test_update_base_config(self, app: FastAPI, client: AsyncClient):
|
||||
|
||||
async def mock_get_current_active_user():
|
||||
return schemas.User(
|
||||
username="admin",
|
||||
user_id=uuid.uuid4(),
|
||||
is_superadmin=True,
|
||||
is_active=True
|
||||
)
|
||||
app.dependency_overrides[get_current_active_user] = mock_get_current_active_user
|
||||
try:
|
||||
template_name = f"TEST_UPDATE_{uuid.uuid4().hex[:8]}"
|
||||
create_resp = await client.post(app.url_path_for("create_template"), json={
|
||||
"name": template_name,
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs"
|
||||
})
|
||||
assert create_resp.status_code == 201
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
payload = {"content": "hello world"}
|
||||
response = await client.put(
|
||||
app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"),
|
||||
json=payload
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["content"] == "hello world"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_active_user, None)
|
||||
|
||||
async def test_update_base_config_missing_content(self, app: FastAPI, client: AsyncClient):
|
||||
|
||||
async def mock_get_current_active_user():
|
||||
return schemas.User(
|
||||
username="admin",
|
||||
user_id=uuid.uuid4(),
|
||||
is_superadmin=True,
|
||||
is_active=True
|
||||
)
|
||||
app.dependency_overrides[get_current_active_user] = mock_get_current_active_user
|
||||
try:
|
||||
template_name = f"TEST_MISSING_{uuid.uuid4().hex[:8]}"
|
||||
create_resp = await client.post(app.url_path_for("create_template"), json={
|
||||
"name": template_name,
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs"
|
||||
})
|
||||
assert create_resp.status_code == 201
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
response = await client.put(
|
||||
app.url_path_for("update_base_config", template_id=template_id, filename="test.txt"),
|
||||
json={}
|
||||
)
|
||||
|
||||
assert response.status_code in (400, 422)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_active_user, None)
|
||||
|
||||
async def test_base_config_template_not_found(self, app: FastAPI, client: AsyncClient):
|
||||
response = await client.get(
|
||||
app.url_path_for("get_base_config", template_id=str(uuid.uuid4()), filename="x.txt")
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_list_base_configs(self, app: FastAPI, client: AsyncClient):
|
||||
|
||||
async def mock_get_current_active_user():
|
||||
return schemas.User(
|
||||
username="admin",
|
||||
user_id=uuid.uuid4(),
|
||||
is_superadmin=True,
|
||||
is_active=True
|
||||
)
|
||||
app.dependency_overrides[get_current_active_user] = mock_get_current_active_user
|
||||
try:
|
||||
template_name = f"TEST_LIST_{uuid.uuid4().hex[:8]}"
|
||||
create_resp = await client.post(app.url_path_for("create_template"), json={
|
||||
"name": template_name,
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs"
|
||||
})
|
||||
assert create_resp.status_code == 201
|
||||
template_id = create_resp.json()["template_id"]
|
||||
|
||||
await client.put(
|
||||
app.url_path_for("update_base_config", template_id=template_id, filename="config1.txt"),
|
||||
json={"content": "file1"}
|
||||
)
|
||||
await client.put(
|
||||
app.url_path_for("update_base_config", template_id=template_id, filename="config2.txt"),
|
||||
json={"content": "file2"}
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
app.url_path_for("list_base_configs", template_id=template_id)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
filenames = [item["filename"] for item in response.json()]
|
||||
assert "config1.txt" in filenames
|
||||
assert "config2.txt" in filenames
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_active_user, None)
|
||||
|
||||
|
||||
class TestDuplicateTemplates:
|
||||
|
||||
|
||||
@ -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:
|
||||
@ -366,3 +359,157 @@ class TestTemplate:
|
||||
m.return_value = _mock_conn({})
|
||||
result = delete_template_handler({"template_id": "t1"}, ctx)
|
||||
assert "deleted" in str(result).lower()
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLinkMarker:
|
||||
"""link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null)."""
|
||||
|
||||
mod = "links"
|
||||
|
||||
def test_update_direction_both_clears(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "update",
|
||||
"marker_name": "icmp", "direction": "both"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
|
||||
json_data={"direction": None},
|
||||
)
|
||||
|
||||
def test_update_direction_tx_sets(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "update",
|
||||
"marker_name": "icmp", "direction": "tx"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
|
||||
json_data={"direction": "tx"},
|
||||
)
|
||||
|
||||
def test_update_direction_omitted_preserved(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "update",
|
||||
"marker_name": "icmp", "tag": 1}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
|
||||
json_data={"tag": 1},
|
||||
)
|
||||
|
||||
def test_create_direction_both_omitted(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "create",
|
||||
"bpf": "icmp", "direction": "both"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
|
||||
json_data={"bpf": "icmp"},
|
||||
)
|
||||
|
||||
def test_create_direction_tx(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "create",
|
||||
"bpf": "icmp", "direction": "tx"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
|
||||
json_data={"bpf": "icmp", "direction": "tx"},
|
||||
)
|
||||
|
||||
|
||||
class TestMarkerDefinition:
|
||||
"""marker_definition_handler build create/update bodies.
|
||||
|
||||
A definition has NO direction: it fans out to every link and auto-selects its
|
||||
capture node on each, so tx/rx (relative to that node) has no consistent
|
||||
meaning — any direction passed is ignored, never reaching the request body.
|
||||
"""
|
||||
|
||||
mod = "links"
|
||||
|
||||
def test_create_builds_body(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
{"project_id": "p", "action": "create",
|
||||
"bpf": "arp", "tag": 1, "color": "#fff"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions",
|
||||
json_data={"bpf": "arp", "tag": 1, "color": "#fff"},
|
||||
)
|
||||
|
||||
def test_create_ignores_direction(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
{"project_id": "p", "action": "create",
|
||||
"bpf": "arp", "direction": "tx"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions",
|
||||
json_data={"bpf": "arp"},
|
||||
)
|
||||
|
||||
def test_update_builds_body(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
{"project_id": "p", "action": "update",
|
||||
"def_name": "arp", "tag": 1}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp",
|
||||
json_data={"tag": 1},
|
||||
)
|
||||
|
||||
def test_update_ignores_direction(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
{"project_id": "p", "action": "update",
|
||||
"def_name": "arp", "tag": 1, "direction": "rx"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp",
|
||||
json_data={"tag": 1},
|
||||
)
|
||||
|
||||
def test_update_requires_a_field(self, ctx):
|
||||
from gns3server.api.routes.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector"):
|
||||
result = marker_definition_handler(
|
||||
{"project_id": "p", "action": "update", "def_name": "arp"}, ctx,
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -72,9 +72,9 @@ async def test_json_with_ports(on_gns3vm, compute_project, manager):
|
||||
}
|
||||
],
|
||||
"interfaces": [
|
||||
{'name': 'eth0', 'special': False, 'type': 'ethernet'},
|
||||
{'name': 'eth1', 'special': False, 'type': 'ethernet'},
|
||||
{'name': 'virbr0', 'special': True, 'type': 'ethernet'}
|
||||
{'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']},
|
||||
{'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']},
|
||||
{'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}
|
||||
]
|
||||
}
|
||||
|
||||
@ -111,9 +111,9 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager):
|
||||
}
|
||||
],
|
||||
"interfaces": [
|
||||
{'name': 'eth0', 'special': False, 'type': 'ethernet'},
|
||||
{'name': 'eth1', 'special': False, 'type': 'ethernet'},
|
||||
{'name': 'virbr0', 'special': True, 'type': 'ethernet'}
|
||||
{'name': 'eth0', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 1000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']},
|
||||
{'name': 'eth1', 'special': False, 'type': 'ethernet', 'ip_addresses': [], 'status': 'down', 'speed': 0, 'mtu': 1500, 'flags': ['broadcast']},
|
||||
{'name': 'virbr0', 'special': True, 'type': 'ethernet', 'ip_addresses': [], 'status': 'up', 'speed': 10000, 'mtu': 1500, 'flags': ['up', 'broadcast', 'running', 'multicast']}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -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": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -169,6 +169,92 @@ async def test_pull_image():
|
||||
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_skips_image_available_locally():
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "existing"}) as query_mock:
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.http_query") as pull_mock:
|
||||
await Docker.instance().pull_image("ubuntu")
|
||||
query_mock.assert_called_once_with("GET", "images/ubuntu/json")
|
||||
pull_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_pull_image():
|
||||
|
||||
response = MagicMock()
|
||||
response.content.read = AsyncioMagicMock(return_value=b"")
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query") as query_mock:
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
|
||||
await Docker.instance().pull_image("ubuntu", force=True)
|
||||
query_mock.assert_not_called()
|
||||
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_error():
|
||||
|
||||
class Content:
|
||||
|
||||
def __init__(self):
|
||||
self._chunks = [b'{"error": "image not found"}', b""]
|
||||
|
||||
async def read(self, size):
|
||||
return self._chunks.pop(0)
|
||||
|
||||
response = MagicMock()
|
||||
response.content = Content()
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
|
||||
with pytest.raises(DockerError, match="image not found"):
|
||||
await Docker.instance().pull_image("missing")
|
||||
response.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_rejects_incomplete_response():
|
||||
|
||||
class Content:
|
||||
|
||||
def __init__(self):
|
||||
self._read = False
|
||||
|
||||
async def read(self, size):
|
||||
if self._read:
|
||||
return b""
|
||||
self._read = True
|
||||
return b'{"status": "Pulling"'
|
||||
|
||||
response = MagicMock()
|
||||
response.content = Content()
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
|
||||
with pytest.raises(DockerError, match="Invalid response"):
|
||||
await Docker.instance().pull_image("ubuntu")
|
||||
response.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_image_propagates_timeout():
|
||||
|
||||
class Content:
|
||||
|
||||
async def read(self, size):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
response = MagicMock()
|
||||
response.content = Content()
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
|
||||
with pytest.raises(DockerError, match="Timeout while pulling"):
|
||||
await Docker.instance().pull_image("ubuntu")
|
||||
response.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_check_connection_docker_minimum_version(vm):
|
||||
|
||||
|
||||
@ -1882,3 +1882,19 @@ async def test_memory(compute_project, manager):
|
||||
"Cmd": ["/bin/sh"]
|
||||
})
|
||||
assert vm._cid == "e90e34656806"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_exited_container_no_stop_query(vm):
|
||||
|
||||
vm._ubridge_hypervisor = None
|
||||
vm._fix_permissions = MagicMock()
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="exited"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query:
|
||||
vm._permissions_fixed = False
|
||||
await vm.stop()
|
||||
assert not any(
|
||||
call.args[:2] == ("POST", "containers/e90e34656842/stop")
|
||||
for call in mock_query.mock_calls
|
||||
)
|
||||
assert vm.status == "stopped"
|
||||
|
||||
@ -21,13 +21,14 @@ import asyncio
|
||||
import os
|
||||
import stat
|
||||
import socket
|
||||
import struct
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from gns3server.compute.iou.iou_vm import IOUVM
|
||||
from unittest.mock import MagicMock, call
|
||||
from gns3server.compute.iou.iou_vm import IOUL1KeepaliveProtocol, IOUVM
|
||||
from gns3server.compute.iou.iou_error import IOUError
|
||||
from gns3server.compute.iou import IOU
|
||||
|
||||
@ -76,6 +77,7 @@ def test_vm(compute_project, manager):
|
||||
vm = IOUVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
|
||||
assert vm.name == "test"
|
||||
assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0f"
|
||||
assert vm.l1_keepalives is False
|
||||
|
||||
|
||||
def test_vm_startup_config_content(compute_project, manager):
|
||||
@ -111,6 +113,45 @@ async def test_start(vm):
|
||||
vm._ubridge_send.assert_any_call("iol_bridge start IOL-BRIDGE-513")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_does_not_start_l1_responder_without_l_option(vm):
|
||||
|
||||
process = MagicMock(returncode=None)
|
||||
process.communicate = AsyncioMagicMock(return_value=(None, None))
|
||||
vm.l1_keepalives = True
|
||||
vm._check_requirements = AsyncioMagicMock(return_value=True)
|
||||
vm._check_iou_license = AsyncioMagicMock(return_value=True)
|
||||
vm._start_ubridge = AsyncioMagicMock(return_value=True)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm._build_command = AsyncioMagicMock(return_value=[vm.path, str(vm.application_id)])
|
||||
vm._start_l1_keepalive_responder = AsyncioMagicMock()
|
||||
|
||||
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process):
|
||||
await vm.start()
|
||||
|
||||
vm._start_l1_keepalive_responder.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_failure_stops_l1_responder(vm):
|
||||
|
||||
vm.l1_keepalives = True
|
||||
vm._check_requirements = AsyncioMagicMock(return_value=True)
|
||||
vm._check_iou_license = AsyncioMagicMock(return_value=True)
|
||||
vm._start_ubridge = AsyncioMagicMock(return_value=True)
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm._build_command = AsyncioMagicMock(return_value=[vm.path, "-l", str(vm.application_id)])
|
||||
vm._start_l1_keepalive_responder = AsyncioMagicMock()
|
||||
vm._stop_l1_keepalive_responder = MagicMock()
|
||||
|
||||
with asyncio_patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("missing image")):
|
||||
with pytest.raises(IOUError):
|
||||
await vm.start()
|
||||
|
||||
vm._start_l1_keepalive_responder.assert_called_once_with()
|
||||
vm._stop_l1_keepalive_responder.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_with_iourc(vm, tmpdir, config):
|
||||
|
||||
@ -261,10 +302,155 @@ def test_create_netmap_config(vm):
|
||||
assert "513:15/3 1:15/3" in content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"adapter_number,port_number,interface",
|
||||
[
|
||||
(0, 0, 0x00),
|
||||
(0, 1, 0x10),
|
||||
(0, 2, 0x20),
|
||||
(0, 3, 0x30),
|
||||
(1, 0, 0x01),
|
||||
(1, 3, 0x31),
|
||||
(15, 3, 0x3F),
|
||||
],
|
||||
)
|
||||
def test_l1_keepalive_interface_encoding(adapter_number, port_number, interface):
|
||||
|
||||
assert IOUL1KeepaliveProtocol.encode_interface(adapter_number, port_number) == interface
|
||||
assert IOUL1KeepaliveProtocol.decode_interface(interface) == (adapter_number, port_number)
|
||||
|
||||
|
||||
def test_l1_keepalive_response_for_connected_interface(vm):
|
||||
|
||||
vm._adapters[0].add_nio(1, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
keepalive = struct.pack("!HHBBBB", 513, 1, 0x10, 0x10, 3, 0)
|
||||
protocol.datagram_received(keepalive, None)
|
||||
|
||||
transport.sendto.assert_called_once_with(
|
||||
struct.pack("!HHBBBB", 1, 513, 0x10, 0x10, 3, 0),
|
||||
vm.l1_iou_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_l1_keepalive_sent_for_connected_interface(vm):
|
||||
|
||||
vm._adapters[0].add_nio(2, MagicMock())
|
||||
vm._adapters[0].add_nio(3, MagicMock())
|
||||
vm._adapters[1].add_nio(2, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.send_keepalives()
|
||||
|
||||
assert transport.sendto.call_args_list == [
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x20, 0x20, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x30, 0x30, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x21, 0x21, 3, 0), vm.l1_iou_socket_path),
|
||||
]
|
||||
|
||||
|
||||
def test_l1_keepalives_preserve_mixed_iou_interface_numbers(vm):
|
||||
|
||||
vm.ethernet_adapters = 4
|
||||
vm.serial_adapters = 2
|
||||
vm._adapters[0].add_nio(0, MagicMock()) # Ethernet0/0
|
||||
vm._adapters[1].add_nio(1, MagicMock()) # Ethernet1/1
|
||||
vm._adapters[2].add_nio(2, MagicMock()) # Ethernet2/2
|
||||
vm._adapters[4].add_nio(0, MagicMock()) # Serial4/0
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.send_keepalives()
|
||||
|
||||
assert transport.sendto.call_args_list == [
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x00, 0x00, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x11, 0x11, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x22, 0x22, 3, 0), vm.l1_iou_socket_path),
|
||||
call(struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0), vm.l1_iou_socket_path),
|
||||
]
|
||||
|
||||
|
||||
def test_l1_keepalive_response_for_serial4_0(vm):
|
||||
|
||||
vm.ethernet_adapters = 4
|
||||
vm.serial_adapters = 2
|
||||
vm._adapters[4].add_nio(0, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0), None)
|
||||
|
||||
transport.sendto.assert_called_once_with(
|
||||
struct.pack("!HHBBBB", 1, 513, 0x04, 0x04, 3, 0),
|
||||
vm.l1_iou_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_stop_l1_keepalive_responder_cleans_up(vm):
|
||||
|
||||
task = MagicMock()
|
||||
transport = MagicMock()
|
||||
vm._l1_keepalive_task = task
|
||||
vm._l1_keepalive_transport = transport
|
||||
|
||||
with asyncio_patch("os.path.lexists", return_value=False):
|
||||
vm._stop_l1_keepalive_responder()
|
||||
vm._stop_l1_keepalive_responder()
|
||||
|
||||
task.cancel.assert_called_once_with()
|
||||
transport.close.assert_called_once_with()
|
||||
assert vm._l1_keepalive_task is None
|
||||
assert vm._l1_keepalive_transport is None
|
||||
|
||||
|
||||
def test_l1_keepalive_ignored_for_disconnected_interface(vm):
|
||||
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 3, 0), None)
|
||||
|
||||
transport.sendto.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keepalive",
|
||||
[
|
||||
b"invalid",
|
||||
struct.pack("!HHBBBB", 514, 1, 0x00, 0x00, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 2, 0x00, 0x00, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x00, 0x00, 2, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x04, 0x04, 3, 0),
|
||||
struct.pack("!HHBBBB", 513, 1, 0x40, 0x40, 3, 0),
|
||||
],
|
||||
)
|
||||
def test_invalid_l1_keepalive_is_ignored(vm, keepalive):
|
||||
|
||||
vm._adapters[0].add_nio(0, MagicMock())
|
||||
transport = MagicMock()
|
||||
protocol = IOUL1KeepaliveProtocol(vm)
|
||||
protocol.connection_made(transport)
|
||||
|
||||
protocol.datagram_received(keepalive, None)
|
||||
|
||||
transport.sendto.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_command(vm):
|
||||
|
||||
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", str(vm.application_id)]
|
||||
vm.l1_keepalives = True
|
||||
help_output = "-l\t\tEnable Layer 1 keepalive messages\n"
|
||||
with asyncio_patch("gns3server.utils.asyncio.subprocess_check_output", return_value=help_output):
|
||||
assert await vm._build_command() == [vm.path, "-n", "256", "-m", "1024", "-l", str(vm.application_id)]
|
||||
|
||||
|
||||
def test_get_startup_config(vm):
|
||||
@ -350,7 +536,7 @@ async def test_enable_l1_keepalives(vm):
|
||||
command = ["test"]
|
||||
with pytest.raises(IOUError):
|
||||
await vm._enable_l1_keepalives(command)
|
||||
assert command == ["test"]
|
||||
assert command == ["test"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user