mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-01 23:55:15 +03:00
Merge remote-tracking branch 'origin/3.1' into gh-pages
This commit is contained in:
commit
0a47558f77
@ -16,5 +16,25 @@
|
||||
- Key point: UDPLink only passes through jwt_token, ultimately used by curl command inside Web Wireshark container to authenticate with GNS3 capture stream API
|
||||
- **[Xpra HTML5 Client](./xpra-html5-client.md)** - Xpra HTML5 client menu control parameters for customizing the web interface
|
||||
|
||||
### RBAC & User Isolation
|
||||
- **[RBAC User Isolation Design](./rbac-user-isolation-design.md)** — Three-step permission check design: ACE batch check → created_by filtering → resource pools
|
||||
|
||||
### Appliance Management
|
||||
- **[GNS3 Appliance Loading](./gns3-appliance-loading.md)** - How GNS3 loads appliance files from builtin and custom directories with priority rules
|
||||
|
||||
### 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)
|
||||
|
||||
### Device Console / Copilot Known Bugs
|
||||
- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - FIXED: root cause was our own 80x24 initial PTY geometry for the docker_exec console (the XR pager reads PTY rows, not `terminal length`); initial geometry is now 511x10000. Copilot reconnect-before-retry + session_log still open; `--More--` auto-answer kept as fallback design
|
||||
|
||||
### MCP Service
|
||||
- **[MCP Service Design](./mcp-service-design.md)** - MCP (Model Context Protocol) service architecture using FastMCP with SSE transport, JWT auth, 29 tools across 5 domains
|
||||
- **[MCP Tool Description Location](./mcp-tool-description-guide.md)** - Where to define MCP tool descriptions: in `@mcp.tool()` functions in `__init__.py`, not in `*_TOOLS` arrays
|
||||
|
||||
### Python Code Verification
|
||||
- **[Import Validation](./python-import-validation.md)** - Use actual module imports (`python -c "from ... import ..."`) instead of `py_compile` to catch missing imports
|
||||
|
||||
41
.claude/memory/docker-container-stop-delay.md
Normal file
41
.claude/memory/docker-container-stop-delay.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: docker-container-stop-delay
|
||||
description: Docker containers not responding to SIGTERM cause ~5s stop delays when closing a project
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# Docker Container Stop Delay Analysis
|
||||
|
||||
## Background
|
||||
When stopping a GNS3 project, some Docker containers take ~5s to exit while others stop instantly.
|
||||
|
||||
## Root Cause
|
||||
Docker's `stop` command sends SIGTERM and waits `t` seconds (GNS3 sets `t=5`) before sending SIGKILL. Containers that don't handle SIGTERM are stuck waiting for the full timeout.
|
||||
|
||||
## Affected Containers
|
||||
|
||||
| Container | PID 1 | Why it's slow |
|
||||
|-----------|-------|---------------|
|
||||
| **AlpiNet** (alpine) | `dumb-init` → `bash -i` | Interactive bash ignores SIGTERM by design |
|
||||
| **OstinatoWireshark** | `bash` (PID 1) | Linux kernel won't apply default signal actions to PID 1 without an explicit handler; interactive bash doesn't install one |
|
||||
|
||||
## Normal Containers (for comparison)
|
||||
|
||||
| Container | PID 1 | Why fast |
|
||||
|-----------|-------|----------|
|
||||
| Chromium | `/usr/bin/chromium` | Chromium handles SIGTERM natively |
|
||||
| webterm | `dumb-init` → firefox | Firefox responds to SIGTERM immediately |
|
||||
|
||||
## Related Files
|
||||
- `gns3-registry/docker/alpinet/Dockerfile`
|
||||
- `gns3-registry/docker/ostinato-wireshark/Dockerfile`
|
||||
- `gns3-registry/docker/ostinato-wireshark/entry.sh`
|
||||
- `gns3-registry/docker/chromium/Dockerfile`
|
||||
- `gns3-registry/docker/ipterm/web/Dockerfile`
|
||||
- `gns3-server/gns3server/compute/docker/docker_vm.py:1040` — stop timeout parameter `t=5`
|
||||
|
||||
## Note
|
||||
This is not a GNS3 server bug (except a minor `or` vs `and` logic issue at `docker_vm.py:1037` which doesn't affect behavior). The root cause is in the Docker images themselves.
|
||||
|
||||
See also: [[docker-container-stop-delay]]
|
||||
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)
|
||||
20
.claude/memory/gns3-appliance-loading.md
Normal file
20
.claude/memory/gns3-appliance-loading.md
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: gns3-appliance-loading
|
||||
description: GNS3 appliance file loading mechanism and storage locations
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
GNS3 loads appliance (.gns3a) files from two locations with specific priority order:
|
||||
|
||||
1. **Builtin appliances directory**: `~/.local/share/GNS3/appliances/`
|
||||
- Stores automatically downloaded devices from GNS3 registry
|
||||
- Maintained and updated by the system automatically
|
||||
|
||||
2. **Custom appliances directory**: `~/GNS3/appliances/`
|
||||
- Stores user-customized or modified appliance files
|
||||
- Manually managed by users
|
||||
|
||||
**Loading priority**: System loads builtin appliances first, then custom appliances. If both directories contain devices with the same `device_id`, the custom appliance overwrites the builtin one. This design allows users to customize devices without having their modifications overwritten by automatic registry updates.
|
||||
|
||||
**Implementation**: See `gns3server/controller/appliance_manager.py` in the `load_appliances()` method (lines 314-351).
|
||||
88
.claude/memory/mcp-service-design.md
Normal file
88
.claude/memory/mcp-service-design.md
Normal file
@ -0,0 +1,88 @@
|
||||
---
|
||||
name: mcp-service-design
|
||||
description: MCP (Model Context Protocol) service architecture and tool design for GNS3 server
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
# MCP (Model Context Protocol) Service Design
|
||||
|
||||
## Background
|
||||
|
||||
Provide a standard MCP interface for GNS3 Server, allowing AI assistants (Claude Code, Claude Desktop) to interact with GNS3 network simulations through the Model Context Protocol.
|
||||
|
||||
## Decision/Implementation
|
||||
|
||||
### Transport
|
||||
- **SSE (Server-Sent Events)** with JWT token authentication
|
||||
- Endpoint: `/v3/mcp/transport/sse`
|
||||
- Message endpoint: `/v3/mcp/transport/messages/`
|
||||
|
||||
### Authentication
|
||||
- JWT token obtained via `/v3/access/users/authenticate`
|
||||
- Two ways to pass token:
|
||||
- `Authorization: Bearer <jwt>` header (Claude Code via `-H`)
|
||||
- `?token=<jwt>` query param (Claude Desktop, EventSource limitation)
|
||||
- Token validated using GNS3's existing `auth_service`
|
||||
- Token stored in `contextvars.ContextVar` for per-session isolation
|
||||
- Python ≥ 3.9 `asyncio.to_thread` propagates contextvars to threads
|
||||
|
||||
### Architecture
|
||||
```
|
||||
Claude Code / Desktop → SSE → Auth Wrapper → FastMCP Server → Tool Handler → Gns3Connector → GNS3 REST API
|
||||
```
|
||||
|
||||
### Tool Organization
|
||||
Tools are separated by domain into individual files under `gns3server/api/routes/mcp/`:
|
||||
|
||||
| File | Domain | Tool Count |
|
||||
|------|--------|:----------:|
|
||||
| `projects.py` | Project CRUD, open/close/stats | 7 |
|
||||
| `nodes.py` | Node CRUD, start/stop/reload/suspend, console WS | 10 |
|
||||
| `links.py` | Link CRUD | 5 |
|
||||
| `templates.py` | Template CRUD | 5 |
|
||||
| `computes.py` | Compute list/get/images | 3 |
|
||||
|
||||
**Total: 30 tools**
|
||||
|
||||
### Handler Pattern
|
||||
- Synchronous functions receiving `(params: dict, gns3_ctx: dict)`
|
||||
- Run via `asyncio.to_thread()` to avoid blocking the event loop
|
||||
- `gns3_ctx` contains `server_url` and `jwt_token`
|
||||
- `Gns3Connector` is created per-handler from `gns3_client.connector` (per-handler instantiation keeps each tool call isolated)
|
||||
|
||||
### Token Lifetime
|
||||
- Default: 1440 minutes (24 hours)
|
||||
- Configurable via `jwt_access_token_expire_minutes` in `gns3_server.conf`
|
||||
|
||||
## Rationale
|
||||
- **Why not Direct Controller calls**: MCP layer calls GNS3's own REST API through Gns3Connector, keeping full decoupling and supporting future multi-user/multi-instance scenarios
|
||||
- **Why not Streamable HTTP**: Claude Code supports SSE natively via `--transport sse` with custom headers; Streamable HTTP session manager lifecycle conflicts with FastAPI mount
|
||||
- **Why not stdio**: stdio is local-only; SSE supports both local and remote deployments
|
||||
|
||||
## Related Files
|
||||
- `gns3server/agent/mcp/__init__.py` — FastMCP server, `@mcp.tool()` decorators, auth wrapper (`_resolve_token` exchanges API keys for JWTs)
|
||||
- `gns3server/agent/mcp/*.py` — tool handlers for projects/templates/computes/snapshots/drawings/symbols/appliances/images
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/api_handlers.py` — shared node/link handler layer (single implementation, consumed by both MCP tools and copilot `tools_v2`; tests must patch `_get_connector` HERE, not in mcp modules)
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/connector.py` — Gns3Connector (JWT auth + http_call only; the old `custom_gns3fy.py` Node/Link/Project wrappers were removed)
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/project_inventory.py` — nodes/links aggregation feeding the topology context and Nornir inventory
|
||||
|
||||
## Configuration
|
||||
|
||||
### Claude Code
|
||||
```bash
|
||||
claude mcp add --transport sse My_GNS3_Server \
|
||||
http://host:3080/v3/mcp/transport/sse \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"My_GNS3_Server": {
|
||||
"url": "http://host:3080/v3/mcp/transport/sse?token=<jwt>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
43
.claude/memory/mcp-tool-description-guide.md
Normal file
43
.claude/memory/mcp-tool-description-guide.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
name: mcp-tool-description-location
|
||||
description: Where to define MCP tool descriptions so AI can see them
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
# MCP Tool Description Location
|
||||
|
||||
## Key Point
|
||||
MCP tool descriptions are defined in `@mcp.tool()` decorator functions in `__init__.py`, NOT in the `*_TOOLS` arrays in individual module files.
|
||||
|
||||
## Correct Location
|
||||
**File**: `gns3server/api/routes/mcp/__init__.py`
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def update_link(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
link_id: Annotated[str, Field(description="UUID of the link to update")],
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Update a link's properties.
|
||||
|
||||
Put detailed descriptions here, especially for complex parameters.
|
||||
Include format requirements, ranges, and examples.
|
||||
"""
|
||||
# implementation
|
||||
```
|
||||
|
||||
## Wrong Location
|
||||
- ❌ `LINK_TOOLS` in `gns3server/api/routes/mcp/links.py`
|
||||
- ❌ `TEMPLATE_TOOLS` in `gns3server/api/routes/mcp/templates.py`
|
||||
|
||||
## Activation
|
||||
**Must restart GNS3 server** for description updates to take effect.
|
||||
|
||||
## Description Requirements
|
||||
- Be explicit about data formats (arrays vs single values)
|
||||
- Include parameter ranges and constraints
|
||||
- Provide usage examples
|
||||
- Prevent common errors in the description itself
|
||||
30
.claude/memory/python-import-validation.md
Normal file
30
.claude/memory/python-import-validation.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Python Import Validation
|
||||
|
||||
## Background
|
||||
|
||||
When checking if modified Python code is correct, `py_compile` only validates syntax (e.g., balanced parentheses, valid keywords). It does **not** catch missing imports or other runtime errors (e.g., using `UUID()` without importing `UUID`).
|
||||
|
||||
## Decision/Implementation
|
||||
|
||||
Use actual module imports to verify code correctness:
|
||||
|
||||
```bash
|
||||
# ✅ This catches missing imports and runtime errors
|
||||
venv/bin/python -c "
|
||||
from gns3server.api.routes.controller.dependencies.authentication import get_user_from_token
|
||||
from gns3server.api.routes.mcp.__init__ import _resolve_token
|
||||
print('All imports OK')
|
||||
"
|
||||
|
||||
# ❌ This only checks syntax, not references
|
||||
venv/bin/python -c "import py_compile; py_compile.compile('file.py', doraise=True)"
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
`gns3server/api/routes/controller/dependencies/authentication.py` — missed `from uuid import UUID`
|
||||
`gns3server/api/routes/mcp/__init__.py` — missed `from uuid import UUID`
|
||||
|
||||
## Why
|
||||
|
||||
A `NameError` at runtime is far more expensive than a failed import check. Real import testing catches the full dependency chain.
|
||||
231
.claude/memory/rbac-user-isolation-design.md
Normal file
231
.claude/memory/rbac-user-isolation-design.md
Normal file
@ -0,0 +1,231 @@
|
||||
---
|
||||
name: rbac-user-isolation-design
|
||||
description: GNS3 RBAC user isolation design and implementation thought process
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
## RBAC User Isolation Design Summary
|
||||
|
||||
### Core Problem
|
||||
GNS3 3.0 has a complete RBAC framework (ACE + Role + Privilege), but lacks user isolation implementation, causing users to see resources they shouldn't have access to.
|
||||
|
||||
### Design Conflict
|
||||
Traditional RBAC's **path permission model** fundamentally conflicts with **user data isolation**:
|
||||
- **Path permission model**: Controls which paths users can access (e.g., `/projects`)
|
||||
- **User data isolation**: Controls which specific resources users can access (e.g., alice's project vs bob's project)
|
||||
|
||||
### Final Implementation Approach
|
||||
|
||||
#### Three-Step Permission Check Logic
|
||||
|
||||
```python
|
||||
# Step 1: Batch ACE + resource pool check (3 DB queries regardless of project count)
|
||||
direct_ace_ids, pool_accessible_ids = await rbac_repo.get_accessible_project_ids(
|
||||
current_user.user_id, "Project.Audit", all_project_ids
|
||||
)
|
||||
|
||||
# Step 2: Filter direct ACE projects by created_by (user's own projects)
|
||||
# Direct project sharing is only available through resource pools
|
||||
for p in controller.projects.values():
|
||||
if p.id in direct_ace_ids and p.created_by == current_user.username:
|
||||
projects.append(p.asdict())
|
||||
|
||||
# Step 3: Resource pool projects (no created_by filter)
|
||||
for p in controller.projects.values():
|
||||
if p.id in pool_accessible_ids:
|
||||
projects.append(p.asdict())
|
||||
```
|
||||
|
||||
##### Super Admin Bypass
|
||||
```python
|
||||
if current_user.is_superadmin:
|
||||
return [p.asdict() for p in controller.projects.values()]
|
||||
```
|
||||
Super admins skip all three steps and see every project.
|
||||
|
||||
##### seen_project_ids Deduplication
|
||||
A simple `seen_project_ids` set prevents the same project from appearing twice when it exists in both direct ACE and pool results. This is a lightweight dedup, not the complex blocking mechanism from earlier rejected designs.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
#### 1. ACE vs created_by Relationship
|
||||
- **ACE for basic access control**: Whether user can access the system
|
||||
- **created_by for user isolation**: Which specific resources user can access
|
||||
- **Step 2 filtering is critical**: Even with broad ACE, created_by filtering ensures user isolation
|
||||
|
||||
#### 2. Project Sharing Mechanism
|
||||
- **Project sharing only through resource pools**: Cannot configure ACE directly for specific projects
|
||||
- **Avoids complexity of direct ACE sharing**: Prevents permission configuration chaos
|
||||
|
||||
#### 3. Broad ACE Fault Tolerance
|
||||
```
|
||||
Even with configuration: ACE: Users group + User role + "/" + propagate: True
|
||||
Result: Users still only see projects they created
|
||||
Reason: Step 2 created_by filtering removes other users' projects
|
||||
```
|
||||
|
||||
### RbacRepository.get_accessible_project_ids()
|
||||
|
||||
The core batch-check method in `gns3server/db/repositories/rbac.py` uses 3 DB queries:
|
||||
|
||||
1. **User ACEs**: Direct ACE entries matching user + privilege
|
||||
2. **Group ACEs**: Group-level ACE entries via `UserGroup` membership
|
||||
3. **All resources with pool memberships**: Preloads all resources and their pool relationships
|
||||
|
||||
Then it computes two sets:
|
||||
- **`direct_ace_ids`**: Project paths matching user or group ACEs (path-based check)
|
||||
- **`pool_accessible_ids`**: Projects in pools the user/group can access (pool-based check)
|
||||
|
||||
Pool IDs are precomputed into a `pool_id -> set(project_ids)` map for O(1) lookup.
|
||||
|
||||
### Problems Solved
|
||||
|
||||
#### Problem 1: Missing User Isolation
|
||||
- **Issue**: All users can see all projects
|
||||
- **Solution**: Filter by created_by to implement user isolation
|
||||
|
||||
#### Problem 2: Broad ACE Breaking Isolation
|
||||
- **Issue**: `path: "/" + propagate: true` breaks user isolation
|
||||
- **Solution**: Step 2 created_by filtering ensures user isolation even with broad ACE
|
||||
|
||||
#### Problem 3: Permission Check Order Conflicts
|
||||
- **Issue**: seen mechanism blocks subsequent checks (from earlier design phases)
|
||||
- **Solution**: Simple pipeline-style filtering with lightweight dedup set
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### API Layer Permission Check
|
||||
```python
|
||||
@router.get("/projects") # Note: no has_privilege decorator
|
||||
async def get_projects(current_user=..., rbac_repo=...):
|
||||
# Permission checks in business logic
|
||||
```
|
||||
|
||||
#### Duplicate Prevention
|
||||
- Simple `seen_project_ids` set for deduplication between direct ACE and pool results
|
||||
- Not the complex blocking mechanism from earlier phases
|
||||
|
||||
#### Performance Considerations
|
||||
- Super admin path: O(1) — returns all projects directly
|
||||
- Regular user path: 3 fixed DB queries regardless of project count
|
||||
- Path-based ACE check: O(n * m) in worst case, where n = projects, m = ACE entries
|
||||
- Pool lookup: O(1) via precomputed pool->project map
|
||||
|
||||
### Use Cases
|
||||
|
||||
#### Scenario 1: Personal Use
|
||||
```
|
||||
alice creates project → alice only sees alice's projects ✅
|
||||
bob creates project → bob only sees bob's projects ✅
|
||||
No extra configuration needed, automatic user isolation
|
||||
```
|
||||
|
||||
#### Scenario 2: Team Collaboration
|
||||
```
|
||||
Admin creates resource pool → adds projects → team members can access
|
||||
alice creates project → alice still sees her own projects ✅
|
||||
Team sharing + user isolation coexist
|
||||
```
|
||||
|
||||
#### Scenario 3: Broad ACE Configuration
|
||||
```
|
||||
ACE: Users group + "/" + propagate: True
|
||||
alice still only sees alice's projects ✅
|
||||
User isolation unaffected by ACE configuration
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
#### 1. Separation of Concerns
|
||||
- **Basic access permission**: Controlled by ACE
|
||||
- **Data ownership**: Controlled by created_by
|
||||
- **Team sharing**: Controlled by resource pools
|
||||
|
||||
#### 2. Defensive Design
|
||||
- User isolation remains effective even with improper ACE configuration
|
||||
- Secure by default: users only see their own resources
|
||||
|
||||
#### 3. Simplicity
|
||||
- Avoid complex seen mechanisms and mutual exclusion logic
|
||||
- Clear pipeline-style check order
|
||||
|
||||
### Relationship with Original ACE System
|
||||
|
||||
#### Preserved Components
|
||||
- ✅ ACE framework (permission check mechanism)
|
||||
- ✅ Role and privilege definitions
|
||||
- ✅ Resource pool functionality
|
||||
|
||||
#### Improved Components
|
||||
- ✅ Added user isolation (created_by filtering)
|
||||
- ✅ Clarified project sharing mechanism (only through resource pools)
|
||||
- ✅ Simplified permission check logic
|
||||
|
||||
#### Removed Components
|
||||
- ❌ Removed `/projects` path privilege check on `get_projects` route
|
||||
- ❌ Removed the old complex `seen_project_ids` blocking mechanism
|
||||
- ❌ Avoided "can see all non-pool projects" privilege leak
|
||||
|
||||
### Implementation Details
|
||||
- **Main modification**: `gns3server/api/routes/controller/projects.py`
|
||||
- **Function**: `get_projects()`
|
||||
- **Batch check method**: `gns3server/db/repositories/rbac.py::RbacRepository.get_accessible_project_ids()`
|
||||
- **Privilege dependency**: `gns3server/api/routes/controller/dependencies/rbac.py::has_privilege()`
|
||||
- **Branch**: `feature/simple-user-isolation`
|
||||
- **Base branch**: `master`
|
||||
|
||||
### Key Commits
|
||||
1. Implemented basic created_by filtering
|
||||
2. Implemented three-layer permission check (with logic issues)
|
||||
3. Fixed to correct three-step check logic
|
||||
|
||||
### Design Evolution Process
|
||||
|
||||
#### Phase 1: Simple created_by Filtering
|
||||
```python
|
||||
user_projects = [p for p in all_projects() if p.created_by == user.username]
|
||||
```
|
||||
**Issue**: Didn't integrate with ACE system
|
||||
|
||||
#### Phase 2: Three-Layer Check (Wrong Version)
|
||||
```python
|
||||
# Used complex seen_project_ids blocking mechanism
|
||||
# Issue: Broad ACE breaks user isolation
|
||||
```
|
||||
|
||||
#### Phase 3: Three-Step Check (Correct Version)
|
||||
```python
|
||||
# Step 1: Batch ACE check via get_accessible_project_ids()
|
||||
# Step 2: Filter direct_ace_ids by created_by
|
||||
# Step 3: Resource pool projects (no created_by filter)
|
||||
```
|
||||
**Solution**: User isolation works even with broad ACE
|
||||
|
||||
### Relationship with RBAC Roadmap
|
||||
This implementation addresses items mentioned in the roadmap:
|
||||
- **Phase 1 (MVP)**: Basic project isolation implementation
|
||||
- **Auto-ACE on create**: Still needs to be implemented separately
|
||||
- **Template isolation**: Can use same design pattern
|
||||
|
||||
### Unsolved Issues
|
||||
|
||||
1. **Auto-ACE on project creation**: Part of roadmap Phase 1 — `create_project()` sets `created_by` but doesn't create ACE entries
|
||||
2. **Template and image isolation**: Can apply same design pattern
|
||||
3. **Default ACE configuration**: Need reasonable default permissions for Users group
|
||||
|
||||
### Design Limitations
|
||||
|
||||
1. **Project sharing only through resource pools**: No direct ACE configuration for sharing
|
||||
2. **ACE configuration required**: Users need basic ACE to access system
|
||||
3. **Performance considerations**: ACE check queries database for each project path
|
||||
|
||||
### Future Improvement Directions
|
||||
|
||||
1. **Auto-create ACE**: Automatically add ACE for creator when creating projects
|
||||
2. **Default ACE strategy**: Configure reasonable default permissions for Users group
|
||||
3. **Performance optimization**: Cache ACE check results to reduce database queries
|
||||
|
||||
This design achieves effective user isolation while maintaining RBAC system integrity, and solves the problem of broad ACE configurations breaking isolation.
|
||||
|
||||
**Key insight**: The Step 2 created_by filtering is the critical innovation that allows ACE and user isolation to coexist properly.
|
||||
72
.claude/memory/xrd-console-more-pager-bug.md
Normal file
72
.claude/memory/xrd-console-more-pager-bug.md
Normal file
@ -0,0 +1,72 @@
|
||||
# XRd Console --More-- Pager Bug (PTY window size paging vs terminal length 0)
|
||||
|
||||
## Background
|
||||
|
||||
Copilot commands with long output consistently failed against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reported `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface <iface>` worked fine.
|
||||
|
||||
Failure sequence (from the 2026-08-18 logs):
|
||||
|
||||
1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo matched, but the prompt never appeared within 60s
|
||||
2. The copilot's single-retry reused the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure died earlier in `command_echo_read` — a follow-on effect of the dirty session, not an independent fault
|
||||
|
||||
## Root Cause (fully traced)
|
||||
|
||||
The XRd node uses the `docker_exec` console type (`gns3server/compute/docker/vendor_docker_vm.py`, `_LazyExecTelnetServer`): a telnet TCP server whose backend is a `docker exec` PTY. Three facts combine:
|
||||
|
||||
1. The XR pager (at least for table-engine/TABLAST-style commands) pages on the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length` — `show terminal` happily reports `Length: 0 lines` while output still pages
|
||||
2. `_LazyExecTelnetServer.client_connected_hook` explicitly resized the exec PTY to **80×24** "before NAWS" (`await self._on_naws(80, 24)`) — the 24 rows were **our own initial geometry, not a Docker default**. Live test: `show ipv4 interface brief` paged after exactly 24 lines
|
||||
3. netmiko's telnetlib never negotiates **NAWS**, so the initial geometry is never corrected for copilot/bare-telnet clients. Real NAWS clients resize to their own geometry via the existing `_on_naws` → `POST /exec/{id}/resize` wiring
|
||||
|
||||
### Dead ends (do not retry)
|
||||
|
||||
- `show ipv4 interface brief | no-more` → `% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it
|
||||
- `terminal length 0`: takes effect at the CLI layer, but this pager ignores it
|
||||
- The XR CLI has no command to change PTY rows
|
||||
|
||||
### Related fact: paramiko vs docker_exec
|
||||
|
||||
paramiko (SSH) cannot connect to a `docker_exec` console — the client-side endpoint is a plain telnet server (`AsyncioTelnetServer`); only `console_type: ssh` (standard attach path, `AsyncioSSHServer`) speaks SSH. The copilot correctly uses netmiko `*_telnet` drivers (netmiko's vendored `_telnetlib`, stdlib-free on Python 3.13).
|
||||
|
||||
## Decision/Implementation
|
||||
|
||||
### Fix (implemented 2026-08-18, branch `feat/docker-exec-default-pty-geometry`)
|
||||
|
||||
Change the initial exec geometry in `vendor_docker_vm.py` `client_connected_hook` from 80×24 to **511×10000** (`await self._on_naws(511, 10000)`):
|
||||
|
||||
- Tall/wide default so CLIs that page on PTY rows never hit `--More--` for clients that never send NAWS (netmiko, bare telnet)
|
||||
- Width 511 matches netmiko's `terminal width 511` convention
|
||||
- Real NAWS clients still resize to their actual geometry right after connecting (existing `_on_naws` path unchanged)
|
||||
- Test: `test_first_connect_sets_tall_default_pty_geometry` in `tests/compute/docker/test_vendor_docker_vm.py`
|
||||
|
||||
### Fallback design (NOT implemented — keep if the pager ever resurfaces on another console type)
|
||||
|
||||
Channel-level loop answering `--More--` in the copilot display tool for `cisco_xr*`: prompt regex breaks; **tail-anchored** `re.search(r"--More--\s*$", buf)` with a ~150ms quiet double-confirmation before `write_channel(" ")`. Never put `--More--` into netmiko's `expect_string` — expect `re.search`es accumulated output, so content containing "More" would false-trigger.
|
||||
|
||||
### Still-open copilot improvements (agreed, not yet implemented)
|
||||
|
||||
1. Reconnect before retry: `_run_all_device_configs_with_single_retry` (display tool) and the config tool's retry should `task.host.close_connection("netmiko")` first — retrying on a dirty session is doomed
|
||||
2. `session_log_file` in hosts_data netmiko extras — the copilot has no session_log anywhere, which made this bug a guessing game
|
||||
|
||||
## Rationale
|
||||
|
||||
Resizing the PTY at exec creation attacks the root (geometry is fixed before the CLI outputs anything); it covers every consumer of the docker_exec console (copilot, MCP, bare telnet) with a one-line change, while the `--More--` auto-answer design remains as a generic fallback for consoles without a resize path.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `gns3server/compute/docker/vendor_docker_vm.py` — `_LazyExecTelnetServer`: `_on_naws` (exec resize), `_create_exec` (Tty=True, TERM=xterm), `client_connected_hook` (initial geometry — the fix)
|
||||
- `gns3server/compute/docker/docker_vm.py:1074-1087` — standard console path: NAWS → `containers/{cid}/resize`
|
||||
- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — dirty-session retry (open item)
|
||||
- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data (session_log extras insertion point)
|
||||
|
||||
## Examples
|
||||
|
||||
Live console transcript (2026-08-17):
|
||||
|
||||
```
|
||||
RP/0/RP0/CPU0:ios#show terminal
|
||||
Length: 0 lines, Width: 511 columns <- CLI-layer setting in effect
|
||||
|
||||
RP/0/RP0/CPU0:ios#show ipv4 interface brief
|
||||
(exactly 24 lines: timestamp + blank + header + 21 interface rows)
|
||||
--More-- <- the 80x24 initial exec geometry was paging
|
||||
```
|
||||
58
.claude/skills/gns3-api-test-writing/SKILL.md
Normal file
58
.claude/skills/gns3-api-test-writing/SKILL.md
Normal file
@ -0,0 +1,58 @@
|
||||
---
|
||||
name: gns3-api-test-writing
|
||||
description: Use this skill when writing pytest tests for gns3-server API routes — the conftest fixture model, auth token variants, config isolation, and the shared-client order-dependency trap.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Writing pytest API Route Tests
|
||||
|
||||
## Test Environment
|
||||
|
||||
- Run tests with the repo venv: `venv/bin/python -m pytest tests/api/routes/controller/test_xxx.py` (there is no system python/pytest).
|
||||
- The app runs in-process (httpx `ASGIWebSocketTransport`); the database is in-memory sqlite; superadmin `admin` is seeded when the users table is created.
|
||||
- `pytestmark = pytest.mark.asyncio`. Tests run on function-scoped event loops while class-scoped fixtures bind to the class loop — this works, but don't move class fixtures to function scope casually.
|
||||
|
||||
## The Fixture Model (tests/conftest.py)
|
||||
|
||||
Class-scoped: `app`, `db_session`, `base_client` (**one** shared httpx `AsyncClient`), `test_user` (idempotent `user1` in the "Users" group).
|
||||
|
||||
`client` (admin), `authorized_client` (user1), `unauthorized_client`, `compute_client` are class-scoped wrappers that each **rewrite the shared `base_client.headers` at instantiation time**. They are instantiated lazily — by the first test that requests them.
|
||||
|
||||
### The order-dependency trap
|
||||
|
||||
- `unauthorized_client` is a passthrough: it sets no header and only behaves as "unauthorized" if nothing set a token on `base_client` before it.
|
||||
- Mixing auth variants in one test class makes the default `Authorization` header depend on fixture instantiation order → tests pass alone but fail in a class run (or the reverse).
|
||||
- Symptom signature: a 401/403 assertion receives 200. That is fixture pollution, **not** an RBAC/auth bug in the product.
|
||||
|
||||
### Rule: per-request headers for auth variants
|
||||
|
||||
Never rely on the client's default header for 401/403/specific-user tests. Send the token explicitly (request-level headers override client defaults — the `test_users.py` idiom):
|
||||
|
||||
```python
|
||||
from gns3server.services import auth_service
|
||||
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
||||
|
||||
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
|
||||
response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401
|
||||
```
|
||||
|
||||
Note the import: `auth_service` lives in `gns3server.services`, NOT `gns3server.services.authentication` (which only exports `DEFAULT_JWT_SECRET_KEY` and the class).
|
||||
|
||||
Always pass `secret_key=DEFAULT_JWT_SECRET_KEY`: the autouse `run_around_tests` resets `Config` per test and forces the default secret, so a token minted with the config-of-the-moment dies in the next test.
|
||||
|
||||
## Config-Isolated Tests
|
||||
|
||||
- Request the function-scoped `config` fixture whenever the test reads/writes configuration or the endpoint under test reloads it. It points `Config` at `tmpdir/server.conf` (accessible as `config._main_config_file`).
|
||||
- Any endpoint that triggers a config reload re-reads `<secrets_dir>/gns3_jwt_secret_key`, which invalidates class-scoped bearer tokens. Fix: write `DEFAULT_JWT_SECRET_KEY` to that file first — see the `stable_jwt_secret` fixture in `tests/api/routes/controller/test_settings.py`.
|
||||
|
||||
## Misc Gotchas
|
||||
|
||||
- Build URLs with `app.url_path_for("route_function_name")`. Router introspection is unreliable (lazy `_IncludedRouter` wrapper) — check `GET /openapi.json` instead.
|
||||
- pydantic v2: an empty `SecretStr('')` serializes as `""`, **not** the mask — assert `in ("", SECRET_MASK)` for secrets that may be unset in tests.
|
||||
- New privileges are seeded only at table creation (`gns3server/db/models/privileges.py`); the fresh in-memory test DB always has them, but existing deployments need manual grants.
|
||||
- Error mapping: `ControllerBadRequestError` → 400, `ControllerError`/`HTTPException(409)` → 409, request schema violations → 422.
|
||||
|
||||
## Failure-Diagnosis Heuristic
|
||||
|
||||
**Passes in isolation, fails in a class run → suspect shared fixture state first** (`base_client.headers`, the `Config` singleton, class-scoped DB rows) — never the product code. Reproduce with `-k "test_a or test_b"` pairs to find the polluting test. Do not add debug prints to product code to chase test-order issues; make the test order-independent with explicit per-request headers instead.
|
||||
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
|
||||
@ -10,7 +10,7 @@ jobs:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v1.0.1
|
||||
- uses: actions/add-to-project@v2
|
||||
with:
|
||||
project-url: https://github.com/orgs/GNS3/projects/3
|
||||
github-token: ${{ secrets.ADD_NEW_ISSUES_TO_PROJECT }}
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@ -56,11 +56,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@ -88,6 +88,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
50
.github/workflows/docker-build.yml
vendored
50
.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@v4
|
||||
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
|
||||
|
||||
@ -12,18 +12,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: "gh-pages"
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Merge changes from 3.0 branch
|
||||
- name: Merge changes from 3.1 branch
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git merge origin/3.0 -X theirs
|
||||
git merge origin/3.1 -X theirs
|
||||
- name: Install GNS3 server and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
23
.github/workflows/testing.yml
vendored
23
.github/workflows/testing.yml
vendored
@ -4,41 +4,32 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 3.0
|
||||
- 3.1
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- 3.0
|
||||
- 3.1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Display Python version
|
||||
run: python -c "import sys; print(sys.version)"
|
||||
cache: pip
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install .[ai-copilot,dev]
|
||||
|
||||
- name: Install Windows specific dependencies
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
python -m pip install -r win-requirements.txt
|
||||
curl -O "http://www.win10pcap.org/download/Win10Pcap-v10.2-5002.msi"
|
||||
msiexec /i "Win10Pcap-v10.2-5002.msi" /qn /norestart
|
||||
python -m pip install .[ai-features,dev]
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -85,3 +85,5 @@ venv
|
||||
# Tiktoken cache files
|
||||
gns3server/agent/gns3_copilot/cache/tiktoken/
|
||||
|
||||
gns3.log
|
||||
/configs/
|
||||
|
||||
41
.whitesource
41
.whitesource
@ -2,13 +2,44 @@
|
||||
"scanSettings": {
|
||||
"configMode": "AUTO",
|
||||
"configExternalURL": "",
|
||||
"projectToken" : "",
|
||||
"baseBranches": ["master", "2.2", "3.0"]
|
||||
"projectToken": "",
|
||||
"baseBranches": []
|
||||
},
|
||||
"scanSettingsSAST": {
|
||||
"enableScan": false,
|
||||
"scanPullRequests": false,
|
||||
"incrementalScan": true,
|
||||
"baseBranches": [],
|
||||
"snippetSize": 10
|
||||
},
|
||||
"checkRunSettings": {
|
||||
"vulnerableCheckRunConclusionLevel": "failure"
|
||||
"vulnerableCheckRunConclusionLevel": "failure",
|
||||
"displayMode": "diff",
|
||||
"useMendCheckNames": true
|
||||
},
|
||||
"checkRunSettingsSAST": {
|
||||
"checkRunConclusionLevel": "failure",
|
||||
"severityThreshold": "high"
|
||||
},
|
||||
"issueSettings": {
|
||||
"minSeverityLevel": "LOW"
|
||||
}
|
||||
"minSeverityLevel": "LOW",
|
||||
"issueType": "DEPENDENCY"
|
||||
},
|
||||
"issueSettingsSAST": {
|
||||
"minSeverityLevel": "high",
|
||||
"issueType": "repo"
|
||||
},
|
||||
"remediateSettings": {
|
||||
"workflowRules": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"imageSettings":{
|
||||
"imageTracing":{
|
||||
"enableImageTracingPR": false,
|
||||
"addRepositoryCoordinate": false,
|
||||
"addDockerfilePath": false,
|
||||
"addMendIdentifier": false
|
||||
}
|
||||
}
|
||||
}
|
||||
429
CHANGELOG
429
CHANGELOG
@ -1,5 +1,434 @@
|
||||
# Change Log
|
||||
|
||||
## 3.1.0a5 19/08/2026
|
||||
|
||||
* Bundle web-ui v3.1.0a5
|
||||
* Sync appliances
|
||||
* copilot: sync CONSOLE_TYPES with the server ConsoleType enum
|
||||
* feat: Enhance information displayed on the system status dashboard
|
||||
* docker: cap GNS3_STOP_TIMEOUT at 210 s (controller stop budget)
|
||||
* docker: harden the shm/devices/extra_configs/masking work (code review)
|
||||
* docker: make the vendor graceful-stop grace period configurable (GNS3_STOP_TIMEOUT)
|
||||
* docker: graceful stop for vendor NOS containers (SIGTERM + 60s grace)
|
||||
* fix: UDP port allocation race causing link self-loop (one-way links)
|
||||
* add support basic support for OpenBSD.
|
||||
* docker: use the container's chown (not busybox) in vendor volume/perms path
|
||||
* docker: mask systemd-udevd in privileged containers (GNS3_MASK_UDEV)
|
||||
* docker: persist extra_configs in the template DB table
|
||||
* docker: inject config files into containers via extra_configs
|
||||
* docker: warn about low inotify/file-max and missing FUSE at connect
|
||||
* docker: inject /dev/shm size and host devices via HostConfig from env
|
||||
* appliance: expose custom_adapters on the v1-6 appliance model
|
||||
* appliance: accept docker_exec console type in Docker appliances
|
||||
* fix: recreate docker_exec console on reconnect after CLI exit
|
||||
* vendor: drop the hardcoded /etc/network mount for SKIP_INIT containers
|
||||
* vendor: skip /etc/network in volume bridge and permission passes
|
||||
* fix: run _fix_permissions container-side on /gns3volumes mount targets
|
||||
* fix: host-side permission fix + SKIP_INIT volume persistence docs
|
||||
* refactor: move vendor NOS Docker support into VendorDockerVM subclass
|
||||
* docker_exec console: wrap exec command in while-true loop so sr_cli restarts on quit
|
||||
* Prototype: vendor NOS Docker node support (SR Linux etc.) — docker_exec console via Docker exec API (pty + hijacked HTTP + NAWS resize)
|
||||
* fix: update CI configuration to use a single OS and remove Windows-specific dependencies
|
||||
* fix: breaking change with dependency FastAPI v0.137.0 https://fastapi.tiangolo.com/release-notes/#specific-breaking-changes
|
||||
* fix: suppress redundant console port setter log during Docker node create
|
||||
* revert: restore force_close=True in compute._session()
|
||||
* perf: parallelize batch NIO creation across nodes
|
||||
* fix: scope marker reconcile to the current bridge/NIO
|
||||
* cleanup: remove stray debug print in Dynamips node creation
|
||||
* fix: reconcile marker filters in _ubridge_apply_markers (delete/update)
|
||||
* log: suppress marker sink stats when no matches received
|
||||
* log: add periodic marker.match throughput statistics
|
||||
* marker: route marker.match to a dedicated project WS channel
|
||||
* perf: raise marker UDP receive buffer from ~208KB to 8MB
|
||||
* perf: move project-open marker inheritance to prepare phase
|
||||
* perf: parallelise compute-side batch NIO update per node
|
||||
* perf: batch update/delete marker-def fan-out too (full project-level)
|
||||
* perf: batch marker-def fan-out to one PUT /nios/batch per compute
|
||||
* log: add marker fan-out timing for project-open and interactive def-change
|
||||
* tune: raise start_all concurrency from 3 to 10
|
||||
* revert: drop _connect_nio thread-pool executor, restore async _ubridge_send
|
||||
* fix: use socket.fromfd in send_batch_sync for Python 3.13 compat
|
||||
* log: demote all per-node lifecycle INFO logs to DEBUG
|
||||
* fix: await Dynamips.create_nio (it is async, unlike sync base)
|
||||
* fix: use bound-method param count for Dynamips create_nio detection
|
||||
* fix: Dynamips create_nio(node, nio_settings) takes extra arg + add tests
|
||||
* log: add start/stop/close progress lines; revert start concurrency to 3
|
||||
* log: lower per-node lifecycle logs in base_node to DEBUG
|
||||
* perf: raise start/stop/suspend/reset-console concurrency from 3
|
||||
* fix: race in concurrent node creation sending duplicate POST /projects
|
||||
* log: lower per-node docker lifecycle logs to DEBUG
|
||||
* log: emit progress lines for node and link loading on project open
|
||||
* cleanup: remove project-open stage timing logs, lower NIO-added log to debug
|
||||
* perf: skip per-link topology dump during project-open prepare
|
||||
* perf: batch NIO dispatch on project open (one HTTP per compute)
|
||||
* perf: cache compute.host_ip + add UDPLink.create timing log
|
||||
* perf: dedicated 500-worker thread pool for ubridge batch I/O
|
||||
* perf: offload per-NIO ubridge commands to thread-pool executor
|
||||
* perf: enable TCP keep-alive for local compute HTTP requests
|
||||
* debug: add per-command timing to _connect_nio ubridge calls
|
||||
* perf: parallelize UDP port allocation and NIO creation in UDPLink.create
|
||||
* fix: SIGKILL docker container on stop instead of 5s grace period
|
||||
* fix: add event handling for deleted projects
|
||||
* fix: tests after merging
|
||||
* server: raise the open-files (RLIMIT_NOFILE) limit at startup
|
||||
* marker: batch topology dumps in bulk fan-out (per-def on 500+ links was ~1 minute)
|
||||
* marker: concurrent (bounded) definition fan-out
|
||||
* marker: forward data_link_type on per-link marker create
|
||||
* iou: skip already-installed markers in _ubridge_apply_markers (NIO update idempotency)
|
||||
* marker: serial-link (WAN) support via data_link_type -> uBridge linktype
|
||||
* iou: forward data_link_type on capture start so serial pcaps use the correct linktype
|
||||
* ubridge: default the control transport to unix
|
||||
* ubridge: require version >= 1.2.0
|
||||
* fix: add missing HTTPException import
|
||||
* mcp: drop direction from marker_definition tool
|
||||
* marker: tighten marker name max length from 128 to 32
|
||||
* marker: drop deleted marker from port NIO cache to stop empty pcap on restart
|
||||
* marker: validate def BPF once, skip re-validation on inherited fan-out
|
||||
* marker: point directional defs at BPF, refresh implementation doc
|
||||
* marker: incremental apply, clear bridge map on uBridge stop
|
||||
* marker: fine-grained filter ops, clean pcap on remove
|
||||
* marker: restore direction on project load, reject tx/rx on definitions
|
||||
* schema: accept direction='both' in marker schemas, normalize to None
|
||||
* qemu: demote QEMU monitor connect and set_link logs to DEBUG
|
||||
* marker: key _marker_filter_bridges by (name, link_id) so multi-link nodes toggle every copy
|
||||
* marker: make per-filter toggle fall back to NIO rebuild when the marker isn't installed
|
||||
* marker: make per-filter toggle a no-op when the marker isn't installed
|
||||
* marker: rework pause/resume from project-wide to per-definition
|
||||
* marker: fix PUT marker with an enabled-only body (bpf no longer required)
|
||||
* marker: persist project-wide markers_paused to the .gns3 file
|
||||
* marker: document pause/resume and instant enabled toggle
|
||||
* marker: test enabled/pause/resume and instant toggle
|
||||
* marker: drive uBridge enabled/pause/resume in real time
|
||||
* marker: add direction-clear, routing, and ubridge transport tests
|
||||
* marker: support clearing direction via explicit null / "both"
|
||||
* marker: let callers pin the capture node via capture_node_id
|
||||
* Name AF_UNIX sockets by node id for self-describing debuggability
|
||||
* Detect unsupported -U flag and fail fast with a clear error
|
||||
* Add configurable uBridge control channel transport (tcp/unix)
|
||||
* fix(import): unvalidated symlink creation in import_project
|
||||
* fix(qemu): move the bios options after the network ones
|
||||
* fix(qemu): fix addition of QEMU RNG device causes interface names to change
|
||||
* bugfix: docker workflow needs lowercase name
|
||||
* fix(qemu): remove trailing space from RNG object argument
|
||||
* Add MCP tools for traffic-insight marker feature
|
||||
* Improve MCP tool descriptions for link_update, device_show_run, and link_reset
|
||||
* Do not start nodes when deleting a project
|
||||
* fix: correct always-true state check in DockerVM.stop()
|
||||
* add bug report issue template
|
||||
* Adds controller and compute API support for explicitly pulling or updating Docker images.
|
||||
* feat(tests): increase maximum open file descriptors for test runs on Unix
|
||||
* (feat): Implement functional Layer 1 keepalive support for IOU/IOL nodes
|
||||
* Fix typo with ovmf_firmware_dir
|
||||
* Fix issues after merging
|
||||
* fix(builtin): hide GNS3 internal bridge interfaces from Cloud node
|
||||
* feat(builtin): NAT node returns its interface IP info in asdict()
|
||||
* fix(controller): refresh cloud/nat node interfaces from compute on GET
|
||||
* refactor(builtin): use deterministic bridge name from switch UUID
|
||||
* fix(builtin): bring kernel bridge UP after creation
|
||||
* feat: migrate builtin EthernetSwitch from Dynamips ethsw to ubridge brctl
|
||||
* fix(mcp): remove unreliable node_reload / node_reload_all tools
|
||||
* fix(rbac): guard None current_user on websocket auth failure
|
||||
* fix(mcp): thread token_version into console/download token minting
|
||||
* fix(mcp): pass user.token_version when generating temp JWT from API key
|
||||
* fix(marker): avoid reentrant-lock deadlock when uBridge starts lazily
|
||||
* fix(marker): per-link attribution via link field in MARK signals
|
||||
* fix(marker): set nio.markers on iou/dynamips/cloud nio update
|
||||
* fix(marker): reject reserved global-prefix names only on create
|
||||
* fix(marker): clean inherited markers on def delete
|
||||
* feat(marker): add highlight_duration render hint
|
||||
* fix(marker): fix inheritance hook, topology load, update sync, and asdict
|
||||
* fix(marker): export MarkerDefinitionCreate from schemas package
|
||||
* fix(marker): replace pydantic lookahead pattern with field_validator
|
||||
* feat(marker): add project-level marker definition inheritance
|
||||
* fix(marker): reserve "global" name prefix for future marker inheritance
|
||||
* fix(marker): extend marker support to dynamips and cloud nodes
|
||||
* fix(marker): enable marker support for docker nodes
|
||||
* fix(marker): tolerate port conflict on marker listener startup
|
||||
* fix(marker): validate marker name and expose enabled via REST
|
||||
* fix(marker): uniquify auto-generated marker names per link
|
||||
* feat(marker): add project-level marker aggregation endpoint
|
||||
* feat(marker): add IOU node marker support via iol_bridge
|
||||
* refactor(marker): converge to filter single-path model, remove dual-apply endpoints
|
||||
* fix(marker): restore markers from topology on project load
|
||||
* feat(marker): add optional color field to marker data model
|
||||
* fix(marker): use allowlist in _choose_marker_side, align with _get_filter_node
|
||||
* fix(marker): stop auto-deleting markers on node stop in node_updated
|
||||
* fix(marker): ride markers on NIO so they survive node restart and project reload
|
||||
* config: set marker_listen_port default to 3070 and document in config templates
|
||||
* fix(marker): add _choose_marker_side to exclude non-uBridge node types
|
||||
* 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
|
||||
* UEFI improvements for Qemu VMs * 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
|
||||
* Replace ControllerForbiddenError with aiohttp.web.HTTPForbidden for project deletion error handling
|
||||
* Fix: Check compute connectivity before open() during project deletion
|
||||
* Use typed InterfaceStatus enum and simplify stats fallback in interfaces()
|
||||
* Expose full interface address list and link state in cloud node API
|
||||
|
||||
## 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
|
||||
* fix: update MCP link tools descriptions with detailed filter info
|
||||
* fix: correct MCP nodes and links tool parameter handling for nested kwargs
|
||||
* fix: correct MCP template tool parameter handling for nested kwargs
|
||||
* feat: add client information logging to MCP connection rejection
|
||||
* refactor: replace MCP ready state polling with asyncio.Event
|
||||
* fix: revert duplicate image check to fix failing tests
|
||||
* fix (templates): Add ordering to handle the duplicate cases gracefully
|
||||
* fix(templates): Database error detected when saving a template with a disk image change
|
||||
* fix: return 503 error on MCP server ready timeout
|
||||
* fix: add MCP server ready check to prevent initialization errors
|
||||
* fix: resolve MCP server URL host via default route IP when bound to 0.0.0.0
|
||||
* fix: correct MCP transport security config to actually allow all hosts by default
|
||||
* feat: add configurable MCP transport security settings via gns3_server.conf
|
||||
* fix: add load_feature_skills() to properly load network planning features
|
||||
* refactor: convert all MCP tool parameter descriptions to Annotated+Field
|
||||
* fix: remove platformdirs upper bound to resolve fastmcp-slim dependency conflict
|
||||
* fix: add missing fastmcp dependency to resolve CI test failures
|
||||
* refactor: move all imports to top of __init__.py
|
||||
* test: add /v3/mcp/ to allowed public endpoints
|
||||
* fix: remove console_host/port from get_node_console_info
|
||||
* feat: add get_node_console_info tool
|
||||
* feat: add 3 Compute MCP tools - Add list_computes, get_compute, get_compute_images - Total MCP tools: 29
|
||||
* feat: add 5 Template MCP tools
|
||||
* feat: add Node and Link MCP tools, update copyright - Add 9 node tools and 5 link tools - Update copyright year to 2026, add author
|
||||
* feat: complete MCP SSE transport with JWT auth
|
||||
* feat: support Authorization header and query param for MCP token
|
||||
* feat: implement standard MCP protocol with SSE transport
|
||||
* feat: add MCP (Model Context Protocol) service with project tools
|
||||
* Add project memory: Docker container stop delay analysis
|
||||
* Remove extra blank line from merge
|
||||
* Revert container state detection in create()
|
||||
* Fix Docker VM tests for container status detection on node creation
|
||||
* Add running project check for fast duplication
|
||||
* Move running project check before fast duplication
|
||||
* Add running project check for fast duplication
|
||||
* Fix Docker container status detection on node creation
|
||||
* Fix web-ui update script to handle custom GitHub URL changes
|
||||
* Fix unnecessary Docker container recreation when renaming a project
|
||||
* Fix project rename and duplicate issues
|
||||
* Fix double deletion issue in remove_resource_from_pool
|
||||
* Complete fix: delete resource records when deleting resource pool
|
||||
* Apply fix from PR #2315: delete resource from resource table when removing from pool
|
||||
* Remove deprecated 'PermissionsStartOnly' setting for Systemd service. Ref #1830
|
||||
* Optimize project loading by implementing parallel node creation
|
||||
* Fix delay filter validation: ensure delay: [0, X] returns proper error message
|
||||
* Fix packet filter validation tests: use correct ubridge filter type names
|
||||
* Update tests to match show_interface_labels default change
|
||||
* Set default value of show_interface_labels to True
|
||||
* Optimize project variable updates to use parallel node processing
|
||||
* Fix ghost Docker nodes causing 60-second VNC timeout on variable updates
|
||||
* Fix Docker container variable compatibility with Pydantic models
|
||||
* Fix delay latency minimum: ubridge rejects latency <= 0
|
||||
* Improve packet filter validation: use tcpdump, handle multi-line BPF, safe project load
|
||||
* Add packet filter parameter validation to prevent ubridge errors
|
||||
* chore: update GNS3 skills repository to official organization
|
||||
* docs: update RBAC user isolation design doc to match actual implementation
|
||||
* docs: update skills repo URL in command-security.md
|
||||
* docs: remove Chinese overview docs, keep only English versions
|
||||
* docs: add overview docs for packet analysis, fault injection, and AI assistant
|
||||
* feat: add mermaid-to-SVG conversion script with environment setup
|
||||
* test: fix privilege count assertions after adding LLMConfig privileges
|
||||
* docs: add user node limit roadmap
|
||||
* test: fix RBAC test to match implementation logic
|
||||
* test: add --prefix and --cleanup-only parameters to benchmark script
|
||||
* perf: batch RBAC permission checking for GET /projects
|
||||
* test: add benchmark script for GET /projects performance testing
|
||||
* fix: prevent duplicate projects when user projects are in resource pools
|
||||
* fix: check both regular ACEs and resource pool ACEs for proper access control
|
||||
* test: update RBAC test to use test_user.username for user isolation
|
||||
* docs: add Phase 9 and 10 user self-registration and email service
|
||||
* docs: add Phase 8 per-user project namespace to roadmap
|
||||
* docs: add Phase 7 resource pool renaming to roadmap
|
||||
* feat: add alembic migration for LLMConfig privileges
|
||||
* feat: add independent LLMConfig permissions for AI profile management
|
||||
* docs: add Phase 6 frontend permission query API to roadmap
|
||||
* docs: add Phase 5 ACE architecture refactoring plan to roadmap
|
||||
* feat: remove resource pools from 'all endpoints' list
|
||||
* refactor: add efficient get_aces_for_path method for resource pool checks
|
||||
* feat: prevent deletion of resource pools used by ACE configurations
|
||||
* docs: update RBAC user isolation roadmap and add design memory
|
||||
* feat: fix permission check logic to properly handle ACE and user isolation
|
||||
* feat: implement layered permission checks for proper user isolation and sharing
|
||||
* feat: implement simple user isolation based on project ownership
|
||||
* fix: clean BPF syntax error message and specify loopback interface
|
||||
* feat: add BPF syntax validation using tshark
|
||||
* feat: add show_filters_icon parameter to packet filter tool
|
||||
* docs: add GNS3 appliance loading mechanism to memory
|
||||
* feat: add packet filter management tool for GNS3-Copilot fault injection
|
||||
* fix: update test_json expected output to include show_filters_icon field
|
||||
* fix: add getattr fallback to show_filters_icon property for backward compatibility
|
||||
* feat: add show_filters_icon property to Link for controlling Web UI filter icon display
|
||||
* fix: ensure show_filters_icon is always returned in API responses
|
||||
* feat: add show_filters_icon property to Link for controlling Web UI filter icon display
|
||||
* fix: remove explicit paramiko pin to resolve dependency conflict with netmiko
|
||||
* fix: update netmiko to 4.7.0 and pin paramiko>=5.0.0 to fix CVE-2026-44405
|
||||
* fix: close DockerHTTPClient session to prevent UnixConnector leak in Web Wireshark
|
||||
|
||||
|
||||
## 3.1.0a2 12/05/2026
|
||||
|
||||
* Bundle web-ui v3.1.0a2
|
||||
|
||||
@ -31,7 +31,7 @@ RUN add-apt-repository ppa:gns3/ppa && apt update && DEBIAN_FRONTEND=noninteract
|
||||
|
||||
COPY . /gns3server
|
||||
|
||||
RUN mkdir -p ~/.config/GNS3/3.0/
|
||||
RUN cp scripts/gns3_server.conf ~/.config/GNS3/3.0/
|
||||
RUN mkdir -p ~/.config/GNS3/3.1/
|
||||
RUN cp scripts/gns3_server.conf ~/.config/GNS3/3.1/
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --ignore-installed .
|
||||
|
||||
24
README.md
24
README.md
@ -1,7 +1,7 @@
|
||||
# GNS3 server repository
|
||||
|
||||
[](https://github.com/psf/black)
|
||||
[](https://github.com/GNS3/gns3-server/actions?query=workflow%3Atesting+branch%3A3.0)
|
||||
[](https://github.com/GNS3/gns3-server/actions?query=workflow%3Atesting+branch%3A3.0)
|
||||
[](https://pypi.python.org/pypi/gns3-server)
|
||||
[](https://snyk.io/test/github/GNS3/gns3-server)
|
||||
|
||||
@ -58,11 +58,11 @@ python3 -m pip install gns3-server
|
||||
|
||||
GNS3 server supports optional features that can be installed as needed:
|
||||
|
||||
**AI Copilot** (Optional):
|
||||
**AI Features** (Optional — includes AI Copilot and MCP):
|
||||
```shell
|
||||
python3 -m pip install gns3-server[ai-copilot]
|
||||
python3 -m pip install gns3-server[ai-features]
|
||||
```
|
||||
AI-powered assistant for network topology design and automation.
|
||||
AI-powered assistant for network topology design, automation, and MCP protocol support for AI agent integration.
|
||||
|
||||
**Development** (For contributors):
|
||||
```shell
|
||||
@ -78,7 +78,7 @@ Browser-based packet capture analysis using Wireshark in a Docker container.
|
||||
**Combination Installation**:
|
||||
You can install multiple optional features together:
|
||||
```shell
|
||||
python3 -m pip install gns3-server[ai-copilot,dev]
|
||||
python3 -m pip install gns3-server[ai-features,dev]
|
||||
```
|
||||
|
||||
**Why optional?**
|
||||
@ -89,17 +89,15 @@ python3 -m pip install gns3-server[ai-copilot,dev]
|
||||
|
||||
**Note:** If you install without optional extras, the server will work normally but optional features will be disabled. You can add features later by running the appropriate install command.
|
||||
|
||||
**Uninstalling AI Copilot:**
|
||||
**Uninstalling AI Features:**
|
||||
|
||||
To remove AI Copilot dependencies:
|
||||
To remove AI Features dependencies (AI Copilot + MCP):
|
||||
|
||||
```shell
|
||||
gns3server-uninstall-ai-copilot
|
||||
gns3server-uninstall-ai-features
|
||||
```
|
||||
|
||||
This will remove all AI Copilot dependencies while keeping the core functionality intact. The server will continue to work, but AI features will return a 501 (Not Implemented) status code.
|
||||
|
||||
The downside of this method is you will have to manually install all dependencies (see below).
|
||||
This will remove all AI Copilot and MCP dependencies while keeping the core functionality intact. The server will continue to work, but AI features will be disabled.
|
||||
|
||||
Please see our [documentation](https://docs.gns3.com/docs/getting-started/installation/linux) for more details.
|
||||
|
||||
@ -127,7 +125,7 @@ These commands will install the server with core Python dependencies:
|
||||
```shell
|
||||
git clone https://github.com/GNS3/gns3-server
|
||||
cd gns3-server
|
||||
git checkout 3.0
|
||||
git checkout 3.1
|
||||
python3 -m venv venv-gns3server
|
||||
source venv-gns3server/bin/activate
|
||||
python3 -m pip install .
|
||||
@ -137,7 +135,7 @@ python3 -m gns3server
|
||||
**For AI Copilot development**, install with additional dependencies:
|
||||
|
||||
```shell
|
||||
python3 -m pip install .[ai-copilot,dev]
|
||||
python3 -m pip install .[ai-features,dev]
|
||||
```
|
||||
|
||||
**For development (tests and linting)**:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# ==============================================================================
|
||||
# GNS3 Copilot AI Agent Dependencies
|
||||
# ==============================================================================
|
||||
# Install with: pip install gns3-server[ai-copilot]
|
||||
# Install with: pip install gns3-server[ai-features]
|
||||
# Or directly: pip install -r ai-requirements.txt
|
||||
# ==============================================================================
|
||||
|
||||
@ -30,7 +30,7 @@ tiktoken>=0.8.0
|
||||
langsmith>=0.7.7
|
||||
|
||||
# Network Automation
|
||||
netmiko>=4.6.0
|
||||
netmiko>=4.7.0
|
||||
nornir>=3.5.0
|
||||
nornir-netmiko>=1.0.1
|
||||
nornir-utils>=0.2.0
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
pytest==9.0.3 # fix CVE-2025-71176; Python 3.10+ required
|
||||
pytest==9.1.1
|
||||
flake8==7.3.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest-asyncio==1.2.0; python_version == '3.9' # version 1.2.0 is the last one supporting Python 3.9
|
||||
pytest-asyncio==1.3.0; python_version >= '3.10'
|
||||
pytest-asyncio==1.4.0
|
||||
httpx==0.28.1
|
||||
httpx_ws==0.7.2 # upgrading leads to failures in tests
|
||||
@ -72,6 +72,15 @@ 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.
|
||||
|
||||
### Docker exec Console (Vendor NOS) (`features/docker-exec-console.md`)
|
||||
Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PID 1: runs the vendor CLI via the Docker exec API, plus `GNS3_SKIP_INIT`/`GNS3_INTERFACE_NAMES` boot knobs and SKIP_INIT volume persistence.
|
||||
|
||||
### Cisco XRd Control Plane (`features/vendor-nos-xrd.md`)
|
||||
Cisco XRd as a GNS3 Docker router: vendor path + shm/device injection (`GNS3_SHM_SIZE`/`GNS3_DEVICES`), config-file injection (`extra_configs`), udev masking (`GNS3_MASK_UDEV`) so privileged systemd containers don't disturb the host, and the host-readiness check.
|
||||
|
||||
---
|
||||
|
||||
## GNS3 AI Copilot (`gns3-copilot/`)
|
||||
@ -100,6 +109,7 @@ Web-based packet capture analysis using Docker + xpra HTML5 client. Zero-install
|
||||
## Known Issues (`bugs/`)
|
||||
|
||||
- [Telnet Server Connection Race Condition](bugs/telnet-server-connection-race-condition.md) — `getpeername()` error when client disconnects during connection setup (High severity, Open)
|
||||
- [Docker Link UDP Self-Loop](bugs/link-udp-self-loop.md) — intermittent one-way link (both ends handed the same UDP port by an allocation race); **fixed** (Medium severity)
|
||||
|
||||
---
|
||||
|
||||
@ -117,4 +127,4 @@ Quick-start guide for Ubuntu 24.04: install via PPA, set up dependencies, and ru
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-04-20_
|
||||
_Last updated: 2026-08-14_
|
||||
|
||||
101
docs/bugs/link-udp-self-loop.md
Normal file
101
docs/bugs/link-udp-self-loop.md
Normal file
@ -0,0 +1,101 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
# Docker Link UDP Self-Loop Bug (One-Way Link)
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Date**: 2026-08-14
|
||||
**Severity**: Medium (one-way connectivity, CPU burn from packet duplication; intermittent)
|
||||
**Status**: **Fixed** — root cause found and unit-tested (same day)
|
||||
**Component**: UDP port allocation — `gns3server/compute/port_manager.py`
|
||||
(`get_free_udp_port` find-then-add race); secondary: `gns3server/controller/udp_link.py`
|
||||
(`_prepare` accumulated stale `_link_data` on reset)
|
||||
|
||||
## Symptoms
|
||||
|
||||
Two Docker nodes (observed with Cisco XRd; node-type agnostic) linked on the
|
||||
same compute cannot ping each other. Packet capture on the link shows only **one**
|
||||
side sending ARP. The other side's traffic never appears on the link at all.
|
||||
|
||||
## Evidence (from the live occurrence)
|
||||
|
||||
Captured simultaneously on both nodes' uBridge `bridge1` (see diagnostics below):
|
||||
|
||||
| Direction | Result |
|
||||
|---|---|
|
||||
| A → B | works — A's ARP requests arrive at B's bridge, B replies |
|
||||
| B → A | dead — B's replies/ICMP appear only on **B's own bridge** (duplicated ×2–×3), nothing arrives at A |
|
||||
| B's `ethN` counters | RX ≈ TX ≈ 5000+ — B receives its own transmissions back |
|
||||
| A's `ethN` counters | TX > 0, RX = 0 — never receives anything |
|
||||
|
||||
## Root Cause (confirmed)
|
||||
|
||||
**`PortManager.get_free_udp_port` had an unguarded find-then-add sequence.**
|
||||
A link allocates the UDP port for **both ends concurrently**
|
||||
(`asyncio.gather` in `UDPLink._prepare` → two `POST /ports/udp`). The route
|
||||
handler is a sync `def`, so FastAPI executes the two requests **in parallel
|
||||
threads**. Both threads ran `find_unused_port` (socket-probing, GIL-releasing)
|
||||
before either reached `_used_udp_ports.add(port)` — the set add is idempotent,
|
||||
so no error was raised and **both ends were handed the same port number**:
|
||||
`lport == rport` on both NIOs, a literal self-loop.
|
||||
|
||||
Why it was *silent* and *asymmetric*:
|
||||
|
||||
- uBridge sets `SO_REUSEADDR` on UDP NIO sockets (`ubridge/src/nio_udp.c`), so
|
||||
the second bind of the same port **succeeds** instead of failing with
|
||||
`EADDRINUSE` — link creation returned success.
|
||||
- With two sockets bound to the same port, the kernel delivers to one of them
|
||||
(last bound wins). The node that started later — in the live case B,
|
||||
restarted ~77 s after A — received **everything**: A's packets *and* its own
|
||||
transmissions echoed back. The 77 s restart did not cause the corruption; it
|
||||
only decided which end starves.
|
||||
- The same-compute condition is part of the trigger: both allocations hit the
|
||||
same `PortManager` instance (a cross-compute link races two processes and
|
||||
cannot self-collide).
|
||||
|
||||
A second, smaller defect was found while auditing: `UDPLink._prepare()`
|
||||
**appended** to `self._link_data` but the committed NIOs are always taken from
|
||||
indices 0/1 — after `reset()` (delete + create on the same object) the stale,
|
||||
already-released port pair was re-committed and the freshly allocated ports
|
||||
were leaked.
|
||||
|
||||
## Fix
|
||||
|
||||
| Change | Where |
|
||||
|---|---|
|
||||
| `threading.RLock` making find-then-add (and reserve/release) atomic for TCP and UDP | `gns3server/compute/port_manager.py` |
|
||||
| `_prepare()` rebuilds `_link_data` from scratch instead of appending | `gns3server/controller/udp_link.py` |
|
||||
| Regression tests: threaded allocation never returns duplicates (red on the old code); `reset()` commits the fresh mirrored pair with `lport != rport` | `tests/compute/test_port_manager.py`, `tests/controller/test_udp_link.py` |
|
||||
|
||||
## Diagnostics (uBridge console is the fast path)
|
||||
|
||||
1. **uBridge console** — each node's uBridge listens on a Unix socket
|
||||
`/run/user/1000/gns3/ubridge-<node_id>.sock`; connect and send:
|
||||
`bridge list` (NIO count per bridge), and
|
||||
`bridge start_capture bridge<N> "/tmp/ub-<node>.pcap"` /
|
||||
`bridge stop_capture bridge<N>` to capture what the bridge actually forwards.
|
||||
Comparing the two ends' pcaps localizes the break immediately.
|
||||
2. **Container counters** — `docker exec <cid> ip -s link show ethN`:
|
||||
TX>0/RX=0 → peer never returns; RX≈TX huge with µs-scale duplicates → self-loop.
|
||||
3. **UDP sockets** — `ss -uln` (no `-p`; uBridge runs setuid-root so process names
|
||||
are hidden, ports are visible): a two-node link owns exactly two "orphan" UDP
|
||||
ports. **One port instead of two = this bug.**
|
||||
4. **Recovery** — delete and re-create the link (or stop/start both nodes);
|
||||
with the fix, the corruption no longer occurs in the first place.
|
||||
|
||||
Note: a Docker node's in-container `ethN` is a **TAP device** whose file descriptor
|
||||
lives inside uBridge (the interface is created host-side, then moved into the
|
||||
container namespace and renamed). There is no veth host end to look for — do not
|
||||
waste time hunting for one in the host namespace.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/features/vendor-nos-xrd.md` — troubleshooting table entry pointing here.
|
||||
- Link-create batch optimization (`controller/udp_link.py`, project-open bulk path)
|
||||
was exonerated: the pool path allocates sequentially in one handler and cannot
|
||||
self-collide.
|
||||
@ -104,7 +104,7 @@ source venv/bin/activate
|
||||
# pip install -e . -i https://mirrors.aliyun.com/pypi/simple/
|
||||
|
||||
pip install -e . && gns3server-web-wireshark-setup
|
||||
pip install -e .[ai-copilot]
|
||||
pip install -e .[ai-features]
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
@ -114,10 +114,10 @@ Run the server:
|
||||
python3 -m gns3server
|
||||
```
|
||||
|
||||
## Optional: Install AI Copilot Development Dependencies
|
||||
## Optional: Install AI Features Development Dependencies
|
||||
|
||||
```bash
|
||||
python3 -m pip install .[ai-copilot,dev]
|
||||
python3 -m pip install .[ai-features,dev]
|
||||
```
|
||||
|
||||
## Optional: Expand LVM Root Partition
|
||||
|
||||
266
docs/features/builtin-ethernet-switch-ubridge.md
Normal file
266
docs/features/builtin-ethernet-switch-ubridge.md
Normal file
@ -0,0 +1,266 @@
|
||||
<!--
|
||||
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`. The controller therefore allows
|
||||
packet filters and traffic-insight markers on switch links (including switch-to-switch):
|
||||
`ethernet_switch` is a marker/filter-capable node type, and the compute API exposes the
|
||||
matching NIO-update and per-marker endpoints. The `ethernet_hub` — still Dynamips-hosted,
|
||||
no uBridge — remains excluded.
|
||||
|
||||
### `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.
|
||||
571
docs/features/docker-exec-console.md
Normal file
571
docs/features/docker-exec-console.md
Normal file
@ -0,0 +1,571 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Docker exec Console (Vendor NOS Containers)
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Docker nodes normally expose their console by attaching to the container's
|
||||
PID 1 stdio. That works for CLIs that run as PID 1 (e.g. FRR's `vtysh`), but it
|
||||
does **not** work for vendor NOS containers (Nokia SR Linux, Arista cEOS,
|
||||
Juniper cRPD, …) whose CLI is a separate, full-screen TUI process that is *not*
|
||||
on PID 1. For those, attaching to PID 1 only shows boot logs and never yields a
|
||||
CLI prompt.
|
||||
|
||||
The `docker_exec` console type solves this. It runs a chosen command inside the
|
||||
running container via the Docker exec API (with a pty) and bridges it to the
|
||||
GNS3 console, so the vendor's native TUI CLI renders in the Web UI (xterm.js)
|
||||
exactly as if you had run `docker exec -it <container> <cli>` in a real
|
||||
terminal.
|
||||
|
||||
Two companion environment knobs (`GNS3_SKIP_INIT`, `GNS3_INTERFACE_NAMES`) make
|
||||
the container itself boot and wire correctly for vendor NOS images. Together
|
||||
they let a vendor NOS run as a first-class GNS3 Docker router node.
|
||||
|
||||
> Prototype status: the knobs are environment-driven and intentionally avoid
|
||||
> schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected.
|
||||
> `console_type: "docker_exec"` is added to the `ConsoleType` enum.
|
||||
|
||||
## Environment knobs
|
||||
|
||||
All are read from the node's `environment` field. Entries prefixed with
|
||||
`GNS3_` are **not** forwarded into the container (existing GNS3 behaviour), so
|
||||
they stay host-side configuration. The console-relevant ones (the full vendor
|
||||
set, incl. `GNS3_SHM_SIZE` / `GNS3_DEVICES` / `GNS3_MASK_UDEV` /
|
||||
`GNS3_STOP_TIMEOUT`, is documented in [vendor-nos-xrd.md](./vendor-nos-xrd.md)):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `GNS3_SKIP_INIT=1` | Do **not** prepend `/gns3/init.sh` to the entrypoint. Vendor NOS images must run their own entrypoint (e.g. SR Linux's `sr_linux`); GNS3's init script (busybox bootstrap, `ifup`, eth wait) interferes with them. |
|
||||
| `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. |
|
||||
| `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. |
|
||||
| `GNS3_CONSOLE_RESIZE=0` | Ignore client-driven console resizes (WS terminal-size control frames / telnet NAWS) and keep the tall no-paging PTY geometry. Set for CLIs that page on the PTY window size (IOS-XR) — see [Terminal geometry](#terminal-geometry-and-size-forwarding). |
|
||||
|
||||
## Architecture: `VendorDockerVM` subclass
|
||||
|
||||
All vendor-specific logic lives in a `VendorDockerVM(DockerVM)` subclass in
|
||||
`gns3server/compute/docker/vendor_docker_vm.py` — `docker_vm.py` itself stays
|
||||
on its baseline behaviour and is never touched by this feature.
|
||||
|
||||
`DockerVM` exposes four small extension hooks (pure refactorings, zero
|
||||
behaviour change for existing nodes):
|
||||
|
||||
| Hook | Baseline behaviour | `VendorDockerVM` override |
|
||||
|------|--------------------|---------------------------|
|
||||
| `_prepare_init_and_interface_env(params)` | prepend `/gns3/init.sh`, set `GNS3_MAX_ETHERNET=eth{N-1}` | conditional init.sh (`GNS3_SKIP_INIT`), `GNS3_MAX_ETHERNET` follows the interface rename |
|
||||
| `_start_console_server()` | telnet/ssh/http console dispatch | adds the `docker_exec` branch |
|
||||
| `_get_container_ifname(adapter_number)` | `eth{N}` | `GNS3_INTERFACE_NAMES` lookup, fallback `eth{N}` |
|
||||
| `_cleanup_console_resources()` | no-op | closes the docker-exec pty socket before restart/stop |
|
||||
|
||||
### Class selection
|
||||
|
||||
The Docker manager picks the class per node in `Docker.create_node()`
|
||||
(`gns3server/compute/docker/__init__.py`):
|
||||
|
||||
```python
|
||||
def _select_node_class(self, **kwargs):
|
||||
if kwargs.get("console_type") == "docker_exec":
|
||||
return VendorDockerVM
|
||||
return DockerVM
|
||||
```
|
||||
|
||||
`console_type == "docker_exec"` is the **only** trigger — every other console
|
||||
type (telnet, vnc, ssh, http, …) keeps using the unmodified `DockerVM`. All
|
||||
vendor features are opt-in: without the `GNS3_*` environment variables a
|
||||
`VendorDockerVM` instance behaves identically to `DockerVM` (init.sh still
|
||||
runs, interfaces stay `eth{N}`, the exec command defaults to `/bin/sh`), so a
|
||||
regular container can use `docker_exec` too.
|
||||
|
||||
## The `docker_exec` console type
|
||||
|
||||
Setting `console_type: "docker_exec"` makes the node's primary console port run
|
||||
`_start_docker_exec_console()` instead of the attach-to-PID-1 path.
|
||||
|
||||
### Console architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Web UI xterm.js] -->|console WS: text frames| B[Controller forward]
|
||||
B -->|WS: text + binary| C[GNS3 Compute telnet server]
|
||||
C -->|binary pty stream| D[Docker exec API]
|
||||
D -->|Tty:true pty| E[sr_cli / vendor CLI]
|
||||
A -.->|binary control frame {"cols","rows"}| B
|
||||
B -.-> C
|
||||
C -.->|POST exec/.../resize| D
|
||||
```
|
||||
|
||||
The console uses GNS3's **existing shared/broadcast telnet model**: a single
|
||||
exec instance (one CLI session) is broadcast to every console client, exactly
|
||||
like the primary console shares one PID 1. There is deliberately **no
|
||||
per-client session isolation** — this matches how every other GNS3 console
|
||||
behaves. The PTY geometry is likewise shared: the last client resize wins
|
||||
(see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
|
||||
### Implementation
|
||||
|
||||
**File**: `gns3server/compute/docker/vendor_docker_vm.py` —
|
||||
`_start_docker_exec_console()`
|
||||
|
||||
A small subclass `_LazyExecTelnetServer(AsyncioTelnetServer)` implements the
|
||||
console. Key points:
|
||||
|
||||
1. **Lazy exec creation.** The exec is created on the **first client
|
||||
connection** (`client_connected_hook`), not when the node starts. This is
|
||||
essential: vendor CLIs (e.g. `sr_cli` via `prompt_toolkit`) send a
|
||||
cursor-position request (`\e[6n`, CPR) during startup and block waiting for
|
||||
the terminal's answer. If the exec starts at node-start time there is no
|
||||
xterm.js client to answer, the probe times out, and the TUI degrades (no
|
||||
status bar, "Terminal doesn't support CPR" warning). Creating the exec on
|
||||
first connect means the probe runs with a real xterm.js attached, which
|
||||
answers CPR → full TUI. After creation the exec is shared by all clients.
|
||||
|
||||
2. **Exec API with a pty.** `POST containers/{cid}/exec` with
|
||||
`Tty: true`, `User: "root"` (vendor CLIs reject the image's default
|
||||
unprivileged user — SR Linux returns *"User 'user' is not authorized to use
|
||||
CLI"* otherwise), and `Env: ["TERM=xterm"]` (the TUI library needs a
|
||||
recognised terminal).
|
||||
|
||||
3. **No while-true wrapper.** The command runs as `sh -c "<cmd>"` (no
|
||||
restart loop). When the CLI exits (`quit`, the NOS's own idle timeout, or a
|
||||
crash) the exec pty closes, the broadcast task ends, and the next client
|
||||
connection **recreates** the exec (see *Reconnection*). A `while true`
|
||||
wrapper would restart the CLI mid-session with no client attached to
|
||||
answer its startup CPR probe, producing a blank/degraded screen on
|
||||
reconnect.
|
||||
|
||||
### Reconnection
|
||||
|
||||
The exec is created lazily and **recreated on reconnect if it has died**.
|
||||
`client_connected_hook` checks `_upstream_alive()` (exec id set, writer open,
|
||||
broadcast task not done) before each connect:
|
||||
|
||||
- **First connect / dead upstream** → (re)create the exec. Because a client is
|
||||
now attached, the CLI's startup CPR probe is answered by xterm.js → full
|
||||
TUI. A half-dead writer is closed first to avoid a socket leak.
|
||||
- **Live upstream** → reuse the existing exec, just send `Ctrl-L` to redraw
|
||||
for the new client.
|
||||
|
||||
This is what makes the console survive `quit`, idle timeout, and CLI
|
||||
crashes: the death is detected (pty EOF ends the broadcast task) and the
|
||||
next connection spins up a fresh exec with a terminal present. The
|
||||
`_LazyExecTelnetServer` is extracted to module level specifically so this
|
||||
reconnect logic is unit-tested.
|
||||
|
||||
4. **Hijacked raw-HTTP start.** The exec is started with
|
||||
`POST exec/{eid}/start` sent as a raw HTTP upgrade over the Docker unix
|
||||
socket (`asyncio.open_unix_connection`), the same approach docker-py uses.
|
||||
This is required because aiohttp's websocket client (`ws_connect`) is
|
||||
rejected by Docker's exec-start endpoint (HTTP 400), while a raw POST
|
||||
upgrade succeeds (101). With `Tty:true` the response body is a raw,
|
||||
non-multiplexed bidirectional pty byte stream — no frame demux needed.
|
||||
|
||||
5. **NAWS → exec resize.** The telnet server runs with `naws=True`; the
|
||||
`window_size_changed_callback` (`_on_naws`, gated by
|
||||
`GNS3_CONSOLE_RESIZE`) calls `POST exec/{eid}/resize?h=&w=` so the TUI
|
||||
lays out for the client's window size. The internal `_resize_exec` path
|
||||
(creation-time default, restore-on-idle) is not gated. See
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding).
|
||||
|
||||
6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach
|
||||
xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect
|
||||
a `Ctrl-L` (`\x0c`) is sent to the pty so a TUI that already drew its
|
||||
screen for a previous client redraws for the new one (otherwise a
|
||||
reconnect shows a blank screen until the next output).
|
||||
|
||||
### Terminal geometry and size forwarding
|
||||
|
||||
The exec PTY geometry is a shared resource with three consumers that want
|
||||
different things:
|
||||
|
||||
- **Browser clients (xterm.js)** need the PTY to match their real window, or
|
||||
TUI CLIs misrender and over-render (below).
|
||||
- **Non-NAWS clients** (netmiko, bare telnet — no terminal-size negotiation)
|
||||
need the PTY *tall*: CLIs that page on the PTY window size (the IOS-XR
|
||||
pager ignores `terminal length 0`) park at `--More--` on a 24-row PTY.
|
||||
- **Concurrent sessions share one exec** — one browser resize changes what
|
||||
every attached client sees.
|
||||
|
||||
Resolution:
|
||||
|
||||
1. **Tall default.** The exec is created at 511×10000 (width 511 matches
|
||||
netmiko's `terminal width 511` convention). Non-NAWS clients get no paging
|
||||
and no hard wrapping.
|
||||
2. **WS terminal-size forwarding.** Console WebSocket clients may send
|
||||
**binary control frames** — UTF-8 JSON `{"cols": N, "rows": N}` —
|
||||
alongside text frames carrying terminal data (xterm.js's AttachAddon only
|
||||
sends text, so binary is an unambiguous side channel; valid ranges are
|
||||
cols 2–5000, rows 2–100000, anything else is silently ignored). The
|
||||
controller forwards binary frames (previously only text was forwarded —
|
||||
and a binary frame would have crashed the old `receive_text` loop), and
|
||||
the compute side turns them into a telnet NAWS subnegotiation for
|
||||
telnet-based consoles (docker_exec included) or an asyncssh
|
||||
`change_terminal_size` for SSH consoles
|
||||
(`base_node.py` `start_websocket_console`).
|
||||
3. **Races.** A size frame that arrives before/during the exec creation is
|
||||
remembered and applied right after creation — it is **not** overwritten by
|
||||
the tall default. When the **last** client disconnects the exec goes back
|
||||
to 511×10000, so a later non-NAWS client attaching to the still-live exec
|
||||
doesn't inherit a browser geometry and hit PTY-window paging.
|
||||
4. **`GNS3_CONSOLE_RESIZE=0`** makes the console ignore client resizes
|
||||
entirely (the tall default is then permanent). Set it for paging CLIs
|
||||
where a browser resize would break concurrent netmiko sessions on the
|
||||
shared exec — XRd, which is line-oriented and doesn't need browser
|
||||
resizing at all.
|
||||
|
||||
**Why the browser must send its size — the SR Linux flicker.** `sr_cli` is a
|
||||
prompt_toolkit TUI that anchors its layout with cursor-position requests
|
||||
(CPR), which xterm.js answers. On a 10000-row PTY canvas the CPR-anchored
|
||||
model conflicts with the winsize model, and every incremental render re-emits
|
||||
the accumulated output: measured with a CPR-answering client, one `info`
|
||||
command produces **~145 KB instead of ~60 KB** (~7× duplicated lines either
|
||||
way — the CLI re-renders its output region as a scroll-append stream; that
|
||||
part is inherent to `sr_cli` and identical outside GNS3, verified via manual
|
||||
`docker exec`). The inflation is driven by **rows** (24/32 → normal, 10000 →
|
||||
pathological, at any width) and is invisible without CPR answers — which is
|
||||
why plain-telnet probes and real xterm.js sessions behaved so differently.
|
||||
In the Web UI the excess renders as frequent full-screen clear/redraw — the
|
||||
"flicker". With the browser's real size forwarded (rows ≈ 30), output volume
|
||||
and rendering return to normal.
|
||||
|
||||
**File**: `gns3server/compute/base_node.py` — the console WebSocket guard now
|
||||
allows `docker_exec` (alongside `telnet`/`ssh`), since the WS bridge connects to
|
||||
the console TCP port exactly as it does for telnet. The same WS handler also
|
||||
intercepts binary control frames and propagates client terminal sizes (see
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
|
||||
### Why earlier approaches failed (context)
|
||||
|
||||
- `script` + `docker exec -it`: the `script` pty had size 0 (no NAWS) → the TUI
|
||||
could not lay out → blank.
|
||||
- `docker exec -i` (no `-t`) + `sr_cli -d` (dumb mode): line-mode output was
|
||||
block-buffered and visually messy.
|
||||
- Direct pipe relay: telnet `CRLF` polluted line input.
|
||||
|
||||
The exec-API approach fixes all of these: a real pty (`Tty:true`), a real size
|
||||
(NAWS resize), and a real terminal emulator (xterm.js answering CPR).
|
||||
|
||||
## Configuration
|
||||
|
||||
### SR Linux node example
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "srlinux-1",
|
||||
"node_type": "docker",
|
||||
"image": "ghcr.io/nokia/srlinux:latest",
|
||||
"adapters": 4,
|
||||
"console_type": "docker_exec",
|
||||
"start_command": "sudo -E bash -c 'touch /.dockerenv && /opt/srlinux/bin/sr_linux'",
|
||||
"environment": "GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3\nGNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"
|
||||
}
|
||||
```
|
||||
|
||||
- `start_command` is the SR Linux launch line (as used by containerlab).
|
||||
- Connect the node's ports as usual — links still use GNS3's UDP NIO datapath
|
||||
(container-agnostic); the rename only affects the in-container interface name.
|
||||
- For the Web UI port **labels** to match (display `mgmt0`/`e1-1` instead of
|
||||
`Ethernet0..3`), set `custom_adapters` per port
|
||||
(`{"adapter_number": 0, "port_name": "mgmt0"}`, …). Port labels are a
|
||||
controller-side concept, independent of the compute-side interface rename.
|
||||
|
||||
### Appliance (`gns3a`) packaging
|
||||
|
||||
A SR Linux appliance lives in `gns3-registry/appliances/srlinux.gns3a`
|
||||
(`registry_version: 6`). It sets the full chassis — **35 adapters**
|
||||
(`mgmt0` + `e1-1`..`e1-34`) — with matching `GNS3_INTERFACE_NAMES` and 35
|
||||
`custom_adapters` entries (`mgmt0`, `e1-1`..`e1-34`) so the canvas labels,
|
||||
the kernel interface names and the `ethernet-1/N` CLI names all line up.
|
||||
|
||||
Three appliance-schema fixes are required for this appliance to load (all on
|
||||
the gns3-server side; the registry JSON schema is unchanged because its docker
|
||||
block allows `additionalProperties`):
|
||||
|
||||
1. **`DockerConsoleType`** (`schemas/controller/appliances.py`) must include
|
||||
`docker_exec`, or the Pydantic appliance model rejects the file at import.
|
||||
2. **`ApplianceV1_6.custom_adapters`** must be declared on the top-level
|
||||
appliance model, or `GET /appliances` (response_model=`schemas.Appliance`)
|
||||
strips `custom_adapters` from the API response even though the file and the
|
||||
server-side template conversion handle it. (Node creation still worked
|
||||
because `appliance_to_template._add_docker_config` reads it from the raw
|
||||
dict; only the GET response was lossy.)
|
||||
3. `extra_volumes` rides inside the `docker` block (passed through by
|
||||
`new_config.update(appliance_config["docker"])`); no schema change needed.
|
||||
|
||||
> **Symbol theme caveat.** An appliance `symbol` that starts with
|
||||
> `:/symbols/` is forcibly rewritten at load time
|
||||
> (`appliance_manager._load_appliances`) to the current theme's default for the
|
||||
> appliance category — so `:/symbols/affinity/circle/blue/router_cloud.svg` (or
|
||||
> `router2.svg`) becomes `:/symbols/affinity/circle/blue/router.svg`, because
|
||||
> the theme maps only the canonical name `"router"`. This is intentional: it
|
||||
> lets theme switching re-skin every node consistently. To use a non-default
|
||||
> icon (e.g. `router_cloud`), install it as a **custom symbol** under the
|
||||
> configured `symbols_path` and reference it by filename (no `:/symbols/`
|
||||
> prefix) — custom symbols do not participate in re-theming. The SR Linux
|
||||
> appliance uses `router.svg`.
|
||||
|
||||
### Persistent state
|
||||
|
||||
For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) and
|
||||
`/var/log/srlinux` (logs, optional) by adding them to the node's
|
||||
`extra_volumes`. The image also declares its own `VOLUME` directories
|
||||
(e.g. `/opt/srlinux/appmgr`), which GNS3 persists automatically.
|
||||
|
||||
## Volume persistence with `GNS3_SKIP_INIT`
|
||||
|
||||
This is the one place where skipping init.sh changes behaviour beyond boot:
|
||||
`/gns3/init.sh` normally performs the volume-persistence bridge, and without it
|
||||
**nothing writes through to the host** — the container writes to its overlay
|
||||
filesystem and the data is lost on stop.
|
||||
|
||||
init.sh (as the entrypoint) is safe because it runs **before** the
|
||||
application: for each volume it seeds the host directory with the image's
|
||||
original files on first start, then `mount --bind /gns3volumes<path> <path>`
|
||||
bridges persistent storage into place.
|
||||
|
||||
`VendorDockerVM` cannot use that position (the NOS must own its entrypoint),
|
||||
so the same persistence is established entirely **outside the container and
|
||||
before it exists**:
|
||||
|
||||
```
|
||||
create() 之前: host dir seeded from the image (docker create + docker cp, first time only)
|
||||
create() 时: host ──Docker bind mount──▶ /etc/opt/srlinux (direct, at the real path)
|
||||
启动: NOS native entrypoint — the persisted config is visible from the first process
|
||||
```
|
||||
|
||||
1. **`_prepare_volumes()`** — host-side, at `create()` time (after the image
|
||||
is present, before the container is created). For each persistent volume
|
||||
whose host directory lacks the `.gns3_perms` marker, a throwaway
|
||||
`docker create` container (nothing executes) is used as a `docker cp -a`
|
||||
source to seed the host directory with the image's original content. The
|
||||
marker is written after the copy attempt — a volume that has it (every
|
||||
node that ever started, on any GNS3 version) is **never re-seeded**, so
|
||||
saved configuration is never overwritten with factory content.
|
||||
|
||||
2. **`_mount_binds()` override** — the volume binds target the **real
|
||||
in-container paths** (`/etc/opt/srlinux`) instead of `/gns3volumes<volume>`.
|
||||
With the content seeded first, the image's files are never shadowed by an
|
||||
empty mount, and the NOS sees its persisted configuration from the very
|
||||
first process — no post-start mount pass that could race the NOS reading
|
||||
its startup config (see "History: the exec-bridge race" below).
|
||||
|
||||
3. **Container-side `_fix_permissions()` override** — runs the same busybox
|
||||
record/chmod/chown script **inside the container (as root) on the volume
|
||||
paths**. Because the volumes are Docker bind mounts created with the
|
||||
container, the in-container paths resolve to the host files for the whole
|
||||
container lifetime. A stopped/exited container is **not** restarted (the
|
||||
base class would, just to chown; vendor NOS images are heavy to boot):
|
||||
the pass is skipped and the next start fixes ownership. It runs at start
|
||||
(so the controller can read project files while the node runs) and at
|
||||
stop (for files written during runtime).
|
||||
|
||||
> The fix must run container-side: files written by the container are
|
||||
> host-side root-owned, and an unprivileged GNS3 process cannot chown them
|
||||
> from the host. Container-side root (with GNS3's `UsernsMode: host`) can.
|
||||
|
||||
With `GNS3_SKIP_INIT`, GNS3's hardcoded `/etc/network` volume (see
|
||||
`docker_vm.py` `_mount_binds()`) is dropped entirely by
|
||||
`VendorDockerVM._mount_binds()`: it holds GNS3's own network config for
|
||||
init.sh's `ifup`, which never runs for SKIP_INIT containers — the NOS
|
||||
manages its own interfaces. The override removes the bind, filters the
|
||||
volume out of `self._volumes`, and deletes the host-side skeleton directory
|
||||
the base class just created. Without `GNS3_SKIP_INIT` the mount is kept
|
||||
(behaviour matches the base class).
|
||||
|
||||
### Lifecycle summary
|
||||
|
||||
| Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` |
|
||||
|-------|--------------------|--------------------------------------|
|
||||
| create | — | `_prepare_volumes()` seeds host dirs from the image (first create only); volumes bound **directly** at their real paths |
|
||||
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
|
||||
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
|
||||
| volume config | `_mount_binds`: host → `/gns3volumes<path>` | `_mount_binds` override: host → `<path>` directly |
|
||||
|
||||
### Runtime ownership safety
|
||||
|
||||
The start-time fix pass chowns the volume files to the host user **while the
|
||||
container is running** — a deliberate deviation from the standard model, where
|
||||
init.sh restores container-native ownership at start and the container never
|
||||
sees host-owned files during runtime. Verified harmless for SR Linux:
|
||||
|
||||
1. **Most processes run as root** (`sr_linux`, appmgr) — root ignores file
|
||||
ownership entirely.
|
||||
2. **Self-healing daemons.** SR Linux's `aaamgr` rewrites its managed files
|
||||
with its own ownership at boot: after the start-time pass chowned
|
||||
`etc/opt/srlinux/aaamgr_local_user.json` to the host user, the daemon
|
||||
re-created it as `srlinux:srlinux` (uid 1002, mode 700) within seconds.
|
||||
3. **ACL-based access.** The directory carries a default ACL
|
||||
(`default:group:srlinux:rwx`, `default:other::rwx`), so named group ACL
|
||||
entries grant access independently of the owner uid; the observed file ACL
|
||||
(`group:srlinux:rwx`, owner `srlinux`) survives chown.
|
||||
|
||||
Caveat: a NOS that strictly validates ownership of its files (e.g. "SSH keys
|
||||
must be root:root 600 or refuse to start") would not tolerate this. If that
|
||||
ever matters, drop the start-time pass and keep only the stop-time one
|
||||
(standard behaviour — the trade-off is mid-run `Permission denied` in the
|
||||
file browser, identical to regular Docker nodes).
|
||||
|
||||
### History: the exec-bridge race (fixed)
|
||||
|
||||
The first SKIP_INIT implementation replicated init.sh's script **via
|
||||
`docker exec` after the container started** instead of binding directly at
|
||||
create time. That copied the mechanism but not the invariant that makes
|
||||
init.sh safe — the entrypoint position, which guarantees the volume is in
|
||||
place *before* the application runs. An exec-based bind runs **concurrently**
|
||||
with the NOS boot, so whether the NOS reads its persisted config or the
|
||||
overlay's factory copy was a timing race:
|
||||
|
||||
- a single node stop/start on an idle system won it (the exec landed ~1 s
|
||||
in, SR Linux reads its startup config at ~2–4 s) — which is why the
|
||||
round-trip "save → stop → start → config still there" passed;
|
||||
- a server restart + project reload lost it (all nodes start concurrently,
|
||||
the Docker API queue delays the execs by several seconds) — SR Linux
|
||||
booted factory while the persisted `config.json` sat intact on the host;
|
||||
- XRd was immune either way (systemd boots for tens of seconds before any
|
||||
XR process touches `/xr-storage`), which is why the race was never seen
|
||||
on it.
|
||||
|
||||
The direct-bind-at-create design removes the window entirely; there is no
|
||||
ordering requirement left to verify when adopting a new NOS image.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**1. Console shows only boot logs, no CLI**
|
||||
- You are on the primary attach console. Set `console_type: "docker_exec"` and
|
||||
use `GNS3_CONSOLE_CMD` to point at the vendor CLI.
|
||||
|
||||
**2. `User '...' is not authorized to use CLI`**
|
||||
- The exec must run as root. The implementation sets `User: "root"`; if you
|
||||
fork it, keep that.
|
||||
|
||||
**3. `Terminal doesn't support cursor position requests (CPR)`**
|
||||
- This means the exec was started without an xterm.js client connected (the
|
||||
startup probe had no one to answer). The lazy-start design avoids this; if you
|
||||
see it, ensure the exec is created on first connect, not at node start.
|
||||
|
||||
**4. Reconnecting the Web console shows a blank screen**
|
||||
- A `Ctrl-L` is sent on each connect to force a TUI redraw. If the TUI does not
|
||||
redraw, verify the `client_connected_hook` still writes `\x0c` to the pty.
|
||||
|
||||
**5. `aiohttp WSServerHandshakeError: 400` on exec start**
|
||||
- Do **not** use the websocket client to start an exec. Use the hijacked raw
|
||||
HTTP upgrade over the unix socket (see Implementation).
|
||||
|
||||
**6. SR Linux data interfaces stay down**
|
||||
- SR Linux defaults its data ports to `admin-state disable`; enable them in the
|
||||
CLI (`interface ethernet-1/1 admin-state enable`) and bind the interface to a
|
||||
network-instance before ping works. This is SR Linux behaviour, not a GNS3
|
||||
issue.
|
||||
|
||||
**7. "Session has been idle, will logout in 300 seconds" → Connection closed**
|
||||
- SR Linux's own CLI idle timeout logs the CLI out, the exec pty closes, and
|
||||
the console disconnects. Reopening the console recreates the exec (see
|
||||
*Reconnection*) and gives a fresh login. To keep a permanent session,
|
||||
disable the timeout in the CLI: `enter candidate` →
|
||||
`/system cli idle-timeout disable` → `commit now`.
|
||||
|
||||
**8. Controller logs `Permission denied` reading files under the node's
|
||||
project directory while the node runs**
|
||||
- Root-written files inside a persistent volume. The container-side
|
||||
`_fix_permissions` pass runs at start (fixes the seeded files) and at stop;
|
||||
files created by the container *during* runtime become readable after the
|
||||
next stop.
|
||||
- Concrete example: SR Linux's `aaamgr` daemon rewrites
|
||||
`etc/opt/srlinux/aaamgr_local_user.json` during boot, **after** the
|
||||
start-time pass, as the image's `srlinux` user (uid 1002, mode 700) — so
|
||||
the host-side file stays `1002:1002` until the stop-time pass chowns it.
|
||||
- The log line comes from the file-browser API chain: Web UI *Show in file
|
||||
manager* → `GET /v3/projects/{pid}/nodes/{nid}/files`
|
||||
(`controller/nodes.py:538`) → `project.list_node_files`
|
||||
(`compute/project.py:510`), where `magic.from_file()` cannot read the
|
||||
file and the `file_type` field is left empty for that entry. The MCP
|
||||
`list_node_files` tool uses the same code path. Size/modified-at fields
|
||||
and everything else keep working; only the type sniff and one warning
|
||||
line are affected — same behaviour as any regular Docker node writing
|
||||
root-owned files at runtime.
|
||||
|
||||
**9. Persistent volume empty on the host after `save` + stop**
|
||||
- Ensure `GNS3_SKIP_INIT=1` is set (so the direct-bind path is taken) and
|
||||
the volume path is in `extra_volumes`; check that the host directory
|
||||
carries the `.gns3_perms` marker (written at create-time seeding) and the
|
||||
compute log for `Seeded persistent volume`.
|
||||
|
||||
**10. Persisted config present on the host but not applied after restart**
|
||||
- On builds since the direct-bind rework this should not happen: the volume
|
||||
is in place before the first process. If you see it, confirm the server
|
||||
build includes the rework (older builds established the bind via a
|
||||
post-start `docker exec` that could lose the race against the NOS reading
|
||||
its startup config — see *History: the exec-bridge race*).
|
||||
|
||||
**11. Web console flickers (full-screen clear/redraw) on every command**
|
||||
- The PTY is stuck at the tall 511×10000 default while a CPR-answering client
|
||||
is attached — see
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding). Check that the
|
||||
Web UI actually sends the binary size control frames on connect/resize
|
||||
(F12 → the console WS should show outgoing binary frames), and that the
|
||||
server is new enough to forward them (the controller used to forward text
|
||||
frames only). A client that never negotiates/forwards size (old Web UI,
|
||||
bare telnet without NAWS) cannot trigger the fix — but also never answers
|
||||
CPR, so it doesn't flicker either.
|
||||
- The much milder per-keystroke/5 s cursor toggles (`\e[?25l…\e[?25h`) from
|
||||
the TUI are normal and not this bug.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Shared session (broadcast).** All console clients share one CLI session
|
||||
and can see each other's input — identical to GNS3's existing primary
|
||||
console model. There is no per-client independent session. The PTY
|
||||
geometry is shared too (last resize wins): two browsers of different sizes
|
||||
disagree harmlessly, but a browser on a *paging* CLI needs
|
||||
`GNS3_CONSOLE_RESIZE=0` to stop resizing on behalf of concurrent netmiko
|
||||
sessions (see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
2. **`reset_console` not wired.** The console-reset action only handles
|
||||
`telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect
|
||||
works fine).
|
||||
3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` /
|
||||
`GNS3_CONSOLE_CMD` / `GNS3_CONSOLE_RESIZE` are environment-driven; they are
|
||||
not yet first-class node schema fields and are not declared in the
|
||||
appliance (`gns3a`) schema.
|
||||
4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3
|
||||
Docker nodes) so the container-side chown acts on the host files' real
|
||||
uid/gid (see the volume-persistence section).
|
||||
5. **Docker CLI dependency.** Volume seeding shells out to the `docker`
|
||||
binary (`docker create` + `docker cp` + `docker rm`) at create time —
|
||||
the same dependency the permission passes already have.
|
||||
|
||||
## References
|
||||
|
||||
- `gns3server/compute/docker/vendor_docker_vm.py` — `VendorDockerVM`:
|
||||
`_start_docker_exec_console`, `_LazyExecTelnetServer`,
|
||||
`_prepare_volumes` (host-side seeding), direct volume binds in
|
||||
`_mount_binds`, container-side `_fix_permissions`, `start()`.
|
||||
- `gns3server/compute/docker/docker_vm.py` — `DockerVM` extension hooks
|
||||
(`_prepare_init_and_interface_env`, `_start_console_server`,
|
||||
`_get_container_ifname`, `_cleanup_console_resources`).
|
||||
- `gns3server/compute/docker/__init__.py` — `Docker._select_node_class` /
|
||||
`create_node` factory.
|
||||
- `gns3server/compute/base_node.py` — console WebSocket guard; binary
|
||||
terminal-size control frames → NAWS / asyncssh resize
|
||||
(`start_websocket_console`).
|
||||
- `gns3server/api/routes/controller/nodes.py` — console WS forwarding
|
||||
(text and binary frames).
|
||||
- `gns3server/schemas/common.py` — `ConsoleType.docker_exec`.
|
||||
- containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and
|
||||
interface naming.
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.7 | 2026-08-22 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp -a`, marker-gated so saved config is never overwritten) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge. Root cause: the exec bridge raced the NOS reading its startup config — SR Linux read `config.json` at ~2–4 s and booted factory whenever concurrent node starts (server restart + project reload) delayed the exec past that point, while single-node stop/start and XRd (systemd touches `/xr-storage` tens of seconds in) never lost the race. New `_prepare_volumes` hook on `DockerVM`; `_fix_permissions` now targets the volume paths directly. |
|
||||
| 1.6 | 2026-08-20 | Terminal geometry and size forwarding: WS binary control frames `{"cols","rows"}` → NAWS / asyncssh resize (controller now forwards binary frames; compute intercepts them); tall 511×10000 default kept for non-NAWS clients, applied post-creation and restored on last disconnect (client size racing exec creation wins over the default); new `GNS3_CONSOLE_RESIZE=0` knob for paging CLIs (XRd) where a browser resize would break concurrent netmiko sessions on the shared exec; documented the SR Linux flicker root cause (tall rows × CPR-answering client → ~2.4× re-emitted output; rows-driven, width-independent). |
|
||||
| 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). |
|
||||
| 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. |
|
||||
| 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. |
|
||||
| 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. |
|
||||
| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. |
|
||||
| 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. |
|
||||
380
docs/features/marker-traffic-insight.md
Normal file
380
docs/features/marker-traffic-insight.md
Normal file
@ -0,0 +1,380 @@
|
||||
<!--
|
||||
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`, `ethernet_switch`) already use one
|
||||
bridge per link (the switch's per-port relay); `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`, `ethernet_switch`); 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`, `ethernet_switch` (one capable endpoint suffices). The
|
||||
`ethernet_switch` hosts markers on its per-port uBridge relays (brctl backend); the
|
||||
`ethernet_hub` is still Dynamips-hosted and has no uBridge. 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.
|
||||
474
docs/features/mcp-service.md
Normal file
474
docs/features/mcp-service.md
Normal file
@ -0,0 +1,474 @@
|
||||
# MCP (Model Context Protocol) Service
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Server provides a standard [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) interface, allowing AI assistants like Claude to interact with GNS3 network simulations through SSE (Server-Sent Events) transport.
|
||||
|
||||
The MCP service exposes GNS3 project management operations as MCP tools that can be discovered and called by MCP clients.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Method | Description |
|
||||
|------|--------|-------------|
|
||||
| `/v3/mcp/` | GET | MCP service metadata |
|
||||
| `/v3/mcp/transport/sse` | GET | SSE stream (MCP connection) |
|
||||
| `/v3/mcp/transport/messages/` | POST | JSON-RPC messages |
|
||||
|
||||
## Authentication
|
||||
|
||||
The SSE endpoint supports two types of credentials, passed the same way.
|
||||
|
||||
1. **Authorization header** (recommended):
|
||||
```
|
||||
Authorization: Bearer <jwt_or_api_key>
|
||||
```
|
||||
|
||||
2. **Query parameter** (for clients that don't support custom headers):
|
||||
```
|
||||
GET /v3/mcp/transport/sse?token=<jwt_or_api_key>
|
||||
```
|
||||
|
||||
### Option 1: JWT Token (24h expiry)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3080/v3/access/users/authenticate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "admin", "password": "admin"}'
|
||||
```
|
||||
|
||||
Default lifetime is **1440 minutes (24 hours)**. Configurable in `gns3_server.conf`:
|
||||
```ini
|
||||
jwt_access_token_expire_minutes = 1440 ; 24 hours
|
||||
```
|
||||
|
||||
### Option 2: API Key (permanent, revocable) — Recommended for MCP
|
||||
|
||||
API keys never expire and can be revoked individually. Format: `gns3_<api_key_id>_<random_secret>` — the embedded UUID enables O(1) lookup without scanning all keys.
|
||||
|
||||
Create one via the REST API:
|
||||
|
||||
```bash
|
||||
# Create an API key (requires a JWT to authenticate)
|
||||
curl -X POST http://localhost:3080/v3/access/api-keys \
|
||||
-H "Authorization: Bearer <your_jwt>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "MCP Production"}'
|
||||
# Response: {"api_key": "gns3_550e8400-e29b-41d4-a716-446655440000_a1b2c3d4...", ...}
|
||||
# ⚠️ The key is only shown once — save it immediately.
|
||||
```
|
||||
|
||||
API key management endpoints:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `POST /v3/access/api-keys` | Create a new key (returns plaintext once) |
|
||||
| `GET /v3/access/api-keys` | List all your keys |
|
||||
| `POST /v3/access/api-keys/{id}/revoke` | Revoke a key (can be restored) |
|
||||
| `POST /v3/access/api-keys/{id}/restore` | Restore a revoked key |
|
||||
| `DELETE /v3/access/api-keys/{id}` | Permanently delete a key |
|
||||
|
||||
Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably.
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
When connecting with an API key:
|
||||
|
||||
```
|
||||
SSE connect → Authorization: Bearer gns3_<uuid>_<secret>
|
||||
↓
|
||||
MCP auth wrapper extracts UUID → single DB query → 1 bcrypt (thread pool)
|
||||
↓
|
||||
Generates a fresh short-lived JWT → stored in ContextVar for the session
|
||||
↓
|
||||
All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
```
|
||||
|
||||
### Concurrency
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| MCP batch workers | 100 (`BATCH_MAX_WORKERS`) |
|
||||
| MCP HTTP client timeout | 30s |
|
||||
| HTTP connection pool (`pool_connections`/`pool_maxsize`) | 500 / 1000 |
|
||||
| REST API node/link creation pool | 100 (`Pool(concurrency=100)`) |
|
||||
|
||||
## Available Tools
|
||||
|
||||
**76 tools** across 11 categories:
|
||||
|
||||
### Project (14)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `project_list` | List all projects |
|
||||
| `project_get` | Get project details |
|
||||
| `project_create` | Create a project |
|
||||
| `project_delete` | Delete a project |
|
||||
| `project_open` | Open a closed project |
|
||||
| `project_close` | Close an open project |
|
||||
| `project_stats` | Get project statistics |
|
||||
| `project_update` | Update project properties |
|
||||
| `project_duplicate` | Duplicate a project |
|
||||
| `project_readme_get` | Get project README content |
|
||||
| `project_readme_update` | Update project README |
|
||||
| `project_lock` | Lock project (prevent edits) |
|
||||
| `project_unlock` | Unlock project |
|
||||
| `project_locked` | Check if project is locked |
|
||||
|
||||
### Node (22)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `node_list` | List all nodes (`fields` to filter columns, e.g. `["name","status"]`) |
|
||||
| `node_get` | Get node details (`fields` to filter columns) |
|
||||
| `node_create` | Create node(s) — single via `template_id` or batch via `nodes` array. Supports `fields` to filter response. Top-level `template_id` applies as default in batch mode. Pass `name` to override template naming. Coordinates: left-handed Cartesian (origin at canvas center, X right-positive, Y down-positive). |
|
||||
| `node_delete` | Delete a node |
|
||||
| `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_suspend` | Suspend node(s) — `node_id` or `node_ids` array |
|
||||
| `node_console` | Get WebSocket console URL |
|
||||
| `node_file_list` | List files in node directory |
|
||||
| `node_file_get` | Read a file (with offset/limit) |
|
||||
| `node_file_write` | Write a file |
|
||||
| `node_file_delete` | Delete a file |
|
||||
| `node_start_all` | Start all nodes |
|
||||
| `node_stop_all` | Stop all nodes |
|
||||
| `node_suspend_all` | Suspend all nodes |
|
||||
| `node_duplicate` | Duplicate a node |
|
||||
| `node_isolate` | Isolate a node (suspend links) |
|
||||
| `node_unisolate` | Un-isolate a node (resume links) |
|
||||
| `node_links` | List links connected to a node |
|
||||
|
||||
### Link (9)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `link_list` | List all links (`fields` to filter columns) |
|
||||
| `link_get` | Get link details |
|
||||
| `link_create` | Create link(s) — single via `nodes` or batch via `links` array. Nodes support compact `[id, ad, pt, id, ad, pt]` format. Supports `fields` to filter response. |
|
||||
| `link_delete` | Delete link(s) — `link_id` or `link_ids` array |
|
||||
| `link_update` | Update link (suspend, filters) |
|
||||
| `link_reset` | Reset link(s) — `link_id` or `link_ids` array |
|
||||
| `link_capture_start` | Start capture(s) — `link_id` or `link_ids` array |
|
||||
| `link_capture_stop` | Stop capture(s) — `link_id` or `link_ids` array |
|
||||
| `link_capture_download` | Get PCAP download URL(s) — `link_id` or `link_ids` array |
|
||||
|
||||
### Template (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `template_list` | List all templates. Supports `fields` to filter response columns. |
|
||||
| `template_get` | Get template details |
|
||||
| `template_create` | Create a template (Docker needs `image`) |
|
||||
| `template_update` | Update a template |
|
||||
| `template_delete` | Delete a template |
|
||||
|
||||
### Compute (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `compute_list` | List registered remote computes |
|
||||
| `compute_get` | Get compute details (requires UUID) |
|
||||
| `compute_images` | List emulator images on a compute |
|
||||
|
||||
### Snapshot (4)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `snapshot_list` | List snapshots |
|
||||
| `snapshot_create` | Create a snapshot |
|
||||
| `snapshot_delete` | Delete a snapshot |
|
||||
| `snapshot_restore` | Restore a snapshot |
|
||||
|
||||
### Drawing (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `drawing_list` | List drawings on canvas |
|
||||
| `drawing_get` | Get drawing details |
|
||||
| `drawing_create` | Create drawing (SVG label/shape/image) |
|
||||
| `drawing_update` | Update drawing (position, rotation, SVG) |
|
||||
| `drawing_delete` | Delete a drawing |
|
||||
|
||||
<!--
|
||||
Symbol tools (symbol_list / symbol_get / symbol_dimensions /
|
||||
symbol_defaults / symbol_upload / symbol_delete) are disabled for now:
|
||||
they require a vision-capable model to be genuinely useful. Revisit later.
|
||||
-->
|
||||
|
||||
### Appliance (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `appliance_list` | List appliances (`fields` to filter, e.g. `["name","category"]`) |
|
||||
| `appliance_get` | Get appliance details |
|
||||
| `appliance_install` | Create template from appliance (images must exist locally) |
|
||||
|
||||
### Image (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `image_list` | List all images |
|
||||
| `image_get` | Get image details |
|
||||
| `image_delete` | Delete an image |
|
||||
| `image_prune` | Remove images not referenced by any template |
|
||||
| `image_install` | Auto-create templates from uploaded images by checksum |
|
||||
|
||||
### Server (2)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `server_version` | Get GNS3 server version |
|
||||
| `server_statistics` | Get server statistics (computes, projects, nodes) |
|
||||
|
||||
### Device Config (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `device_config_send` | Push config commands to devices via console (Nornir + Netmiko). Supports Jinja2 `template` + `vars` |
|
||||
| `device_show_run` | Run read-only show commands on devices. Supports Jinja2 `template` + `vars` |
|
||||
| `vpcs_config_set` | Configure VPCS devices (IP, gateway, etc.) |
|
||||
|
||||
The tool connects to each device's console via telnet/SSH. Nodes must be in the `started` state (use `node_start` or `node_start_all`). Device type is auto-detected from the node's `device_type:<type>` tag in GNS3.
|
||||
|
||||
#### Jinja2 Template Mode
|
||||
|
||||
Both `device_config_send` and `device_show_run` support an optional `template` parameter. When provided, each device's `vars` dict is rendered against the template to produce commands. Entries with the same `device_name` are merged into a single device session.
|
||||
|
||||
```python
|
||||
# Direct commands (single/batch)
|
||||
device_config_send(project_id, device_configs=[
|
||||
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
|
||||
])
|
||||
|
||||
# Jinja2 template (reduces token usage for batch)
|
||||
device_config_send(project_id,
|
||||
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
|
||||
device_configs=[
|
||||
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
|
||||
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
|
||||
])
|
||||
|
||||
# Show commands with template
|
||||
device_show_run(project_id,
|
||||
template="show ip route {{ protocol }}",
|
||||
device_configs=[
|
||||
{"device_name": "R1", "vars": {"protocol": "ospf"}},
|
||||
{"device_name": "R2", "vars": {"protocol": "bgp"}},
|
||||
])
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
**Prefer template over direct commands for batch.** When ≥2 nodes share the same config structure with different values, use `template`+`vars` instead of writing `config_commands` per node. This reduces token usage and transcription errors.
|
||||
|
||||
**Batch merging.** Multiple entries with the same `device_name` are merged into a single Nornir session. The output contains all commands' results in one block. Match results by `device_name`, not list index.
|
||||
|
||||
**Don't rely on `status: success` alone.** It only means commands entered config mode. IOS errors (`% Invalid input`, `% overlaps`, `% Incomplete command`) appear inside `output` text — always scan for `%` lines.
|
||||
|
||||
**Pilot before full rollout.** Test template + vars on 1–2 devices first to verify rendering and syntax, then expand to all nodes.
|
||||
|
||||
**Config backup via file operations.** IOU and Dynamips nodes save startup config as a plain text file (`startup-config.cfg`) in the node directory after `write memory`. These can be backed up and restored via `node_file_get`/`node_file_write`.
|
||||
|
||||
```python
|
||||
# Save config on device
|
||||
device_show_run(project_id, device_configs=[
|
||||
{"device_name": "R1", "commands": ["write memory"]},
|
||||
])
|
||||
# Backup
|
||||
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_stop(project_id, node_id)
|
||||
node_start(project_id, node_id)
|
||||
```
|
||||
|
||||
### Device Config Workflow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant AI as AI Agent
|
||||
participant MCP as MCP Handler
|
||||
participant TM as Template Renderer
|
||||
participant DP as Device Discovery
|
||||
participant NR as Nornir
|
||||
participant NM as Netmiko
|
||||
participant D as Device Console
|
||||
|
||||
Note over AI: Decide: template or direct commands?
|
||||
|
||||
alt Direct commands
|
||||
AI->>MCP: device_config_send(config_commands=[...])
|
||||
else Jinja2 template
|
||||
AI->>MCP: device_config_send(template + vars)
|
||||
MCP->>TM: Render template per device
|
||||
TM->>TM: Jinja2.render(**vars)
|
||||
TM-->>MCP: device_configs with rendered commands
|
||||
end
|
||||
|
||||
MCP->>DP: get_device_ports_from_topology()
|
||||
DP-->>MCP: hosts_data (console port, device_type)
|
||||
|
||||
Note over MCP: Prepare Nornir inventory
|
||||
|
||||
MCP->>NR: InitNornir(hosts, threaded runner)
|
||||
par Device 1 to N (parallel, max 10)
|
||||
NR->>NM: netmiko_send_config(commands)
|
||||
NM->>D: telnet/SSH console session
|
||||
D-->>NM: command output
|
||||
NM-->>NR: execution result
|
||||
end
|
||||
NR-->>MCP: aggregated results
|
||||
MCP-->>AI: per-device results with output
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Claude Code (CLI)
|
||||
|
||||
```bash
|
||||
# Option A: Using API key (recommended — never expires)
|
||||
claude mcp add --transport sse My_GNS3_Server \
|
||||
http://localhost:3080/v3/mcp/transport/sse \
|
||||
-H "Authorization: Bearer gns3_a1b2c3d4..."
|
||||
|
||||
# Option B: Using JWT token (expires after 24h)
|
||||
TOKEN=$(curl -s -X POST http://localhost: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'])")
|
||||
|
||||
claude mcp add --transport sse My_GNS3_Server \
|
||||
http://localhost:3080/v3/mcp/transport/sse \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Transport Security
|
||||
|
||||
MCP server uses FastMCP's DNS rebinding protection to prevent attackers from
|
||||
exploiting DNS resolution to access the MCP endpoint through unauthorized domains.
|
||||
|
||||
### Default Behaviour
|
||||
|
||||
DNS rebinding protection is **disabled by default**, allowing connections from
|
||||
any host. This aligns with GNS3 server's default `host = 0.0.0.0` binding policy,
|
||||
which is designed for VM distribution scenarios where users access the server
|
||||
from various network locations.
|
||||
|
||||
### Enabling Protection
|
||||
|
||||
Add to `gns3_server.conf` under the `[Server]` section:
|
||||
|
||||
```ini
|
||||
; Enable DNS rebinding protection for MCP server
|
||||
mcp_enable_dns_rebinding_protection = True
|
||||
|
||||
; Allowed hosts (comma-separated, "host:*" port wildcard patterns only)
|
||||
mcp_allowed_hosts = 127.0.0.1:*,localhost:*,192.168.1.3:*
|
||||
|
||||
; Allowed origins (comma-separated)
|
||||
mcp_allowed_origins = http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:*
|
||||
```
|
||||
|
||||
> **Note**: The MCP library only supports `"host:*"` port wildcard patterns
|
||||
> (e.g., `"192.168.1.3:*"`). Standalone `"*"` wildcards are not supported.
|
||||
|
||||
### Protection Mechanism
|
||||
|
||||
When protection is enabled, the MCP server validates the `Host` header of
|
||||
incoming SSE connection requests:
|
||||
|
||||
```python
|
||||
# Verify the request's Host header matches allowed patterns
|
||||
validate_request → check Host header → 421 Misdirected Request if invalid
|
||||
```
|
||||
|
||||
This prevents DNS rebinding attacks:
|
||||
1. Attacker registers `evil.com` pointing to your server's IP
|
||||
2. User's browser makes requests to `evil.com:3080`
|
||||
3. MCP server checks Host header = `"evil.com:3080"`
|
||||
4. `"evil.com:3080"` is not in `allowed_hosts` → connection rejected
|
||||
|
||||
### Behaviour Summary
|
||||
|
||||
| `mcp_enable_dns_rebinding_protection` | Result |
|
||||
|:---|:---|
|
||||
| `False` (default) | All hosts allowed |
|
||||
| `True` + correct hosts configured | Only configured hosts allowed |
|
||||
| `True` + missing/wrong hosts | Connections rejected with 421 |
|
||||
|
||||
For public-facing MCP servers, set `allowed_hosts` to your server's domain name.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Claude Code
|
||||
participant MCP as MCP Service
|
||||
participant Auth as Auth
|
||||
participant GNS3 as GNS3 REST API
|
||||
|
||||
Note over Client: 1. Connect with API Key or JWT
|
||||
Client->>MCP: GET /sse (Authorization: Bearer <key>)
|
||||
|
||||
alt API Key (gns3_<uuid>_<secret>)
|
||||
MCP->>Auth: Extract UUID → DB lookup → 1 bcrypt (thread pool)
|
||||
Auth-->>MCP: Generate fresh JWT
|
||||
else JWT
|
||||
MCP->>Auth: Decode JWT
|
||||
Auth-->>MCP: Token valid
|
||||
end
|
||||
|
||||
MCP-->>Client: event: endpoint /messages/?session_id=xxx
|
||||
|
||||
Note over Client: 2. Initialize & Call Tools
|
||||
Client->>MCP: POST /messages/ (tools/call ...)
|
||||
MCP->>GNS3: HTTP request (with JWT from step 1)
|
||||
GNS3-->>MCP: Response
|
||||
MCP-->>Client: event: message (tool result)
|
||||
```
|
||||
|
||||
## Internal Implementation
|
||||
|
||||
- **FastMCP** (Anthropic MCP SDK) is used for tool registration and SSE transport
|
||||
- The SSE app is mounted as a Starlette sub-application under `/v3/mcp/transport`
|
||||
- **Auth:** JWT validation via `auth_service`. API key (`gns3_<uuid>_<secret>`) extracts UUID for O(1) DB lookup, runs bcrypt in thread pool, returns a fresh JWT — subsequent calls use the JWT with zero extra bcrypt.
|
||||
- Tool handlers use `Gns3Connector` (from `gns3_copilot.gns3_client.connector`) via the shared handler layer (`gns3_copilot.gns3_client.api_handlers`), keeping the MCP layer decoupled
|
||||
- The JWT token is stored in a `contextvars.ContextVar` so it is available within tool handler threads (Python ≥ 3.9 propagates contextvars through `asyncio.to_thread`)
|
||||
|
||||
### Console WebSocket
|
||||
|
||||
The `node_console` tool returns a WebSocket URL for connecting to a node's console. The URL includes a short-lived **access ticket** (10 min) — a short random string (`gns3t_…`, 22 chars) minted server-side and bound to that node's console endpoints. `link_capture_download` uses the same kind of ticket bound to one exact resource path instead of embedding a Bearer JWT in the returned curl command. Tickets replaced the long JWTs previously embedded in these URLs/commands: LLM clients retyping them into shell commands reliably corrupted a ~200-char JWT, while a short ticket survives copying. Re-request the URL when the ticket expires (logging out also invalidates outstanding tickets). This endpoint is protocol-agnostic — it works for **telnet**, **ssh**, and **vnc** console types alike. The WebSocket simply proxies raw byte streams between the client and the compute node; protocol negotiation (e.g. SSH key exchange) happens on the compute side.
|
||||
|
||||
The WebSocket URL is constructed using the server's `_server_url()`, which resolves the host as follows:
|
||||
|
||||
| `Server.host` value | Resolved host in URL |
|
||||
|:---|:---|
|
||||
| Specific IP or hostname (e.g. `192.168.1.3`) | Used as-is |
|
||||
| `0.0.0.0` (IPv4 any, default) | Detected via **default route interface IP** |
|
||||
| `::` (IPv6 any) | Detected via default route interface IP |
|
||||
| Detection failure | Fallback to `127.0.0.1` |
|
||||
|
||||
When `Server.host` is `0.0.0.0` (listen on all interfaces), the MCP server discovers the default route interface IP using a UDP socket connect to `8.8.8.8:80` — no network data is sent, the operating system simply selects the interface that would be used for the default route. This ensures the returned WebSocket URL uses a reachable address (e.g. `192.168.1.3` instead of `127.0.0.1`).
|
||||
|
||||
If the configured host is already a specific IP or hostname (not `0.0.0.0`), it is used directly in the URL without modification.
|
||||
|
||||
Use `websocat` to connect from the command line (use the `command` returned by `node_console` verbatim — never reconstruct the URL by hand):
|
||||
|
||||
```bash
|
||||
# The host in the URL is automatically resolved to a reachable address
|
||||
websocat ws://192.168.1.3:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token=<console_ticket>
|
||||
```
|
||||
|
||||
### Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `gns3server/agent/mcp/__init__.py` | FastMCP server, tool decorators, SSE transport, JWT auth wrapper |
|
||||
| `gns3server/agent/mcp/projects.py` | Project tool handlers |
|
||||
| `gns3server/agent/mcp/nodes.py` | Node tool handlers |
|
||||
| `gns3server/agent/mcp/links.py` | Link tool handlers |
|
||||
| `gns3server/agent/mcp/templates.py` | Template tool handlers |
|
||||
| `gns3server/agent/mcp/computes.py` | Compute tool handlers |
|
||||
| `gns3server/api/server.py` | Mounts MCP routes via `register_starlette_routes()` |
|
||||
148
docs/features/project-open-performance.md
Normal file
148
docs/features/project-open-performance.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Project Open Performance
|
||||
|
||||
## Overview
|
||||
|
||||
Optimizations to accelerate project opening (`POST /projects/{id}/open`) and node creation for topologies with many nodes and links. The main bottlenecks were sequential link creation, redundant subprocess calls, and SQLite write contention.
|
||||
|
||||
## Before vs After
|
||||
|
||||
| Scenario | Before | After |
|
||||
|----------|--------|-------|
|
||||
| 20 IOU nodes + 20 links (project open) | ~2s | ~1s |
|
||||
| 40 QEMU nodes creation (MCP batch) | ~40s | ~1-2s |
|
||||
|
||||
## Optimizations
|
||||
|
||||
### 1. Parallel Link Creation
|
||||
|
||||
**File:** `gns3server/controller/project.py`
|
||||
|
||||
Links were created sequentially during project loading, each requiring up to 5 HTTP round-trips to the compute. Now uses `Pool(concurrency=100)` for parallel creation.
|
||||
|
||||
```python
|
||||
# Before: sequential loop
|
||||
for link_data in topology.get("links", []):
|
||||
link = await self.add_link(...)
|
||||
await link.add_node(...)
|
||||
|
||||
# After: parallel Pool
|
||||
pool = Pool(concurrency=100)
|
||||
for link_data in topology.get("links", []):
|
||||
pool.append(self._create_link_from_topology_data, link_data)
|
||||
await pool.join()
|
||||
```
|
||||
|
||||
### 2. Batch UDP Port Allocation
|
||||
|
||||
**Files:** `gns3server/api/routes/compute/compute.py`, `gns3server/controller/project.py`, `gns3server/controller/udp_link.py`
|
||||
|
||||
During project loading, all required UDP ports are pre-allocated per compute in a single batch call before link creation begins. `UDPLink.create()` checks the pre-allocated pool first, falling back to individual allocation if unavailable.
|
||||
|
||||
```python
|
||||
# New batch endpoint
|
||||
POST /projects/{id}/ports/udp/batch → {"count": N} → {"udp_ports": [...]}
|
||||
```
|
||||
|
||||
### 3. IOU Image Subprocess Cache
|
||||
|
||||
**File:** `gns3server/compute/iou/iou_vm.py`
|
||||
|
||||
Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses. With 20 nodes using the same image, this ran 40 redundant subprocesses. Now results are cached per image path at the class level.
|
||||
|
||||
```python
|
||||
# Class-level caches shared across all instances
|
||||
IOUVM._loader_cache = {} # image path → loader command
|
||||
IOUVM._default_values_cache = {} # image path → (ram, nvram)
|
||||
```
|
||||
|
||||
Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values.
|
||||
|
||||
### 4. SQLite WAL Mode
|
||||
|
||||
**File:** `gns3server/db/tasks.py`
|
||||
|
||||
Write-Ahead Logging allows concurrent reads without blocking on writes. The PRAGMA is registered on `engine.sync_engine` instead of the `Engine` class to correctly fire for async engine connections.
|
||||
|
||||
```python
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
```
|
||||
|
||||
Without WAL mode, concurrent API requests caused `sqlite3.OperationalError: database is locked`.
|
||||
|
||||
### 5. API Key Authentication O(1) Lookup
|
||||
|
||||
**Files:** `gns3server/api/routes/controller/api_keys.py`, `gns3server/api/routes/controller/dependencies/authentication.py`
|
||||
|
||||
**Old format:** `gns3_<random>` — required scanning ALL keys and running bcrypt on each (O(n)).
|
||||
**New format:** `gns3_<api_key_id>_<random_secret>` — extract UUID from token, single DB query (O(1)), single bcrypt.
|
||||
|
||||
```python
|
||||
# New auth flow
|
||||
parts = token.split("_", 2)
|
||||
key_id = UUID(parts[1])
|
||||
secret = parts[2]
|
||||
db_key = await api_keys_repo.get_api_key(key_id) # O(1) lookup
|
||||
if await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.encode()):
|
||||
# Authenticated — 1 query + 1 bcrypt regardless of total key count
|
||||
```
|
||||
|
||||
### 6. bcrypt in Thread Pool
|
||||
|
||||
**File:** `gns3server/api/routes/controller/dependencies/authentication.py`
|
||||
|
||||
`bcrypt.checkpw()` is CPU-bound (~1.3s per call) and was blocking the async event loop. With 5 API keys and 10 concurrent requests, this caused ~13s delay before any handler could start.
|
||||
|
||||
```python
|
||||
# Before: blocking the event loop
|
||||
if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()):
|
||||
...
|
||||
|
||||
# After: offloaded to thread pool
|
||||
if await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.encode()):
|
||||
...
|
||||
```
|
||||
|
||||
### 7. Concurrency Settings
|
||||
|
||||
| Setting | Before | After | File |
|
||||
|---------|--------|-------|------|
|
||||
| Node creation Pool | 5 | 100 | `controller/project.py` |
|
||||
| Link creation Pool | 5 | 100 | `controller/project.py` |
|
||||
| MCP BATCH_MAX_WORKERS | 10 | 100 | `agent/mcp/nodes.py` |
|
||||
| MCP HTTP timeout | 10s | 30s | `agent/gns3_copilot/gns3_client/connector.py` |
|
||||
| HTTP connection pool | 10 (default) | 500/1000 | `agent/gns3_copilot/gns3_client/connector.py` |
|
||||
| Start nodes Pool | 3 | 3 (unchanged) | `controller/project.py` |
|
||||
|
||||
### 8. MCP Auth Returns JWT
|
||||
|
||||
**File:** `gns3server/agent/mcp/__init__.py`
|
||||
|
||||
When an MCP client connects with an API key, the `_resolve_token` function validates the key then returns a fresh short-lived JWT instead of the raw API key. The JWT is stored in a `ContextVar` and reused for all subsequent tool calls within the same SSE session — zero extra bcrypt.
|
||||
|
||||
```python
|
||||
if user:
|
||||
fresh_token = auth_service.create_access_token(user.username)
|
||||
return fresh_token # JWT for subsequent REST API calls
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `gns3server/controller/project.py` | Parallel link creation, batch UDP, Pool(100) |
|
||||
| `gns3server/compute/iou/iou_vm.py` | Image subprocess cache |
|
||||
| `gns3server/db/tasks.py` | WAL mode + sync_engine event listener |
|
||||
| `gns3server/api/routes/compute/compute.py` | Batch UDP endpoint |
|
||||
| `gns3server/controller/udp_link.py` | Pre-allocated port consumption |
|
||||
| `gns3server/api/routes/controller/api_keys.py` | O(1) key format |
|
||||
| `gns3server/api/routes/controller/dependencies/authentication.py` | O(1) auth + thread pool bcrypt |
|
||||
| `gns3server/agent/mcp/__init__.py` | Auth returns JWT, tool enhancements |
|
||||
| `gns3server/agent/mcp/nodes.py` | fields filter, inherited template_id, name passthrough |
|
||||
| `gns3server/agent/mcp/links.py` | fields filter, compact array format |
|
||||
| `gns3server/agent/gns3_copilot/gns3_client/connector.py` | Timeout 30s, connection pool 500/1000 |
|
||||
| `gns3server/utils/images.py` | md5sum cache error → warning |
|
||||
154
docs/features/refresh-token-mechanism.md
Normal file
154
docs/features/refresh-token-mechanism.md
Normal file
@ -0,0 +1,154 @@
|
||||
# Stateless JWT Refresh Token Mechanism
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 server now supports a stateless JWT refresh token mechanism for interactive sessions (e.g., Web UI). This allows clients to stay authenticated across page reloads without repeated username/password prompts, while keeping access tokens short-lived.
|
||||
|
||||
No new database table or migration is required — refresh tokens are signed JWTs using the same secret and algorithm as access tokens.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client -->|login / authenticate| API[Controller API]
|
||||
API -->|access_token + refresh_token| Client
|
||||
Client -->|POST /refresh| Refresh[Refresh Endpoint]
|
||||
Refresh -->|new access_token + new refresh_token| Client
|
||||
Client -->|Bearer access_token| Protected[Protected Endpoints]
|
||||
Protected -->|401| Client
|
||||
Client -->|refresh_token in body| Refresh
|
||||
Refresh -->|401 if invalid/expired/revoked| Client
|
||||
Refresh -->|verify type, exp, ver| AuthService[AuthService]
|
||||
AuthService -->|check token_version| DB[(users table)]
|
||||
```
|
||||
|
||||
## Business Process
|
||||
|
||||
### Login / Authenticate Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant API as Controller API
|
||||
participant AS as AuthService
|
||||
participant DB as Database
|
||||
|
||||
C->>API: POST /login or /authenticate (username + password)
|
||||
API->>DB: authenticate_user()
|
||||
DB-->>API: user (with token_version)
|
||||
API->>AS: create_access_token(user, ver)
|
||||
API->>AS: create_refresh_token(user, ver)
|
||||
AS-->>API: access_token (type: access, exp: 15min)
|
||||
AS-->>API: refresh_token (type: refresh, exp: 30d)
|
||||
API-->>C: { access_token, token_type, refresh_token }
|
||||
```
|
||||
|
||||
### Refresh Flow (Silent Renewal)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant API as Controller API
|
||||
participant AS as AuthService
|
||||
participant DB as Database
|
||||
|
||||
Note over C: access_token expired
|
||||
C->>API: POST /refresh { refresh_token }
|
||||
API->>AS: get_token_data(refresh_token)
|
||||
AS-->>API: { username, ver, token_use: "refresh" }
|
||||
API->>DB: get_user_by_username()
|
||||
DB-->>API: user (with current token_version)
|
||||
Note over API,DB: rejects if user not found, inactive, or token_version mismatch
|
||||
API->>AS: create_access_token(user, ver)
|
||||
API->>AS: create_refresh_token(user, ver)
|
||||
AS-->>API: new access_token (sliding window)
|
||||
AS-->>API: new refresh_token (sliding window)
|
||||
API-->>C: { access_token, token_type, refresh_token }
|
||||
C->>API: Retry original request with new access_token
|
||||
```
|
||||
|
||||
### Logout — Token Revocation
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant API as Controller API
|
||||
participant DB as Database
|
||||
|
||||
C->>API: POST /logout (Bearer access_token)
|
||||
API->>DB: logout_user(user_id) → token_version += 1
|
||||
DB-->>API: done
|
||||
API-->>C: 204 No Content
|
||||
Note over C, DB: All existing access and refresh tokens with old ver are now invalid
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description | Authentication |
|
||||
|--------|------|-------------|---------------|
|
||||
| POST | `/v3/access/users/login` | Login with form data, returns access + refresh tokens | Public |
|
||||
| POST | `/v3/access/users/authenticate` | Login with JSON, returns access + refresh tokens | Public |
|
||||
| POST | `/v3/access/users/refresh` | Exchange a refresh token for a new access token + refresh token | Public (token itself proves identity) |
|
||||
| POST | `/v3/access/users/logout` | Revoke all tokens for the current user | Bearer token required |
|
||||
|
||||
### POST /v3/access/users/refresh
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"refresh_token": "<refresh_token>"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"access_token": "<new_access_token>",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "<new_refresh_token>"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401` — Invalid, expired, or revoked refresh token
|
||||
- `422` — Missing `refresh_token` field in request body
|
||||
|
||||
## Security Design
|
||||
|
||||
### Token Claims
|
||||
|
||||
| Claim | Access Token | Refresh Token |
|
||||
|-------|-------------|---------------|
|
||||
| `sub` | username | username |
|
||||
| `exp` | 24h (configurable) | 30d (configurable) |
|
||||
| `ver` | user's `token_version` | user's `token_version` |
|
||||
| `type` | `"access"` | `"refresh"` |
|
||||
|
||||
### Key Security Properties
|
||||
|
||||
- **Type-based isolation**: Access tokens (`type: access`) are rejected by `/refresh`. Refresh tokens (`type: refresh`) are rejected by HTTP and WebSocket authentication paths. This prevents a stolen long-lived refresh token from being used directly for API access.
|
||||
- **Token version integration**: Both token types carry the user's `token_version`. `logout` increments `token_version` in the database, immediately invalidating all outstanding access and refresh tokens.
|
||||
- **Stateless (no replay detection)**: Since there is no `refresh_tokens` database table, a stolen refresh token remains valid until its `exp` or until the user logs out. This is an accepted trade-off for avoiding a new table and migration.
|
||||
- **Sliding window**: Each `/refresh` call issues a new refresh token with a fresh expiry, keeping active sessions alive indefinitely until logout or inactivity.
|
||||
|
||||
### Implementation Files
|
||||
|
||||
- `gns3server/services/authentication.py` — `_create_token`, `create_access_token`, `create_refresh_token`, `get_token_data`
|
||||
- `gns3server/api/routes/controller/users.py` — `refresh_access_token` endpoint handler
|
||||
- `gns3server/api/routes/controller/dependencies/authentication.py` — `_reject_refresh_token` guard in HTTP and WebSocket paths
|
||||
- `gns3server/schemas/controller/tokens.py` — `Token`, `TokenData`, `RefreshTokenRequest` models
|
||||
- `gns3server/schemas/config.py` — `jwt_refresh_token_expire_minutes` configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `Controller.jwt_access_token_expire_minutes` | 1440 (24h) | Access token TTL. Web UI recommends 15 min. |
|
||||
| `Controller.jwt_refresh_token_expire_minutes` | 43200 (30d) | Refresh token TTL. |
|
||||
| `Controller.jwt_secret_key` | (random) | HMAC signing key for all JWT tokens. |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Web UI integration**: The client should implement a response interceptor that catches 401, silently calls `/refresh`, and retries the original request. Multiple concurrent 401s should be queued with a single refresh request.
|
||||
- **No per-session revocation**: All tokens for a user share the same `token_version`. Logout revokes everything. Per-session granularity would require adding a `refresh_tokens` table.
|
||||
- **Rate limiting**: `/refresh` is a public endpoint with a valid credential (the refresh token). Rate limiting is recommended if brute-force attacks are a concern.
|
||||
221
docs/features/vendor-nos-xrd.md
Normal file
221
docs/features/vendor-nos-xrd.md
Normal file
@ -0,0 +1,221 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Cisco XRd Control Plane (Vendor NOS Adaptation)
|
||||
|
||||
## Overview
|
||||
|
||||
Cisco XRd Control Plane runs as a first-class GNS3 Docker router node by
|
||||
combining the existing vendor NOS path (`console_type: "docker_exec"` +
|
||||
`GNS3_SKIP_INIT=1`, see [docker-exec-console.md](./docker-exec-console.md))
|
||||
with four generic server mechanisms added for heavy/systemd NOS containers:
|
||||
`/dev/shm` and host-device injection, config-file injection (`extra_configs`),
|
||||
and udev masking. XRd itself is pure appliance configuration — no image
|
||||
rebuild, no source patching.
|
||||
|
||||
## Why XRd must take the vendor path
|
||||
|
||||
XRd boots `/usr/sbin/init` (systemd) as PID 1. GNS3's generic init.sh
|
||||
wrapper chain (`/gns3/init.sh → su → run-cmd.sh → /usr/sbin/init`) crashes
|
||||
XRd's glibc loader with `Fatal glibc error: dl-call-libc-early-init.c:37
|
||||
(sym != NULL)` (SIGABRT loop). With `GNS3_SKIP_INIT=1` the container runs
|
||||
its native entrypoint directly and boots cleanly — same arrangement as
|
||||
SR Linux.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Appliance["XRd appliance (.gns3a) — pure configuration"]
|
||||
ENV["environment: GNS3_SKIP_INIT / GNS3_CONSOLE_CMD / GNS3_MASK_UDEV / GNS3_SHM_SIZE / GNS3_DEVICES + XR_*"]
|
||||
XC["extra_configs: /firstboot.cfg"]
|
||||
XV["extra_volumes: /xr-storage + /xr-storage-shadow"]
|
||||
end
|
||||
subgraph Server["gns3-server (generic mechanisms)"]
|
||||
CREATE["DockerVM.create() HostConfig"]
|
||||
MASK["GNS3_MASK_UDEV → /dev/null binds"]
|
||||
HOSTCFG["ShmSize / Devices"]
|
||||
CFGINJ["extra_configs → RO single-file bind"]
|
||||
VBRIDGE["VendorDockerVM volume seeding + direct binds"]
|
||||
HOSTCHK["host-readiness check (read-only)"]
|
||||
end
|
||||
subgraph Container["XRd container"]
|
||||
SYSTEMD["systemd (/usr/sbin/init)"]
|
||||
XR["XR control plane"]
|
||||
XRS["/xr-storage (persisted, live data layer)"]
|
||||
end
|
||||
ENV --> CREATE --> SYSTEMD
|
||||
ENV --> MASK & HOSTCFG
|
||||
XC --> CFGINJ
|
||||
XV --> VBRIDGE --> XRS
|
||||
HOSTCHK -.->|"warn: inotify/file-max/fuse"| Server
|
||||
```
|
||||
|
||||
## Mechanisms added (all generic, XRd is just the first consumer)
|
||||
|
||||
| Mechanism | Interface | Effect | Where |
|
||||
|-----------|-----------|--------|-------|
|
||||
| shm size | `GNS3_SHM_SIZE=1024` (MB) in `environment` | native `HostConfig.ShmSize` at create time — works with or without init.sh | `docker_vm.py` `create()` |
|
||||
| host devices | `GNS3_DEVICES=/dev/fuse` (`docker run --device` syntax, space-separated) | native `HostConfig.Devices` | `docker_vm.py` `_format_devices()` |
|
||||
| config injection | `extra_configs: [{target, content}]` (template/node/appliance schema field) | content written under the node dir, bind-mounted **read-only** at `target` — seeds NOS startup configs without rebuilding the image | `docker_vm.py` `_mount_binds()`; persisted in `docker_templates.extra_configs` (Alembic migration) |
|
||||
| udev masking | `GNS3_MASK_UDEV=1` | `/dev/null` over the 5 udev systemd units **and** `/bin|/sbin|/usr/bin/udevadm` | `docker_vm.py` `create()` |
|
||||
| generic unit mask | `GNS3_MASK_SYSTEMD=u1,u2` | `/dev/null` over arbitrary `/etc/systemd/system/<unit>` | `docker_vm.py` `create()` |
|
||||
| graceful stop | `GNS3_STOP_TIMEOUT=60` (seconds; default 60, max 210) | explicit user stop sends SIGTERM + a grace period (Docker SIGKILLs once it expires) instead of the base class' immediate kill — systemd NOS images require a graceful shutdown; internal paths (delete/update/close) keep the immediate kill since the container is force-deleted right after. Max 210 keeps the +30 s HTTP margin inside the controller's 240 s stop budget | `vendor_docker_vm.py` `_terminate_container()` |
|
||||
| host check | automatic at Docker connect | read-only `/proc` check of inotify/file-max/FUSE; warns with exact fix commands (server is unprivileged — it can only check) | `compute/docker/__init__.py` `_check_host_readiness()` |
|
||||
|
||||
`GNS3_*` variables are consumed host-side only and never forwarded into the
|
||||
container (existing GNS3 behaviour); `XR_*` variables pass through normally.
|
||||
|
||||
## Host-disturbance root causes (all fixed)
|
||||
|
||||
A privileged systemd container can disturb the *host* desktop. Three
|
||||
independent causes were isolated with plain-`docker run` A/B/C experiments
|
||||
(udevd coldplug / busybox chown crash / direct `udevadm trigger`):
|
||||
|
||||
| Host symptom | Root cause | Fix |
|
||||
|---|---|---|
|
||||
| Audio muted on every node start | container `systemd-udevd` coldplug replays **all** devices it can see (privileged → host `/sys`) | `GNS3_MASK_UDEV=1` (unit masks) |
|
||||
| USB reconnects (mouse notification), journal noise | XRd's own `xr_startup.sh` calls `udevadm trigger --action=add --parent-match=<usb>` (USB license-dongle probing) — a direct binary call, unit masks don't stop it | `GNS3_MASK_UDEV=1` (udevadm null-bind) |
|
||||
| Same USB/journal noise + broken persistence | static busybox `chown` dlopens container NSS modules → glibc abort → per-file coredump storm → host `systemd-coredump` rescans devices | vendor volume path prefers the container's own `chown` (`vendor_docker_vm.py`) |
|
||||
|
||||
Diagnostics: `udevadm monitor --kernel --udev` (uevent stream),
|
||||
`docker exec <cid> grep -n udevadm /opt/cisco/install-iosxr/base/etc/xr_startup.sh`.
|
||||
Note: "journal corrupted" messages with varying machine-IDs come from the
|
||||
*container's* journald (random machine-id per start), not the host journal.
|
||||
|
||||
## XRd appliance recipe
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| `image` | official `ios-xr/xrd-control-plane:<ver>` — no wrapper image needed |
|
||||
| `console_type` | `docker_exec` |
|
||||
| `extra_volumes` | `["/xr-storage", "/xr-storage-shadow"]` |
|
||||
| `extra_configs` | `{target: /firstboot.cfg, content: <XR CLI first-boot config>}` |
|
||||
|
||||
```
|
||||
GNS3_SKIP_INIT=1
|
||||
GNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh
|
||||
GNS3_CONSOLE_RESIZE=0
|
||||
GNS3_MASK_UDEV=1
|
||||
GNS3_SHM_SIZE=1024
|
||||
GNS3_DEVICES=/dev/fuse
|
||||
GNS3_STOP_TIMEOUT=40
|
||||
XR_FIRST_BOOT_CONFIG=/firstboot.cfg
|
||||
XR_MGMT_INTERFACES=linux:eth0,xr_name=Mg0/RP0/CPU0/0,chksum,snoop_v4,snoop_v6
|
||||
XR_INTERFACES=linux:eth1,xr_name=Gi0/0/0/0;linux:eth2,xr_name=Gi0/0/0/1;...
|
||||
```
|
||||
|
||||
`GNS3_CONSOLE_RESIZE=0` (console geometry lock): the XR pager pages on the
|
||||
PTY window size and ignores `terminal length 0`, so the exec PTY must stay at
|
||||
the tall no-paging default for **every** client. The docker_exec console is a
|
||||
single shared exec — one browser's terminal-size resize (WS control frames →
|
||||
NAWS) would change the geometry concurrent netmiko/copilot sessions see and
|
||||
bring `--More--` back. XRd's CLI is line-oriented, so browsers lose nothing
|
||||
by not resizing. See
|
||||
[docker-exec-console.md](./docker-exec-console.md#terminal-geometry-and-size-forwarding).
|
||||
|
||||
XRd-specific gotchas (image-side, not GNS3):
|
||||
|
||||
- Management interface xr_name is **`Mg0/RP0/CPU0/0`** (short prefix, `CPU0`
|
||||
without slash). `MgmtEth0/RP0/CPU/0` is rejected: "not a valid
|
||||
rack/slot/instance/port combination".
|
||||
- `XR_INTERFACES` must list exactly `adapters − 1` data interfaces (eth0 is
|
||||
management). Changing the adapter count requires regenerating the string.
|
||||
- **Persistence layout**: in the *image*, `/xr-storage/{config,disk1,log,
|
||||
scratch}` are symlinks into `/xr-storage-shadow` (a pristine spare copy of
|
||||
the initial state). At boot the bootstrap replaces the symlinks with real
|
||||
directories, and XR writes everything — committed config (`commitdb`,
|
||||
`running`) included — into **`/xr-storage`**, never touching the shadow
|
||||
again. This mirrors containerlab, which bind-mounts `/xr-storage`
|
||||
(`nodes/xrd/xrd.go`: "persist data by mounting /xr-storage"). The
|
||||
appliance persists **both** paths so writes land on host regardless of
|
||||
whether they happen before or after the symlink→directory transition.
|
||||
- `XR_FIRST_BOOT_CONFIG` only applies when XR's config storage is empty
|
||||
(first boot). To re-seed, delete and recreate the node.
|
||||
- The official image ships no default login; the first-boot config must
|
||||
create one (e.g. `username admin / group root-lr / secret ...`).
|
||||
- Docker mounts are fixed at container *create* time: after changing a
|
||||
template's `extra_volumes`, existing nodes must be deleted and recreated
|
||||
(a stop/start keeps the old mounts).
|
||||
- Host sysctls (XRd's own requirements, same for containerlab):
|
||||
`fs.inotify.max_user_instances=64000`, `max_user_watches=524288`,
|
||||
`fs.file-max=1000000`, FUSE module loaded. GNS3 warns about these at
|
||||
Docker connect; the admin raises them once. XRd also warns (non-fatal)
|
||||
about `net.core.*` socket buffer sizes.
|
||||
|
||||
## Business process
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant S as gns3-server
|
||||
participant D as Docker daemon
|
||||
participant X as XRd container
|
||||
U->>S: create node from template
|
||||
S->>S: parse GNS3_* env host-side
|
||||
S->>D: seed volume host dirs from image (docker create + cp, first time only)
|
||||
S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind, volumes bound directly at /xr-storage*)
|
||||
U->>S: start
|
||||
S->>X: container start (native entrypoint /usr/sbin/init)
|
||||
Note over X: systemd boots; udevd + udevadm masked → host untouched
|
||||
S->>X: docker exec permission fix (container's own chown)
|
||||
U->>S: open console
|
||||
S->>X: docker exec pty: /pkg/bin/xr_cli.sh
|
||||
X-->>U: IOS XR CLI (first boot: apply /firstboot.cfg, save to /xr-storage-shadow)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| Node exits 139, `Fatal glibc error ... sym != NULL` in logs | init.sh wrapper path — set `GNS3_SKIP_INIT=1` **and** `console_type: docker_exec` (the flag is only honoured on the vendor class) |
|
||||
| `XR_FIRST_BOOT_CONFIG ... File not found` | env path and `extra_configs` target disagree (e.g. `/firstboot.cfg` vs `/first_boot.cfg`), or entry missing |
|
||||
| Console stuck at `Username:` with no credentials | image has no default user; provide a first-boot config creating one, then **recreate** the node (first-boot only runs on empty config storage) |
|
||||
| `Invalid interface entries ... XR_MGMT_INTERFACES` | use `xr_name=Mg0/RP0/CPU0/0` |
|
||||
| Host audio muted / USB reconnects when the node starts | set `GNS3_MASK_UDEV=1` |
|
||||
| Config lost across stop/start | `extra_volumes` must include `/xr-storage` (XR's live data layer; the shadow alone is only a pristine spare). Changing `extra_volumes` requires deleting and recreating the node — Docker mounts are fixed at create time |
|
||||
| Two nodes can't ping, only one side ARPs | GNS3 link wiring bug (UDP self-loop on one end), not XRd — fixed (port-allocation race); on older builds delete and re-create the link; see [link-udp-self-loop](../bugs/link-udp-self-loop.md) |
|
||||
| Compute log: busybox coredump storm | fixed by the container-chown change; verify gns3-server is current |
|
||||
|
||||
## Notes
|
||||
|
||||
- All four mechanisms are opt-in: nodes that don't set the variables or the
|
||||
field get byte-identical container configuration.
|
||||
- `extra_configs` is a schema field (unlike the env knobs) because the
|
||||
`environment` field is line-delimited and cannot carry multi-line file
|
||||
content.
|
||||
- Template fields live in three places (pydantic schema, DB column, Alembic
|
||||
migration) — see the `extra_configs` DB migration when adding new ones.
|
||||
- `net.core.*` socket-buffer requirements are not yet part of the
|
||||
host-readiness check (XRd warns about them itself, non-fatally).
|
||||
|
||||
## References
|
||||
|
||||
- `gns3server/compute/docker/docker_vm.py` — HostConfig env injection,
|
||||
`_UDEV_UNITS`/`_UDEVADM_PATHS`, `extra_configs` binds, `_format_devices()`
|
||||
- `gns3server/compute/docker/vendor_docker_vm.py` — vendor path, volume
|
||||
seeding + direct binds, container-chown
|
||||
- `gns3server/compute/docker/__init__.py` — `_check_host_readiness()`
|
||||
- `gns3server/schemas/common.py` — `ExtraConfig`
|
||||
- `gns3server/db/models/templates.py` + `db_migrations/` — persistence
|
||||
- [docker-exec-console.md](./docker-exec-console.md) — the vendor NOS base
|
||||
(docker_exec console, SKIP_INIT volume persistence)
|
||||
- containerlab `nodes/xrd/xrd.go` — reference for XRd env defaults and
|
||||
`/xr-storage` persistence
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.6 | 2026-08-21 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp`) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge, which raced the NOS reading its startup config (visible on SR Linux: factory boot after a server restart + project reload; XRd was immune only because systemd touches `/xr-storage` tens of seconds in). No behaviour change for XRd beyond the race removal. |
|
||||
| 1.5 | 2026-08-20 | Appliance env gains `GNS3_CONSOLE_RESIZE=0`: client-driven console resizes are ignored so the shared exec PTY stays at the tall no-paging geometry for concurrent netmiko/copilot sessions (browsers included). |
|
||||
| 1.4 | 2026-08-15 | Code-review hardening: stop-query HTTP timeout scales with `GNS3_STOP_TIMEOUT` (values >300 s no longer abort); overlapping mask/config bind targets deduplicated (Docker "Duplicate mount point"); `ExtraConfig.target` validated at save time and directory forms rejected; host-readiness check no longer aborts on one unreadable `/proc/sys` key; base env parser strips trailing commas; vendor env knobs re-parsed on create (PUT environment takes effect); graceful stop limited to explicit user stop (delete/update/close keep the immediate kill); extra_configs under a persisted volume warns. |
|
||||
| 1.3 | 2026-08-14 | Persistence corrected: XR's live data layer is `/xr-storage` (the image's symlink farm is materialized into real directories at bootstrap; `/xr-storage-shadow` is a pristine spare) — the appliance persists both. Vendor containers now stop gracefully (SIGTERM + `GNS3_STOP_TIMEOUT` grace, default 60 s) instead of being SIGKILLed on the spot. |
|
||||
| 1.2 | 2026-08-14 | Link UDP self-loop root-caused to a port-allocation race and fixed — see [link-udp-self-loop](../bugs/link-udp-self-loop.md) (now marked Fixed). |
|
||||
| 1.1 | 2026-08-14 | Datapath validated end-to-end (XRd brings its own interfaces up; ARP/ICMP bidirectional). Add troubleshooting entry for the one-way-link symptom (GNS3 link UDP self-loop bug, see bugs/link-udp-self-loop.md). |
|
||||
| 1.0 | 2026-08-14 | Initial documentation of the XRd control-plane adaptation: vendor path requirement, shm/devices/extra_configs/udev-mask mechanisms, host-disturbance root causes, appliance recipe. |
|
||||
@ -58,7 +58,7 @@ graph LR
|
||||
**URL**: `ws://{controller_host}:{port}/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={jwt_token}`
|
||||
|
||||
**Authentication**:
|
||||
- JWT token via query parameter
|
||||
- JWT token via query parameter, **or** a short-lived console ticket (`gns3t_…`, minted per node by the `node_console` MCP tool, valid 10 min, bound to this node's console endpoints)
|
||||
- User must have `Node.Console` privilege
|
||||
|
||||
**WebSocket Subprotocols**:
|
||||
@ -179,7 +179,7 @@ sequenceDiagram
|
||||
|
||||
1. **Authentication**:
|
||||
- JWT token validation via `has_privilege_on_websocket("Node.Console")` dependency
|
||||
- Token passed as query parameter: `?token={jwt}`
|
||||
- Token passed as query parameter: `?token={jwt}`, or a console ticket (`gns3t_…`) redeemable only on the node it was minted for
|
||||
|
||||
2. **Authorization**:
|
||||
- RBAC privilege check: `Node.Console`
|
||||
|
||||
161
docs/gns3-copilot/implemented/ai-assistant-overview.en.md
Normal file
161
docs/gns3-copilot/implemented/ai-assistant-overview.en.md
Normal file
@ -0,0 +1,161 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
# GNS3-Copilot AI Assistant Overview
|
||||
|
||||
## Overall Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Client"
|
||||
A["Web UI"] --> B["SSE Streaming"]
|
||||
end
|
||||
|
||||
subgraph "FastAPI Route Layer"
|
||||
B --> C["POST /chat/stream\nPOST /chat/inject"]
|
||||
C --> D["Auth + LLM Config Loading\nSet ContextVars"]
|
||||
end
|
||||
|
||||
subgraph "AgentService (Project-level)"
|
||||
D --> E["LangGraph Agent\nStateGraph"]
|
||||
E --> F["SQLite Checkpointer\ncopilot_checkpoints.db"]
|
||||
end
|
||||
|
||||
subgraph "LangGraph Workflow"
|
||||
E --> G["llm_call node\nmodel invocation"]
|
||||
E --> H["tool_node\ntool execution"]
|
||||
E --> I["title_generator_node\nauto title"]
|
||||
E --> J["abort_handler_node\ninterrupt handling"]
|
||||
end
|
||||
|
||||
subgraph "Three Copilot Modes"
|
||||
G --> K["teaching_assistant\ndiagnostic read-only"]
|
||||
G --> L["lab_automation_assistant\nfull control"]
|
||||
G --> M["troubleshooting_injection\nfault injection"]
|
||||
end
|
||||
|
||||
subgraph "LLM Config System"
|
||||
D --> N["User configs\nGroup config inheritance\nAPI key encryption"]
|
||||
end
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Function |
|
||||
|---|---|
|
||||
| `POST /v3/projects/{pid}/chat/stream` | Streaming conversation (SSE), supports three copilot modes |
|
||||
| `POST /v3/projects/{pid}/chat/inject` | Fault injection entry, auto-switches to `troubleshooting_injection` mode |
|
||||
| `GET /v3/projects/{pid}/chat/sessions` | List sessions (supports filtering, pagination) |
|
||||
| `DELETE /v3/projects/{pid}/chat/sessions/{sid}` | Delete session |
|
||||
| `PATCH /v3/projects/{pid}/chat/sessions/{sid}` | Update session (rename, pin) |
|
||||
| `POST /v3/projects/{pid}/chat/sessions/{sid}/abort` | Abort an active session |
|
||||
|
||||
## LangGraph Agent Workflow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant API as FastAPI
|
||||
participant AS as AgentService
|
||||
participant LLM as LLM Node
|
||||
participant Tool as Tool Node
|
||||
participant TGen as Title Node
|
||||
|
||||
U->>API: send message
|
||||
API->>AS: stream_chat()
|
||||
AS->>AS: set ContextVars<br/>(jwt_token, llm_config)
|
||||
|
||||
Note over AS,LLM: llm_call node
|
||||
AS->>LLM: invoke pre-compiled model
|
||||
LLM->>LLM: pre_model_hook<br/>inject topology + trim context
|
||||
LLM-->>AS: AI reply (may include tool_calls)
|
||||
|
||||
opt has tool calls
|
||||
AS->>Tool: execute tools
|
||||
Tool-->>AS: tool results
|
||||
AS->>LLM: continue LLM call
|
||||
end
|
||||
|
||||
opt first turn and no title
|
||||
AS->>TGen: auto-generate title
|
||||
TGen-->>AS: session title
|
||||
end
|
||||
|
||||
AS-->>API: SSE streaming response
|
||||
API-->>U: stream output
|
||||
```
|
||||
|
||||
## Three Copilot Modes
|
||||
|
||||
### Mode Comparison
|
||||
|
||||
| Mode | Tool Scope | Use Case |
|
||||
|---|---|---|
|
||||
| `teaching_assistant` (default) | Diagnostic read-only + packet analysis + node management | Teaching demos, troubleshooting guidance |
|
||||
| `lab_automation_assistant` | All tools (including config changes) | Lab automation, device configuration |
|
||||
| `troubleshooting_injection` | Fault injection tool set | Troubleshooting practice, fault simulation |
|
||||
|
||||
### Tool Binding Details
|
||||
|
||||
| Tool | teaching_assistant | lab_automation_assistant | troubleshooting_injection |
|
||||
|---|---|---|---|
|
||||
| `GNS3TemplateTool` get templates | ✓ | ✓ | |
|
||||
| `GNS3CreateNodeTool` create nodes | ✓ | ✓ | |
|
||||
| `GNS3LinkTool` create links | ✓ | ✓ | |
|
||||
| `GNS3StartNodeTool` start nodes | ✓ | ✓ | |
|
||||
| `GNS3UpdateNodeNameTool` rename | ✓ | ✓ | |
|
||||
| `GNS3StopNodeTool` stop nodes | | ✓ | |
|
||||
| `GNS3SuspendNodeTool` suspend nodes | | ✓ | |
|
||||
| `ExecuteMultipleDeviceCommands` read-only commands | ✓ | ✓ | ✓ |
|
||||
| `ExecuteMultipleDeviceConfigCommands` config commands | | ✓ | ✓ |
|
||||
| `VPCSCommands` VPCS commands | | ✓ | |
|
||||
| `PacketAnalysisTool` live packet analysis | ✓ | ✓ | |
|
||||
| `PacketAnalysisSkillsTool` protocol knowledge | ✓ | ✓ | |
|
||||
| `DeviceSkillsTool` device skills | ✓ | ✓ | |
|
||||
| `GNS3PacketFilterTool` link filters | | | ✓ |
|
||||
| `InjectionSkillsTool` fault injection skills | | | ✓ |
|
||||
| `GNS3TopologyTool` topology info | | | ✓ |
|
||||
|
||||
The mode is selected in the `llm_call` node via `copilot_mode`, which picks the corresponding tool list and binds it to the LLM model instance through `create_base_model_with_tools(mode_tools, llm_config)`.
|
||||
|
||||
## Context Window Management
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["LLM call triggered"] --> B["pre_model_hook"]
|
||||
B --> C["Inject topology\ninto System Prompt"]
|
||||
B --> D["Estimate tool definition\ntoken cost"]
|
||||
B --> E["trim_messages\nby strategy"]
|
||||
E --> F["conservative 60%\nbalanced 75%\naggressive 85%"]
|
||||
F --> G["Invoke LLM"]
|
||||
```
|
||||
|
||||
- Accurate token counting via tiktoken (`cl100k_base`)
|
||||
- Three trimming strategies: conservative / balanced / aggressive
|
||||
- Auto-injects `{{topology_info}}` into System Prompt
|
||||
|
||||
## Session Management
|
||||
|
||||
- Per-project independent SQLite database (`gns3-copilot/copilot_checkpoints.db`)
|
||||
- Supports pin, rename, delete, history query
|
||||
- Auto-records token usage, message count, LLM call count
|
||||
|
||||
## LLM Config System
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| User-level configs | Each user can independently configure provider / model / api_key |
|
||||
| Group inheritance | Users auto-inherit group config when no personal config is set |
|
||||
| API key encryption | Auto-encrypted at database storage |
|
||||
| Optimistic locking | `version` field prevents concurrent modification conflicts |
|
||||
|
||||
## Key Design Points
|
||||
|
||||
1. **Project-level Isolation** — Each GNS3 project has its own Agent instance and SQLite storage
|
||||
2. **ContextVars Safe Passing** — JWT token, API key exist only in memory, auto-cleared when request ends
|
||||
3. **LangGraph StateGraph** — Custom nodes + conditional edges, supports ReAct loop and recursion limits
|
||||
4. **SSE Streaming** — Real-time push of content / tool_call / tool_start / tool_end / error / done events
|
||||
5. **Hot Reload** — System Prompt, Skills, Protocols all support runtime reload
|
||||
6. **Mode-based Tool Sets** — Three copilot modes bind different tools, safely isolated by scenario
|
||||
@ -86,7 +86,7 @@ flowchart TD
|
||||
|
||||
### Forbidden Commands Configuration
|
||||
|
||||
The forbidden commands list is loaded from the external [GNS3-Skills](https://github.com/yueguobin/GNS3-Skills) repository at `config/forbidden_commands.txt`.
|
||||
The forbidden commands list is loaded from the external [GNS3-Skills](https://github.com/gns3/gns3-skills) repository at `config/forbidden_commands.txt`.
|
||||
|
||||
**Format:**
|
||||
- One command pattern per line
|
||||
@ -168,7 +168,7 @@ Applies to any command with embedded newlines: `banner`, multi-line ACLs, route-
|
||||
|
||||
### Customizing Forbidden Commands
|
||||
|
||||
Edit `config/forbidden_commands.txt` in the [GNS3-Skills repository](https://github.com/yueguobin/GNS3-Skills) and push the changes, then call `POST /copilot/reload/skills` to apply them without restarting the server.
|
||||
Edit `config/forbidden_commands.txt` in the [GNS3-Skills repository](https://github.com/gns3/gns3-skills) and push the changes, then call `POST /copilot/reload/skills` to apply them without restarting the server.
|
||||
|
||||
## Implementation Verification
|
||||
|
||||
|
||||
104
docs/gns3-copilot/implemented/fault-injection-overview.en.md
Normal file
104
docs/gns3-copilot/implemented/fault-injection-overview.en.md
Normal file
@ -0,0 +1,104 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
# GNS3-Copilot Fault Injection Overview
|
||||
|
||||
## Core Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "① API Trigger & Mode Switch"
|
||||
A["POST /chat/inject\nUser requests fault injection"] --> B["Verify project is opened"]
|
||||
B --> C["Set copilot_mode =\ntroubleshooting_injection"]
|
||||
C --> D["Start Agent\nwith fault injection tool set"]
|
||||
end
|
||||
|
||||
subgraph "② Topology Analysis & Fault Selection"
|
||||
D --> E["GNS3TopologyTool\nget topology info"]
|
||||
E --> F["ExecuteMultipleDeviceCommands\nget device configs"]
|
||||
F --> G["InjectionSkillsTool\nquery available fault types"]
|
||||
G --> H{"Injection Skills Repository\ngns3/gns3-skills"}
|
||||
H --> I["Return matching fault definitions\nwith config injection commands"]
|
||||
end
|
||||
|
||||
subgraph "③ Fault Injection"
|
||||
I --> J["Choose injection method"]
|
||||
J --> K["ExecuteMultipleDeviceConfigCommands\ninject config changes"]
|
||||
J --> L["GNS3PacketFilterTool\ninject link-layer faults"]
|
||||
end
|
||||
|
||||
subgraph "④ Result Confirmation"
|
||||
K --> M["Verify fault is active"]
|
||||
L --> M
|
||||
M --> N["Document fault details\nincluding restore commands"]
|
||||
end
|
||||
```
|
||||
|
||||
## Tool Overview
|
||||
|
||||
| Tool | Source File | Purpose | Available Modes |
|
||||
|---|---|---|---|
|
||||
| `InjectionSkillsTool` | `registry.py` (skills module) | Query protocol-level fault definitions (config change commands) | troubleshooting_injection |
|
||||
| `GNS3PacketFilterTool` | `gns3_packet_filter.py` | Link-layer fault injection (delay, loss, corruption, BPF) | troubleshooting_injection |
|
||||
| `ExecuteMultipleDeviceConfigCommands` | `config_tools_nornir.py` | Batch device config changes | troubleshooting_injection |
|
||||
| `ExecuteMultipleDeviceCommands` | `display_tools_nornir.py` | Read device configurations (read-only) | troubleshooting_injection |
|
||||
| `GNS3TopologyTool` | `gns3_client` | Get project topology information | troubleshooting_injection |
|
||||
|
||||
## Fault Injection API
|
||||
|
||||
| Endpoint | Function |
|
||||
|---|---|
|
||||
| `POST /v3/projects/{pid}/chat/inject` | Trigger fault injection, sets `troubleshooting_injection` mode then starts Agent |
|
||||
|
||||
**Prerequisite**: Project must be in `opened` status, otherwise returns 403.
|
||||
|
||||
## GNS3PacketFilterTool Link Filters
|
||||
|
||||
| Filter Type | Function | Parameters |
|
||||
|---|---|---|
|
||||
| `delay` | Latency + jitter | `[latency(0-32767), jitter(0-32767)]` |
|
||||
| `packet_loss` | Packet loss percentage | `[chance(0-100)]` |
|
||||
| `corrupt` | Packet corruption percentage | `[chance(0-100)]` |
|
||||
| `frequency_drop` | Drop every Nth packet | `[frequency(-1~32767)]` |
|
||||
| `bpf` | Berkeley Packet Filter | expression text |
|
||||
|
||||
## Agent Workflow (LangGraph)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant API as POST /chat/inject
|
||||
participant LLM as LLM Node
|
||||
participant Topo as GNS3TopologyTool
|
||||
participant DC as ExecuteMultipleDeviceCommands
|
||||
participant CC as ExecuteMultipleDeviceConfigCommands
|
||||
participant Skill as InjectionSkillsTool
|
||||
participant Filter as GNS3PacketFilterTool
|
||||
|
||||
U->>API: Inject an OSPF fault
|
||||
API->>LLM: set mode=troubleshooting_injection
|
||||
LLM->>Topo: get topology
|
||||
Topo-->>LLM: topology info
|
||||
LLM->>DC: read device configs
|
||||
DC-->>LLM: running configs
|
||||
LLM->>Skill: list context=["ospf"]
|
||||
Skill-->>LLM: matching fault types
|
||||
LLM->>Skill: get device_type=injection_ospf
|
||||
Skill-->>LLM: fault definition + injection commands
|
||||
LLM->>CC: execute config injection
|
||||
CC-->>LLM: injection result
|
||||
LLM->>Filter: set filters={delay:[200,50]}
|
||||
Filter-->>LLM: link delay injected successfully
|
||||
LLM-->>U: Fault injected, restore commands included
|
||||
```
|
||||
|
||||
## Key Design Points
|
||||
|
||||
1. **Dedicated API Endpoint** — `POST /chat/inject` is the dedicated entry point, automatically switching to `troubleshooting_injection` mode
|
||||
2. **LLM-driven Fault Selection** — The LLM analyzes the topology then queries matching faults via `InjectionSkillsTool`; no hardcoded fault scenarios
|
||||
3. **Dual-Layer Injection** — Device-level config changes + link-level network impairment, covering complete troubleshooting scenarios
|
||||
4. **Fully Reversible** — Every injection includes restore commands; link filters can be cleared with `action: clear`
|
||||
5. **Safety First** — BPF syntax is pre-validated via tshark; config commands are restricted by `command_filter`
|
||||
6. **Context Filtering** — `InjectionSkillsTool` requires a `context` parameter, returning only faults matching the topology protocols
|
||||
@ -121,7 +121,7 @@ sequenceDiagram
|
||||
|
||||
## Injection Skills Repository
|
||||
|
||||
Skills are organized by protocol/category in the external [GNS3-Skills](https://github.com/yueguobin/GNS3-Skills) repository:
|
||||
Skills are organized by protocol/category in the external [GNS3-Skills](https://github.com/gns3/gns3-skills) repository:
|
||||
|
||||
| Category | File | Example Issues |
|
||||
|----------|------|----------------|
|
||||
|
||||
@ -516,29 +516,39 @@ gns3server/agent/gns3_copilot/tools_v2/
|
||||
|
||||
### API Integration
|
||||
|
||||
The tools use the `Node` and `Link` classes from `custom_gns3fy`:
|
||||
The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_handlers`), the same functions the MCP service exposes as MCP tools:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node, Link, get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx, create_node_handler, create_link_handler,
|
||||
get_nodes_handler, start_node_handler, stop_node_handler,
|
||||
suspend_node_handler, update_node_handler,
|
||||
)
|
||||
|
||||
# Get templates
|
||||
templates = get_gns3_connector().get_templates()
|
||||
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
|
||||
|
||||
# Create node
|
||||
node = Node(project_id=project_id, template_id=template_id, x=x, y=y, connector=gns3_server)
|
||||
node.create()
|
||||
# Create node (single POST, batch mode is parallel)
|
||||
created = create_node_handler(
|
||||
{"project_id": project_id, "template_id": template_id, "x": x, "y": y, "name": name},
|
||||
gns3_ctx,
|
||||
)
|
||||
|
||||
# Create link
|
||||
link = Link(project_id=project_id, connector=gns3_server, nodes=[...])
|
||||
link.create()
|
||||
link = create_link_handler(
|
||||
{"project_id": project_id, "nodes": [{"node_id": nid, "adapter_number": 0, "port_number": 0}, ...]},
|
||||
gns3_ctx,
|
||||
)
|
||||
|
||||
# Update node name
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.update(name=new_name)
|
||||
# Update node name — the PUT response is the updated node
|
||||
updated = update_node_handler({"project_id": project_id, "node_id": node_id, "name": new_name}, gns3_ctx)
|
||||
|
||||
# Start/stop/suspend node
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.start() # or node.stop() / node.suspend()
|
||||
# Start/stop/suspend nodes (node_ids batch runs in parallel)
|
||||
start_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
stop_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
suspend_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
|
||||
# Node listing/status (single call for the whole project)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
74
docs/gns3-copilot/implemented/packet-analysis-overview.en.md
Normal file
74
docs/gns3-copilot/implemented/packet-analysis-overview.en.md
Normal file
@ -0,0 +1,74 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
# GNS3-Copilot Real-time Packet AI Analysis Overview
|
||||
|
||||
## Core Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "① Analysis Trigger & Knowledge Query"
|
||||
A["User asks\n'e.g. Analyze OSPF neighbor state'"] --> B["LLM calls\nPacketAnalysisSkillsTool"]
|
||||
B --> C{"Protocol Knowledge Repository\ngns3/gns3-skills"}
|
||||
C --> D["Returns protocol definition\nfields/base_filter/check_rules"]
|
||||
B --> E["LLM calls\nsearch_fields mode"]
|
||||
E --> F["tshark -G fields\nfield name search"]
|
||||
F --> G["Returns valid field names"]
|
||||
end
|
||||
|
||||
subgraph "② Live Capture & Analysis"
|
||||
D --> H["LLM constructs tshark_args"]
|
||||
G --> H
|
||||
H --> I["PacketAnalysisTool\ncapture analysis mode"]
|
||||
I --> J["GET /capture/file\ndownload live PCAP"]
|
||||
J --> K["Pre-validate -e field names"]
|
||||
K --> L["tshark -r pcap\nrun analysis"]
|
||||
L --> M["Return analysis results"]
|
||||
end
|
||||
```
|
||||
|
||||
## Tool Overview
|
||||
|
||||
| Tool | Source File | Purpose | Available Modes |
|
||||
|---|---|---|---|
|
||||
| `PacketAnalysisTool` | `packet_analysis_tool.py` | Download live PCAP + tshark analysis | teaching / lab_automation |
|
||||
| `PacketAnalysisSkillsTool` | `registry.py` (skills module) | Query protocol-level analysis knowledge (fields, filters) | teaching / lab_automation |
|
||||
|
||||
## Agent Workflow (LangGraph)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant LLM as LLM Node
|
||||
participant Skills as PacketAnalysisSkillsTool
|
||||
participant Pcap as PacketAnalysisTool
|
||||
|
||||
U->>LLM: OSPF neighbors can't establish, analyze
|
||||
LLM->>Skills: get protocol=ospf
|
||||
Skills-->>LLM: OSPF fields, filter definitions
|
||||
LLM->>Pcap: search_fields query=ospf.hello
|
||||
Pcap-->>LLM: valid -e field names
|
||||
LLM->>Pcap: download PCAP + tshark_args
|
||||
Pcap-->>LLM: tshark output results
|
||||
LLM->>LLM: analysis reveals Dead interval mismatch
|
||||
LLM-->>U: OSPF Dead interval mismatch detected
|
||||
```
|
||||
|
||||
## Server Capture API
|
||||
|
||||
| Endpoint | Function |
|
||||
|---|---|
|
||||
| `POST /v3/projects/{pid}/links/{lid}/capture/start` | Start packet capture on a link |
|
||||
| `POST /v3/projects/{pid}/links/{lid}/capture/stop` | Stop packet capture |
|
||||
| `GET /v3/projects/{pid}/links/{lid}/capture/file` | Download PCAP file (available even while capture is active) |
|
||||
| `GET /v3/projects/{pid}/links/{lid}/capture/stream` | Stream PCAP data |
|
||||
| `WS /v3/projects/{pid}/links/{lid}/capture/web-wireshark` | Web Wireshark WebSocket proxy |
|
||||
|
||||
## Key Design Points
|
||||
|
||||
1. **LLM-driven Analysis** — The LLM constructs tshark parameters itself; the framework does not hardcode protocol logic, only performs safety validation
|
||||
2. **Live PCAP** — Captures can be downloaded and analyzed while running, no need to stop capturing
|
||||
3. **Dual Knowledge Sources** — External repository provides protocol-specific knowledge; local tshark field registry provides exact field names
|
||||
4. **Safety First** — Pre-validation of tshark field names prevents execution failures from invalid fields
|
||||
182
docs/gns3-copilot/implemented/packet-analysis.md
Normal file
182
docs/gns3-copilot/implemented/packet-analysis.md
Normal file
@ -0,0 +1,182 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Protocol-Oriented Packet Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Copilot provides protocol-oriented packet analysis that allows the AI assistant to diagnose network issues from live GNS3 captures. Protocol definitions (tshark fields, display filters, check rules) are stored as YAML files in the external GNS3-Skills repository and loaded into memory at startup.
|
||||
|
||||
The system exposes two LangChain tools: `PacketAnalysisSkillsTool` queries protocol definitions, and `PacketAnalysisTool` runs tshark against downloaded pcap files using the LLM-constructed arguments.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "GNS3-Skills Repository"
|
||||
YAML[packet_analysis/*.yaml<br/>40+ protocol files]
|
||||
end
|
||||
|
||||
subgraph "GNS3 Server"
|
||||
SM[SkillsManager]
|
||||
SL[SkillsLoader]
|
||||
REG[PACKET_ANALYSIS_REGISTRY<br/>in-memory dict]
|
||||
SKILL[PacketAnalysisSkillsTool<br/>query protocol definitions]
|
||||
TOOL[PacketAnalysisTool<br/>run tshark on captures]
|
||||
end
|
||||
|
||||
subgraph "tshark"
|
||||
FIELDS["tshark -G fields"<br/>live field registry]
|
||||
CAP["tshark -r pcap"<br/>packet capture analysis]
|
||||
end
|
||||
|
||||
YAML -->|load at startup / reload| SL
|
||||
SL --> REG
|
||||
SM --> SL
|
||||
|
||||
SKILL -->|read protocol fields/filters| REG
|
||||
TOOL -->|validate -e fields| FIELDS
|
||||
TOOL -->|download pcap + run tshark| CAP
|
||||
```
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
Definitions are loaded from `GNS3-Skills/packet_analysis/*.yaml`. The following protocol families are covered:
|
||||
|
||||
| Category | Protocols |
|
||||
|----------|-----------|
|
||||
| Network Layer | ip, ipv6, arp, icmp, icmpv6 |
|
||||
| Transport Layer | tcp, udp |
|
||||
| Data Link | ethernet, ppp, hdlc, frame_relay, vlan, isl, llc |
|
||||
| Routing | ospf, eigrp, rip, bgp, isis, pim, dvmrp |
|
||||
| Link Protocols | l2tp, lacp, pagp, udld |
|
||||
| Infrastructure | cdp, lldp, stp, vtp, dtp |
|
||||
| Management | snmp, telnet, ssh, radius, tacacs |
|
||||
| Application | dns, http, bootp, dhcp |
|
||||
| Tunneling / Security | gre, esp, ah, mpls, eapol, ssl, isakmp |
|
||||
| Miscellaneous | nbns, slarp, ocsp, wccp, auto_rp, loop |
|
||||
|
||||
Each protocol YAML contains:
|
||||
|
||||
```yaml
|
||||
name: "OSPF Packet Analysis"
|
||||
description: "Analyze OSPF routing protocol packets"
|
||||
protocol_key: "ospf"
|
||||
display_filter: "ospf"
|
||||
fields:
|
||||
- label: "Source IP"
|
||||
tshark_field: "ip.src"
|
||||
description: "Source IPv4 address"
|
||||
- label: "OSPF Message Type"
|
||||
tshark_field: "ospf.msg"
|
||||
description: "1=Hello, 2=DBD, 3=LSR, 4=LSU, 5=LSAck"
|
||||
filter_examples:
|
||||
- description: "Show OSPF Hello packets"
|
||||
filter: "ospf.msg == 1"
|
||||
checks:
|
||||
- name: hello_dead_mismatch
|
||||
severity: critical
|
||||
message: "Hello/Dead Interval mismatch between neighbors"
|
||||
```
|
||||
|
||||
## Analysis Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant LLM as LLM Agent
|
||||
participant SKILL as PacketAnalysisSkillsTool
|
||||
participant TOOL as PacketAnalysisTool
|
||||
participant tshark as tshark
|
||||
participant GNS3 as GNS3 Server
|
||||
|
||||
LLM->>SKILL: {"action": "list"}
|
||||
SKILL-->>LLM: Available protocols
|
||||
|
||||
LLM->>SKILL: {"action": "get", "protocol": "ospf"}
|
||||
SKILL-->>LLM: Fields, filters, check rules
|
||||
|
||||
Note over LLM: LLM constructs tshark_args<br/>using protocol knowledge
|
||||
|
||||
LLM->>TOOL: {project_id, link_id,<br/>tshark_args: "-Y ospf -T fields -e ip.src -e ospf.msg"}
|
||||
TOOL->>TOOL: Validate -e field names<br/>against tshark -G fields
|
||||
TOOL->>GNS3: Download pcap for link_id
|
||||
GNS3-->>TOOL: Capture file
|
||||
TOOL->>tshark: tshark -r pcap -Y ospf -T fields -e ip.src -e ospf.msg
|
||||
tshark-->>TOOL: Tab-separated output
|
||||
TOOL-->>LLM: Raw tshark output
|
||||
```
|
||||
|
||||
## Tool Registration
|
||||
|
||||
The packet analysis tools are available in two copilot modes:
|
||||
|
||||
| Tool | Teaching Assistant | Lab Automation | Troubleshooting Injection |
|
||||
|------|:------------------:|:--------------:|:-------------------------:|
|
||||
| `PacketAnalysisTool` | Yes | Yes | No |
|
||||
| `PacketAnalysisSkillsTool` | Yes | Yes | No |
|
||||
|
||||
## Tool Interface
|
||||
|
||||
### PacketAnalysisSkillsTool (`packet_analysis_skills`)
|
||||
|
||||
Queries protocol definitions from the `PACKET_ANALYSIS_REGISTRY`. Used before running tshark to look up valid field names, display filters, and anomaly checks.
|
||||
|
||||
| Action | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| List protocols | `{"action": "list"}` | `{count, protocols: [{protocol, name, description}]}` |
|
||||
| Get protocol | `{"action": "get", "protocol": "ospf"}` | Protocol definition with fields, filters, checks |
|
||||
|
||||
### PacketAnalysisTool (`packet_analysis`)
|
||||
|
||||
Runs tshark against a downloaded GNS3 capture file. Supports two modes:
|
||||
|
||||
**Capture analysis mode:**
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `project_id` | Yes | UUID of the GNS3 project |
|
||||
| `link_id` | Yes | UUID of the link to analyze |
|
||||
| `tshark_args` | Yes | tshark arguments (after `-r <pcap>`) |
|
||||
|
||||
**Field search mode:**
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `action` | Yes | `"search_fields"` |
|
||||
| `query` | Yes | Single keyword (e.g., `"ospf.lsa"`, `"bgp.open"`) |
|
||||
|
||||
### Validation and Error Handling
|
||||
|
||||
Before downloading the capture, the tool validates `-e` field names against the live tshark field registry (`tshark -G fields`). Invalid field names are rejected early with a hint to use `search_fields`.
|
||||
|
||||
| Scenario | Response |
|
||||
|----------|----------|
|
||||
| Invalid `-e` field name | `{error, hint, invalid_fields}` |
|
||||
| tshark filter/field error | `{error: "tshark argument error", hints}` |
|
||||
| Empty capture file | `{error: "Capture file is empty"}` |
|
||||
| No matching packets | `{result: "No matching packets found", hints}` |
|
||||
| tshark timeout (30s) | `{error: "tshark timeout after 30 seconds"}` |
|
||||
| tshark not installed | `{error: "tshark not installed"}` |
|
||||
|
||||
When `-c` is used and no results are found, the tool hints that `-c` limits total packets read (not matched count), and suggests removing it or piping to `head`.
|
||||
|
||||
## Hot Reload
|
||||
|
||||
Packet analysis protocols can be reloaded without restarting the server via the existing reload API:
|
||||
|
||||
```
|
||||
POST /v3/copilot/reload/skills
|
||||
```
|
||||
|
||||
This triggers `SkillsManager.reload_packet_analysis_protocols()`, which re-reads all YAML files from the `packet_analysis/` directory and updates `PACKET_ANALYSIS_REGISTRY` in place.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [External Skills Repository](skills-repository.md)
|
||||
- [Fault Injection](fault-injection.md)
|
||||
- [Chat API](chat-api.md)
|
||||
105
docs/gns3-copilot/implemented/server-settings-api.md
Normal file
105
docs/gns3-copilot/implemented/server-settings-api.md
Normal file
@ -0,0 +1,105 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
# Server Settings API
|
||||
|
||||
## Overview
|
||||
|
||||
REST API for reading and updating `gns3_server.conf` at runtime, enabling a server settings page in the Web UI. Updates are persisted with a read-modify-write strategy (unknown options in the file are preserved), validated before anything touches disk, and hot-reloaded into the running server; the response tells the caller which changes require a restart.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Web UI / Client"] -->|"GET /v3/settings"| R["routes/controller/settings.py"]
|
||||
U -->|"PUT /v3/settings"| R
|
||||
R -->|"Server.Audit / Server.Modify"| RBAC["privilege check"]
|
||||
R --> RS["SettingsResponse / SettingsUpdate schemas<br/>(extra=forbid, secret masking)"]
|
||||
RS --> UC["Config.update_config()<br/>read-modify-write"]
|
||||
UC --> FILE["gns3_server.conf<br/>(atomic replace, mode 0600)"]
|
||||
UC --> RN["reload_and_notify()"]
|
||||
RN --> CB["file-watch callbacks<br/>(runtime hot reload)"]
|
||||
R --> NOTIF["notification stream:<br/>settings.updated"]
|
||||
```
|
||||
|
||||
- **Exposure** — all sections except the deprecated `VirtualBox`/`VMware`: `Server`, `Controller`, `VPCS`, `Dynamips`, `IOU`, `Qemu`, `WebWireshark`. `Controller.jwt_secret_key` is excluded entirely: it is loaded from `<secrets_dir>/gns3_jwt_secret_key` and writing it to the configuration file is a no-op.
|
||||
- **Schema metadata** — every field carries a pydantic `description`, default value and validation bounds. They flow into `/openapi.json` (the `SettingsResponse` component), so clients can render the settings form — labels, tooltips, initial values, input validation — from the OpenAPI schema alone, without maintaining a field table. The human-readable annotated reference is `gns3server/config_samples/gns3_server.conf`, kept in sync with the schema.
|
||||
- **Write strategy** — `Config.update_config()` re-reads the configuration files with `configparser`, applies only the submitted options, and atomically rewrites the main configuration file. Comments and formatting are lost (accepted trade-off); options unknown to the schema are preserved.
|
||||
- **Validate before write** — the merged view of all configuration files is validated as `ServerConfig` *before* any disk write. A validation error must never reach disk: the `FileWatcher` reload callback would raise and permanently stop polling that file.
|
||||
|
||||
## Business Process (PUT)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as PUT /v3/settings
|
||||
participant U as Config.update_config()
|
||||
participant D as gns3_server.conf
|
||||
C->>R: PUT {"Section": {"option": value | null}}
|
||||
R->>R: schema validation (unknown key/section → 422)
|
||||
R->>U: changes (masked/empty secrets skipped)
|
||||
U->>U: locate owning file per option (later file wins)
|
||||
alt option owned by a non-main configuration file
|
||||
U-->>R: ConfigConflictError → 409 (nothing written)
|
||||
else
|
||||
U->>U: set / remove option (null removes, value falls back to default)
|
||||
U->>U: validate merged ServerConfig (failure → 400, file untouched)
|
||||
U->>D: atomic write (.tmp + os.replace, mode 0600)
|
||||
U->>U: reload_and_notify() → runtime hot reload
|
||||
R-->>C: 200 — new values + restart_required
|
||||
R->>C: notification settings.updated (option names only, no values)
|
||||
end
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description | Privilege |
|
||||
|--------|------|-------------|-----------|
|
||||
| GET | `/v3/settings` | Return all current server settings | `Server.Audit` |
|
||||
| PUT | `/v3/settings` | Update and persist server settings | `Server.Modify` |
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"Server": {
|
||||
"report_errors": true,
|
||||
"allowed_interfaces": ["eth0", "lo"],
|
||||
"compute_password": "**********"
|
||||
},
|
||||
"Qemu": {
|
||||
"enable_monitor": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response example (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"Server": { "report_errors": true, "allowed_interfaces": ["eth0", "lo"], "...": "..." },
|
||||
"Qemu": { "enable_monitor": false },
|
||||
"restart_required": ["Server.port"]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Secrets** — `SecretStr` fields are masked in responses. An empty secret (e.g. `compute_password` before the server generates one) serializes as `""` rather than the mask. On PUT, the mask or an empty string means "leave unchanged"; only an explicit new value is written, in clear text like a hand-edited file.
|
||||
- **`restart_required`** — options that only take effect after a server restart (bind host/port, protocol, TLS and certificates, port ranges, image/symbol/config paths, GNS3 VM credentials, skills paths, …). Everything else hot-reloads via `Config.instance().settings`.
|
||||
- **GET reflects runtime values** — in-memory settings may differ from disk (e.g. the generated `compute_password`, the resolved `secrets_dir`); the mask/empty skip rule guarantees PUT never writes echoed values back.
|
||||
- **Privileges** — `Server.Audit`/`Server.Modify` are seeded into the `Administrator` role at table creation only; existing databases need a manual grant. Superadmins bypass RBAC.
|
||||
- **Hardening** — the file watcher callback is exception-guarded (`utils/file_watcher.py`): a callback failure is logged instead of silently killing the polling loop.
|
||||
|
||||
### Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `gns3server/config.py` | `Config.update_config()` (read-modify-write, validate-before-write), `reload_and_notify()` |
|
||||
| `gns3server/utils/file_watcher.py` | callback exception hardening |
|
||||
| `gns3server/schemas/controller/settings.py` | response/update models, `SECRET_MASK` |
|
||||
| `gns3server/api/routes/controller/settings.py` | GET/PUT endpoints, `restart_required`, notification |
|
||||
| `gns3server/db/models/privileges.py` | `Server.Audit` / `Server.Modify` privilege seeds |
|
||||
| `gns3server/config_samples/gns3_server.conf` | annotated sample configuration, human-readable reference kept in sync with the schema |
|
||||
@ -10,11 +10,11 @@ See LICENSE file for licensing information.
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Copilot loads all skills, prompts, and security configurations from an external Git repository at [github.com/yueguobin/GNS3-Skills](https://github.com/yueguobin/GNS3-Skills). This enables dynamic updates without server redeployment.
|
||||
GNS3 Copilot loads all skills, prompts, and security configurations from an external Git repository at [github.com/gns3/gns3-skills](https://github.com/gns3/gns3-skills). This enables dynamic updates without server redeployment.
|
||||
|
||||
The repository provides:
|
||||
- **Injection skills** (39 categories): Network fault scenarios for troubleshooting practice
|
||||
- **Device skills**: Device-specific command knowledge (VPCS, etc.)
|
||||
- **Device skills**: Device-specific command knowledge (VPCS, etc.) — large devices split into per-protocol **topics**
|
||||
- **Feature skills**: Topology planning, network design
|
||||
- **System prompts**: Agent personality and behavior definitions
|
||||
- **Forbidden commands**: Security rules for command filtering
|
||||
@ -24,7 +24,7 @@ The repository provides:
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "GNS3-Skills Repository"
|
||||
YAML[injection/*.yaml<br/>device/*.yaml<br/>feature/*.yaml]
|
||||
YAML[injection/*.yaml<br/>device/*.yaml + device/*/*.yaml<br/>feature/*.yaml]
|
||||
MD[prompts/*.md]
|
||||
CFG[config/forbidden_commands.txt]
|
||||
end
|
||||
@ -56,7 +56,11 @@ GNS3-Skills/
|
||||
│ ├── vlan_issues.yaml
|
||||
│ └── ...
|
||||
├── device/ # Device-specific skills
|
||||
│ └── vpcs.yaml
|
||||
│ ├── vpcs.yaml # small devices: one single file
|
||||
│ └── frr/ # large devices: split per protocol topic
|
||||
│ ├── _base.yaml # device-level skill (console model, notes, aliases)
|
||||
│ ├── ospf.yaml # topic file (merged under "topics" at load time)
|
||||
│ └── bgp.yaml
|
||||
├── feature/ # Feature skills
|
||||
│ └── topology_planner.yaml
|
||||
├── prompts/ # System prompts (Markdown)
|
||||
@ -68,20 +72,40 @@ GNS3-Skills/
|
||||
└── forbidden_commands.txt
|
||||
```
|
||||
|
||||
## Device Topics
|
||||
|
||||
A device with knowledge for many protocols would grow one YAML file indefinitely. Such devices use a split layout instead: `device/<device>/_base.yaml` holds the device-level skill, and one file per protocol topic (`ospf.yaml`, `bgp.yaml`, ...) holds its commands and troubleshooting entries. The loader merges them into a single `SKILLS_REGISTRY` entry:
|
||||
|
||||
```
|
||||
SKILLS_REGISTRY["frr_vtysh"] = { ..._base.yaml..., "topics": { "ospf": {...}, "bgp": {...} } }
|
||||
```
|
||||
|
||||
Topic files must declare `device_type` (matching their `_base.yaml`), `topic` and `name`; the CI validator in the skills repository enforces this.
|
||||
|
||||
The `device_skills` tool exposes a three-step drill-down (mirroring `injection_skills`'s list → index → issue pattern):
|
||||
|
||||
```json
|
||||
{"action": "list"}
|
||||
{"device_type": "frr_vtysh", "detail": "index"}
|
||||
{"device_type": "frr_vtysh", "topic": "bgp"}
|
||||
```
|
||||
|
||||
Topic bodies are only served on an explicit `topic` request — every other detail level returns a topic index — so adding topics to a device does not grow the token cost of device-level lookups.
|
||||
|
||||
## Configuration
|
||||
|
||||
Skills repository settings are configured in `gns3_server.conf` under the `[Server]` section:
|
||||
|
||||
```ini
|
||||
[Server]
|
||||
skills_repo_url = https://github.com/yueguobin/GNS3-Skills.git
|
||||
skills_repo_url = https://github.com/gns3/gns3-skills.git
|
||||
skills_repo_branch = main
|
||||
skills_auto_update = true
|
||||
```
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `skills_repo_url` | `https://github.com/yueguobin/GNS3-Skills.git` | Git repository URL |
|
||||
| `skills_repo_url` | `https://github.com/gns3/gns3-skills.git` | Git repository URL |
|
||||
| `skills_repo_branch` | `main` | Git branch to track |
|
||||
| `skills_auto_update` | `true` | Automatically pull on reload |
|
||||
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This document is a roadmap/planning document. The described features have not been implemented yet.
|
||||
|
||||
|
||||
# Protocol-Oriented Packet Analysis — Roadmap
|
||||
|
||||
## Problem
|
||||
|
||||
The current `PacketCaptureTool` (`analyze_packets`) only accepts a single `packet_number` parameter and runs `tshark -V` on that one frame. This approach:
|
||||
|
||||
- Forces the LLM to guess packet numbers without any visibility into the capture
|
||||
- Returns raw verbose output instead of structured data
|
||||
- Has no protocol awareness — every protocol looks the same to the tool
|
||||
- Provides no built-in anomaly detection; the LLM must infer issues from raw output each time
|
||||
- Downloads the same pcap from the server on every call (no caching)
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
Move from number-based to **protocol-oriented** packet analysis. Protocol definitions (fields, display filters, anomaly checks) are stored as YAML files in the GNS3-Skills repository. The tool only needs `link_id` + `protocol` — no complex parameters for the LLM to get wrong.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
GNS3-Skills/packet_analysis/<protocol>.yaml
|
||||
│
|
||||
▼ loaded at startup / reload
|
||||
PACKET_ANALYSIS_REGISTRY (in-memory dict)
|
||||
│
|
||||
▼
|
||||
PacketAnalysisTool(link_id, protocol)
|
||||
│
|
||||
├── download pcap (with link_id caching)
|
||||
├── tshark -T fields -e <predefined fields>
|
||||
├── run anomaly checks from YAML
|
||||
└── return structured JSON + check results
|
||||
│
|
||||
▼
|
||||
LLM produces natural language explanation
|
||||
```
|
||||
|
||||
### YAML Format
|
||||
|
||||
```yaml
|
||||
name: "OSPF Packet Analysis"
|
||||
protocol: "ospf"
|
||||
display_filter: "ospf"
|
||||
fields:
|
||||
- label: "Source IP"
|
||||
field: "ip.src"
|
||||
description: "Source IPv4 address"
|
||||
- label: "OSPF Message Type"
|
||||
field: "ospf.msg"
|
||||
description: "1=Hello, 2=DBD, 3=LSR, 4=LSU, 5=LSAck"
|
||||
checks:
|
||||
- name: hello_dead_mismatch
|
||||
severity: critical
|
||||
message: "Hello/Dead Interval mismatch between {src} and {dst}"
|
||||
condition: "Same broadcast domain has inconsistent hello/dead intervals"
|
||||
```
|
||||
|
||||
### Planned Protocols
|
||||
|
||||
| File | Protocols | Key Checks |
|
||||
|------|-----------|------------|
|
||||
| `arp.yaml` | ARP, NDP (ICMPv6 NS/NA) | Duplicate IP, no ARP reply, ARP flooding |
|
||||
| `icmp.yaml` | ICMPv4, ICMPv6 | Unreachable classification, ping loss, PMTUD issues |
|
||||
| `ospf.yaml` | OSPFv2, OSPFv3 | Hello/Dead mismatch, Area ID mismatch, Router ID conflict |
|
||||
| `bgp.yaml` | BGPv4, BGP+ | Hold timer mismatch, Notification analysis, AS_PATH loop |
|
||||
|
||||
### Tool Interface
|
||||
|
||||
```json
|
||||
{
|
||||
"link_id": "uuid (required)",
|
||||
"protocol": "arp | icmp | ospf | bgp (required)",
|
||||
"summary_only": "bool (optional, default: false)"
|
||||
}
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] GNS3-Skills: create `packet_analysis/` directory and YAML definitions
|
||||
- [ ] gns3-server: add `PACKET_ANALYSIS_REGISTRY` loading from skills repo
|
||||
- [ ] gns3-server: implement `PacketAnalysisTool` with tshark field extraction
|
||||
- [ ] gns3-server: implement protocol-specific anomaly checks
|
||||
- [ ] gns3-server: add pcap caching by `link_id`
|
||||
- [ ] gns3-server: register tool in teaching assistant and lab automation modes
|
||||
- [ ] gns3-server: deprecate and remove old `PacketCaptureTool`
|
||||
@ -0,0 +1,280 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This document is a roadmap/planning document. The described features have not been implemented yet.
|
||||
|
||||
# AIOps Fault Injection Testing Pipeline — Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
Build a realistic testing pipeline that duplicates the company's production network architecture into GNS3, then systematically injects network faults using the AI Copilot's fault injection capabilities to validate and train the AIOps module before production deployment.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AIOps Testing Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Network │ │ Fault │ │ AIOps │ │
|
||||
│ │ Duplication │───▶│ Injection │───▶│ Validation │ │
|
||||
│ │ (Phase 1) │ │ (Phase 2) │ │ (Phase 3) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ GNS3 Network │ │ Fault │ │ Results & │ │
|
||||
│ │ Replica │ │ Scenarios │ │ Reporting │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Concept
|
||||
|
||||
1. **Duplicate** the company's production network architecture into a GNS3 simulation environment
|
||||
2. **Select** a set of fault types to test (OSPF, BGP, VxLAN, STP, packet filter, etc.)
|
||||
3. **Inject** faults automatically using the AI Copilot's fault injection capabilities
|
||||
4. **Validate** whether the AIOps module correctly identifies and reports each fault
|
||||
5. **Loop** through all selected fault scenarios, building a comprehensive test matrix
|
||||
6. **Train** the AIOps module on results to improve accuracy before production deployment
|
||||
|
||||
## Phase 1: Network Architecture Duplication
|
||||
|
||||
### Goal
|
||||
|
||||
Create a high-fidelity replica of the company production network in GNS3.
|
||||
|
||||
### Key Tasks
|
||||
|
||||
- [ ] **Topology Mapping**: Document production network topology (devices, links, protocols)
|
||||
- [ ] **Device Selection**: Map production devices to GNS3-compatible images (Cisco IOSv, XRv, Juniper vSRX, etc.)
|
||||
- [ ] **Configuration Extraction**: Export sanitized production configs (remove passwords, public IPs, sensitive data)
|
||||
- [ ] **GNS3 Deployment**: Build the network in GNS3 with accurate device placement and links
|
||||
- [ ] **Config Replication**: Apply adapted configurations to GNS3 devices
|
||||
- [ ] **Connectivity Validation**: Verify OSPF/BGP adjacencies, VLANs, VRFs, and end-to-end reachability
|
||||
- [ ] **Baseline Capture**: Record normal operation metrics (CPU, memory, interface counters, routing tables)
|
||||
|
||||
### Considerations
|
||||
|
||||
- Sanitize all production configurations before importing into GNS3
|
||||
- Use environment-specific IP addressing where necessary (loopbacks, management)
|
||||
- Document all deviations from production for traceability
|
||||
|
||||
## Phase 2: Fault Injection Pipeline
|
||||
|
||||
### Goal
|
||||
|
||||
Systematically inject network faults and validate AIOps detection using the existing AI Copilot fault injection infrastructure.
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Fault Injection Pipeline │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │ Scenario │ │ Inject │ │ AIOps │ │ Record │ │
|
||||
│ │ Selector │──▶│ Fault │──▶│ Validate │──▶│ & Report │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────┴──────────────┴───────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Loop until all scenarios tested │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 Scenario Selector
|
||||
|
||||
- Read fault scenarios from the GNS3-Skills repository
|
||||
- Support filtering by:
|
||||
- Protocol (OSPF, BGP, VxLAN, STP, VLAN, etc.)
|
||||
- Severity (critical, high, medium, low)
|
||||
- Difficulty (beginner, intermediate, advanced)
|
||||
- Track which scenarios have been tested
|
||||
- Randomize selection order to avoid bias
|
||||
- Exclude previously tested scenarios
|
||||
|
||||
### 2.2 Fault Injection
|
||||
|
||||
- Use existing `manage_gns3_packet_filter` tool for network-level faults
|
||||
- Use existing `execute_multiple_device_config_commands` for configuration faults
|
||||
- Use existing `InjectionSkillsTool` to query and select appropriate faults
|
||||
- Support combined faults (multiple simultaneous issues)
|
||||
- Auto-recovery between scenarios (restore baseline state)
|
||||
|
||||
### 2.3 AIOps Validation
|
||||
|
||||
- Feed network state (after fault injection) to the AIOps module
|
||||
- Record AIOps diagnosis output
|
||||
- Compare AIOps results against expected fault definition:
|
||||
- **Correct identification**: AIOps names the exact fault
|
||||
- **Partial identification**: AIOps identifies related symptoms but not root cause
|
||||
- **Missed**: AIOps fails to detect any issue
|
||||
- **False positive**: AIOps reports a fault that doesn't exist
|
||||
|
||||
### 2.4 Test Execution Flow
|
||||
|
||||
```
|
||||
1. Reset network to clean baseline state
|
||||
2. Select next untested fault scenario
|
||||
3. Inject the fault into the GNS3 network
|
||||
4. Wait for convergence (configurable delay)
|
||||
5. Query AIOps module for diagnosis
|
||||
6. Compare AIOps output with expected fault definition
|
||||
7. Record result (pass/fail/partial)
|
||||
8. Restore network to baseline
|
||||
9. Repeat from step 2 until all scenarios completed
|
||||
```
|
||||
|
||||
## Phase 3: Traffic Injection (Enhanced Realism)
|
||||
|
||||
### Goal
|
||||
|
||||
Add realistic network traffic to the GNS3 simulation so that AIOps has real telemetry data to analyze, rather than a static network.
|
||||
|
||||
### Approaches
|
||||
|
||||
#### 3.1 Traffic Generators in GNS3
|
||||
|
||||
- Deploy traffic generator appliances in GNS3 (e.g., TRex, Ostinato, Scapy on Linux nodes)
|
||||
- Generate realistic traffic patterns:
|
||||
- VoIP/RTP streams
|
||||
- HTTP/HTTPS web traffic
|
||||
- Database replication
|
||||
- Routing protocol updates (OSPF hellos, BGP keepalives)
|
||||
- ICMP monitoring traffic
|
||||
|
||||
#### 3.2 tcpreplay with Captured Traffic
|
||||
|
||||
- Capture real production traffic (sanitized)
|
||||
- Use `tcpreplay` to replay traffic through the GNS3 network
|
||||
- More realistic than synthetic traffic generators
|
||||
|
||||
#### 3.3 Integration with Network Monitoring
|
||||
|
||||
- Feed simulated device telemetry (SNMP, syslog, NetFlow) to the AIOps module
|
||||
- Enable AIOps to analyze real-time telemetry during fault conditions
|
||||
- Validate that AIOps can distinguish between traffic anomalies and actual faults
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target | Measurement Method |
|
||||
|--------|--------|-------------------|
|
||||
| Fault detection rate | >95% | AIOps correctly identifies injected faults |
|
||||
| False positive rate | <5% | AIOps reports fault when none exists |
|
||||
| Time to detection | <30s | Duration from injection to AIOps alert |
|
||||
| Coverage | >80% of defined scenarios | Percentage of scenarios tested |
|
||||
| Accuracy improvement | Measurable per cycle | Compare pass rates across test cycles |
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Test Orchestrator │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Test Runner (Python) │ │
|
||||
│ │ - Scenario selection & scheduling │ │
|
||||
│ │ - Fault injection coordination │ │
|
||||
│ │ - AIOps query & result collection │ │
|
||||
│ │ - Report generation │ │
|
||||
│ └──────────┬────────────────────────────────────────────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ GNS3 Controller │ │ AIOps Module │ │
|
||||
│ │ (gns3-server) │ │ │ │
|
||||
│ │ - Network mgmt │ │ - Fault diagnosis │ │
|
||||
│ │ - Fault injection │ │ - Alert detection │ │
|
||||
│ │ - State queries │ │ - Root cause │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ GNS3 Network │ │
|
||||
│ │ Replica │ │
|
||||
│ │ - Devices │ │
|
||||
│ │ - Traffic │ │
|
||||
│ │ - Telemetry │ │
|
||||
│ └────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Each test cycle produces:
|
||||
|
||||
- **Summary report**: Pass/fail rates, coverage, trends
|
||||
- **Detailed per-scenario report**: Injection details, AIOps response, comparison
|
||||
- **Regression tracker**: Which scenarios regressed since last cycle
|
||||
- **Accuracy trend**: Improvement or degradation over time
|
||||
|
||||
### Example Report Entry
|
||||
|
||||
```yaml
|
||||
test_cycle: 7
|
||||
date: "2026-06-01"
|
||||
scenarios_planned: 20
|
||||
scenarios_completed: 18
|
||||
failed_injections: 1
|
||||
skipped: 1
|
||||
results:
|
||||
- scenario: ospf_hello_dead_mismatch
|
||||
protocol: ospf
|
||||
severity: major
|
||||
injection_method: device_config
|
||||
target_device: R1
|
||||
aiops_detection: true
|
||||
aiops_diagnosis: "OSPF Hello/Dead interval mismatch between R1 and R2"
|
||||
detection_latency_ms: 12000
|
||||
match: exact
|
||||
- scenario: packet_loss_heavy
|
||||
protocol: performance
|
||||
severity: high
|
||||
injection_method: packet_filter
|
||||
target_link: "R1 ↔ R2 (ethernet)"
|
||||
aiops_detection: true
|
||||
aiops_diagnosis: "High packet loss detected on link R1-R2"
|
||||
detection_latency_ms: 45000
|
||||
match: partial
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [ ] GNS3 network replica ready and validated
|
||||
- [ ] AI Copilot fault injection tools operational
|
||||
- [ ] AIOps module query interface available
|
||||
- [ ] Traffic generation tools deployed (for Phase 3)
|
||||
- [ ] Test orchestrator framework (to be built)
|
||||
- [ ] Result database and reporting system
|
||||
|
||||
## Timeline
|
||||
|
||||
| Phase | Duration | Deliverable |
|
||||
|-------|----------|-------------|
|
||||
| P1: Network Duplication | 2-4 weeks | GNS3 replica of production network |
|
||||
| P2: Fault Injection Pipeline | 2-3 weeks | Automated test runner + first results |
|
||||
| P3: Traffic Injection | 2-4 weeks | Realistic traffic simulation integrated |
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] P1: Network architecture duplication
|
||||
- [ ] Topology mapping documented
|
||||
- [ ] Device configurations sanitized and adapted
|
||||
- [ ] GNS3 replica deployed
|
||||
- [ ] Baseline connectivity verified
|
||||
- [ ] P2: Fault injection pipeline
|
||||
- [ ] Scenario selection framework
|
||||
- [ ] Automated fault injection
|
||||
- [ ] AIOps validation interface
|
||||
- [ ] Report generation
|
||||
- [ ] Loop/retry mechanism
|
||||
- [ ] P3: Traffic injection
|
||||
- [ ] Traffic generator deployment
|
||||
- [ ] Traffic pattern library
|
||||
- [ ] AIOps telemetry integration
|
||||
|
||||
621
docs/gns3-copilot/roadmap/rbac-user-isolation-roadmap.md
Normal file
621
docs/gns3-copilot/roadmap/rbac-user-isolation-roadmap.md
Normal file
@ -0,0 +1,621 @@
|
||||
# RBAC User Isolation Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 3.0 ships with a complete RBAC framework (ACE + Role + Privilege models), but the user isolation layer is incomplete. Users can see resources they should not have access to because:
|
||||
|
||||
- Resource creation does not auto-grant the creator an ACE
|
||||
- List endpoints return unfiltered results
|
||||
- Two GET endpoints have RBAC checks bypassed via FIXME
|
||||
|
||||
**This document has been updated to reflect the implemented solution in `feature/simple-user-isolation` branch.**
|
||||
|
||||
## Current State
|
||||
|
||||
| Resource | Route Check | List Filtering | Auto-ACE on Create | ACE Cleanup on Delete |
|
||||
|---|---|---|---|---|
|
||||
| Project | `Project.Audit/Modify/Allocate` | **Implemented** — Three-step filtering | **Not needed** | Done |
|
||||
| Template | `Template.Audit` **FIXME** | None — all templates returned | **Missing** | Done |
|
||||
| Node | `Node.Audit/Modify/Allocate` | N/A (inherits project) | Inherits project | N/A |
|
||||
| Link | `Link.Audit/Modify/Allocate` | N/A (inherits project) | Inherits project | N/A |
|
||||
| Drawing | `Drawing.Audit/Modify/Allocate` | N/A (inherits project) | Inherits project | N/A |
|
||||
| Snapshot | `Snapshot.Audit/Allocate/Restore` | N/A (inherits project) | Inherits project | N/A |
|
||||
| Image | `Image.Audit/Allocate` | None — all images returned | **Missing** | Missing |
|
||||
| Compute | `Compute.Audit` **FIXME** | None — all computes returned | N/A (shared infra) | Done |
|
||||
| Appliance | `Appliance.Audit/Allocate` | None — all appliances returned | N/A (builtin) | N/A |
|
||||
| Symbol | `Symbol.Audit/Allocate` | None — all symbols returned | N/A (builtin) | N/A |
|
||||
|
||||
## Implemented Solution: Project Isolation
|
||||
|
||||
### Three-Step Permission Check Logic
|
||||
|
||||
**Status**: ✅ Implemented in `feature/simple-user-isolation` branch
|
||||
|
||||
```python
|
||||
# Step 1: ACE check - basic access permission
|
||||
# Get projects user has ACE for
|
||||
ace_projects = []
|
||||
for project in controller.projects.values():
|
||||
project_path = f"/projects/{project.id}"
|
||||
if await rbac_repo.check_user_has_privilege(current_user.user_id, project_path, "Project.Audit"):
|
||||
ace_projects.append(project)
|
||||
|
||||
# Step 2: Filter ace_projects by created_by - user's own projects
|
||||
# Project sharing is only available through resource pools
|
||||
user_projects = [p.asdict() for p in ace_projects if p.created_by == current_user.username]
|
||||
projects.extend(user_projects)
|
||||
|
||||
# Step 3: Resource pool projects
|
||||
# Projects shared through resource pools
|
||||
user_pool_resources = await rbac_repo.get_user_pool_resources(current_user.user_id, "Project.Audit")
|
||||
project_ids_in_pools = [str(r.resource_id) for r in user_pool_resources if r.resource_type == "project"]
|
||||
pool_projects = [p.asdict() for p in controller.projects.values() if p.id in project_ids_in_pools]
|
||||
projects.extend(pool_projects)
|
||||
```
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **ACE for basic access control**: Controls whether user can access the system
|
||||
2. **created_by for user isolation**: Controls which specific resources user can access
|
||||
3. **Resource pools for project sharing**: The only mechanism for sharing projects between users
|
||||
4. **No direct ACE sharing**: Users cannot configure ACE directly on specific projects to share them
|
||||
|
||||
### Advantages of This Approach
|
||||
|
||||
- **Fault tolerance**: Even with broad ACE configuration (`path: "/" + propagate: true`), user isolation remains effective
|
||||
- **Clear separation**: Basic access, data ownership, and team sharing are clearly separated
|
||||
- **Simple mechanism**: No complex auto-ACE or seen_project_ids tracking required
|
||||
- **Performance**: Leverages existing created_by field, no schema changes needed
|
||||
|
||||
## Updated Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Client
|
||||
WebUI
|
||||
CLI
|
||||
end
|
||||
|
||||
subgraph "Controller API"
|
||||
Auth[get_current_active_user]
|
||||
Routes[Resource Routes]
|
||||
ACE_Check[Step 1: ACE Check]
|
||||
Owner_Filter[Step 2: Filter by created_by]
|
||||
Pool_Check[Step 3: Resource Pools]
|
||||
end
|
||||
|
||||
subgraph "RBAC Engine"
|
||||
ACE[(ACE table)]
|
||||
Role[(Role table)]
|
||||
Privilege[(Privilege table)]
|
||||
Checker[check_user_has_privilege]
|
||||
end
|
||||
|
||||
WebUI --> Auth
|
||||
CLI --> Auth
|
||||
Auth --> Routes
|
||||
Routes --> ACE_Check
|
||||
ACE_Check --> Checker
|
||||
ACE_Check --> Owner_Filter
|
||||
Owner_Filter --> Pool_Check
|
||||
Pool_Check --> Checker
|
||||
Checker --> ACE
|
||||
Checker --> Role
|
||||
Role --> Privilege
|
||||
```
|
||||
|
||||
## Business Process
|
||||
|
||||
### Project listing with three-step filtering
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant API as GET /projects
|
||||
participant Controller as Controller
|
||||
participant RBAC as RBAC Engine
|
||||
|
||||
U->>API: List projects
|
||||
API->>API: get_current_active_user (not superadmin)
|
||||
|
||||
API->>RBAC: Step 1: ACE check on each project
|
||||
loop Each project
|
||||
RBAC-->>API: ACE results
|
||||
end
|
||||
|
||||
API->>API: Step 2: Filter by created_by
|
||||
API-->>U: User's own projects
|
||||
|
||||
API->>RBAC: Step 3: Resource pool projects
|
||||
RBAC-->>API: Pool projects
|
||||
|
||||
API-->>U: Combined list (own + pool)
|
||||
```
|
||||
|
||||
## Updated Phased Plan
|
||||
|
||||
### ✅ Phase 1 — MVP: Project isolation (COMPLETED)
|
||||
|
||||
**Goal**: Users only see projects they created or were granted access to through resource pools.
|
||||
|
||||
| Task | Files | Status | Detail |
|
||||
|---|---|---|---|
|
||||
| Fix project list filtering | `projects.py` — `get_projects()` | ✅ **Implemented** | Three-step filtering: ACE check → created_by filter → resource pools |
|
||||
|
||||
**Result**:
|
||||
- ✅ Users can only see projects they created
|
||||
- ✅ Team collaboration through resource pools works
|
||||
- ✅ Fault tolerance: Works correctly even with broad ACE configurations
|
||||
- ✅ No schema changes required
|
||||
- ✅ ~30 lines changed
|
||||
|
||||
### Phase 2 — Template isolation
|
||||
|
||||
**Goal**: Users see their own templates + builtin templates only.
|
||||
|
||||
| Task | Files | Detail |
|
||||
|---|---|---|
|
||||
| Apply same pattern to templates | `templates.py` — `get_templates()` | Use same three-step filtering as projects |
|
||||
| Uncomment `Template.Audit` | `templates.py` | Restore `has_privilege("Template.Audit")` checks |
|
||||
|
||||
**Dependency**: Web UI must handle 403 from `GET /templates/{id}`. Mitigation: keep builtin templates unconditionally visible so the UI always has data.
|
||||
|
||||
### Phase 3 — Image isolation (optional)
|
||||
|
||||
**Goal**: Users see only images they uploaded.
|
||||
|
||||
| Task | Files |
|
||||
|---|---|
|
||||
| Apply same pattern to images | `images.py` — `get_images()` |
|
||||
| Fix image list filtering | `images.py` |
|
||||
| ACE cleanup on delete | `images.py` — `delete_image()` |
|
||||
|
||||
### Phase 4 — Default ACE for "Users" group (optional)
|
||||
|
||||
**Goal**: Users in "Users" group can create/list resources without admin ACE intervention.
|
||||
|
||||
| Task | Detail |
|
||||
|---|---|
|
||||
| Default ACE on `/projects` | Grant "Users" group → User role → `/projects` (propagate=False) |
|
||||
| Default ACE on `/templates` | Grant "Users" group → User role → `/templates` (propagate=False) |
|
||||
|
||||
## API Endpoints Changed
|
||||
|
||||
### Phase 1 (Implemented)
|
||||
|
||||
| Method | Path | Change |
|
||||
|---|---|---|
|
||||
| `GET` | `/v3/projects` | Three-step filtering: ACE → created_by → resource pools |
|
||||
|
||||
### Phase 2 (Planned)
|
||||
|
||||
| Method | Path | Change |
|
||||
|---|---|---|
|
||||
| `GET` | `/v3/templates` | Apply same three-step filtering |
|
||||
| `GET` | `/v3/templates/{id}` | Restore `Template.Audit` check |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Project sharing through resource pools only**: Users cannot configure ACE directly to share specific projects. All sharing must go through resource pools. This prevents permission configuration chaos and maintains clear ownership semantics.
|
||||
|
||||
2. **No auto-ACE required**: The three-step filtering logic works without needing automatic ACE creation on project creation. The created_by field provides sufficient ownership information.
|
||||
|
||||
3. **Fault-tolerant to ACE configuration**: Even if administrators configure broad ACE permissions (like `path: "/" + propagate: true`), user isolation remains effective because Step 2 filters by created_by.
|
||||
|
||||
4. **No DB migration required**: Uses existing ACE/role/privilege tables and created_by field. No schema changes needed.
|
||||
|
||||
5. **Performance**: Project list filtering is O(n) where n is the total number of projects. Each project requires one ACE check. Acceptable for < 500 projects. Can optimize later with batch ACE queries if needed.
|
||||
|
||||
## Updated References
|
||||
|
||||
- **Implementation**: `gns3server/api/routes/controller/projects.py` (feature/simple-user-isolation branch)
|
||||
- **Discussion**: https://github.com/GNS3/gns3-server/discussions/1949
|
||||
- **RBAC models**: `gns3server/db/models/acl.py`, `roles.py`, `privileges.py`
|
||||
- **RBAC repository**: `gns3server/db/repositories/rbac.py`
|
||||
- **Auth dependency**: `gns3server/api/routes/controller/dependencies/authentication.py`
|
||||
- **RBAC dependency**: `gns3server/api/routes/controller/dependencies/rbac.py`
|
||||
- **Resource pools**: `gns3server/db/models/pools.py` and `gns3server/db/repositories/pools.py`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### What Was Implemented
|
||||
|
||||
The `feature/simple-user-isolation` branch implements a robust user isolation mechanism that:
|
||||
|
||||
1. **Integrates with existing RBAC framework** without breaking changes
|
||||
2. **Leverages the created_by field** that already exists in the Project model
|
||||
3. **Uses three-step pipeline filtering** to avoid complex seen_project_ids tracking
|
||||
4. **Supports team collaboration** through existing resource pool functionality
|
||||
5. **Is fault-tolerant to ACE misconfiguration** - broad ACE permissions don't break user isolation
|
||||
|
||||
### What Was Not Implemented
|
||||
|
||||
The original roadmap's Phase 1 included auto-ACE creation on project creation. This was determined to be unnecessary because:
|
||||
|
||||
- The three-step filtering logic achieves user isolation without auto-ACE
|
||||
- Auto-ACE would add complexity without significant benefit
|
||||
- Project sharing through resource pools is cleaner than direct ACE configuration
|
||||
|
||||
### Future Work
|
||||
|
||||
The same three-step filtering pattern can be applied to:
|
||||
- **Templates**: Replace the FIXME comment with proper filtering logic
|
||||
- **Images**: Apply the same pattern for user image isolation
|
||||
- **Other resources**: Extend the pattern as needed
|
||||
|
||||
This implementation provides a solid foundation for user isolation in GNS3 3.0+ while maintaining compatibility with the existing RBAC framework.
|
||||
|
||||
## Phase 5 — ACE Architecture Refactoring (Future)
|
||||
|
||||
**Goal**: Improve ACE manageability by supporting multiple paths and resource pools in a single ACE entry.
|
||||
|
||||
### Current Problem
|
||||
|
||||
With the current design where one ACE = one path:
|
||||
- **ACE explosion**: 5 user groups × 10 resource pools = 50 ACE entries
|
||||
- **Management complexity**: Difficult to maintain and understand ACE purpose
|
||||
- **Performance impact**: Permission checking must iterate through many ACE entries
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Redesign ACE structure to support multiple paths and resource pools in a single ACE entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Development Team Access",
|
||||
"description": "Full access for development team",
|
||||
"ace_type": "group",
|
||||
"group_id": "...",
|
||||
"role_id": "...",
|
||||
"paths": ["/projects", "/templates", "/images"],
|
||||
"resource_pools": ["pool-id-1", "pool-id-2"],
|
||||
"propagate": true,
|
||||
"allowed": true
|
||||
}
|
||||
```
|
||||
|
||||
### Database Changes Required
|
||||
|
||||
1. **Add name and description to ACE table**:
|
||||
```sql
|
||||
ALTER TABLE acl ADD COLUMN name VARCHAR;
|
||||
ALTER TABLE acl ADD COLUMN description TEXT;
|
||||
```
|
||||
|
||||
2. **Create association tables**:
|
||||
```sql
|
||||
CREATE TABLE ace_paths (
|
||||
ace_id UUID REFERENCES acl(ace_id),
|
||||
path VARCHAR,
|
||||
PRIMARY KEY (ace_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE ace_pools (
|
||||
ace_id UUID REFERENCES acl(ace_id),
|
||||
resource_pool_id UUID REFERENCES resource_pools(resource_pool_id),
|
||||
PRIMARY KEY (ace_id, resource_pool_id)
|
||||
);
|
||||
```
|
||||
|
||||
3. **Update permission checking logic** to check both paths and resource_pools tables
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ **Reduced ACE entries**: One ACE covers multiple related paths/pools
|
||||
- ✅ **Better organization**: Logical grouping with clear names and descriptions
|
||||
- ✅ **Easier management**: Edit one ACE instead of multiple related entries
|
||||
- ✅ **Improved performance**: Fewer ACE entries to check during permission validation
|
||||
|
||||
### Implementation Considerations
|
||||
|
||||
- **Migration path**: Need to migrate existing single-path ACEs to new structure
|
||||
- **Backward compatibility**: API should support both old and new formats during transition
|
||||
- **UI updates**: ACE management interface needs to support multi-path/pool selection
|
||||
- **Permission checking**: Update `check_user_has_privilege` to check association tables
|
||||
|
||||
## Phase 6 — Frontend Permission Query API (Future)
|
||||
|
||||
**Goal**: Provide an API endpoint for the Web UI to query the current user's permissions, enabling dynamic UI rendering based on role and ACE configuration.
|
||||
|
||||
### Problem
|
||||
|
||||
Currently the Web UI cannot determine what the authenticated user is allowed to see or do:
|
||||
|
||||
- ❌ Users see menu items and buttons they don't have permission to use
|
||||
- ❌ Clicking a forbidden action results in a 403 error (unexpected UX)
|
||||
- ❌ No way to hide/show UI elements based on actual permissions
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Create a `GET /v3/me/permissions` endpoint that returns the current user's effective permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"is_superadmin": false,
|
||||
"permissions": [
|
||||
{"path": "/projects", "privileges": ["Project.Audit", "Project.Allocate"]},
|
||||
{"path": "/projects/{id}", "privileges": ["Project.Audit", "Project.Modify"]},
|
||||
{"path": "/templates", "privileges": ["Template.Audit"]}
|
||||
],
|
||||
"pools": [
|
||||
{"path": "/pools/{id}", "name": "Team Projects", "privileges": ["Pool.Audit"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ **Dynamic UI**: Frontend can hide inaccessible menus/buttons
|
||||
- ✅ **Better UX**: Users only see what they can actually use
|
||||
- ✅ **Reduced errors**: Fewer 403 responses from hidden operations
|
||||
- ✅ **Faster feedback**: Permission checks happen at render time, not request time
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Phase 5 (ACE refactoring)** may change how permissions are stored, which would affect this API's implementation
|
||||
|
||||
## Phase 7 — Resource Pool Renaming (Future)
|
||||
|
||||
**Goal**: Rename "Resource Pool" to a more descriptive name that better reflects its actual purpose.
|
||||
|
||||
### Problem
|
||||
|
||||
The current name "Resource Pool" is too generic and doesn't clearly convey its actual function:
|
||||
|
||||
- ❌ **Ambiguous name**: "Resource Pool" could refer to compute pools, connection pools, etc.
|
||||
- ❌ **Unclear purpose**: Users don't understand it's primarily for sharing projects
|
||||
- ❌ **Discoverability**: Hard to find the right feature when looking for project sharing
|
||||
|
||||
### Actual Function
|
||||
|
||||
Resource pools in GNS3 are used for:
|
||||
- **Project sharing**: Allow users to access projects created by other users
|
||||
- **Team collaboration**: Enable team members to work on shared projects
|
||||
- **Access control**: Provide fine-grained permissions for project access through three-step filtering (ACE check → created_by filter → resource pools)
|
||||
|
||||
### Proposed Name Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|-------|-------|
|
||||
| **Project Pool** | More explicit, indicates it contains projects | Still uses "pool" terminology |
|
||||
| **Shared Projects** | Directly describes the function | Loses the "collection" concept |
|
||||
| **Team Projects** | Emphasizes collaboration use case | Doesn't cover non-team sharing scenarios |
|
||||
|
||||
**Recommended**: **Project Pool** - strikes a balance between clarity and consistency with existing terminology.
|
||||
|
||||
### Implementation Scope
|
||||
|
||||
Renaming would require changes to:
|
||||
- Database tables: `resource_pools` → `project_pools`
|
||||
- API routes: `/v3/pools` → `/v3/project_pools`
|
||||
- Schema classes and field names throughout the codebase
|
||||
- All documentation and help text
|
||||
- Migration script to preserve existing data
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ **Improved discoverability**: Users can easily find the project sharing feature
|
||||
- ✅ **Better onboarding**: New users understand the purpose without confusion
|
||||
- ✅ **Clearer API**: API endpoints and schemas more self-documenting
|
||||
|
||||
### Implementation Considerations
|
||||
|
||||
- **Breaking change**: Requires API version bump or backward compatibility layer
|
||||
- **Data migration**: Existing resource pools must be preserved during table rename
|
||||
- **Documentation updates**: All references in docs, tutorials, and API specs need updating
|
||||
- **UI changes**: Frontend labels and navigation menus need to match new terminology
|
||||
|
||||
## Phase 8 — Per-User Project Namespace (Future)
|
||||
|
||||
**Goal**: Allow project names to be unique per user instead of globally unique, enabling better user experience.
|
||||
|
||||
### Current Problem
|
||||
|
||||
Although Phase 1 implements user isolation (users only see projects they created or have access to through resource pools), project names remain globally unique:
|
||||
|
||||
- ❌ **Naming conflicts**: Alice and Bob cannot both create a project named "My Project"
|
||||
- ❌ **Unnecessary restrictions**: Even though projects are isolated, users must coordinate globally unique names
|
||||
- ❌ **Poor user experience**: Users get confusing error messages when trying to use common names like "Test Project"
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Change project uniqueness from global to per-user:
|
||||
|
||||
**Current:**
|
||||
```sql
|
||||
UNIQUE(name) -- Project names must be globally unique
|
||||
```
|
||||
|
||||
**Proposed:**
|
||||
```sql
|
||||
UNIQUE(user_id, name) -- Project names unique per user
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ **Better UX**: Users can name projects whatever they want without worrying about global conflicts
|
||||
- ✅ **Natural naming**: Common names like "Test Project" or "Demo" can coexist between users
|
||||
- ✅ **No coordination needed**: Teams don't need to maintain a shared project naming registry
|
||||
- ✅ **Consistent isolation**: Projects are isolated both in visibility AND naming
|
||||
|
||||
### Implementation Scope
|
||||
|
||||
Changes required:
|
||||
- **Database schema**: Modify Project table unique constraint from `(name)` to `(user_id, name)`
|
||||
- **Migration script**: Handle existing projects with conflicting names
|
||||
- **API validation**: Update project creation validation logic
|
||||
- **Frontend**: Remove global name uniqueness checks from UI
|
||||
|
||||
### Migration Considerations
|
||||
|
||||
**Handling existing name conflicts:**
|
||||
If the database already has projects with the same name but different users:
|
||||
- Option 1: Keep existing names, only enforce uniqueness for new projects
|
||||
- Option 2: Append suffixes to duplicates (e.g., "My Project (alice)", "My Project (bob)")
|
||||
- Option 3: Require admin resolution for conflicts before enabling new constraint
|
||||
|
||||
**Recommended**: Option 1 (grandfather existing projects) for minimal disruption.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Phase 1 (User isolation)**: Must be completed first
|
||||
- Database migration required to modify unique constraint
|
||||
|
||||
## Phase 9 — User Self-Registration (Future)
|
||||
|
||||
**Goal**: Allow users to register their own accounts without requiring manual admin creation.
|
||||
|
||||
### Current State
|
||||
|
||||
Currently, user accounts can only be created by administrators or through direct database operations:
|
||||
- ❌ **Admin burden**: Every new user requires manual account creation
|
||||
- ❌ **Poor scalability**: Not suitable for public deployments or large organizations
|
||||
- ❌ **Friction**: Users cannot immediately start using the system
|
||||
|
||||
### Proposed Features
|
||||
|
||||
**Self-Registration Flow:**
|
||||
1. User provides email, username, and password
|
||||
2. System validates input and creates account
|
||||
3. Optional email verification to confirm email address
|
||||
4. Account created with default role (typically "User" role)
|
||||
5. User can immediately log in and start creating projects
|
||||
|
||||
**Email Verification (Optional):**
|
||||
- Send verification email with confirmation link/code
|
||||
- Verify email address before granting full access
|
||||
- Prevent spam account creation
|
||||
- Require SMTP server configuration
|
||||
|
||||
### Implementation Components
|
||||
|
||||
1. **New API endpoint**: `POST /v3/access/register` (public, no authentication required)
|
||||
2. **Email service**: Integration with SMTP server for sending emails
|
||||
3. **Configuration**: SMTP settings (host, port, credentials, encryption)
|
||||
4. **Rate limiting**: Prevent abuse of self-registration
|
||||
5. **Captcha integration**: Optional bot protection
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
email:
|
||||
enabled: true
|
||||
smtp_host: smtp.example.com
|
||||
smtp_port: 587
|
||||
smtp_username: noreply@example.com
|
||||
smtp_password: secret
|
||||
use_tls: true
|
||||
registration:
|
||||
require_email_verification: true
|
||||
default_role: "User"
|
||||
allow_public_registration: true
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- ✅ **Rate limiting**: Prevent spam account creation
|
||||
- ✅ **Email verification**: Confirm email ownership
|
||||
- ✅ **Default permissions**: New users get limited default role
|
||||
- ✅ **Admin approval** (optional): Require admin approval before account activation
|
||||
- ❌ **Superadmin creation**: Never allow self-registration as superadmin
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Phase 10 (Email service)**: SMTP integration required for email verification
|
||||
|
||||
## Phase 10 — Email Service Integration (Future)
|
||||
|
||||
**Goal**: Implement email sending capability for notifications, verification, and alerts.
|
||||
|
||||
### Use Cases
|
||||
|
||||
1. **User registration**: Email verification links/codes
|
||||
2. **Password reset**: Secure password reset emails
|
||||
3. **System alerts**: Error notifications, system updates
|
||||
4. **Project sharing**: Notify users when projects are shared via resource pools
|
||||
5. **Usage reports**: Periodic usage summaries or quota alerts
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
**Email Service Architecture:**
|
||||
- Abstract email service interface
|
||||
- Support multiple email providers (SMTP, SendGrid, AWS SES, etc.)
|
||||
- Email templates with Jinja2 for customization
|
||||
- Async email sending to avoid blocking API responses
|
||||
- Email queue for retry logic on failures
|
||||
|
||||
**Email Templates:**
|
||||
- Registration verification
|
||||
- Password reset
|
||||
- Project shared notification
|
||||
- System alerts
|
||||
- Usage reports
|
||||
|
||||
**API Endpoints:**
|
||||
```python
|
||||
# Configuration (admin only)
|
||||
POST /v3/access/config/email
|
||||
GET /v3/access/config/email
|
||||
PUT /v3/access/config/email
|
||||
|
||||
# Test email (admin only)
|
||||
POST /v3/access/config/email/test
|
||||
|
||||
# Password reset (public)
|
||||
POST /v3/access/users/password/reset/request
|
||||
POST /v3/access/users/password/reset/confirm
|
||||
|
||||
# Email verification (public)
|
||||
POST /v3/access/users/verify/email
|
||||
POST /v3/access/users/verify/confirm
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
|
||||
New table for email tracking:
|
||||
```sql
|
||||
CREATE TABLE email_verification_tokens (
|
||||
token_id UUID PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
token VARCHAR(255), # Verification code
|
||||
purpose VARCHAR(50), # 'registration', 'password_reset', etc.
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME,
|
||||
used BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
email:
|
||||
enabled: true
|
||||
provider: "smtp" # or "sendgrid", "aws_ses", etc.
|
||||
from_address: "noreply@example.com"
|
||||
from_name: "GNS3 Server"
|
||||
reply_to: "support@example.com"
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
username: noreply@example.com
|
||||
password: encrypted_password
|
||||
use_tls: true
|
||||
templates_dir: /etc/gns3/email_templates
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- ✅ **Encrypted credentials**: SMTP passwords stored encrypted in database
|
||||
- ✅ **Token expiration**: Verification tokens expire after configurable time
|
||||
- ✅ **Rate limiting**: Prevent email spamming
|
||||
- ✅ **Async sending**: Don't block API responses on email operations
|
||||
- ✅ **Retry logic**: Handle temporary email service failures
|
||||
- ✅ **Privacy**: Don't expose user information in error messages
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Phase 9 (Self-registration)**: User self-registration requires email verification
|
||||
- Encryption utilities for storing SMTP credentials securely
|
||||
318
docs/gns3-copilot/roadmap/skills-editor-api-roadmap.md
Normal file
318
docs/gns3-copilot/roadmap/skills-editor-api-roadmap.md
Normal file
@ -0,0 +1,318 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Skills Editor API
|
||||
|
||||
## Overview
|
||||
|
||||
A management API that allows the Web UI to browse, edit, save, and contribute skill files (prompts, fault injection, device skills, packet analysis protocols) back to the upstream GNS3-Skills repository via Pull Requests.
|
||||
|
||||
Today, skills are read-only from the server's perspective — the only management endpoint is `POST /copilot/reload/skills` for hot-reloading. This API adds full CRUD operations on the local skills repository plus Git commit/push/PR workflows.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Web UI"
|
||||
UI[Skills Editor Page]
|
||||
end
|
||||
|
||||
subgraph "GNS3 Server API"
|
||||
API[skills_editor.py<br/>/copilot/skills/*]
|
||||
FM[SkillsFileManager]
|
||||
SM[SkillsManager]
|
||||
end
|
||||
|
||||
subgraph "Local Git Repo"
|
||||
INJ[injection/*.yaml]
|
||||
DEV[device/*.yaml]
|
||||
PA[packet_analysis/*.yaml]
|
||||
PRM[prompts/*.md]
|
||||
CFG[config/*.txt]
|
||||
end
|
||||
|
||||
subgraph "Remote"
|
||||
GH[GitHub API<br/>Pull Requests]
|
||||
REPO[gns3/gns3-skills]
|
||||
end
|
||||
|
||||
UI -->|CRUD + PR| API
|
||||
API --> FM
|
||||
FM -->|file read/write| INJ
|
||||
FM -->|file read/write| DEV
|
||||
FM -->|file read/write| PA
|
||||
FM -->|file read/write| PRM
|
||||
FM -->|file read/write| CFG
|
||||
FM -->|git commit/push| REPO
|
||||
FM -->|create PR| GH
|
||||
SM -->|hot reload| INJ
|
||||
SM -->|hot reload| DEV
|
||||
SM -->|hot reload| PA
|
||||
SM -->|hot reload| PRM
|
||||
```
|
||||
|
||||
## Business Process
|
||||
|
||||
### Edit and Contribute Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Web UI
|
||||
participant API as Skills Editor API
|
||||
participant FM as SkillsFileManager
|
||||
participant Git as Local Git Repo
|
||||
participant GH as GitHub
|
||||
|
||||
UI->>API: GET /copilot/skills
|
||||
API-->>UI: List categories + file counts
|
||||
|
||||
UI->>API: GET /copilot/skills/injection
|
||||
API-->>UI: List YAML files
|
||||
|
||||
UI->>API: GET /copilot/skills/injection/ospf_issues
|
||||
API->>FM: read_file("injection", "ospf_issues")
|
||||
FM-->>API: YAML content
|
||||
API-->>UI: File content
|
||||
|
||||
UI->>API: PUT /copilot/skills/injection/ospf_issues<br/>{content: "..."}
|
||||
API->>FM: write_file("injection", "ospf_issues", content)
|
||||
FM->>Git: Write file to disk
|
||||
FM-->>API: Success
|
||||
API-->>UI: Updated
|
||||
|
||||
UI->>API: POST /copilot/skills/commit<br/>{message: "fix: update OSPF fault"}
|
||||
API->>FM: commit_changes(message, files)
|
||||
FM->>Git: git add + git commit
|
||||
FM-->>API: Commit hash
|
||||
API-->>UI: Committed
|
||||
|
||||
UI->>API: POST /copilot/skills/pull-request<br/>{title, body, branch}
|
||||
API->>FM: create_pull_request(...)
|
||||
FM->>Git: git push origin <branch>
|
||||
FM->>GH: POST /repos/{owner}/{repo}/pulls
|
||||
GH-->>FM: PR URL
|
||||
FM-->>API: PR created
|
||||
API-->>UI: PR URL
|
||||
|
||||
UI->>API: POST /copilot/skills/reload
|
||||
API->>FM: Hot reload all registries
|
||||
API-->>UI: Reloaded
|
||||
```
|
||||
|
||||
## Valid Categories
|
||||
|
||||
| Category | Directory | File Extension | Content |
|
||||
|----------|-----------|---------------|---------|
|
||||
| `prompts` | `prompts/` | `.md` | System prompts (teaching_assistant, lab_automation_assistant, etc.) |
|
||||
| `injection` | `injection/` | `.yaml` | Fault injection skill definitions (OSPF, BGP, VLAN, etc.) |
|
||||
| `device` | `device/` | `.yaml` | Device-specific command knowledge |
|
||||
| `packet_analysis` | `packet_analysis/` | `.yaml` | Protocol definitions for tshark-based analysis |
|
||||
| `config` | `config/` | `.txt` | Security and configuration files (forbidden_commands, etc.) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require **superadmin** authentication. Prefix: `/v3/copilot/skills`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/copilot/skills` | List all categories with file counts |
|
||||
| GET | `/copilot/skills/{category}` | List files in a category |
|
||||
| GET | `/copilot/skills/{category}/{filename}` | Read file content (without extension) |
|
||||
| POST | `/copilot/skills/{category}` | Create a new skill file |
|
||||
| PUT | `/copilot/skills/{category}/{filename}` | Update existing file content |
|
||||
| DELETE | `/copilot/skills/{category}/{filename}` | Delete a skill file |
|
||||
| GET | `/copilot/skills/status` | Git status (modified/untracked/deleted files) + repo info |
|
||||
| POST | `/copilot/skills/commit` | Stage and commit changes |
|
||||
| POST | `/copilot/skills/push` | Push a branch to remote |
|
||||
| POST | `/copilot/skills/pull-request` | Create a Pull Request (direct or via fork) |
|
||||
| POST | `/copilot/skills/reload` | Hot reload all skills into memory (replaces `/reload/skills`) |
|
||||
| POST | `/copilot/skills/rollback/{commit_hash}` | Rollback repository to a specific commit |
|
||||
|
||||
### Response Examples
|
||||
|
||||
**GET /copilot/skills** — List categories
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": [
|
||||
{"category": "injection", "file_count": 39, "path": "injection/"},
|
||||
{"category": "device", "file_count": 2, "path": "device/"},
|
||||
{"category": "packet_analysis", "file_count": 8, "path": "packet_analysis/"},
|
||||
{"category": "prompts", "file_count": 4, "path": "prompts/"},
|
||||
{"category": "config", "file_count": 1, "path": "config/"}
|
||||
],
|
||||
"repository": {
|
||||
"repo_url": "https://github.com/gns3/gns3-skills.git",
|
||||
"branch": "main",
|
||||
"current_version": "abc123def456...",
|
||||
"is_dirty": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET /copilot/skills/injection** — List files in category
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "injection",
|
||||
"files": [
|
||||
{"filename": "ospf_issues", "extension": ".yaml", "size": 4521, "last_modified": "2026-05-10T08:30:00Z"},
|
||||
{"filename": "bgp_issues", "extension": ".yaml", "size": 3820, "last_modified": "2026-05-09T14:00:00Z"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**GET /copilot/skills/injection/ospf_issues** — Read file content
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "injection",
|
||||
"filename": "ospf_issues",
|
||||
"extension": ".yaml",
|
||||
"content": "name: OSPF Fault Injection\n...\n",
|
||||
"size": 4521,
|
||||
"last_modified": "2026-05-10T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**PUT /copilot/skills/injection/ospf_issues** — Update file
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "name: OSPF Fault Injection\n..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "injection",
|
||||
"filename": "ospf_issues",
|
||||
"size": 4600,
|
||||
"last_modified": "2026-05-12T10:00:00Z",
|
||||
"status": "modified"
|
||||
}
|
||||
```
|
||||
|
||||
**POST /copilot/skills/commit** — Commit changes
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "fix: update OSPF hello/dead interval fault descriptions",
|
||||
"files": ["injection/ospf_issues.yaml"]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"commit_hash": "def456abc789...",
|
||||
"message": "fix: update OSPF hello/dead interval fault descriptions",
|
||||
"files_committed": 1
|
||||
}
|
||||
```
|
||||
|
||||
**POST /copilot/skills/pull-request** — Create PR
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Fix OSPF fault injection descriptions",
|
||||
"body": "Updated OSPF hello/dead interval fault descriptions for clarity.",
|
||||
"branch": "fix/ospf-descriptions",
|
||||
"target_branch": "main",
|
||||
"fork_url": "https://github.com/user/GNS3-Skills.git"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"pr_url": "https://github.com/gns3/gns3-skills/pull/42",
|
||||
"pr_number": 42,
|
||||
"branch": "fix/ospf-descriptions"
|
||||
}
|
||||
```
|
||||
|
||||
**GET /copilot/skills/status** — Git status
|
||||
|
||||
```json
|
||||
{
|
||||
"current_version": "abc123def456...",
|
||||
"branch": "main",
|
||||
"is_dirty": true,
|
||||
"modified": ["injection/ospf_issues.yaml"],
|
||||
"untracked": [],
|
||||
"deleted": [],
|
||||
"staged": [],
|
||||
"available_versions": [
|
||||
{"hash": "abc123def456", "message": "Add MPLS fault scenarios", "author": "dev", "date": "2026-05-10T08:00:00Z"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `gns3server/agent/gns3_copilot/skills/file_manager.py` | `SkillsFileManager` class — file CRUD, git commit/push, GitHub PR API |
|
||||
| `gns3server/api/routes/controller/skills_editor.py` | FastAPI router with all endpoints above |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `gns3server/agent/gns3_copilot/skills/manager.py` | Add `get_file_manager()` method returning a `SkillsFileManager` |
|
||||
| `gns3server/api/routes/controller/__init__.py` | Register `skills_editor` router under `/copilot/skills` prefix |
|
||||
| `gns3server/api/routes/controller/copilot.py` | Deprecate `/reload/skills` in favor of `/skills/reload` |
|
||||
|
||||
### SkillsFileManager Key Methods
|
||||
|
||||
| Method | Returns |
|
||||
|--------|---------|
|
||||
| `list_categories()` | `list[{category, file_count, path}]` |
|
||||
| `list_files(category)` | `list[{filename, size, last_modified}]` |
|
||||
| `read_file(category, filename)` | File content as string |
|
||||
| `write_file(category, filename, content)` | Write (create or update) |
|
||||
| `delete_file(category, filename)` | Delete file |
|
||||
| `get_git_status()` | Modified/untracked/deleted file lists |
|
||||
| `commit_changes(message, files)` | git add + commit |
|
||||
| `push_to_remote(branch)` | git push |
|
||||
| `create_pull_request(title, body, branch, target, fork_url)` | Push + GitHub PR API |
|
||||
|
||||
## Security
|
||||
|
||||
- **Authentication**: All endpoints require superadmin (`current_user.is_superadmin` check)
|
||||
- **Path traversal prevention**: Category validated against whitelist; filename sanitized (no `/`, `..`, or absolute paths)
|
||||
- **File extension enforcement**: `.yaml` for injection/device/packet_analysis, `.md` for prompts, `.txt` for config
|
||||
- **File size limit**: Reject files > 1MB
|
||||
- **YAML validation**: Validate with `yaml.safe_load` before saving
|
||||
- **GitHub token**: Required for PR creation, stored in GNS3 server config
|
||||
|
||||
### PR Creation Modes
|
||||
|
||||
1. **Direct push**: Push to a new branch on the main repo, create PR via GitHub API
|
||||
2. **Fork**: Push to user's fork, create PR against upstream
|
||||
|
||||
The `fork_url` parameter selects the mode. When omitted, the API pushes to the same repository and creates a PR directly.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [External Skills Repository](../implemented/skills-repository.md)
|
||||
- [Fault Injection](../implemented/fault-injection.md)
|
||||
- [Chat API](../implemented/chat-api.md)
|
||||
311
docs/gns3-copilot/roadmap/user-node-limit-roadmap.md
Normal file
311
docs/gns3-copilot/roadmap/user-node-limit-roadmap.md
Normal file
@ -0,0 +1,311 @@
|
||||
# User Node Limit Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a user-level node startup limit feature for GNS3 server to prevent single users from consuming excessive system resources. This feature is **disabled by default** and can be enabled through configuration files, supporting a three-tier configuration priority system.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, GNS3 server has no mechanism to limit the number of nodes a user can start across all their projects. This can lead to:
|
||||
|
||||
- **Resource exhaustion**: A single user can consume all available system resources
|
||||
- **Unfair usage**: Some users may prevent others from using the system
|
||||
- **System instability**: Too many running nodes can degrade overall performance
|
||||
- **Cost issues**: In cloud environments, this can lead to unexpected costs
|
||||
|
||||
## Solution Design
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Default to no limits**: System maintains backward compatibility by defaulting to unrestricted usage
|
||||
2. **Configuration-driven**: All limits can be controlled through configuration files
|
||||
3. **Multi-tier priority**: User-specific > User group-specific > Global configuration
|
||||
4. **Intelligent filtering**: Only count nodes that actually consume resources
|
||||
5. **Clear error messages**: Users receive actionable feedback when limits are reached
|
||||
|
||||
### Technical Architecture
|
||||
|
||||
#### 1. Database Layer Extension
|
||||
|
||||
**Files**: `gns3server/db/models/users.py`
|
||||
|
||||
Add `max_nodes` field to both `User` and `UserGroup` models:
|
||||
|
||||
```python
|
||||
# In User model
|
||||
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
||||
|
||||
# In UserGroup model
|
||||
max_nodes = Column(Integer, nullable=True) # NULL = no limit
|
||||
```
|
||||
|
||||
#### 2. Configuration System Extension
|
||||
|
||||
**File**: `gns3server/schemas/config.py`
|
||||
|
||||
Add node limit configuration to `ControllerSettings`:
|
||||
|
||||
```python
|
||||
class NodeLimitSettings(BaseModel):
|
||||
enabled: bool = False # Feature toggle (default disabled)
|
||||
default_max_nodes: int = 5 # Default limit when enabled
|
||||
excluded_node_types: List[str] = Field(default_factory=lambda: [
|
||||
"ethernet_switch", "ethernet_hub", "cloud", "nat"
|
||||
])
|
||||
```
|
||||
|
||||
#### 3. Core Service Implementation
|
||||
|
||||
**New File**: `gns3server/services/node_limit_service.py`
|
||||
|
||||
Implement the `NodeLimitService` class with key methods:
|
||||
|
||||
```python
|
||||
class NodeLimitService:
|
||||
async def get_user_active_node_count(self, username: str, excluded_types: List[str]) -> int:
|
||||
"""Count user's active nodes across all projects"""
|
||||
|
||||
async def get_user_node_limit(self, user: User) -> Optional[int]:
|
||||
"""Get user's node limit with priority logic"""
|
||||
|
||||
async def check_user_node_limit(self, user: User, project: Project) -> Tuple[bool, str]:
|
||||
"""Check if user can start more nodes"""
|
||||
```
|
||||
|
||||
#### 4. API Integration
|
||||
|
||||
**File**: `gns3server/api/routes/controller/nodes.py`
|
||||
|
||||
Add limit checking to node startup endpoint:
|
||||
|
||||
```python
|
||||
@router.post("/{node_id}/start")
|
||||
async def start_node(
|
||||
node: Node = Depends(dep_node),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
node_limit_service: NodeLimitService = Depends(get_node_limit_service)
|
||||
):
|
||||
# Node limit check
|
||||
can_start, error_msg = await node_limit_service.check_user_node_limit(
|
||||
current_user, node.project
|
||||
)
|
||||
if not can_start:
|
||||
raise HTTPException(status_code=403, detail=error_msg)
|
||||
|
||||
# Original startup logic
|
||||
await node.start()
|
||||
```
|
||||
|
||||
### Node Counting Logic
|
||||
|
||||
#### What Counts Toward the Limit
|
||||
|
||||
- **Status**: Only nodes in `started` or `suspended` state
|
||||
- **Ownership**: Only projects where `project.created_by == current_user.username`
|
||||
- **Node types**: All node types except those explicitly excluded
|
||||
|
||||
#### What's Excluded from the Limit
|
||||
|
||||
- **Always-running nodes**: Ethernet switches, hubs (nodes where `is_always_running()` returns true)
|
||||
- **Infrastructure nodes**: Cloud nodes and NAT nodes
|
||||
- **Stopped nodes**: Nodes in `stopped` state
|
||||
- **Other users' projects**: Nodes in projects created by other users
|
||||
|
||||
#### Configuration Priority
|
||||
|
||||
```
|
||||
User-specific limit (highest priority)
|
||||
↓ not set
|
||||
User group limit
|
||||
↓ not set
|
||||
Global configuration (if enabled)
|
||||
↓ disabled
|
||||
No limit (default)
|
||||
```
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
#### Scenario 1: Default No Limits (System Default)
|
||||
|
||||
```ini
|
||||
[Controller]
|
||||
node_limits_enabled = false
|
||||
```
|
||||
|
||||
**Result**: All users have no node limits
|
||||
|
||||
#### Scenario 2: Enable Global Limits
|
||||
|
||||
```ini
|
||||
[Controller]
|
||||
node_limits_enabled = true
|
||||
node_limits_default_max_nodes = 5
|
||||
node_limits_excluded_types = ethernet_switch,ethernet_hub,cloud,nat
|
||||
```
|
||||
|
||||
**Result**: All users limited to 5 active nodes (excluding infrastructure nodes)
|
||||
|
||||
#### Scenario 3: User-Specific Limits
|
||||
|
||||
Configuration file: `node_limits_enabled = false`
|
||||
|
||||
Database:
|
||||
- User A: `max_nodes = 10` (limited to 10 nodes)
|
||||
- User B: `max_nodes = NULL` (no limit)
|
||||
- Other users: no limit
|
||||
|
||||
#### Scenario 4: User Group Limits
|
||||
|
||||
Configuration file: `node_limits_enabled = false`
|
||||
|
||||
Database:
|
||||
- "Users" group: `max_nodes = 5`
|
||||
- "Premium Users" group: `max_nodes = 20`
|
||||
- "Administrators" group: `max_nodes = NULL`
|
||||
|
||||
**Result**: Members inherit limits from their groups
|
||||
|
||||
## Implementation Files
|
||||
|
||||
| File Path | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `gns3server/db/models/users.py` | Modify | Add `max_nodes` field to User and UserGroup |
|
||||
| `gns3server/db_migrations/versions/xxx_add_node_limits.py` | New | Database migration script |
|
||||
| `gns3server/schemas/config.py` | Modify | Add NodeLimitSettings configuration class |
|
||||
| `gns3server/schemas/controller/users.py` | Modify | Add `max_nodes` to API schemas |
|
||||
| `gns3server/services/node_limit_service.py` | New | Core node limit service |
|
||||
| `gns3server/api/routes/controller/nodes.py` | Modify | Add limit check to node startup |
|
||||
| `gns3server/api/routes/controller/users.py` | Modify | Add user limit configuration API |
|
||||
| `gns3server/api/routes/controller/groups.py` | Modify | Add group limit configuration API |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Database Layer
|
||||
1. Add `max_nodes` field to `User` and `UserGroup` models
|
||||
2. Create database migration file
|
||||
3. Test database migration and rollback
|
||||
|
||||
### Phase 2: Configuration System
|
||||
1. Add `NodeLimitSettings` to configuration schema
|
||||
2. Update configuration file loading logic
|
||||
3. Test configuration parsing and validation
|
||||
|
||||
### Phase 3: Core Service
|
||||
1. Implement `NodeLimitService` class
|
||||
2. Implement node counting logic
|
||||
3. Implement limit checking logic
|
||||
4. Add unit tests for service methods
|
||||
|
||||
### Phase 4: API Integration
|
||||
1. Modify node startup endpoint to add limit check
|
||||
2. Add user limit configuration endpoints
|
||||
3. Add group limit configuration endpoints
|
||||
4. Add user node usage statistics endpoint
|
||||
|
||||
### Phase 5: Testing
|
||||
1. Unit tests for all core functions
|
||||
2. Integration tests for API endpoints
|
||||
3. End-to-end tests for complete workflows
|
||||
4. Performance tests for node counting operations
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Functional Tests
|
||||
- [ ] Default state verification (no limits)
|
||||
- [ ] Global limit enablement
|
||||
- [ ] User-specific limits
|
||||
- [ ] User group limits
|
||||
- [ ] Configuration priority verification
|
||||
|
||||
### Boundary Tests
|
||||
- [ ] Exactly at limit (can start last node)
|
||||
- [ ] One over limit (startup rejected)
|
||||
- [ ] Stop node then restart (should work)
|
||||
- [ ] Special node type exclusion
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Multi-user concurrent startups
|
||||
- [ ] Node state transitions
|
||||
- [ ] Dynamic configuration changes
|
||||
- [ ] Project ownership filtering
|
||||
|
||||
### Performance Tests
|
||||
- [ ] Node counting performance with many projects
|
||||
- [ ] Concurrent startup request handling
|
||||
- [ ] Memory usage monitoring
|
||||
|
||||
## Error Messages
|
||||
|
||||
### User-Friendly Error Response
|
||||
|
||||
When a user hits their node limit:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "节点启动限制:您当前有 5 个活跃节点,限制为 5 个。请停止一些节点后再试,或联系管理员调整限制。"
|
||||
}
|
||||
```
|
||||
|
||||
Alternative formats:
|
||||
- Show current usage vs limit
|
||||
- Provide action suggestions
|
||||
- Include contact information for administrators
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Existing Systems
|
||||
|
||||
1. **Database migration**: Add new nullable fields (safe, no data loss)
|
||||
2. **Configuration update**: Add new optional settings (backward compatible)
|
||||
3. **API changes**: Add new optional dependency injection (no breaking changes)
|
||||
4. **Behavior**: No changes to existing functionality when disabled
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
If issues occur:
|
||||
1. Set `node_limits_enabled = false` in configuration
|
||||
2. Service automatically disables limit checking
|
||||
3. System returns to pre-feature behavior
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Resource Management**: Prevent resource exhaustion
|
||||
2. **Fair Usage**: Ensure equitable resource distribution
|
||||
3. **Cost Control**: Manage cloud resource costs
|
||||
4. **System Stability**: Maintain performance under load
|
||||
5. **Flexibility**: Support different usage patterns and tiers
|
||||
6. **Backward Compatible**: No impact on existing deployments
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Per-project limits (in addition to global user limits)
|
||||
- Time-based limits (different limits for different times)
|
||||
- Burst limits (temporary allowance for peak usage)
|
||||
- Usage quotas with reset periods (daily/weekly/monthly)
|
||||
- Monitoring and alerting for limit approaching
|
||||
- Administrative override capabilities
|
||||
- Usage history and analytics
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
- [ ] Update API documentation with new endpoints
|
||||
- [ ] Add configuration guide to admin documentation
|
||||
- [ ] Update user guide with limit information
|
||||
- [ ] Add troubleshooting section for limit issues
|
||||
- [ ] Provide migration guide for existing deployments
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status**: Design Phase
|
||||
|
||||
**Next Steps**:
|
||||
1. Review and approve this roadmap
|
||||
2. Begin Phase 1 implementation (Database Layer)
|
||||
3. Create detailed technical specification
|
||||
4. Set up development and testing environment
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-05-27
|
||||
**Author**: GNS3 Development Team
|
||||
@ -0,0 +1,372 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This document is a roadmap/planning document. The described features have not been implemented yet.
|
||||
|
||||
|
||||
# Web Wireshark Docker Image Size Optimization — Roadmap
|
||||
|
||||
## Problem
|
||||
|
||||
The `gns3/web-wireshark` Docker image currently occupies **~2GB** of disk space, which impacts:
|
||||
- Initial pull/download time for users
|
||||
- Storage requirements on Docker Hub
|
||||
- Deployment flexibility in resource-constrained environments
|
||||
|
||||
### Current Size Breakdown
|
||||
|
||||
Based on `docker history gns3/web-wireshark:latest`:
|
||||
|
||||
| Component | Size | Percentage |
|
||||
|-----------|------|------------|
|
||||
| `debian:trixie` base image | 120 MB | 6% |
|
||||
| xpra + dependencies | 65.1 MB | 3.3% |
|
||||
| Wireshark + GUI stack (Qt, GTK, X11) | 1.82 GB | 91% |
|
||||
| **Total** | **~2 GB** | **100%** |
|
||||
|
||||
### Detailed File System Analysis
|
||||
|
||||
Analysis of container file system reveals significant cleanup opportunities:
|
||||
|
||||
| Directory | Size | Cleanup Potential |
|
||||
|-----------|------|-------------------|
|
||||
| `/usr/lib` | 1.3 GB | ~50MB (static libraries) |
|
||||
| `/usr/share/locale` | 151 MB | **~145MB** (192 locales → 1) |
|
||||
| `/usr/share/ibus` | 130 MB | **~130MB** (input framework) |
|
||||
| `/usr/share/doc` | 76 MB | **~76MB** (documentation) |
|
||||
| `/usr/share/backgrounds` | 37 MB | **~37MB** (desktop backgrounds) |
|
||||
| `/usr/share/man` | 27 MB | **~27MB** (man pages) |
|
||||
| `/usr/share/icons` | 16 MB | ~5MB (keep essential) |
|
||||
| Development packages | ~50 MB | **~50MB** (21 `-dev` packages) |
|
||||
| Static libraries (`*.a`, `*.la`) | 16 MB | **~16MB** |
|
||||
| Sounds/help/perl | ~20 MB | **~20MB** |
|
||||
| **Total Cleanable** | **~570 MB** | |
|
||||
|
||||
**Key Findings**:
|
||||
- 192 locale languages installed (only need en_US)
|
||||
- ibus input framework installed (not needed in headless container)
|
||||
- 21 development packages with headers/headers
|
||||
- 161 static library files (`.a`, `.la`)
|
||||
- Complete documentation and man pages
|
||||
- Desktop environment components (backgrounds, sounds)
|
||||
|
||||
## Proposed Optimizations
|
||||
|
||||
### Phase 1: Safe Optimizations (Estimated: -300~400MB)
|
||||
|
||||
Low-risk changes that maintain full compatibility.
|
||||
|
||||
#### 1. Use Slim Base Image (-50MB)
|
||||
|
||||
```dockerfile
|
||||
FROM debian:trixie-slim # Instead of debian:trixie
|
||||
```
|
||||
|
||||
**Impact**: Reduces base from 120MB to ~70MB
|
||||
**Risk**: Low - slim variant contains all essential runtime libraries
|
||||
**Testing Required**: Verify xpra and Wireshark launch without errors
|
||||
|
||||
#### 2. Install Without Recommended Packages (-150~200MB)
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
wireshark-common \
|
||||
wireshark \
|
||||
xpra=6.4.3* \
|
||||
xpra-x11 \
|
||||
xvfb \
|
||||
curl \
|
||||
x11-utils
|
||||
```
|
||||
|
||||
**Impact**: Prevents installation of non-essential recommended packages
|
||||
**Risk**: Low - only excludes recommended packages, not required dependencies
|
||||
**Testing Required**: Full functionality test (capture, analysis, WebSocket)
|
||||
|
||||
#### 3. Cleanup Unnecessary Files (-400~500MB)
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get install -y --no-install-recommends \
|
||||
wireshark-common wireshark xpra=6.4.3* xpra-x11 xvfb curl x11-utils \
|
||||
# Remove documentation and man pages
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/help/* \
|
||||
# Remove unnecessary locales (keep only en_US)
|
||||
&& rm -rf /usr/share/locale/* \
|
||||
&& localedef -i en_US -f UTF-8 en_US.UTF-8 \
|
||||
# Remove desktop environment components
|
||||
&& rm -rf /usr/share/backgrounds/* /usr/share/sounds/* \
|
||||
# Remove static libraries
|
||||
&& find /usr/lib -name '*.a' -delete \
|
||||
&& find /usr/lib -name '*.la' -delete \
|
||||
# Remove unnecessary packages
|
||||
&& apt-get purge -y -y \
|
||||
ibus ibus-data ibus-gtk* python3-ibus-1.0 \
|
||||
gnome-backgrounds \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean
|
||||
```
|
||||
|
||||
**Impact**: Removes documentation, locales, desktop components, input framework
|
||||
**Risk**: Low - all removed components are unnecessary in headless container
|
||||
**Testing Required**: Verify Wireshark GUI renders correctly without icons/themes
|
||||
|
||||
### Phase 2: Experimental (Requires Testing, -200~500MB)
|
||||
|
||||
Higher-risk optimizations that need extensive validation.
|
||||
|
||||
#### 4. Alpine Linux Alternative (-500MB~1GB)
|
||||
|
||||
```dockerfile
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache wireshark xpra xvfb curl ...
|
||||
```
|
||||
|
||||
**Impact**: Could reduce image to ~500MB-1GB
|
||||
**Risk**: **High** - Wireshark and xpra have complex Qt/GTK dependencies
|
||||
**Challenges**:
|
||||
- Wireshark Qt dependencies may not be available in Alpine repos
|
||||
- xpra package availability and compatibility
|
||||
- X11 library differences
|
||||
- May require building dependencies from source
|
||||
|
||||
**Testing Required**:
|
||||
- [ ] Verify Wireshark package availability in Alpine
|
||||
- [ ] Test xpra compilation/installation on Alpine
|
||||
- [ ] Validate all GUI libraries work correctly
|
||||
- [ ] Full integration testing
|
||||
|
||||
**Recommendation**: Do not pursue unless Phase 1 insufficient
|
||||
|
||||
## Compression Feasibility Analysis
|
||||
|
||||
### Question: Can we reduce image size through compression?
|
||||
|
||||
**Short Answer**: **Not recommended** - limited benefit with performance trade-offs
|
||||
|
||||
### Current Compression Status
|
||||
|
||||
Docker images already use compression:
|
||||
|
||||
| Format | Size | Compression Rate | Use Case |
|
||||
|--------|------|------------------|----------|
|
||||
| Runtime size | 2.0 GB | - | Container running |
|
||||
| docker save (raw) | 1.9 GB | 5% | Docker internal compression |
|
||||
| docker save + gzip | 718 MB | **64%** | Standard transfer |
|
||||
| docker save + xz | 563 MB | **72%** | Maximum compression |
|
||||
| docker save + zstd | ~600 MB | **70%** | Fast compression |
|
||||
|
||||
### Binary Compression Analysis
|
||||
|
||||
#### File Already Stripped
|
||||
All binaries already have debug symbols removed:
|
||||
```bash
|
||||
wireshark: ELF 64-bit... stripped
|
||||
python3.13: ELF 64-bit... stripped
|
||||
libc.so.6: ELF 64-bit... stripped
|
||||
```
|
||||
|
||||
**No further stripping possible**
|
||||
|
||||
#### UPX Executable Compression (Limited Benefit)
|
||||
|
||||
Test results compressing major executables:
|
||||
|
||||
| Binary | Original | UPX Compressed | Savings | Startup Impact |
|
||||
|--------|----------|----------------|---------|----------------|
|
||||
| wireshark (11MB) | 11.0 MB | 4.2 MB | 62% | +0.3s |
|
||||
| Xvfb (2.1MB) | 2.1 MB | 0.9 MB | 57% | +0.1s |
|
||||
| python3.13 (6.6MB) | 6.6 MB | 2.8 MB | 58% | +0.2s |
|
||||
| **Total** | **19.7 MB** | **7.9 MB** | **60%** | **+0.6s** |
|
||||
|
||||
**Overall Impact**: Only ~20MB savings (1%) with 0.6s startup penalty
|
||||
|
||||
### Why Compression Has Limited Benefit
|
||||
|
||||
1. **Small Executable Footprint**: Binaries are only 74MB (3.7% of image)
|
||||
2. **Libraries Are Data Files**: `/usr/lib` contains mostly data, not code
|
||||
3. **Already Compressed**: Docker storage drivers compress layers automatically
|
||||
4. **Resource Files Dominate**: Fonts, icons, themes don't compress well
|
||||
|
||||
### Compression Trade-offs
|
||||
|
||||
| Method | Potential Savings | Performance Impact | Complexity | Risk |
|
||||
|--------|-------------------|-------------------|------------|------|
|
||||
| **File cleanup** | 400-500MB (20-25%) | None | Low | Low |
|
||||
| UPX compression | 50-100MB (2.5-5%) | +0.6s startup | Medium | Medium |
|
||||
| Layer squashing | 10-50MB (0.5-2.5%) | None | Low | Low |
|
||||
| Transfer compression | 1.4GB (70%) | None (transfer only) | None | None |
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Do not pursue binary compression** because:
|
||||
- ✅ File cleanup is **4-10x more effective**
|
||||
- ✅ No performance penalty
|
||||
- ✅ Simpler build process
|
||||
- ✅ Better compatibility
|
||||
|
||||
**For transfer/storage optimization**, use standard tools:
|
||||
```bash
|
||||
# For archiving (use zstd for best speed/ratio)
|
||||
docker save gns3/web-wireshark:latest | \
|
||||
zstd -19 -o web-wireshark.tar.zst
|
||||
|
||||
# For maximum compression (slow)
|
||||
docker save gns3/web-wireshark:latest | \
|
||||
xz -9 -T 0 > web-wireshark.tar.xz
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Create Optimized Dockerfile
|
||||
|
||||
Create `gns3server/agent/web_wireshark/docker/Dockerfile.optimized` with Phase 1 changes.
|
||||
|
||||
### Step 2: Local Testing
|
||||
|
||||
```bash
|
||||
# Build optimized image
|
||||
cd gns3server/agent/web_wireshark/docker
|
||||
docker build -f Dockerfile.optimized -t gns3/web-wireshark:optimized .
|
||||
|
||||
# Verify size reduction
|
||||
docker images | grep web-wireshark
|
||||
|
||||
# Test Wireshark functionality
|
||||
docker run --rm gns3/web-wireshark:optimized wireshark --version
|
||||
|
||||
# Test xpra functionality
|
||||
docker run --rm gns3/web-wireshark:optimized xpra --version
|
||||
```
|
||||
|
||||
### Step 3: Integration Testing
|
||||
|
||||
```bash
|
||||
# Test with actual GNS3 server
|
||||
pip install . && gns3server-web-wireshark-setup
|
||||
|
||||
# Start a test session
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "test-optimized" \
|
||||
--link-id "test-link-1" \
|
||||
--jwt-token "test-token" \
|
||||
--image "gns3/web-wireshark:optimized"
|
||||
|
||||
# Verify WebSocket connectivity and packet capture
|
||||
```
|
||||
|
||||
### Step 4: Production Rollout
|
||||
|
||||
1. Tag optimized image: `gns3/web-wireshark:v1.6-optimized`
|
||||
2. Deploy to staging environment
|
||||
3. Monitor for 1 week with real workloads
|
||||
4. If stable, promote to `latest` tag
|
||||
5. Keep old image available for rollback
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before marking as complete:
|
||||
|
||||
- [ ] Wireshark launches without errors
|
||||
- [ ] xpra HTML5 client connects successfully
|
||||
- [ ] Packet capture works end-to-end
|
||||
- [ ] Packet analysis and filtering functional
|
||||
- [ ] WebSocket proxy integration works
|
||||
- [ ] Multi-session handling tested (3+ simultaneous captures)
|
||||
- [ ] Image size measured and documented
|
||||
- [ ] Tested on Docker 20.10, 24.x, 29.x
|
||||
- [ ] Startup time not negatively impacted
|
||||
- [ ] Memory/CPU usage unchanged
|
||||
- [ ] All existing tests pass
|
||||
|
||||
## Expected Results
|
||||
|
||||
### Phase 1: Safe Optimizations (Recommended)
|
||||
|
||||
| Metric | Current | Target | Improvement |
|
||||
|--------|---------|--------|-------------|
|
||||
| Image Size | 2GB | ~1.5GB | **-25% (500MB)** |
|
||||
| Pull Time | 3-5 min | 2-3 min | **-40%** |
|
||||
| Startup Time | 5-6s | 5-6s | No change |
|
||||
| Functionality | Full | Full | No regression |
|
||||
| Compatibility | All | All | No change |
|
||||
|
||||
### Phase 2: Experimental (If Needed)
|
||||
|
||||
| Metric | Current | Target | Improvement |
|
||||
|--------|---------|--------|-------------|
|
||||
| Image Size | 2GB | ~1GB | **-50% (1GB)** |
|
||||
| Pull Time | 3-5 min | 1-2 min | **-60%** |
|
||||
| Startup Time | 5-6s | 5-7s | Slight increase |
|
||||
| Functionality | Full | Full | Risk of regressions |
|
||||
| Compatibility | All | Alpine only | Significant testing required |
|
||||
|
||||
### Compression Comparison (Not Recommended)
|
||||
|
||||
| Method | Size | Savings | Trade-offs |
|
||||
|--------|------|---------|------------|
|
||||
| **File cleanup** | ~1.5GB | 500MB | None |
|
||||
| UPX compression | ~1.9GB | 100MB | +0.6s startup, compatibility risk |
|
||||
| Transfer compression | 600MB | 1.4GB | Transfer only, no runtime benefit |
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Current State | Changes Needed |
|
||||
|------|---------------|----------------|
|
||||
| `gns3server/agent/web_wireshark/docker/Dockerfile` | Current 2GB image | Create optimized variant |
|
||||
| `gns3server/agent/web_wireshark/setup_wireshark_image.py` | Pulls/builds current image | Support optimized image option |
|
||||
| `gns3server/schemas/config.py` | WebWiresharkSettings | Add image variant config |
|
||||
| `gns3server/agent/web_wireshark/WEB_WIRESHARK.md` | Documents current image | Update with optimization notes |
|
||||
|
||||
## Status
|
||||
|
||||
### Phase 1: Safe Optimizations
|
||||
|
||||
- [ ] Create `Dockerfile.optimized` with slim base + --no-install-recommends + cleanup
|
||||
- [ ] Local build and size verification
|
||||
- [ ] Functional testing (Wireshark, xpra, WebSocket)
|
||||
- [ ] Integration testing with GNS3 server
|
||||
- [ ] Document actual size reduction achieved
|
||||
- [ ] Deploy to staging for 1-week observation
|
||||
- [ ] Promote to production if stable
|
||||
|
||||
### Phase 2: Experimental (Only if Phase 1 insufficient)
|
||||
|
||||
- [ ] Research Alpine Wireshark/xpra package availability
|
||||
- [ ] Prototype Alpine build if feasible
|
||||
- [ ] Extensive compatibility testing
|
||||
- [ ] Performance benchmarking vs. Phase 1
|
||||
|
||||
## Notes
|
||||
|
||||
- All size estimates based on actual `docker history` and container filesystem analysis
|
||||
- Phase 1 optimizations are conservative and should be safe
|
||||
- Compression techniques (UPX, layer squashing) provide minimal benefit (1-5%)
|
||||
- File cleanup is **10x more effective** than compression (25% vs 2.5%)
|
||||
- Phase 2 requires significant research and testing effort
|
||||
- Backward compatibility must be maintained during transition
|
||||
- Consider maintaining both `latest` and `optimized` tags during migration period
|
||||
|
||||
### Key Findings from Analysis
|
||||
|
||||
1. **Major space waste**: 570MB of cleanable files (28.5% of image)
|
||||
- Locale files: 151MB → 6MB (keep only en_US)
|
||||
- ibus input framework: 130MB → 0MB (not needed in headless container)
|
||||
- Documentation: 76MB → 0MB (docs, man pages, help)
|
||||
- Desktop components: 37MB → 0MB (backgrounds, sounds)
|
||||
- Development packages: 50MB → 0MB (21 -dev packages)
|
||||
|
||||
2. **Compression not viable**:
|
||||
- Binaries already stripped (no debug symbols)
|
||||
- UPX only saves 20MB (1%) with 0.6s startup penalty
|
||||
- Docker already compresses layers internally
|
||||
- Resource files (fonts, themes) don't compress well
|
||||
|
||||
3. **Best approach**: Clean up unnecessary files rather than compress
|
||||
- 25x better compression ratio than UPX
|
||||
- No performance impact
|
||||
- Simpler build process
|
||||
- Better compatibility
|
||||
@ -1,67 +0,0 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This document is a roadmap/planning document. The described features have not been implemented yet.
|
||||
|
||||
|
||||
# Server Settings REST API — Roadmap
|
||||
|
||||
## Problem
|
||||
|
||||
Currently, `gns3_server.conf` can only be modified by directly editing the file on disk. There is no REST API endpoint to read or write server configuration, which prevents the Web UI from offering a settings page for server parameters.
|
||||
|
||||
## Proposed API
|
||||
|
||||
```
|
||||
GET /v3/settings → Return all current server settings
|
||||
PUT /v3/settings → Update and persist server settings
|
||||
```
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
**1. Add `save_config()` to `Config` class** (`gns3server/config.py`)
|
||||
|
||||
The `Config` class currently only reads configuration (via `read_config()` / `reload()`). A `save_config()` method is needed to serialize the in-memory `ServerConfig` pydantic model back to INI format and write it to disk.
|
||||
|
||||
Serialization details:
|
||||
- `bool` → `"True"` / `"False"` (configparser convention)
|
||||
- `SecretStr` → `get_secret_value()`
|
||||
- `Enum` → `.value`
|
||||
- `List[str]` → semi-colon for `additional_images_paths`, comma for `allowed_interfaces`
|
||||
- `None` → skip
|
||||
|
||||
**2. Add a settings getter/setter** to `Config` to allow programmatic updates to the in-memory settings.
|
||||
|
||||
**3. New route file** (`gns3server/api/routes/controller/settings.py`):
|
||||
|
||||
- `GET /v3/settings` — returns the full `ServerConfig` as JSON (pydantic automatically masks `SecretStr` fields as `"********"`)
|
||||
- `PUT /v3/settings` — accepts `ServerConfig`, merges existing secrets when placeholder values (`"********"`) are submitted, calls `save_config()`, and triggers runtime config update callbacks
|
||||
|
||||
Both endpoints require `get_current_active_user` for authentication.
|
||||
|
||||
**4. Register the new router** in `gns3server/api/routes/controller/__init__.py` under the `/settings` prefix.
|
||||
|
||||
### Security
|
||||
|
||||
- All settings endpoints require admin authentication (`get_current_active_user`)
|
||||
- `SecretStr` fields (`compute_password`, `default_admin_password`, `jwt_secret_key`) are masked in responses
|
||||
- On write, unchanged secrets are preserved via placeholder detection
|
||||
|
||||
### Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `gns3server/config.py` | Config singleton with `read_config()` / `reload()` |
|
||||
| `gns3server/schemas/config.py` | `ServerConfig` pydantic model with all 9 sub-models |
|
||||
| `gns3server/api/routes/controller/__init__.py` | Controller router mounting |
|
||||
| `gns3server/controller/__init__.py` | `Controller._update_config()` for runtime credential sync |
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] gns3server/config.py: add `save_config()` method
|
||||
- [ ] gns3server/config.py: add settings getter/setter
|
||||
- [ ] gns3server/api/routes/controller/settings.py: new route file with GET and PUT endpoints
|
||||
- [ ] gns3server/api/routes/controller/__init__.py: register settings router under `/settings`
|
||||
- [ ] gns3server/api/routes/controller/controller.py: add notification emission on config change
|
||||
File diff suppressed because one or more lines are too long
@ -15,14 +15,15 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Agent module with optional AI Copilot support.
|
||||
Agent module with optional AI Copilot and MCP support.
|
||||
|
||||
This module provides the AI Copilot functionality as an optional feature.
|
||||
If the AI dependencies are not installed, the module will be disabled but
|
||||
will not prevent the server from starting.
|
||||
This module provides the AI Copilot and MCP (Model Context Protocol)
|
||||
functionality as optional features. If the respective dependencies are
|
||||
not installed, the affected features will be disabled but will not
|
||||
prevent the server from starting.
|
||||
|
||||
Installation:
|
||||
pip install gns3-server[ai-copilot]
|
||||
pip install gns3-server[ai-features] # Install all AI features
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -49,7 +50,8 @@ except ImportError as e:
|
||||
# AI dependencies not installed, disable AI Copilot feature
|
||||
logging.warning(
|
||||
f"AI Copilot dependencies not installed: {e}. "
|
||||
"AI features will be disabled. Install with: pip install gns3-server[ai-copilot]"
|
||||
"AI features will be disabled. "
|
||||
"Install with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
AI_COPILOT_AVAILABLE = False
|
||||
|
||||
@ -63,7 +65,7 @@ except ImportError as e:
|
||||
"""
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
class ProjectAgentManager:
|
||||
@ -74,12 +76,33 @@ except ImportError as e:
|
||||
def __init__(self):
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
|
||||
# Feature flag: MCP (Model Context Protocol) is available
|
||||
MCP_AVAILABLE = False
|
||||
|
||||
# Try to import MCP dependencies
|
||||
try:
|
||||
# Use importlib so the top-level SDK name "mcp" is not bound in this
|
||||
# namespace — it would shadow the gns3server.agent.mcp subpackage.
|
||||
import importlib
|
||||
importlib.import_module("mcp.server.fastmcp")
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
# MCP dependencies not installed, disable MCP feature
|
||||
logging.warning(
|
||||
"MCP dependencies not installed. "
|
||||
"MCP features will be disabled. "
|
||||
"Install with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
MCP_AVAILABLE = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AI_COPILOT_AVAILABLE",
|
||||
"MCP_AVAILABLE",
|
||||
"get_project_agent_manager",
|
||||
"ProjectAgentManager",
|
||||
]
|
||||
|
||||
@ -88,6 +88,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3PacketFilterTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
|
||||
@ -144,6 +145,7 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
|
||||
TROUBLESHOOTING_INJECTION_MODE_TOOLS = [
|
||||
ExecuteMultipleDeviceCommands(), # Get device configurations (READ-ONLY)
|
||||
ExecuteMultipleDeviceConfigCommands(), # Inject configuration changes
|
||||
GNS3PacketFilterTool(), # Manage packet filters on links (delay, loss, corrupt, etc.)
|
||||
InjectionSkillsTool(), # Query injection skills and fault types
|
||||
GNS3TopologyTool(), # Get topology information
|
||||
]
|
||||
|
||||
@ -34,7 +34,7 @@ from gns3server.config import Config
|
||||
# Default skills repository configuration
|
||||
SKILLS_CONFIG = {
|
||||
# Git repository URL for skills
|
||||
"repo_url": "https://github.com/yueguobin/GNS3-Skills.git",
|
||||
"repo_url": "https://github.com/gns3/gns3-skills.git",
|
||||
|
||||
# Git branch to use
|
||||
"branch": "main",
|
||||
|
||||
@ -26,27 +26,27 @@
|
||||
"""
|
||||
GNS3 Client Package
|
||||
|
||||
This package provides a Python interface for interacting with GNS3 servers.
|
||||
Adapted from the upstream gns3fy project with modifications for compatibility
|
||||
with langchain and reduced dependency conflicts.
|
||||
This package provides the shared GNS3 REST client layer:
|
||||
|
||||
Main classes:
|
||||
- Gns3Connector: Connector for GNS3 server API interaction
|
||||
- Project: GNS3 Project management
|
||||
- Node: GNS3 Node management
|
||||
- Link: GNS3 Link management
|
||||
- GNS3TopologyTool: GNS3 topology reading tool
|
||||
- GNS3ProjectInfoTool: GNS3 project info tool
|
||||
- Gns3Connector (connector.py): authenticated HTTP session for the
|
||||
controller API — v2 basic / v3 JWT auth, token refresh, error extraction
|
||||
- api_handlers.py: endpoint handlers taking ``(params, gns3_ctx)`` dicts,
|
||||
shared by the copilot tools and the MCP service
|
||||
- project_inventory.py: nodes/links aggregation for the topology context
|
||||
- GNS3TopologyTool / GNS3ProjectInfoTool: LangChain reader tools
|
||||
|
||||
Main functions:
|
||||
- get_gns3_connector: Factory function to create Gns3Connector
|
||||
- get_gns3_connector_with_llm_config: Create connector AND retrieve LLM config
|
||||
- get_gns3_server_host: Get GNS3 server hostname from Controller or Config
|
||||
- get_llm_config: Get LLM model configuration for a user
|
||||
- get_llm_config: Get user's default LLM config with API key
|
||||
|
||||
Upstream gns3fy: https://github.com/davidban77/gns3fy
|
||||
The connector is adapted from the upstream gns3fy project
|
||||
(https://github.com/davidban77/gns3fy).
|
||||
"""
|
||||
|
||||
from .api_handlers import build_gns3_ctx
|
||||
from .connector import Gns3Connector
|
||||
from .connector_factory import get_gns3_connector
|
||||
from .connector_factory import get_gns3_connector_with_llm_config
|
||||
from .connector_factory import get_gns3_server_host
|
||||
@ -55,13 +55,6 @@ from .context_helpers import get_current_jwt_token
|
||||
from .context_helpers import get_current_llm_config
|
||||
from .context_helpers import set_current_jwt_token
|
||||
from .context_helpers import set_current_llm_config
|
||||
from .custom_gns3fy import CONSOLE_TYPES
|
||||
from .custom_gns3fy import LINK_TYPES
|
||||
from .custom_gns3fy import NODE_TYPES
|
||||
from .custom_gns3fy import Gns3Connector
|
||||
from .custom_gns3fy import Link
|
||||
from .custom_gns3fy import Node
|
||||
from .custom_gns3fy import Project
|
||||
from .gns3_project_info import GNS3ProjectInfoTool
|
||||
from .gns3_topology_reader import GNS3TopologyTool
|
||||
|
||||
@ -79,12 +72,7 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
__all__ = [
|
||||
"Gns3Connector",
|
||||
"Project",
|
||||
"Node",
|
||||
"Link",
|
||||
"NODE_TYPES",
|
||||
"CONSOLE_TYPES",
|
||||
"LINK_TYPES",
|
||||
"build_gns3_ctx",
|
||||
"GNS3TopologyTool",
|
||||
"GNS3ProjectInfoTool",
|
||||
"get_gns3_connector",
|
||||
|
||||
1010
gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
Normal file
1010
gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
309
gns3server/agent/gns3_copilot/gns3_client/connector.py
Normal file
309
gns3server/agent/gns3_copilot/gns3_client/connector.py
Normal file
@ -0,0 +1,309 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot 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.
|
||||
#
|
||||
# GNS3-Copilot 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 GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
GNS3 REST API connector.
|
||||
|
||||
A minimal authenticated HTTP session for the GNS3 controller API: URL/base
|
||||
URL handling, v2 basic / v3 JWT authentication and token refresh, plus GNS3
|
||||
error extraction. Callers make requests through ``http_call`` — the
|
||||
endpoint-specific logic lives in ``api_handlers``.
|
||||
|
||||
The class is adapted from the upstream gns3fy project
|
||||
(https://github.com/davidban77/gns3fy) Gns3Connector.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol)
|
||||
service. Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
import urllib3
|
||||
from requests import HTTPError
|
||||
|
||||
|
||||
class Gns3Connector:
|
||||
"""
|
||||
Connector to be used for interaction against the GNS3 server controller API.
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `url` (str): URL of the GNS3 server (**required**)
|
||||
- `user` (str): User used for authentication
|
||||
- `cred` (str): Password used for authentication
|
||||
- `jwt_token` (str): JWT token for direct authentication (API v3)
|
||||
- `verify` (bool): Whether or not to verify SSL
|
||||
- `api_version` (int): GNS3 server REST API version
|
||||
- `api_calls`: Counter of amount of `http_calls` has been performed
|
||||
- `base_url`: url passed + api_version
|
||||
- `session`: Requests Session object
|
||||
|
||||
**Returns:**
|
||||
|
||||
`Gns3Connector` instance
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
>>> # API v2 with basic auth
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080", user="admin", cred="password",
|
||||
... api_version=2
|
||||
... )
|
||||
>>> # API v3 with username/password (auto-fetches JWT token)
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080", user="admin", cred="password",
|
||||
... api_version=3
|
||||
... )
|
||||
>>> # API v3 with direct JWT token
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080",
|
||||
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
... api_version=3
|
||||
... )
|
||||
>>> print(server.http_call("get", f"{server.base_url}/version").json())
|
||||
{'local': False, 'version': '2.2.0b4'}
|
||||
```
|
||||
"""
|
||||
|
||||
access_token: str | None
|
||||
token_expiry: float | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str | None = None,
|
||||
user: str | None = None,
|
||||
cred: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
verify: bool = False,
|
||||
api_version: int = 2,
|
||||
) -> None:
|
||||
# Disable SSL warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
if url is None:
|
||||
raise ValueError("URL is required for Gns3Connector")
|
||||
self.url = url.strip("/") # Store original URL for reference
|
||||
self.base_url = f"{self.url}/v{api_version}"
|
||||
self.user = user
|
||||
self.cred = cred
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self.verify = verify
|
||||
self.api_calls = 0
|
||||
|
||||
# v3 authentication attributes
|
||||
# If jwt_token is provided directly, use it; otherwise will be
|
||||
# fetched via username/password
|
||||
self.access_token = jwt_token
|
||||
self.token_expiry = None
|
||||
self.auth_type = "basic" if api_version == 2 else "jwt"
|
||||
self.api_version = api_version
|
||||
|
||||
# Create session object
|
||||
self._create_session()
|
||||
|
||||
def _create_session(self) -> None:
|
||||
"""
|
||||
Creates the requests.Session object and applies the necessary parameters
|
||||
"""
|
||||
self.session = requests.Session() # pragma: no cover
|
||||
# Increase connection pool size to support concurrent MCP batch operations
|
||||
adapter = requests.adapters.HTTPAdapter(pool_connections=500, pool_maxsize=1000)
|
||||
self.session.mount("http://", adapter)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.headers["Accept"] = "application/json" # pragma: no cover
|
||||
|
||||
# Set authentication based on API version
|
||||
if (
|
||||
self.auth_type == "basic"
|
||||
and self.user is not None
|
||||
and self.cred is not None
|
||||
):
|
||||
self.session.auth = (self.user, self.cred) # pragma: no cover
|
||||
|
||||
elif self.auth_type == "jwt" and self.access_token:
|
||||
self.session.headers["Authorization"] = (
|
||||
f"Bearer {self.access_token}"
|
||||
)
|
||||
|
||||
def _authenticate_v3(self) -> None:
|
||||
"""
|
||||
Performs v3 API authentication using username and password to get JWT token.
|
||||
Skips authentication if a JWT token is already provided.
|
||||
"""
|
||||
# If token is already provided, skip authentication
|
||||
if self.access_token:
|
||||
return
|
||||
|
||||
if not self.user or not self.cred:
|
||||
raise ValueError(
|
||||
"Username and password are required for v3 authentication "
|
||||
"when no JWT token is provided"
|
||||
)
|
||||
|
||||
# Construct authentication URL (v3 API uses different base URL)
|
||||
auth_url = (
|
||||
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
|
||||
)
|
||||
auth_data = {"username": self.user, "password": self.cred}
|
||||
|
||||
# Use temporary session for authentication
|
||||
temp_session = requests.Session()
|
||||
temp_session.headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
response = temp_session.post(
|
||||
auth_url, json=auth_data, verify=self.verify, timeout=10.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
auth_result = response.json()
|
||||
self.access_token = auth_result["access_token"]
|
||||
# Update session with new token
|
||||
self.session.headers["Authorization"] = (
|
||||
f"Bearer {self.access_token}"
|
||||
)
|
||||
else:
|
||||
raise HTTPError(
|
||||
f"v3 API authentication failed: {response.status_code} - "
|
||||
f"{response.text}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
|
||||
|
||||
def _is_token_expired(self) -> bool:
|
||||
"""
|
||||
Check if the JWT token is expired (basic implementation)
|
||||
"""
|
||||
token = self.access_token
|
||||
if not token:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Decode token without verification to check expiry
|
||||
decoded: dict[str, Any] = jwt.decode(
|
||||
token, options={"verify_signature": False}
|
||||
)
|
||||
exp = decoded.get("exp")
|
||||
if exp is not None:
|
||||
return time.time() > float(exp)
|
||||
return False
|
||||
except (jwt.PyJWTError, ValueError, TypeError):
|
||||
return True
|
||||
|
||||
def _refresh_token(self) -> None:
|
||||
"""
|
||||
Refresh the JWT token (for now, just re-authenticate)
|
||||
"""
|
||||
print("Refreshing v3 API token...")
|
||||
self._authenticate_v3()
|
||||
|
||||
def http_call(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Any | None = None,
|
||||
json_data: dict[str, Any] | list[Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
verify: bool = False,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Executes HTTP operations and handles GNS3-specific error logic.
|
||||
"""
|
||||
|
||||
# Handle JWT authentication
|
||||
if (
|
||||
self.auth_type == "jwt"
|
||||
and not self.access_token
|
||||
and self.user
|
||||
and self.cred
|
||||
):
|
||||
self._authenticate_v3()
|
||||
|
||||
# Get request function (e.g., session.get, session.post)
|
||||
caller = getattr(self.session, method.lower())
|
||||
|
||||
# Prepare request parameters, avoiding multiple repeated calls to caller
|
||||
kwargs: dict[str, Any] = {
|
||||
"headers": headers,
|
||||
"params": params,
|
||||
"verify": verify,
|
||||
"timeout": 30.0, # Main request timeout (auth call uses 10s)
|
||||
}
|
||||
if data is not None:
|
||||
kwargs["data"] = data
|
||||
elif json_data is not None:
|
||||
kwargs["json"] = json_data
|
||||
|
||||
# Execute request
|
||||
_response: requests.Response = caller(url, **kwargs)
|
||||
|
||||
self.api_calls += 1
|
||||
|
||||
try:
|
||||
_response.raise_for_status()
|
||||
except HTTPError as e:
|
||||
# Throw enhanced error
|
||||
raise self._extract_gns3_error(e) from e
|
||||
|
||||
return _response
|
||||
|
||||
def _extract_gns3_error(self, e: HTTPError) -> HTTPError:
|
||||
"""
|
||||
Extract GNS3-specific JSON error information from HTTPError.
|
||||
If parsing fails, return the original error.
|
||||
"""
|
||||
# e.response might be None, need explicit check
|
||||
response = e.response
|
||||
if response is None:
|
||||
return e
|
||||
|
||||
try:
|
||||
# Only attempt parsing when Content-Type is JSON
|
||||
if (
|
||||
"application/json"
|
||||
in response.headers.get("Content-Type", "").lower()
|
||||
):
|
||||
error_json = response.json()
|
||||
status = error_json.get("status", "Unknown Status")
|
||||
message = error_json.get(
|
||||
"message", "No message provided in JSON."
|
||||
)
|
||||
# Construct a more descriptive new error
|
||||
new_err = HTTPError(
|
||||
f"{status}: {message} (Original {response.status_code} Error)",
|
||||
response=response,
|
||||
)
|
||||
return new_err
|
||||
except Exception:
|
||||
# If JSON parsing fails, return error with original text
|
||||
return HTTPError(
|
||||
f"Original Error: {str(e)}. GNS3 response text: {response.text}",
|
||||
response=response,
|
||||
)
|
||||
return e
|
||||
@ -29,6 +29,10 @@ GNS3 Connector Factory Module
|
||||
This module provides factory functions for creating Gns3Connector instances
|
||||
with JWT token authentication and context-aware configuration management.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
The get_gns3_connector() function is used by both gns3-copilot and MCP.
|
||||
Modifications must be tested with BOTH.
|
||||
|
||||
Features:
|
||||
- Context variable based request-scoped data management (JWT tokens, LLM
|
||||
config)
|
||||
@ -50,7 +54,7 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
)
|
||||
|
||||
# Local imports
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import (
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import (
|
||||
Gns3Connector,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -38,8 +38,12 @@ from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Project
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
fetch_project_inventory,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -101,11 +105,11 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
)
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": (
|
||||
@ -118,37 +122,33 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
logger.info(
|
||||
f"Retrieving project info for project_id: {project_id}"
|
||||
)
|
||||
project = Project(project_id=project_id, connector=server)
|
||||
project.get() # Load project details
|
||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||
|
||||
# Get node and link counts
|
||||
nodes_inventory = project.nodes_inventory()
|
||||
links_summary = project.links_summary(is_print=False)
|
||||
|
||||
node_count = len(nodes_inventory) if nodes_inventory else 0
|
||||
link_count = len(links_summary) if links_summary else 0
|
||||
node_count = len(inventory["nodes_inventory"])
|
||||
link_count = len(inventory["links_summary"])
|
||||
|
||||
# Build result in tuple format consistent with GNS3ProjectList
|
||||
result = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"project_id": inventory["project_id"],
|
||||
"name": inventory["name"],
|
||||
"status": inventory["status"],
|
||||
"node_count": node_count,
|
||||
"link_count": link_count,
|
||||
"tuple": (
|
||||
project.name,
|
||||
project.project_id,
|
||||
inventory["name"],
|
||||
inventory["project_id"],
|
||||
node_count,
|
||||
link_count,
|
||||
project.status,
|
||||
inventory["status"],
|
||||
),
|
||||
}
|
||||
|
||||
# Log result
|
||||
logger.info(
|
||||
"Project info retrieved: name=%s, status=%s, nodes=%d, links=%d",
|
||||
project.name,
|
||||
project.status,
|
||||
inventory["name"],
|
||||
inventory["status"],
|
||||
node_count,
|
||||
link_count,
|
||||
)
|
||||
|
||||
@ -30,6 +30,10 @@ This module provides a LangChain BaseTool to retrieve the topology of a
|
||||
specific GNS3 project by project ID. Returns nodes, links, and project
|
||||
metadata.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
GNS3TopologyTool._run() is called by MCP device config handlers.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@ -39,8 +43,12 @@ from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Project
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
fetch_project_inventory,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -70,6 +78,8 @@ class GNS3TopologyTool(BaseTool):
|
||||
tool_input: Any = None,
|
||||
run_manager: Any = None,
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Synchronous method to retrieve the topology of a specific GNS3 project.
|
||||
@ -80,6 +90,8 @@ class GNS3TopologyTool(BaseTool):
|
||||
run_manager: Callback manager for tool run.
|
||||
project_id: The UUID of the specific GNS3 project to retrieve
|
||||
topology from.
|
||||
jwt_token: JWT token for authentication (used by MCP handlers).
|
||||
url: GNS3 server URL (used by MCP handlers).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the project ID, name, status, nodes,
|
||||
@ -101,11 +113,13 @@ class GNS3TopologyTool(BaseTool):
|
||||
"Please provide a valid project UUID."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL)
|
||||
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
||||
# or auto-detected (e.g. from gns3-copilot agent)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx(jwt_token=jwt_token, url=url)
|
||||
|
||||
if server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. Please check "
|
||||
@ -114,18 +128,17 @@ class GNS3TopologyTool(BaseTool):
|
||||
|
||||
# Use the provided project_id directly
|
||||
logger.info(f"Retrieving topology for project_id: {project_id}")
|
||||
project = Project(project_id=project_id, connector=server)
|
||||
project.get() # Load project details
|
||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||
|
||||
# Get topology JSON: includes nodes (devices), links, etc.
|
||||
topology = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"project_id": inventory["project_id"],
|
||||
"name": inventory["name"],
|
||||
"status": inventory["status"],
|
||||
"nodes": self._clean_nodes_ports(
|
||||
copy.deepcopy(project.nodes_inventory())
|
||||
copy.deepcopy(inventory["nodes_inventory"])
|
||||
),
|
||||
"links": project.links_summary(is_print=False),
|
||||
"links": inventory["links_summary"],
|
||||
}
|
||||
|
||||
# Log topology result
|
||||
|
||||
148
gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
Normal file
148
gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
Normal file
@ -0,0 +1,148 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot 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.
|
||||
#
|
||||
# GNS3-Copilot 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 GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
Project inventory aggregation over the raw GNS3 REST listings.
|
||||
|
||||
Replaces the aggregation previously living on the ``Project`` dataclass
|
||||
(``nodes_inventory`` / ``links_summary``). The output shapes are kept
|
||||
field-for-field: they feed the LLM topology context and the Nornir
|
||||
inventory, so any change here is consumer-visible.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
|
||||
|
||||
|
||||
def build_nodes_inventory(
|
||||
nodes: list[dict[str, Any]], server_host: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build an inventory-style dict keyed by node name.
|
||||
|
||||
Shape (per node name):
|
||||
{server, name, node_id, console_port, console_type, type, ports,
|
||||
status, x, y, tags, netmiko_device_type, default_username,
|
||||
default_password}
|
||||
"""
|
||||
inventory: dict[str, Any] = {}
|
||||
for n in nodes:
|
||||
inventory[n.get("name")] = {
|
||||
"server": server_host,
|
||||
"name": n.get("name"),
|
||||
"node_id": n.get("node_id"),
|
||||
"console_port": n.get("console"),
|
||||
"console_type": n.get("console_type"),
|
||||
"type": n.get("node_type"),
|
||||
"ports": n.get("ports"),
|
||||
"status": n.get("status"),
|
||||
"x": n.get("x"),
|
||||
"y": n.get("y"),
|
||||
"tags": n.get("tags") if n.get("tags") else [],
|
||||
"netmiko_device_type": n.get("netmiko_device_type"),
|
||||
"default_username": n.get("default_username"),
|
||||
"default_password": n.get("default_password"),
|
||||
}
|
||||
return inventory
|
||||
|
||||
|
||||
def build_links_summary(
|
||||
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Build a human/LLM-friendly link list resolving node and port names.
|
||||
|
||||
Shape (per link): {link_id, node_a, port_a, node_b, port_b}.
|
||||
Links whose endpoints cannot be resolved are skipped, mirroring the
|
||||
original Project.links_summary behavior.
|
||||
"""
|
||||
summary: list[dict[str, str]] = []
|
||||
for link in links:
|
||||
if not link.get("nodes"):
|
||||
continue
|
||||
side_a = link["nodes"][0]
|
||||
side_b = link["nodes"][1]
|
||||
try:
|
||||
node_a = next(
|
||||
x for x in nodes if x.get("node_id") == side_a["node_id"]
|
||||
)
|
||||
port_a = str(
|
||||
next(
|
||||
p["name"]
|
||||
for p in (node_a.get("ports") or [])
|
||||
if p["port_number"] == side_a["port_number"]
|
||||
and p["adapter_number"] == side_a["adapter_number"]
|
||||
)
|
||||
)
|
||||
node_b = next(
|
||||
x for x in nodes if x.get("node_id") == side_b["node_id"]
|
||||
)
|
||||
port_b = str(
|
||||
next(
|
||||
p["name"]
|
||||
for p in (node_b.get("ports") or [])
|
||||
if p["port_number"] == side_b["port_number"]
|
||||
and p["adapter_number"] == side_b["adapter_number"]
|
||||
)
|
||||
)
|
||||
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
|
||||
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
|
||||
summary.append({
|
||||
"link_id": link.get("link_id"),
|
||||
"node_a": name_a,
|
||||
"port_a": port_a,
|
||||
"node_b": name_b,
|
||||
"port_b": port_b,
|
||||
})
|
||||
except (StopIteration, KeyError, AttributeError):
|
||||
# Prevent errors when lookups can't match data
|
||||
continue
|
||||
return summary
|
||||
|
||||
|
||||
def fetch_project_inventory(
|
||||
gns3_ctx: dict[str, Any], project_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Fetch a project's metadata, nodes and links and return the aggregated
|
||||
inventory — the equivalent of the old ``Project.get()`` +
|
||||
``nodes_inventory()`` + ``links_summary()`` sequence (minus the
|
||||
stats/snapshots/drawings calls no consumer ever read).
|
||||
"""
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = conn.base_url
|
||||
project = conn.http_call("get", f"{base}/projects/{project_id}").json()
|
||||
nodes = conn.http_call("get", f"{base}/projects/{project_id}/nodes").json()
|
||||
links = conn.http_call("get", f"{base}/projects/{project_id}/links").json()
|
||||
server_host = urlparse(gns3_ctx["server_url"]).hostname
|
||||
return {
|
||||
"project_id": project.get("project_id", project_id),
|
||||
"name": project.get("name"),
|
||||
"status": project.get("status"),
|
||||
"nodes_inventory": build_nodes_inventory(nodes, server_host),
|
||||
"links_summary": build_links_summary(nodes, links),
|
||||
}
|
||||
@ -29,7 +29,7 @@ Prompts Module for GNS3-Copilot
|
||||
This package provides system prompts loading utilities for the GNS3-Copilot AI agent.
|
||||
|
||||
All system prompts are now loaded from the external GNS3-Skills repository:
|
||||
https://github.com/yueguobin/GNS3-Skills
|
||||
https://github.com/gns3/gns3-skills
|
||||
|
||||
Available prompts (loaded from external repository):
|
||||
- lab_automation_assistant.md: Lab automation mode (diagnostics + config)
|
||||
|
||||
@ -98,7 +98,131 @@ class SkillsLoader:
|
||||
|
||||
def load_device_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all device/feature skills from YAML files.
|
||||
Load all device skills from YAML files.
|
||||
|
||||
Supports two layouts:
|
||||
- Single file: device/<device>.yaml
|
||||
- Split directory: device/<device>/_base.yaml + device/<device>/<topic>.yaml
|
||||
(topic files are merged into the base skill under "topics",
|
||||
keyed by their "topic" field)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
"""
|
||||
if yaml is None:
|
||||
logger.error("PyYAML is not installed. Cannot load skills from YAML.")
|
||||
return {}
|
||||
|
||||
skills: Dict[str, Dict[str, Any]] = {}
|
||||
device_dir = self.skills_dir / "device"
|
||||
|
||||
if not device_dir.exists():
|
||||
logger.warning(f"Device skills directory not found: {device_dir}")
|
||||
return {}
|
||||
|
||||
for entry in sorted(device_dir.iterdir()):
|
||||
if entry.is_file() and entry.suffix == ".yaml":
|
||||
self._load_single_device_skill(skills, entry)
|
||||
elif entry.is_dir():
|
||||
self._load_split_device_skill(skills, entry)
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} device skills from device directory")
|
||||
return skills
|
||||
|
||||
def _load_single_device_skill(self, skills: Dict[str, Dict[str, Any]], yaml_file: Path) -> None:
|
||||
"""
|
||||
Load a single-file device skill into the skills dictionary.
|
||||
"""
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
return
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
|
||||
def _load_split_device_skill(self, skills: Dict[str, Dict[str, Any]], device_path: Path) -> None:
|
||||
"""
|
||||
Load a split device skill (directory with _base.yaml + topic files).
|
||||
|
||||
The base file provides the device-level skill; every other YAML file
|
||||
in the directory is a protocol topic merged under "topics".
|
||||
"""
|
||||
base_file = device_path / "_base.yaml"
|
||||
if not base_file.exists():
|
||||
logger.error(f"No _base.yaml in device directory: {device_path}, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
base_data = self._load_yaml(base_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {base_file}: {e}")
|
||||
return
|
||||
if not base_data:
|
||||
logger.warning(f"Skipping empty YAML file: {base_file}")
|
||||
return
|
||||
|
||||
skill_key = base_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = device_path.name
|
||||
logger.warning(f"No device_type in {base_file}, using directory name '{skill_key}' as key")
|
||||
|
||||
# Seed topics from the base file (if any), then merge topic files
|
||||
base_topics = base_data.get("topics")
|
||||
topics: Dict[str, Any] = dict(base_topics) if isinstance(base_topics, dict) else {}
|
||||
|
||||
for yaml_file in sorted(device_path.glob("*.yaml")):
|
||||
if yaml_file.name == "_base.yaml":
|
||||
continue
|
||||
try:
|
||||
topic_data = self._load_yaml(yaml_file)
|
||||
if not topic_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
|
||||
topic_device_type = topic_data.pop("device_type", None)
|
||||
if topic_device_type is not None and topic_device_type != skill_key:
|
||||
logger.error(
|
||||
f"device_type mismatch in {yaml_file}: '{topic_device_type}' "
|
||||
f"!= base '{skill_key}', skipping topic"
|
||||
)
|
||||
continue
|
||||
|
||||
topic_key = topic_data.pop("topic", None)
|
||||
if not topic_key:
|
||||
topic_key = yaml_file.stem
|
||||
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
|
||||
if topic_key in topics:
|
||||
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
|
||||
|
||||
# category/topics belong to the base skill only
|
||||
topic_data.pop("category", None)
|
||||
topic_data.pop("topics", None)
|
||||
|
||||
topics[topic_key] = topic_data
|
||||
logger.debug(f"Loaded device topic: {skill_key}/{topic_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load topic from {yaml_file}: {e}")
|
||||
|
||||
if topics:
|
||||
base_data["topics"] = topics
|
||||
skills[skill_key] = base_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {device_path} ({len(topics)} topics)")
|
||||
|
||||
def load_feature_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all feature skills from YAML files.
|
||||
|
||||
Feature skills are network planning and design functionalities
|
||||
(e.g., topology planner), not device-specific features.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
@ -108,13 +232,13 @@ class SkillsLoader:
|
||||
return {}
|
||||
|
||||
skills = {}
|
||||
device_dir = self.skills_dir / "device"
|
||||
feature_dir = self.skills_dir / "feature"
|
||||
|
||||
if not device_dir.exists():
|
||||
logger.warning(f"Device skills directory not found: {device_dir}")
|
||||
if not feature_dir.exists():
|
||||
logger.warning(f"Feature skills directory not found: {feature_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in device_dir.glob("*.yaml"):
|
||||
for yaml_file in feature_dir.glob("*.yaml"):
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
@ -127,11 +251,11 @@ class SkillsLoader:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
logger.debug(f"Loaded feature skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
logger.error(f"Failed to load feature skill from {yaml_file}: {e}")
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} device skills from {device_dir}")
|
||||
logger.debug(f"Loaded {len(skills)} feature skills from feature directory")
|
||||
return skills
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
|
||||
@ -76,12 +76,12 @@ class SkillsManager:
|
||||
Initialize the skills manager.
|
||||
|
||||
Args:
|
||||
repo_url: Git repository URL (default: https://github.com/yueguobin/GNS3-Skills.git)
|
||||
repo_url: Git repository URL (default: https://github.com/gns3/gns3-skills.git)
|
||||
branch: Git branch to use (default: "main")
|
||||
auto_update: Whether to automatically pull updates on reload
|
||||
"""
|
||||
if repo_url is None:
|
||||
repo_url = "https://github.com/yueguobin/GNS3-Skills.git"
|
||||
repo_url = "https://github.com/gns3/gns3-skills.git"
|
||||
|
||||
# Get local path from GNS3 config directory
|
||||
config_dir = Config.instance().config_dir
|
||||
@ -221,15 +221,30 @@ class SkillsManager:
|
||||
logger.warning("No injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
|
||||
# Validate injection skills
|
||||
# Validate injection skills (drop invalid ones before merging)
|
||||
valid_injection_skills = {}
|
||||
for skill_key, skill_data in new_injection_skills.items():
|
||||
if not self.loader.validate_skill_format(skill_data):
|
||||
if self.loader.validate_skill_format(skill_data):
|
||||
valid_injection_skills[skill_key] = skill_data
|
||||
else:
|
||||
logger.error(f"Invalid skill format for {skill_key}, skipping")
|
||||
continue
|
||||
|
||||
# Load new device/feature skills from YAML files
|
||||
if not valid_injection_skills:
|
||||
logger.warning("No valid injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
new_injection_skills = valid_injection_skills
|
||||
|
||||
# Load new device skills from YAML files
|
||||
new_device_skills = self.loader.load_device_skills()
|
||||
|
||||
# Load new feature skills from YAML files
|
||||
new_feature_skills = self.loader.load_feature_skills()
|
||||
|
||||
# Merge device and feature skills into single registry
|
||||
all_skills = {}
|
||||
all_skills.update(new_device_skills)
|
||||
all_skills.update(new_feature_skills)
|
||||
|
||||
# Update registries (safe replace - never leaves dict empty)
|
||||
for k in list(INJECTION_SKILLS_REGISTRY):
|
||||
if k not in new_injection_skills:
|
||||
@ -237,11 +252,11 @@ class SkillsManager:
|
||||
INJECTION_SKILLS_REGISTRY.update(new_injection_skills)
|
||||
|
||||
for k in list(SKILLS_REGISTRY):
|
||||
if k not in new_device_skills:
|
||||
if k not in all_skills:
|
||||
del SKILLS_REGISTRY[k]
|
||||
SKILLS_REGISTRY.update(new_device_skills)
|
||||
SKILLS_REGISTRY.update(all_skills)
|
||||
|
||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills and {len(new_device_skills)} device skills")
|
||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload skills: {e}")
|
||||
|
||||
@ -373,6 +373,7 @@ def get_skill(
|
||||
category: str | None = None,
|
||||
detail: str = "full",
|
||||
issue: str | None = None,
|
||||
topic: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get skill by device_type, with configurable detail level.
|
||||
@ -382,6 +383,9 @@ def get_skill(
|
||||
category: Optional category filter
|
||||
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
|
||||
issue: Optional specific issue key to retrieve
|
||||
topic: Optional protocol topic to retrieve (split devices only).
|
||||
Topic bodies are NEVER included without an explicit topic
|
||||
request - all other detail levels return a topic index.
|
||||
|
||||
Returns:
|
||||
Skill dictionary (detail varies by level), or error dict
|
||||
@ -412,6 +416,28 @@ def get_skill(
|
||||
],
|
||||
}
|
||||
|
||||
topics = skill.get("topics", {})
|
||||
|
||||
# Single topic lookup (topic bodies stay out of every other response)
|
||||
if topic:
|
||||
topic_data = topics.get(topic)
|
||||
if not topic_data:
|
||||
for key, data in topics.items():
|
||||
if key.lower() == topic.lower():
|
||||
topic_data = data
|
||||
topic = key
|
||||
break
|
||||
if not topic_data:
|
||||
return {
|
||||
"error": f"Unknown topic '{topic}' in {device_type}",
|
||||
"available_topics": list(topics.keys()),
|
||||
}
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"skill_name": skill.get("name"),
|
||||
"topic": {topic: topic_data},
|
||||
}
|
||||
|
||||
issues = skill.get("issues", {})
|
||||
|
||||
# Single issue lookup (most token-efficient)
|
||||
@ -429,17 +455,20 @@ def get_skill(
|
||||
}
|
||||
|
||||
if detail == "index":
|
||||
# Minimal: only issue keys and names (90%+ token savings)
|
||||
return {
|
||||
# Minimal: only issue/topic keys and names (90%+ token savings)
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {k: v["name"] for k, v in issues.items()},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
if detail == "summary":
|
||||
# Moderate: names + description + severity + difficulty
|
||||
return {
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
@ -453,14 +482,22 @@ def get_skill(
|
||||
for k, v in issues.items()
|
||||
},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {
|
||||
k: {"name": v.get("name", k), "description": v.get("description", "")}
|
||||
for k, v in topics.items()
|
||||
}
|
||||
return result
|
||||
|
||||
# Full detail (original behavior)
|
||||
result = dict(skill)
|
||||
# Full detail: topic bodies are replaced by the topic index
|
||||
result = {k: v for k, v in skill.items() if k != "topics"}
|
||||
result["device_type"] = device_type
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all available device/feature skills, optionally filtered by category."""
|
||||
skills = []
|
||||
for did, skill in SKILLS_REGISTRY.items():
|
||||
@ -470,12 +507,14 @@ def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
else:
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
return skills
|
||||
|
||||
@ -642,15 +681,21 @@ class DeviceSkillsTool(BaseTool):
|
||||
Provides access to device command knowledge (VPCS), topology planning, etc.
|
||||
For fault injection skills, use the injection_skills tool.
|
||||
|
||||
INPUT FORMAT (JSON string):
|
||||
{
|
||||
"action": "get", # "get" (default) or "list"
|
||||
"device_type": "gns3_vpcs_telnet", # Required for action="get"
|
||||
"detail": "full" # "full" (default) for complete skill information
|
||||
}
|
||||
TOKEN-EFFICIENT USAGE:
|
||||
1. List devices: {"action": "list"}
|
||||
2. List topics of a device: {"device_type": "frr_vtysh", "detail": "index"}
|
||||
3. Get ONE protocol topic (devices with topics): {"device_type": "frr_vtysh", "topic": "bgp"}
|
||||
4. Devices without topics: {"device_type": "gns3_vpcs_telnet"}
|
||||
|
||||
For action="list":
|
||||
{"action": "list"} # Lists all available device/feature skills
|
||||
Topic bodies are NEVER returned without an explicit "topic" - fetching a
|
||||
device without one only returns its base skill plus the topic index, so
|
||||
always request the specific protocol topic before configuring it.
|
||||
|
||||
PARAMETERS:
|
||||
- action: "list" or "get" (default "get")
|
||||
- device_type: Required for action="get" (e.g., "frr_vtysh")
|
||||
- topic: Protocol topic key from the topic index (e.g., "ospf", "bgp")
|
||||
- detail: "index" | "summary" | "full" (default "full")
|
||||
"""
|
||||
|
||||
def _run(
|
||||
@ -693,8 +738,9 @@ class DeviceSkillsTool(BaseTool):
|
||||
category = params.get("category")
|
||||
detail = params.get("detail", "full")
|
||||
issue = params.get("issue")
|
||||
topic = params.get("topic")
|
||||
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue)
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue, topic=topic)
|
||||
|
||||
return json.dumps(skill, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ This package provides various tools for interacting with GNS3 network simulator:
|
||||
- Multiple device command execution using Nornir
|
||||
- VPCS device configuration using Netmiko
|
||||
- Node and link management
|
||||
- Packet filter management
|
||||
|
||||
Main modules:
|
||||
- config_tools_nornir: Multiple device configuration command execution tool using Nornir
|
||||
@ -42,6 +43,7 @@ Main modules:
|
||||
- gns3_start_node: GNS3 node startup tool
|
||||
- gns3_get_node_temp: GNS3 template retrieval tool
|
||||
- gns3_update_node_name: GNS3 node name update tool
|
||||
- gns3_packet_filter: GNS3 packet filter management tool
|
||||
|
||||
Note: GNS3TopologyTool is now available from gns3_client package
|
||||
|
||||
@ -54,6 +56,7 @@ from .display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||
from .gns3_create_link import GNS3LinkTool
|
||||
from .gns3_create_node import GNS3CreateNodeTool
|
||||
from .gns3_get_node_temp import GNS3TemplateTool
|
||||
from .gns3_packet_filter import GNS3PacketFilterTool
|
||||
from .gns3_start_node import GNS3StartNodeQuickTool
|
||||
from .gns3_start_node import GNS3StartNodeTool
|
||||
from .gns3_stop_node import GNS3StopNodeTool
|
||||
@ -79,6 +82,7 @@ __all__ = [
|
||||
"ExecuteMultipleDeviceCommands",
|
||||
"GNS3CreateNodeTool",
|
||||
"GNS3LinkTool",
|
||||
"GNS3PacketFilterTool",
|
||||
"GNS3StartNodeTool",
|
||||
"GNS3StartNodeQuickTool",
|
||||
"GNS3StopNodeTool",
|
||||
|
||||
@ -26,6 +26,11 @@
|
||||
|
||||
This module provides a tool to execute configuration commands on multiple
|
||||
devices in a GNS3 topology using Nornir.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
ExecuteMultipleDeviceConfigCommands._run() is called by the MCP device_config_send handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -169,6 +174,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str, # or Union[str, List[Any], Dict[str, Any]]
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@ -177,6 +184,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
Args:
|
||||
tool_input (str): A JSON string containing project_id and device
|
||||
configuration commands to execute.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: A list of dicts containing device names and
|
||||
@ -214,11 +223,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
@ -245,7 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -269,7 +278,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
logger.error(
|
||||
"Error executing configurations on all devices: %s", e
|
||||
)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.info(
|
||||
"Multiple device configuration execution completed. Results: %s",
|
||||
@ -359,7 +368,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -380,7 +389,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = (
|
||||
@ -388,13 +397,13 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
"Expected UUID format."
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -417,7 +426,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
f"{type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
@ -547,6 +556,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
@ -556,7 +567,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
hosts_data = get_device_ports_from_topology(
|
||||
device_names, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
|
||||
@ -26,6 +26,11 @@
|
||||
|
||||
This module provides a tool to execute display commands on multiple devices
|
||||
in a GNS3 topology using Nornir.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
ExecuteMultipleDeviceCommands._run() is called by the MCP device_command_run handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -171,6 +176,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@ -181,6 +188,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and diagnostic commands.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List[Dict]: A list of dicts with device names and outputs.
|
||||
@ -210,11 +219,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
@ -241,7 +250,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -263,7 +272,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing display on all devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"Multiple device display execution completed. Results: %s",
|
||||
@ -361,7 +370,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -382,18 +391,18 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -414,7 +423,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
f"or legacy JSON array, got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
@ -490,6 +499,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
@ -499,7 +510,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
hosts_data = get_device_ports_from_topology(
|
||||
device_names, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Link
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_link_handler,
|
||||
get_nodes_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -133,11 +136,11 @@ class GNS3LinkTool(BaseTool):
|
||||
{"error": "Invalid links data: must be a non-empty array"}
|
||||
]
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return [
|
||||
{
|
||||
@ -148,6 +151,12 @@ class GNS3LinkTool(BaseTool):
|
||||
}
|
||||
]
|
||||
|
||||
# Fetch all nodes once for port resolution
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return [{"error": listing["error"]}]
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
created_links = []
|
||||
|
||||
# Process each link definition
|
||||
@ -170,13 +179,9 @@ class GNS3LinkTool(BaseTool):
|
||||
created_links.append({"error": error_msg})
|
||||
continue
|
||||
|
||||
# Get node details
|
||||
node1 = gns3_server.get_node(
|
||||
project_id=project_id, node_id=node_id1
|
||||
)
|
||||
node2 = gns3_server.get_node(
|
||||
project_id=project_id, node_id=node_id2
|
||||
)
|
||||
# Get node details from the pre-fetched map
|
||||
node1 = nodes_by_id.get(node_id1)
|
||||
node2 = nodes_by_id.get(node_id2)
|
||||
if not node1 or not node2:
|
||||
error_msg = f"Node not found in link {i}"
|
||||
logger.error(error_msg)
|
||||
@ -206,39 +211,48 @@ class GNS3LinkTool(BaseTool):
|
||||
created_links.append({"error": error_msg})
|
||||
continue
|
||||
|
||||
# Create the link
|
||||
link = Link(
|
||||
project_id=project_id,
|
||||
connector=gns3_server,
|
||||
nodes=[
|
||||
{
|
||||
"node_id": node_id1,
|
||||
"adapter_number": port1_info.get(
|
||||
"adapter_number", 0
|
||||
),
|
||||
"port_number": port1_info.get(
|
||||
"port_number", 0
|
||||
),
|
||||
"label": {"text": port1_info.get("short_name") or port1},
|
||||
},
|
||||
{
|
||||
"node_id": node_id2,
|
||||
"adapter_number": port2_info.get(
|
||||
"adapter_number", 0
|
||||
),
|
||||
"port_number": port2_info.get(
|
||||
"port_number", 0
|
||||
),
|
||||
"label": {"text": port2_info.get("short_name") or port2},
|
||||
},
|
||||
],
|
||||
# Create the link via the shared REST handler
|
||||
link_resp = create_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": node_id1,
|
||||
"adapter_number": port1_info.get(
|
||||
"adapter_number", 0
|
||||
),
|
||||
"port_number": port1_info.get(
|
||||
"port_number", 0
|
||||
),
|
||||
"label": {
|
||||
"text": port1_info.get("short_name")
|
||||
or port1
|
||||
},
|
||||
},
|
||||
{
|
||||
"node_id": node_id2,
|
||||
"adapter_number": port2_info.get(
|
||||
"adapter_number", 0
|
||||
),
|
||||
"port_number": port2_info.get(
|
||||
"port_number", 0
|
||||
),
|
||||
"label": {
|
||||
"text": port2_info.get("short_name")
|
||||
or port2
|
||||
},
|
||||
},
|
||||
],
|
||||
"fields": ["link_id"],
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
link.create()
|
||||
link.get()
|
||||
if "error" in link_resp:
|
||||
raise RuntimeError(link_resp["error"])
|
||||
|
||||
# Collect link details
|
||||
link_info = {
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_resp.get("link_id"),
|
||||
"node_id1": node_id1,
|
||||
"port1": port1,
|
||||
"node_id2": node_id2,
|
||||
|
||||
@ -38,8 +38,10 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -194,11 +196,11 @@ class GNS3CreateNodeTool(BaseTool):
|
||||
f"template_id, x, or y."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
@ -228,22 +230,22 @@ class GNS3CreateNodeTool(BaseTool):
|
||||
name,
|
||||
)
|
||||
|
||||
# Create node
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
x=x,
|
||||
y=y,
|
||||
name=name,
|
||||
connector=gns3_server,
|
||||
# Create node via the shared REST handler
|
||||
created = create_node_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"template_id": template_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"name": name,
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
node.create()
|
||||
|
||||
# Retrieve node details
|
||||
node.get()
|
||||
if "error" in created:
|
||||
raise RuntimeError(created["error"])
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name,
|
||||
"node_id": created.get("node_id"),
|
||||
"name": created.get("name"),
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
|
||||
@ -139,7 +139,9 @@ class GNS3TemplateTool(BaseTool):
|
||||
}
|
||||
|
||||
# Retrieve all available templates
|
||||
templates = gns3_server.get_templates()
|
||||
templates = gns3_server.http_call(
|
||||
"get", f"{gns3_server.base_url}/templates"
|
||||
).json()
|
||||
|
||||
# Filter out utility templates and extract relevant info
|
||||
template_info = []
|
||||
|
||||
500
gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py
Normal file
500
gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py
Normal file
@ -0,0 +1,500 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot 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.
|
||||
#
|
||||
# GNS3-Copilot 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 GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
|
||||
GNS3 packet filter management tool for network simulation.
|
||||
|
||||
Provides functionality to manage packet filters on GNS3 links,
|
||||
including latency, packet loss, corruption, and BPF filtering.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pprint import pprint
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
available_filters_handler,
|
||||
build_gns3_ctx,
|
||||
get_link_handler,
|
||||
update_link_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3PacketFilterTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to manage packet filters on GNS3 links.
|
||||
|
||||
Supports getting available filters, setting filters, and clearing filters
|
||||
on network links to simulate various network conditions.
|
||||
|
||||
**Input:**
|
||||
A JSON object with project_id, link_id, action, and optional filter parameters.
|
||||
Note: show_filters_icon is automatically set to false by default to hide the filter
|
||||
icon in the GNS3 Web UI.
|
||||
|
||||
Example input for getting available filters:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "get_available"
|
||||
}
|
||||
|
||||
Example input for setting filters:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "set",
|
||||
"filters": {
|
||||
"delay": [100, 10],
|
||||
"packet_loss": [5]
|
||||
},
|
||||
"show_filters_icon": false
|
||||
}
|
||||
|
||||
Example input for getting current filters:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "get"
|
||||
}
|
||||
|
||||
Example input for clearing filters:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "clear"
|
||||
}
|
||||
|
||||
**Output:**
|
||||
A dictionary containing the action result.
|
||||
|
||||
For "get_available": returns list of available filter types
|
||||
For "set": returns updated link information with applied filters
|
||||
For "get": returns current filters configured on the link
|
||||
For "clear": returns confirmation that filters were cleared
|
||||
"""
|
||||
|
||||
name: str = "manage_gns3_packet_filter"
|
||||
description: str = """
|
||||
Manages packet filters on GNS3 links to inject network faults and simulate network conditions.
|
||||
|
||||
This tool is primarily used for fault injection scenarios to create realistic network problems
|
||||
for troubleshooting practice, such as latency, packet loss, and corruption.
|
||||
|
||||
By default, the filter icon in the GNS3 Web UI is hidden (show_filters_icon=false) to avoid
|
||||
visual clutter when injecting faults for troubleshooting exercises.
|
||||
|
||||
Supported actions:
|
||||
- "get_available": Get list of available filter types for the link
|
||||
- "set": Set packet filters on the link to inject network faults
|
||||
- "get": Get current filters configured on the link
|
||||
|
||||
Common filter types for fault injection:
|
||||
- "frequency_drop": Drop every Nth packet (parameter: frequency, -1 to 32767)
|
||||
- "packet_loss": Packet loss percentage (parameter: chance, 0-100)
|
||||
- "delay": Delay in ms with optional jitter (parameters: latency 0-32767, jitter 0-32767)
|
||||
- "corrupt": Packet corruption percentage (parameter: chance, 0-100)
|
||||
- "bpf": Berkeley Packet Filter (parameter: filter expression text)
|
||||
|
||||
Input is a JSON object with:
|
||||
- project_id (str): GNS3 project UUID
|
||||
- link_id (str): GNS3 link UUID
|
||||
- action (str): One of "get_available", "set", "get", "clear"
|
||||
- filters (dict, optional): Filter configuration for "set" action
|
||||
|
||||
Example for getting available filters:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "get_available"
|
||||
}
|
||||
|
||||
Example for setting delay and packet loss:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"link_id": "uuid-of-link",
|
||||
"action": "set",
|
||||
"filters": {
|
||||
"delay": [100, 10],
|
||||
"packet_loss": [5]
|
||||
}
|
||||
}
|
||||
|
||||
Returns a dictionary with action result, filter information, or error message.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Manages packet filters on a GNS3 link.
|
||||
|
||||
Args:
|
||||
tool_input: A JSON string with project_id, link_id, action, and optional filters.
|
||||
run_manager: LangChain run manager (unused).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with action result or an error message.
|
||||
"""
|
||||
# Log received input
|
||||
logger.info("Received input: %s", tool_input)
|
||||
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
link_id = input_data.get("link_id")
|
||||
action = input_data.get("action")
|
||||
show_filters_icon = input_data.get("show_filters_icon", False)
|
||||
|
||||
# Validate required fields
|
||||
if not project_id:
|
||||
logger.error("Invalid input: Missing project_id.")
|
||||
return {"error": "Missing project_id."}
|
||||
|
||||
if not link_id:
|
||||
logger.error("Invalid input: Missing link_id.")
|
||||
return {"error": "Missing link_id."}
|
||||
|
||||
if not action:
|
||||
logger.error("Invalid input: Missing action.")
|
||||
return {"error": "Missing action."}
|
||||
|
||||
# Validate action
|
||||
valid_actions = ["get_available", "set", "get", "clear"]
|
||||
if action not in valid_actions:
|
||||
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
|
||||
return {
|
||||
"error": f"Invalid action: {action}. Must be one of {valid_actions}"
|
||||
}
|
||||
|
||||
# Validate filters for "set" action
|
||||
if action == "set":
|
||||
filters = input_data.get("filters")
|
||||
if not filters or not isinstance(filters, dict):
|
||||
logger.error("Invalid input: 'set' action requires 'filters' dict.")
|
||||
return {
|
||||
"error": "'set' action requires 'filters' dict with filter configuration."
|
||||
}
|
||||
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Execute action
|
||||
logger.info(
|
||||
"Processing packet filter action '%s' for link %s...", action, link_id
|
||||
)
|
||||
if action == "get_available":
|
||||
result = self._get_available_filters(gns3_ctx, project_id, link_id)
|
||||
elif action == "set":
|
||||
filters = input_data.get("filters", {})
|
||||
result = self._set_filters(
|
||||
gns3_ctx, project_id, link_id, filters, show_filters_icon
|
||||
)
|
||||
elif action == "get":
|
||||
result = self._get_filters(gns3_ctx, project_id, link_id)
|
||||
elif action == "clear":
|
||||
result = self._clear_filters(
|
||||
gns3_ctx, project_id, link_id, show_filters_icon
|
||||
)
|
||||
else:
|
||||
result = {"error": f"Unknown action: {action}"}
|
||||
|
||||
# Log result
|
||||
logger.info("Packet filter action '%s' completed successfully.", action)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON input: %s", e)
|
||||
return {"error": f"Invalid JSON input: {e}"}
|
||||
except Exception as e:
|
||||
logger.error("Failed to process packet filter request: %s", e)
|
||||
return {
|
||||
"error": f"Failed to process packet filter request: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_available_filters(
|
||||
self, gns3_ctx: dict, project_id: str, link_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Get available filter types for the link."""
|
||||
try:
|
||||
filters = available_filters_handler(
|
||||
{"project_id": project_id, "link_id": link_id}, gns3_ctx
|
||||
)
|
||||
if "error" in filters:
|
||||
raise RuntimeError(filters["error"])
|
||||
logger.info("Retrieved %d available filter types.", len(filters))
|
||||
return {
|
||||
"action": "get_available",
|
||||
"link_id": link_id,
|
||||
"available_filters": filters,
|
||||
"count": len(filters),
|
||||
"status": "success",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get available filters: %s", e)
|
||||
return {
|
||||
"action": "get_available",
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to get available filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
def _validate_bpf_syntax(self, bpf_expression: str) -> dict[str, Any]:
|
||||
"""
|
||||
Validate BPF filter expression syntax using tshark.
|
||||
|
||||
Args:
|
||||
bpf_expression: BPF filter expression to validate
|
||||
|
||||
Returns:
|
||||
dict with 'valid' (bool) and 'error' (str or None) keys
|
||||
"""
|
||||
try:
|
||||
# Use tshark to validate BPF syntax with 1 second timeout
|
||||
# Use -i lo (loopback) to avoid "(null)" interface in error messages
|
||||
result = subprocess.run(
|
||||
["tshark", "-f", bpf_expression, "-i", "lo"],
|
||||
timeout=1,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Check if output contains "Invalid" indicating syntax error
|
||||
if "Invalid" in result.stdout or "Invalid" in result.stderr:
|
||||
error_lines = []
|
||||
if "Invalid" in result.stderr:
|
||||
error_lines.extend(
|
||||
line for line in result.stderr.split("\n") if "Invalid" in line
|
||||
)
|
||||
if "Invalid" in result.stdout:
|
||||
error_lines.extend(
|
||||
line for line in result.stdout.split("\n") if "Invalid" in line
|
||||
)
|
||||
|
||||
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
|
||||
error_msg_parts = []
|
||||
for line in error_lines:
|
||||
clean = line.split(" for interface")[0].strip()
|
||||
if clean:
|
||||
error_msg_parts.append(clean)
|
||||
error_msg = " ".join(error_msg_parts) if error_msg_parts else "Invalid BPF syntax"
|
||||
logger.warning("BPF syntax validation failed: %s", error_msg)
|
||||
return {"valid": False, "error": error_msg}
|
||||
|
||||
logger.info("BPF syntax validation passed")
|
||||
return {"valid": True, "error": None}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout is expected behavior - tshark waits for traffic
|
||||
# No "Invalid" in output means syntax is correct
|
||||
logger.info("BPF syntax validation passed (timeout expected)")
|
||||
return {"valid": True, "error": None}
|
||||
|
||||
except FileNotFoundError:
|
||||
# tshark not installed - skip validation
|
||||
logger.warning(
|
||||
"tshark not found, skipping BPF syntax validation. "
|
||||
"Install tshark to enable BPF validation."
|
||||
)
|
||||
return {"valid": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during BPF validation: %s", e)
|
||||
return {"valid": False, "error": f"BPF validation error: {str(e)}"}
|
||||
|
||||
def _set_filters(
|
||||
self,
|
||||
gns3_ctx: dict,
|
||||
project_id: str,
|
||||
link_id: str,
|
||||
filters: dict[str, Any],
|
||||
show_filters_icon: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Set packet filters on the link."""
|
||||
try:
|
||||
# Validate BPF syntax if BPF filter is present
|
||||
if "bpf" in filters:
|
||||
bpf_filters = filters["bpf"]
|
||||
if isinstance(bpf_filters, list):
|
||||
# Validate each BPF expression
|
||||
for idx, bpf_expr in enumerate(bpf_filters):
|
||||
if isinstance(bpf_expr, str):
|
||||
validation = self._validate_bpf_syntax(bpf_expr)
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link_id,
|
||||
"error": f"BPF syntax error at index {idx}: {validation['error']}",
|
||||
"status": "failed",
|
||||
}
|
||||
elif isinstance(bpf_filters, str):
|
||||
# Single BPF expression
|
||||
validation = self._validate_bpf_syntax(bpf_filters)
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link_id,
|
||||
"error": f"BPF syntax error: {validation['error']}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
# Update filters — the PUT response is the updated link
|
||||
updated = update_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"link_id": link_id,
|
||||
"kwargs": {
|
||||
"filters": filters,
|
||||
"show_filters_icon": show_filters_icon,
|
||||
},
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
|
||||
logger.info("Successfully set filters on link %s", link_id)
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link_id,
|
||||
"filters": updated.get("filters"),
|
||||
"status": "success",
|
||||
"message": "Filters applied successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to set filters: %s", e)
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to set filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
def _get_filters(
|
||||
self, gns3_ctx: dict, project_id: str, link_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Get current filters configured on the link."""
|
||||
try:
|
||||
link = get_link_handler(
|
||||
{"project_id": project_id, "link_id": link_id}, gns3_ctx
|
||||
)
|
||||
if "error" in link:
|
||||
raise RuntimeError(link["error"])
|
||||
|
||||
logger.info("Retrieved current filters for link %s", link_id)
|
||||
return {
|
||||
"action": "get",
|
||||
"link_id": link_id,
|
||||
"filters": link.get("filters"),
|
||||
"status": "success",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get filters: %s", e)
|
||||
return {
|
||||
"action": "get",
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to get filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
def _clear_filters(
|
||||
self,
|
||||
gns3_ctx: dict,
|
||||
project_id: str,
|
||||
link_id: str,
|
||||
show_filters_icon: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Clear all filters from the link."""
|
||||
try:
|
||||
# Clear filters by setting an empty dict — the PUT response
|
||||
# is the updated link
|
||||
updated = update_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"link_id": link_id,
|
||||
"kwargs": {
|
||||
"filters": {},
|
||||
"show_filters_icon": show_filters_icon,
|
||||
},
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
|
||||
logger.info("Successfully cleared filters on link %s", link_id)
|
||||
return {
|
||||
"action": "clear",
|
||||
"link_id": link_id,
|
||||
"filters": updated.get("filters"),
|
||||
"status": "success",
|
||||
"message": "Filters cleared successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to clear filters: %s", e)
|
||||
return {
|
||||
"action": "clear",
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to clear filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool locally
|
||||
# TODO: Replace with actual project and link UUIDs
|
||||
test_input = json.dumps(
|
||||
{
|
||||
"project_id": "d7fc094c-685e-4db1-ac11-5e33a1b2e066",
|
||||
"link_id": "link-uuid-here",
|
||||
"action": "get_available",
|
||||
}
|
||||
)
|
||||
|
||||
tool = GNS3PacketFilterTool()
|
||||
result = tool._run(test_input)
|
||||
pprint(result)
|
||||
@ -39,8 +39,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
start_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -64,7 +67,7 @@ def calculate_startup_time(nodes: list) -> int:
|
||||
- If any node is a slow device: use conservative startup time
|
||||
|
||||
Args:
|
||||
nodes: List of node objects with node_type attribute
|
||||
nodes: List of node dicts with a "node_type" key
|
||||
|
||||
Returns:
|
||||
Calculated wait time in seconds
|
||||
@ -73,7 +76,7 @@ def calculate_startup_time(nodes: list) -> int:
|
||||
return 60 # Default: 60 seconds for empty list
|
||||
|
||||
# Get all node types
|
||||
node_types = [getattr(node, "node_type", "default") for node in nodes]
|
||||
node_types = [node.get("node_type") or "default" for node in nodes]
|
||||
|
||||
# Check if all nodes are fast startup devices (VPCS or IOU)
|
||||
fast_types = {"vpcs", "iou"}
|
||||
@ -198,106 +201,88 @@ class GNS3StartNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# First loop: Get node info and send start commands for all nodes
|
||||
# Phase 1: fetch node info (including node_type) in one call
|
||||
logger.info(
|
||||
"Retrieving node info for %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
nodes = []
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
# Get node info (including node_type)
|
||||
node.get()
|
||||
if node.node_id:
|
||||
nodes.append(node)
|
||||
logger.info(
|
||||
"Node %s (%s) type: %s",
|
||||
node_id,
|
||||
node.name,
|
||||
node.node_type,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
except Exception as e:
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
nodes = [nodes_by_id[nid] for nid in node_ids if nid in nodes_by_id]
|
||||
for node in nodes:
|
||||
logger.info(
|
||||
"Node %s (%s) type: %s",
|
||||
node["node_id"],
|
||||
node.get("name"),
|
||||
node.get("node_type"),
|
||||
)
|
||||
for nid in node_ids:
|
||||
if nid not in nodes_by_id:
|
||||
logger.error(
|
||||
"Failed to get node info for %s: %s",
|
||||
node_id,
|
||||
e,
|
||||
"Node %s not found in project %s", nid, project_id
|
||||
)
|
||||
|
||||
# Calculate startup time based on node types
|
||||
wait_time = calculate_startup_time(nodes)
|
||||
|
||||
# Send start commands for all nodes
|
||||
# Phase 2: send start commands for all nodes (parallel batch)
|
||||
logger.info(
|
||||
"Sending start commands for %d nodes in project %s...",
|
||||
len(nodes),
|
||||
project_id,
|
||||
)
|
||||
for node in nodes:
|
||||
try:
|
||||
node.start()
|
||||
logger.info("Start command sent for node %s", node.node_id)
|
||||
except Exception as e:
|
||||
start_results = start_node_handler(
|
||||
{"project_id": project_id, "node_ids": [n["node_id"] for n in nodes]},
|
||||
gns3_ctx,
|
||||
)
|
||||
for r in start_results:
|
||||
if r.get("status") == "error":
|
||||
logger.error(
|
||||
"Failed to send start command for node %s: %s",
|
||||
node.node_id,
|
||||
e,
|
||||
r.get("node_id"),
|
||||
r.get("error"),
|
||||
)
|
||||
else:
|
||||
logger.info("Start command sent for node %s", r.get("node_id"))
|
||||
|
||||
# Show progress bar with calculated wait time
|
||||
show_progress_bar(
|
||||
duration=wait_time, interval=1, node_count=len(nodes)
|
||||
)
|
||||
|
||||
# Second loop: Get status for all nodes
|
||||
# Phase 3: get final status for all nodes (one call)
|
||||
results = []
|
||||
logger.info("Retrieving status for %d nodes...", len(nodes))
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
final_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
for node in nodes:
|
||||
try:
|
||||
node.get() # Get latest status
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
node_info = final_by_id.get(node["node_id"], node)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node["node_id"],
|
||||
"name": node_info.get("name") or "N/A",
|
||||
"status": node_info.get("status") or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to get status for node %s: %s", node.node_id, e
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"name": getattr(node, "name", "N/A"),
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Handle nodes that failed to be retrieved initially
|
||||
retrieved_node_ids = {node.node_id for node in nodes}
|
||||
retrieved_node_ids = {node["node_id"] for node in nodes}
|
||||
for node_id in node_ids:
|
||||
if node_id not in retrieved_node_ids:
|
||||
results.append(
|
||||
@ -405,77 +390,90 @@ class GNS3StartNodeQuickTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Send start commands for all nodes and collect initial status
|
||||
# Verify nodes exist and capture pre-start info (one call)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
# Send start commands for all nodes (parallel batch)
|
||||
logger.info(
|
||||
"Sending start commands for %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||
start_results = start_node_handler(
|
||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
||||
)
|
||||
start_errors = {
|
||||
r["node_id"]: r.get("error")
|
||||
for r in start_results
|
||||
if r.get("status") == "error"
|
||||
}
|
||||
|
||||
# Get immediate status (likely 'starting' or 'stopped') — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send start command
|
||||
node.start()
|
||||
logger.info(
|
||||
"Start command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get immediate status (likely 'starting' or 'stopped')
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to start node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if node_id in start_errors:
|
||||
logger.error(
|
||||
"Failed to start node %s: %s",
|
||||
node_id,
|
||||
start_errors[node_id],
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": start_errors[node_id],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Start command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results (count based on successful command sending)
|
||||
successful_nodes = [
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
stop_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -103,75 +106,83 @@ class GNS3StopNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Stop all nodes and collect results
|
||||
# Verify nodes exist and capture names (one call)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
# Stop all nodes (parallel batch) and collect results
|
||||
logger.info(
|
||||
"Stopping %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||
stop_results = stop_node_handler(
|
||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
||||
)
|
||||
stop_errors = {
|
||||
r["node_id"]: r.get("error")
|
||||
for r in stop_results
|
||||
if r.get("status") == "error"
|
||||
}
|
||||
|
||||
# Get updated status — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send stop command
|
||||
node.stop()
|
||||
logger.info(
|
||||
"Stop command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get updated status
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to stop node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
elif node_id in stop_errors:
|
||||
logger.error(
|
||||
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": stop_errors[node_id],
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Stop command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -39,8 +39,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
suspend_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -109,75 +112,85 @@ class GNS3SuspendNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Suspend all nodes and collect results
|
||||
# Verify nodes exist and capture names (one call)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
# Suspend all nodes (parallel batch) and collect results
|
||||
logger.info(
|
||||
"Suspending %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
|
||||
suspend_results = suspend_node_handler(
|
||||
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
|
||||
)
|
||||
suspend_errors = {
|
||||
r["node_id"]: r.get("error")
|
||||
for r in suspend_results
|
||||
if r.get("status") == "error"
|
||||
}
|
||||
|
||||
# Get updated status — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send suspend command
|
||||
node.suspend()
|
||||
logger.info(
|
||||
"Suspend command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get updated status
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to suspend node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
elif node_id in suspend_errors:
|
||||
logger.error(
|
||||
"Failed to suspend node %s: %s",
|
||||
node_id,
|
||||
suspend_errors[node_id],
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": suspend_errors[node_id],
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Suspend command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
update_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -126,17 +129,23 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
"error": f"Node {i + 1} missing node_id or new_name."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Fetch current node names in one call (old names + existence)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
# Update node names
|
||||
logger.info(
|
||||
"Updating names for %d nodes in project %s...",
|
||||
@ -158,21 +167,25 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
new_name,
|
||||
)
|
||||
|
||||
# Get node to retrieve current name
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
node = nodes_by_id.get(node_id)
|
||||
if node is None:
|
||||
raise ValueError("Node not found")
|
||||
old_name = node.get("name")
|
||||
|
||||
# Update node name — the PUT response is the updated node
|
||||
updated = update_node_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"name": new_name,
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
node.get()
|
||||
old_name = node.name
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
current_name = updated.get("name")
|
||||
|
||||
# Update node name
|
||||
node.update(name=new_name)
|
||||
|
||||
# Verify update
|
||||
node.get()
|
||||
if node.name == new_name:
|
||||
if current_name == new_name:
|
||||
node_info = {
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
@ -190,7 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
"new_name": new_name,
|
||||
"current_name": node.name,
|
||||
"current_name": current_name,
|
||||
"status": "failed",
|
||||
"error": "Name verification failed",
|
||||
}
|
||||
|
||||
@ -26,6 +26,11 @@
|
||||
"""
|
||||
This module provides a tool to execute commands on VPCS devices
|
||||
in a GNS3 topology using Nornir with Netmiko.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
VPCSCommands._run() is called by the MCP vpcs_config_set handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -154,6 +159,8 @@ class VPCSCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@ -161,6 +168,8 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and VPCS commands.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List of dicts with device names and command outputs.
|
||||
@ -183,11 +192,11 @@ class VPCSCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device)
|
||||
error_devices = {
|
||||
@ -214,7 +223,7 @@ class VPCSCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -235,7 +244,7 @@ class VPCSCommands(BaseTool):
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing commands on all VPCS devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"VPCS command execution completed. Results: %s",
|
||||
@ -329,7 +338,7 @@ class VPCSCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -348,18 +357,18 @@ class VPCSCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -373,7 +382,7 @@ class VPCSCommands(BaseTool):
|
||||
f"got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
@ -408,7 +417,11 @@ class VPCSCommands(BaseTool):
|
||||
}
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self, device_configs_list: list[dict[str, Any]], project_id: str
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
project_id: str,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Prepare Nornir inventory hosts data for VPCS devices.
|
||||
@ -416,6 +429,8 @@ class VPCSCommands(BaseTool):
|
||||
Args:
|
||||
device_configs_list: List of device configurations
|
||||
project_id: GNS3 project ID
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their host data
|
||||
@ -431,7 +446,7 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
# Get device port mappings from topology
|
||||
device_ports = get_device_ports_from_topology(
|
||||
device_names, project_id=project_id
|
||||
device_names, project_id=project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
# Build Nornir inventory hosts data
|
||||
@ -446,6 +461,25 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
port = device_ports[device_name]["port"]
|
||||
|
||||
node_type = device_ports[device_name].get("node_type")
|
||||
if node_type != "vpcs":
|
||||
# VPCS syntax typed into another node's CLI is silently
|
||||
# discarded (e.g. IOS answers "% Invalid input"), so reject
|
||||
# mismatched devices before a console session is opened
|
||||
logger.error(
|
||||
"Device '%s' is a %s node, not a VPCS node",
|
||||
device_name,
|
||||
node_type or "unknown-type",
|
||||
)
|
||||
hosts_data[device_name] = {
|
||||
"error": (
|
||||
f"Device '{device_name}' is a {node_type or 'unknown-type'} node, "
|
||||
"not a VPCS node; use device_config_send / device_show_run "
|
||||
"for network devices"
|
||||
)
|
||||
}
|
||||
continue
|
||||
|
||||
# VPCS devices use gns3_vpcs_telnet device type
|
||||
hosts_data[device_name] = {
|
||||
"port": port,
|
||||
@ -544,8 +578,8 @@ class VPCSCommands(BaseTool):
|
||||
if device_name in hosts_data and "error" in hosts_data[device_name]:
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": hosts_data[device_name]["error"],
|
||||
"status": "failed",
|
||||
"error": hosts_data[device_name]["error"],
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
continue
|
||||
@ -559,8 +593,8 @@ class VPCSCommands(BaseTool):
|
||||
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": error_msg,
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
@ -575,8 +609,8 @@ class VPCSCommands(BaseTool):
|
||||
# Device not in task result (shouldn't happen)
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": f"Device '{device_name}' not in task results",
|
||||
"status": "failed",
|
||||
"error": f"Device '{device_name}' not in task results",
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
|
||||
|
||||
@ -25,6 +25,11 @@
|
||||
"""
|
||||
|
||||
Public module for getting device port information from GNS3 topology
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
get_device_ports_from_topology() is called by MCP device config handlers.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -36,6 +41,8 @@ logger = logging.getLogger(__name__)
|
||||
def get_device_ports_from_topology(
|
||||
device_names: list[str],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get device connection information from GNS3 topology
|
||||
@ -43,6 +50,8 @@ def get_device_ports_from_topology(
|
||||
Args:
|
||||
device_names: List of device names to look up
|
||||
project_id: UUID of the specific GNS3 project to retrieve topology from
|
||||
jwt_token: JWT token for authentication (used by MCP handlers).
|
||||
url: GNS3 server URL (used by MCP handlers).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their connection data:
|
||||
@ -50,10 +59,11 @@ def get_device_ports_from_topology(
|
||||
"device_name": {
|
||||
"port": console_port,
|
||||
"platform": "huawei", # Extracted from tags
|
||||
"node_type": "vpcs", # GNS3 node type from the topology
|
||||
"groups": ["network_devices"], # For inheriting shared settings
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {"device_type": "huawei_telnet"} # Extracted from tags
|
||||
"extras": {"device_type": "huawei_telnet"} # netmiko_device_type field, tag fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,7 +81,7 @@ def get_device_ports_from_topology(
|
||||
|
||||
# Get topology information
|
||||
topo = GNS3TopologyTool()
|
||||
topology = topo._run(project_id=project_id)
|
||||
topology = topo._run(project_id=project_id, jwt_token=jwt_token, url=url)
|
||||
|
||||
# Dynamically build hosts_data from topology
|
||||
hosts_data: dict[str, dict[str, Any]] = {}
|
||||
@ -93,18 +103,21 @@ def get_device_ports_from_topology(
|
||||
logger.warning("Device '%s' missing console_port", device_name)
|
||||
continue
|
||||
|
||||
# Extract device_type and platform from tags
|
||||
device_type = None
|
||||
# Extract device_type and platform.
|
||||
# Precedence: the netmiko_device_type field (node/template/appliance
|
||||
# level, set in GNS3 server >= 3.x) wins over the device_type:<type>
|
||||
# tag, which remains as a fallback.
|
||||
device_type = node_info.get("netmiko_device_type")
|
||||
platform = None
|
||||
tags = node_info.get("tags", [])
|
||||
|
||||
for tag in tags:
|
||||
if tag.startswith("device_type:"):
|
||||
if tag.startswith("device_type:") and device_type is None:
|
||||
device_type = tag.split(":", 1)[1].strip()
|
||||
elif tag.startswith("platform:"):
|
||||
platform = tag.split(":", 1)[1].strip()
|
||||
|
||||
# Return error if device_type not found in tags
|
||||
# Return error if device_type not found anywhere
|
||||
# Using a default would cause command execution errors
|
||||
if device_type is None:
|
||||
tested_device_types = (
|
||||
@ -112,8 +125,9 @@ def get_device_ports_from_topology(
|
||||
"gns3_ruijie_telnet (custom Ruijie)"
|
||||
)
|
||||
error_msg = (
|
||||
f"Device '{device_name}': device_type tag not found. "
|
||||
f"Please add 'device_type:<type>' tag to this device in GNS3. "
|
||||
f"Device '{device_name}': no device type found. "
|
||||
f"Set the template/node 'netmiko_device_type' field (e.g. 'cisco_ios_telnet'), "
|
||||
f"or add a 'device_type:<type>' tag to this device in GNS3. "
|
||||
f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:<type>'. "
|
||||
f"Tested types: {tested_device_types}. "
|
||||
f"Current tags: {tags}"
|
||||
@ -125,7 +139,7 @@ def get_device_ports_from_topology(
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Device '%s': extracted device_type=%s from tags",
|
||||
"Device '%s': device_type=%s",
|
||||
device_name,
|
||||
device_type,
|
||||
)
|
||||
@ -147,9 +161,14 @@ def get_device_ports_from_topology(
|
||||
# This is the Nornir best practice - each host has its own
|
||||
# connection configuration (device_type), while sharing common
|
||||
# settings (hostname, timeout) via group inheritance.
|
||||
hosts_data[device_name] = {
|
||||
# node_type (the GNS3 node type, e.g. vpcs/iou/docker) lets callers
|
||||
# reject mismatched devices before opening a console connection;
|
||||
# DictInventory ignores keys it does not know, so carrying it here
|
||||
# is safe for entries fed straight into Nornir.
|
||||
host_entry = {
|
||||
"port": node_info["console_port"],
|
||||
"platform": platform,
|
||||
"node_type": node_info.get("type"),
|
||||
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
@ -158,6 +177,16 @@ def get_device_ports_from_topology(
|
||||
},
|
||||
}
|
||||
|
||||
# Per-node default credentials (seeded from the template appliance
|
||||
# metadata) override the group's empty fallback. Only inject when
|
||||
# set, so credential-less devices keep inheriting the group values.
|
||||
if node_info.get("default_username"):
|
||||
host_entry["username"] = node_info["default_username"]
|
||||
if node_info.get("default_password"):
|
||||
host_entry["password"] = node_info["default_password"]
|
||||
|
||||
hosts_data[device_name] = host_entry
|
||||
|
||||
logger.info("Returning %d device port mappings", len(hosts_data))
|
||||
|
||||
return hosts_data
|
||||
|
||||
1640
gns3server/agent/mcp/__init__.py
Normal file
1640
gns3server/agent/mcp/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
97
gns3server/agent/mcp/appliances.py
Normal file
97
gns3server/agent/mcp/appliances.py
Normal file
@ -0,0 +1,97 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 appliance management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
VALID_APPLIANCE_FIELDS = {
|
||||
"appliance_id", "name", "category", "description", "vendor_name",
|
||||
"vendor_url", "product_name", "product_url", "documentation_url",
|
||||
"status", "availability", "maintainer", "usage", "symbol",
|
||||
"images", "versions", "tags", "builtin",
|
||||
"first_port_name", "port_name_format", "port_segment_size",
|
||||
"linked_clone", "docker", "iou", "dynamips", "qemu",
|
||||
}
|
||||
|
||||
|
||||
def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
appliances = conn.http_call("get", f"{conn.base_url}/appliances").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_APPLIANCE_FIELDS),
|
||||
}
|
||||
appliances = [{k: a[k] for k in fields if k in a} for a in appliances]
|
||||
return {"appliances": appliances, "count": len(appliances)}
|
||||
|
||||
|
||||
def get_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
appliance_id = params.get("appliance_id")
|
||||
if not appliance_id:
|
||||
return {"error": "appliance_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/appliances/{appliance_id}").json()
|
||||
|
||||
|
||||
def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
appliance_id = params.get("appliance_id")
|
||||
if not appliance_id:
|
||||
return {"error": "appliance_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/appliances/{appliance_id}/install"
|
||||
request_params = {}
|
||||
version = params.get("version")
|
||||
if version:
|
||||
request_params["version"] = version
|
||||
response = conn.http_call("post", url, params=request_params)
|
||||
result = {"message": f"Appliance {appliance_id} installed"}
|
||||
if response.content:
|
||||
# the install endpoint returns the created template (201); tolerate an
|
||||
# empty body in case an older server still replies with 204
|
||||
template = response.json()
|
||||
result["template"] = {
|
||||
k: template[k] for k in ("template_id", "name", "version", "template_type") if k in template
|
||||
}
|
||||
return result
|
||||
91
gns3server/agent/mcp/computes.py
Normal file
91
gns3server/agent/mcp/computes.py
Normal file
@ -0,0 +1,91 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 compute management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
computes = conn.http_call("get", f"{conn.base_url}/computes").json()
|
||||
return {"computes": computes, "count": len(computes)}
|
||||
|
||||
|
||||
def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
compute_id = params.get("compute_id") or "local"
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json()
|
||||
|
||||
|
||||
def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
emulator = params.get("emulator")
|
||||
compute_id = params.get("compute_id") or "local"
|
||||
if not emulator:
|
||||
return {"error": "emulator is required (e.g. qemu, iou, docker)"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
|
||||
return {"images": images, "count": len(images)}
|
||||
|
||||
|
||||
COMPUTE_TOOLS = [
|
||||
{
|
||||
"name": "list_computes",
|
||||
"description": "List all compute nodes available to the server",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
"handler": list_computes_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_compute",
|
||||
"description": "Get detailed information about a compute node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compute_id": {"type": "string", "description": "Compute ID (default: local)"},
|
||||
},
|
||||
},
|
||||
"handler": get_compute_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_compute_images",
|
||||
"description": "List available images for an emulator on a compute node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emulator": {"type": "string", "description": "Emulator type (e.g. qemu, iou, docker)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (default: local)"},
|
||||
},
|
||||
"required": ["emulator"],
|
||||
},
|
||||
"handler": get_compute_images_handler,
|
||||
},
|
||||
]
|
||||
156
gns3server/agent/mcp/device_config.py
Normal file
156
gns3server/agent/mcp/device_config.py
Normal file
@ -0,0 +1,156 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for device configuration via Nornir + Netmiko.
|
||||
|
||||
These tools connect to network device consoles via telnet/SSH and execute
|
||||
configuration or diagnostic commands. Device connection info is automatically
|
||||
discovered from the project topology using the device's tags for device_type.
|
||||
|
||||
Prerequisites:
|
||||
- Device must be started (use node_start / node_start_all)
|
||||
- Device must have a 'device_type:<type>' tag set in GNS3
|
||||
(right-click → Configure → Tags → add 'device_type:cisco_ios_telnet')
|
||||
- Device must have a console port assigned
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _render_template(template: str, device_configs: list[dict], commands_field: str = "config_commands") -> list[dict]:
|
||||
"""Render a Jinja2 template for each device's vars into the specified commands field.
|
||||
|
||||
Entries with the same device_name are merged into a single entry
|
||||
so they share one Nornir session and avoid output fragmentation.
|
||||
|
||||
Each device in device_configs can have:
|
||||
- "vars": dict of template variables (rendered into commands_field)
|
||||
- commands_field: existing commands merged after rendering if present
|
||||
|
||||
Args:
|
||||
commands_field: field name for the rendered commands, e.g. "config_commands", "commands"
|
||||
"""
|
||||
try:
|
||||
jinja = JinjaTemplate(template)
|
||||
except JinjaError as e:
|
||||
# a syntactically invalid template must not escape as a raw exception
|
||||
error_msg = f"Template rendering failed: {e}"
|
||||
log.error(error_msg)
|
||||
return [{"status": "failed", "error": error_msg}]
|
||||
merged: dict[str, dict] = {}
|
||||
for dev in device_configs:
|
||||
name = dev.get("device_name")
|
||||
if not name:
|
||||
continue
|
||||
vars_data = dev.get("vars", {})
|
||||
if name not in merged:
|
||||
merged[name] = {"device_name": name, commands_field: list(dev.get(commands_field, []))}
|
||||
entry = merged[name]
|
||||
if vars_data:
|
||||
try:
|
||||
output = jinja.render(**vars_data)
|
||||
lines = [l for l in output.splitlines() if l.strip()]
|
||||
entry[commands_field].extend(lines)
|
||||
except JinjaError as e:
|
||||
error_msg = f"Template rendering failed for '{name}': {e}"
|
||||
log.error(error_msg)
|
||||
return [{"status": "failed", "error": error_msg}]
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Send configuration commands to network devices via console."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
template = params.get("template")
|
||||
if not project_id or not device_configs:
|
||||
return [{"status": "failed", "error": "project_id and device_configs are required"}]
|
||||
|
||||
if template:
|
||||
device_configs = _render_template(template, device_configs, commands_field="config_commands")
|
||||
if len(device_configs) == 1 and "error" in device_configs[0]:
|
||||
return device_configs
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||
|
||||
tool = ExecuteMultipleDeviceConfigCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
|
||||
|
||||
def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Run read-only diagnostic (show) commands on network devices."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
template = params.get("template")
|
||||
if not project_id or not device_configs:
|
||||
return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}]
|
||||
|
||||
if template:
|
||||
device_configs = _render_template(template, device_configs, commands_field="commands")
|
||||
if len(device_configs) == 1 and "error" in device_configs[0]:
|
||||
return device_configs
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||
|
||||
tool = ExecuteMultipleDeviceCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
|
||||
|
||||
def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Configure VPCS devices (set IP, gateway, etc.)."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
if not project_id or not device_configs:
|
||||
return [{"status": "failed", "error": "project_id and device_configs are required"}]
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
tool = VPCSCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
96
gns3server/agent/mcp/drawings.py
Normal file
96
gns3server/agent/mcp/drawings.py
Normal file
@ -0,0 +1,96 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 drawing management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_drawings_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)
|
||||
drawings = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings").json()
|
||||
return {"drawings": drawings, "count": len(drawings)}
|
||||
|
||||
|
||||
def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
svg = params.get("svg")
|
||||
if not project_id or not svg:
|
||||
return {"error": "project_id and svg are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"svg": svg,
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"z": params.get("z", 0),
|
||||
"locked": params.get("locked", False),
|
||||
"rotation": params.get("rotation", 0),
|
||||
}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Drawing created", "drawing": result}
|
||||
|
||||
|
||||
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}").json()
|
||||
|
||||
|
||||
def update_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {k: v for k, v in params.items() if k not in ("project_id", "drawing_id") and v is not None}
|
||||
return conn.http_call("put", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}", json_data=data).json()
|
||||
|
||||
|
||||
def delete_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}")
|
||||
return {"message": f"Drawing {drawing_id} deleted", "drawing_id": drawing_id}
|
||||
80
gns3server/agent/mcp/images.py
Normal file
80
gns3server/agent/mcp/images.py
Normal file
@ -0,0 +1,80 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 image management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
images = conn.http_call("get", f"{conn.base_url}/images").json()
|
||||
return {"images": images, "count": len(images)}
|
||||
|
||||
|
||||
def get_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
image_id = params.get("image_id")
|
||||
if not image_id:
|
||||
return {"error": "image_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/images/{image_id}").json()
|
||||
|
||||
|
||||
def delete_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
image_id = params.get("image_id")
|
||||
if not image_id:
|
||||
return {"error": "image_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/images/{image_id}")
|
||||
return {"message": f"Image {image_id} deleted", "image_id": image_id}
|
||||
|
||||
|
||||
def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
# Returns 204 No Content on success (empty body, no .json())
|
||||
conn.http_call("delete", f"{conn.base_url}/images/prune")
|
||||
return {"message": "Unused images pruned"}
|
||||
|
||||
|
||||
def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
response = conn.http_call("post", f"{conn.base_url}/images/install")
|
||||
if response.content:
|
||||
# the install endpoint reports which templates were created or skipped
|
||||
return response.json()
|
||||
# tolerate an empty body in case an older server still replies with 204
|
||||
return {"message": "Image installation completed"}
|
||||
334
gns3server/agent/mcp/projects.py
Normal file
334
gns3server/agent/mcp/projects.py
Normal file
@ -0,0 +1,334 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tools for GNS3 project management.
|
||||
|
||||
Tool handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from gns3_copilot.gns3_client.connector).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
"""Create a Gns3Connector from the GNS3 context dict."""
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
|
||||
return {"projects": projects, "count": len(projects)}
|
||||
|
||||
|
||||
def get_project_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)
|
||||
project = conn.http_call("get", f"{conn.base_url}/projects/{project_id}").json()
|
||||
if project is None:
|
||||
return {"error": f"Project '{project_id}' not found"}
|
||||
return project
|
||||
|
||||
|
||||
def create_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return {"error": "name is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
json_data: dict[str, Any] = {"name": name}
|
||||
if params.get("auto_close") is not None:
|
||||
json_data["auto_close"] = params["auto_close"]
|
||||
return conn.http_call("post", f"{conn.base_url}/projects", json_data=json_data).json()
|
||||
|
||||
|
||||
def delete_project_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("delete", f"{conn.base_url}/projects/{project_id}")
|
||||
return {"message": f"Project '{project_id}' deleted", "project_id": project_id}
|
||||
|
||||
|
||||
def open_project_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)
|
||||
url = f"{conn.base_url}/projects/{project_id}/open"
|
||||
return conn.http_call("post", url).json()
|
||||
|
||||
|
||||
def close_project_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)
|
||||
url = f"{conn.base_url}/projects/{project_id}/close"
|
||||
conn.http_call("post", url)
|
||||
return {"message": f"Project '{project_id}' closed", "project_id": project_id}
|
||||
|
||||
|
||||
def get_project_stats_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)
|
||||
url = f"{conn.base_url}/projects/{project_id}/stats"
|
||||
return conn.http_call("get", url).json()
|
||||
|
||||
|
||||
def update_project_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)
|
||||
kwargs = {k: v for k, v in params.items() if k != "project_id" and v is not None}
|
||||
return conn.http_call("put", f"{conn.base_url}/projects/{project_id}", json_data=kwargs).json()
|
||||
|
||||
|
||||
def duplicate_project_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"}
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return {"error": "name is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
kwargs = {k: v for k, v in params.items() if k not in ("project_id",) and v is not None}
|
||||
return conn.http_call("post", f"{conn.base_url}/projects/{project_id}/duplicate", json_data=kwargs).json()
|
||||
|
||||
|
||||
def get_project_readme_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)
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
|
||||
content = conn.http_call("get", url).text
|
||||
return {"project_id": project_id, "file": "README.txt", "content": content}
|
||||
except Exception as e:
|
||||
if "404" in str(e):
|
||||
return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"}
|
||||
raise
|
||||
|
||||
|
||||
def update_project_readme_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"}
|
||||
content = params.get("content")
|
||||
if content is None:
|
||||
return {"error": "content is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
|
||||
return {"message": "README.txt updated", "project_id": project_id}
|
||||
|
||||
|
||||
def lock_project_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}/lock")
|
||||
return {"message": f"Project {project_id} locked", "project_id": project_id}
|
||||
|
||||
|
||||
def unlock_project_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}/unlock")
|
||||
return {"message": f"Project {project_id} unlocked", "project_id": project_id}
|
||||
|
||||
|
||||
def get_locked_project_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)
|
||||
locked = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/locked").json()
|
||||
return {"project_id": project_id, "locked": locked}
|
||||
|
||||
|
||||
# ── Tool definitions (consumed by mcp/__init__.py) ─────────────────────────
|
||||
|
||||
PROJECT_TOOLS = [
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "List all GNS3 projects accessible to the current user",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
"handler": list_projects_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project",
|
||||
"description": "Get detailed information about a specific project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_project",
|
||||
"description": "Create a new GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Project name"},
|
||||
"description": {"type": "string", "description": "Optional project description"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"handler": create_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_project",
|
||||
"description": "Delete a GNS3 project permanently",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project to delete"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": delete_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "open_project",
|
||||
"description": "Open a closed GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": open_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "close_project",
|
||||
"description": "Close an open GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": close_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project_stats",
|
||||
"description": "Get statistics (nodes, links, snapshots, drawings) for a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_stats_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_project",
|
||||
"description": "Update a project's properties (name, auto_close, auto_open, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"name": {"type": "string", "description": "New project name"},
|
||||
"auto_close": {"type": "boolean", "description": "Close project when last client leaves"},
|
||||
"auto_open": {"type": "boolean", "description": "Project opens when GNS3 starts"},
|
||||
"auto_start": {"type": "boolean", "description": "Project starts when opened"},
|
||||
"scene_width": {"type": "integer", "description": "Width of the drawing area"},
|
||||
"scene_height": {"type": "integer", "description": "Height of the drawing area"},
|
||||
"zoom": {"type": "integer", "description": "Zoom of the drawing area"},
|
||||
"show_layers": {"type": "boolean", "description": "Show layers on the drawing area"},
|
||||
"snap_to_grid": {"type": "boolean", "description": "Snap to grid on the drawing area"},
|
||||
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
|
||||
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
|
||||
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
|
||||
"show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": update_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "duplicate_project",
|
||||
"description": "Duplicate a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project to duplicate"},
|
||||
"name": {"type": "string", "description": "New project name"},
|
||||
"reset_mac_addresses": {"type": "boolean", "description": "Reset MAC addresses for this project"},
|
||||
},
|
||||
"required": ["project_id", "name"],
|
||||
},
|
||||
"handler": duplicate_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project_readme",
|
||||
"description": "Get the content of a project's README.md file (project documentation, Markdown format)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_readme_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_project_readme",
|
||||
"description": "Update or create a project's README.md file (project documentation, Markdown format)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"content": {"type": "string", "description": "Content to write to README.md (Markdown format)"},
|
||||
},
|
||||
"required": ["project_id", "content"],
|
||||
},
|
||||
"handler": update_project_readme_handler,
|
||||
},
|
||||
]
|
||||
50
gns3server/agent/mcp/server.py
Normal file
50
gns3server/agent/mcp/server.py
Normal file
@ -0,0 +1,50 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 server information.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/version").json()
|
||||
|
||||
|
||||
def get_statistics_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/statistics").json()
|
||||
79
gns3server/agent/mcp/snapshots.py
Normal file
79
gns3server/agent/mcp/snapshots.py
Normal file
@ -0,0 +1,79 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 snapshot management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_snapshots_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)
|
||||
snapshots = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/snapshots").json()
|
||||
return {"snapshots": snapshots, "count": len(snapshots)}
|
||||
|
||||
|
||||
def create_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
name = params.get("name")
|
||||
if not project_id or not name:
|
||||
return {"error": "project_id and name are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots", json_data={"name": name}).json()
|
||||
return {"message": f"Snapshot '{name}' created", "snapshot": result}
|
||||
|
||||
|
||||
def delete_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
snapshot_id = params.get("snapshot_id")
|
||||
if not project_id or not snapshot_id:
|
||||
return {"error": "project_id and snapshot_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}")
|
||||
return {"message": f"Snapshot {snapshot_id} deleted", "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def restore_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
snapshot_id = params.get("snapshot_id")
|
||||
if not project_id or not snapshot_id:
|
||||
return {"error": "project_id and snapshot_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}/restore").json()
|
||||
return {"message": f"Snapshot {snapshot_id} restored", "project": result}
|
||||
108
gns3server/agent/mcp/symbols.py
Normal file
108
gns3server/agent/mcp/symbols.py
Normal file
@ -0,0 +1,108 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 symbol management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.services import access_ticket_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
|
||||
return {"symbols": symbols, "count": len(symbols)}
|
||||
|
||||
|
||||
def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
path = f"/v3/symbols/{symbol_id}/raw"
|
||||
download_url = f"{gns3_ctx['server_url']}{path}"
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
# short-lived ticket bound to this exact path — LLM clients retyping curl
|
||||
# commands corrupted the long Bearer JWT this used to embed
|
||||
ticket = access_ticket_service.mint(
|
||||
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path
|
||||
) if username else None
|
||||
if ticket:
|
||||
download_url += f"?token={ticket}"
|
||||
result = {
|
||||
"symbol_id": symbol_id,
|
||||
"download_url": download_url,
|
||||
"note": "Symbol files are SVG images.",
|
||||
}
|
||||
if ticket:
|
||||
safe_name = symbol_id.replace(':', '').replace('/', '_')
|
||||
result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
|
||||
result["note"] += " The download URL includes a 10-minute ticket."
|
||||
return result
|
||||
|
||||
|
||||
def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/symbols/{symbol_id}/dimensions").json()
|
||||
|
||||
|
||||
def get_default_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
symbols = conn.http_call("get", f"{conn.base_url}/symbols/default_symbols").json()
|
||||
return {"default_symbols": symbols}
|
||||
|
||||
|
||||
def upload_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
content = params.get("content")
|
||||
if not symbol_id or content is None:
|
||||
return {"error": "symbol_id and content (SVG data) are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/symbols/{symbol_id}/raw"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "image/svg+xml"})
|
||||
return {"message": f"Symbol {symbol_id} uploaded", "symbol_id": symbol_id}
|
||||
|
||||
|
||||
def delete_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/symbols/{symbol_id}")
|
||||
return {"message": f"Symbol {symbol_id} deleted", "symbol_id": symbol_id}
|
||||
229
gns3server/agent/mcp/templates.py
Normal file
229
gns3server/agent/mcp/templates.py
Normal file
@ -0,0 +1,229 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 template management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from gns3_copilot.gns3_client.connector).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
VALID_TEMPLATE_FIELDS = {
|
||||
"template_id", "name", "version", "category", "default_name_format",
|
||||
"symbol", "template_type", "compute_id", "usage", "tags", "builtin",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
|
||||
|
||||
|
||||
def _filter_templates(templates, fields):
|
||||
"""Filter each template to only include requested fields."""
|
||||
if not fields:
|
||||
fields = TEMPLATE_DEFAULT_FIELDS
|
||||
if isinstance(templates, dict):
|
||||
return {k: templates[k] for k in fields if k in templates}
|
||||
return [{k: t[k] for k in fields if k in t} for t in templates]
|
||||
|
||||
|
||||
def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_TEMPLATE_FIELDS),
|
||||
}
|
||||
templates = _filter_templates(templates, fields)
|
||||
return {"templates": templates, "count": len(templates)}
|
||||
|
||||
|
||||
def get_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
if template_id:
|
||||
template = conn.http_call("get", f"{conn.base_url}/templates/{template_id}").json()
|
||||
else:
|
||||
# Find template by name
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template = matches[0]
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def create_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
name = params.get("name")
|
||||
template_type = params.get("template_type")
|
||||
if not name or not template_type:
|
||||
return {"error": "name and template_type are required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"name": name,
|
||||
"template_type": template_type,
|
||||
"compute_id": params.get("compute_id", "local"),
|
||||
}
|
||||
# Pass through optional template-type-specific fields (image, qemu_path, etc.)
|
||||
for key in ("image",):
|
||||
if key in params:
|
||||
data[key] = params[key]
|
||||
return conn.http_call("post", f"{conn.base_url}/templates", json_data=data).json()
|
||||
|
||||
|
||||
def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Resolve name to ID if needed
|
||||
if not template_id and name:
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template_id = matches[0]["template_id"]
|
||||
|
||||
update_data = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")}
|
||||
# Support nested kwargs from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
|
||||
return conn.http_call("put", f"{conn.base_url}/templates/{template_id}", json_data=update_data).json()
|
||||
|
||||
|
||||
def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
if not template_id and name:
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template_id = matches[0]["template_id"]
|
||||
|
||||
conn.http_call("delete", f"{conn.base_url}/templates/{template_id}")
|
||||
return {"message": f"Template deleted"}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATE_TOOLS = [
|
||||
{
|
||||
"name": "list_templates",
|
||||
"description": "List all available templates on the server",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
"handler": list_templates_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_template",
|
||||
"description": "Get detailed information about a specific template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": get_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_template",
|
||||
"description": "Create a new template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
"template_type": {"type": "string", "description": "Template type (e.g. qemu, docker, dynamips)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional, default: local)"},
|
||||
},
|
||||
"required": ["name", "template_type"],
|
||||
},
|
||||
"handler": create_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_template",
|
||||
"description": "Update an existing template's properties",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": update_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_template",
|
||||
"description": "Delete a template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": delete_template_handler,
|
||||
},
|
||||
]
|
||||
@ -15,6 +15,7 @@ RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /
|
||||
&& sed -i 's|http://security.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Add xpra official repository
|
||||
COPY pin-xpra /etc/apt/preferences.d/
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
wget \
|
||||
@ -24,11 +25,11 @@ RUN apt-get update && apt-get install -y \
|
||||
&& apt-get update
|
||||
|
||||
# Install xpra and dependencies
|
||||
# Lock xpra version for reproducible builds
|
||||
# Locking of xpra version is done in /etc/apt/preferences.d/pin-xpra
|
||||
RUN apt-get install -y \
|
||||
wireshark-common \
|
||||
wireshark \
|
||||
xpra=6.4.3* \
|
||||
xpra \
|
||||
xpra-x11 \
|
||||
xvfb \
|
||||
curl \
|
||||
|
||||
14
gns3server/agent/web_wireshark/docker/pin-xpra
Normal file
14
gns3server/agent/web_wireshark/docker/pin-xpra
Normal file
@ -0,0 +1,14 @@
|
||||
Explanation: Lock Xpra packages to v6.4
|
||||
Package: xpra*
|
||||
Pin: version 6.4*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Explanation: xpra-html5 uses different version scheme, lock it to v19
|
||||
Package: xpra-html5
|
||||
Pin: version 19*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Explanation: Block the installation of other xpra versions
|
||||
Package: xpra*
|
||||
Pin: version *
|
||||
Pin-Priority: -1
|
||||
@ -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}
|
||||
|
||||
@ -21,7 +21,9 @@ API routes for compute.
|
||||
|
||||
import os
|
||||
import psutil
|
||||
import cpuinfo
|
||||
|
||||
from functools import lru_cache
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.cpu_percent import CpuPercent
|
||||
from gns3server.version import __version__
|
||||
@ -42,6 +44,11 @@ from typing import Optional, List
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cpu_model() -> str:
|
||||
return cpuinfo.get_cpu_info().get("brand_raw", "")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/ports/udp", status_code=status.HTTP_201_CREATED)
|
||||
def allocate_udp_port(project_id: UUID) -> dict:
|
||||
"""
|
||||
@ -55,6 +62,27 @@ def allocate_udp_port(project_id: UUID) -> dict:
|
||||
return {"udp_port": udp_port}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/ports/udp/batch", status_code=status.HTTP_201_CREATED)
|
||||
def batch_allocate_udp_ports(project_id: UUID, body: dict) -> dict:
|
||||
"""
|
||||
Allocate multiple UDP ports on the compute in a single call.
|
||||
|
||||
Used during project loading to pre-allocate all required UDP ports
|
||||
before creating links, reducing HTTP round-trips.
|
||||
"""
|
||||
|
||||
count = body.get("count", 1)
|
||||
try:
|
||||
count = max(1, min(int(count), 10000))
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="count must be a positive integer")
|
||||
pm = ProjectManager.instance()
|
||||
project = pm.get_project(str(project_id))
|
||||
m = PortManager.instance()
|
||||
udp_ports = [m.get_free_udp_port(project) for _ in range(count)]
|
||||
return {"udp_ports": udp_ports}
|
||||
|
||||
|
||||
@router.get("/network/interfaces")
|
||||
def network_interfaces() -> List[dict]:
|
||||
"""
|
||||
@ -98,10 +126,15 @@ def compute_statistics() -> dict:
|
||||
swap_free = psutil.swap_memory().free
|
||||
swap_used = psutil.swap_memory().used
|
||||
cpu_percent = int(CpuPercent.get())
|
||||
load_average_percent = [int(x / psutil.cpu_count() * 100) for x in psutil.getloadavg()]
|
||||
cpu_count = psutil.cpu_count(logical=True) or 1
|
||||
cpu_count_physical = psutil.cpu_count(logical=False)
|
||||
raw_load_average = psutil.getloadavg()
|
||||
load_average = [round(x, 2) for x in raw_load_average]
|
||||
load_average_percent = [round(x / cpu_count * 100, 2) for x in raw_load_average]
|
||||
memory_percent = int(psutil.virtual_memory().percent)
|
||||
swap_percent = int(psutil.swap_memory().percent)
|
||||
disk_usage_percent = int(psutil.disk_usage(get_default_project_directory()).percent)
|
||||
disk_usage = psutil.disk_usage(get_default_project_directory())
|
||||
disk_usage_percent = int(disk_usage.percent)
|
||||
except psutil.Error as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
# raise HTTPConflict(text="Psutil error detected: {}".format(e))
|
||||
@ -114,9 +147,16 @@ def compute_statistics() -> dict:
|
||||
"swap_free": swap_free,
|
||||
"swap_used": swap_used,
|
||||
"cpu_usage_percent": cpu_percent,
|
||||
"cpu_count": cpu_count,
|
||||
"cpu_count_physical": cpu_count_physical,
|
||||
"cpu_model": get_cpu_model(),
|
||||
"memory_usage_percent": memory_percent,
|
||||
"swap_usage_percent": swap_percent,
|
||||
"disk_usage_percent": disk_usage_percent,
|
||||
"disk_total": disk_usage.total,
|
||||
"disk_used": disk_usage.used,
|
||||
"disk_free": disk_usage.free,
|
||||
"load_average": load_average,
|
||||
"load_average_percent": load_average_percent,
|
||||
}
|
||||
|
||||
|
||||
@ -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"}}
|
||||
@ -79,9 +78,20 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
|
||||
aux_type=node_data.pop("aux_type", "none"),
|
||||
extra_hosts=node_data.get("extra_hosts"),
|
||||
extra_volumes=node_data.get("extra_volumes"),
|
||||
extra_configs=node_data.get("extra_configs"),
|
||||
memory=node_data.get("memory", 0),
|
||||
cpus=node_data.get("cpus", 0),
|
||||
)
|
||||
# Pop keys already consumed by create_node above so the setattr
|
||||
# fallback loop below only applies truly extra keys and does not
|
||||
# re-trigger console/aux port setter logging.
|
||||
for key in (
|
||||
"console", "console_type", "console_resolution", "console_http_port",
|
||||
"console_http_path", "aux", "aux_type", "start_command", "environment",
|
||||
"adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs",
|
||||
"memory", "cpus",
|
||||
):
|
||||
node_data.pop(key, None)
|
||||
for name, value in node_data.items():
|
||||
if name != "node_id":
|
||||
if hasattr(container, name) and getattr(container, name) != value:
|
||||
@ -129,6 +139,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
|
||||
"custom_adapters",
|
||||
"extra_hosts",
|
||||
"extra_volumes",
|
||||
"extra_configs",
|
||||
"memory",
|
||||
"cpus",
|
||||
]
|
||||
@ -166,10 +177,12 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||
)
|
||||
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||
"""
|
||||
Stop a Docker node.
|
||||
Stop a Docker node. This is the explicit user stop — the only path that
|
||||
asks for a graceful SIGTERM shutdown (vendor NOS override); internal
|
||||
paths (delete/update/close) keep the immediate kill.
|
||||
"""
|
||||
|
||||
await node.stop()
|
||||
await node.stop(graceful=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -235,6 +248,7 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
|
||||
"""
|
||||
|
||||
await node.delete()
|
||||
await node.project.remove_node(node)
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -292,6 +306,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()
|
||||
|
||||
@ -407,3 +422,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}
|
||||
|
||||
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