mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-04 14:31:13 +02:00
Merge remote-tracking branch 'origin/3.0' into gh-pages
This commit is contained in:
commit
5bdd307ea9
471 files changed
+75257
-4705
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# GNS3 Server Project Memory
|
||||
|
||||
> **Note**: This directory stores important project-related memories and case studies, managed with the code repository.
|
||||
>
|
||||
> **How to record**: Use the `/memory` skill to record important information to the project memory directory.
|
||||
|
||||
## Quick Reference
|
||||
- **Memory directory**: `.claude/memory/`
|
||||
- **Skill file**: `.claude/skills/memory/SKILL.md`
|
||||
- **Main index**: `MEMORY.md` (this file)
|
||||
|
||||
## Topics
|
||||
|
||||
### Web Wireshark Integration
|
||||
- **[JWT Token Flow](./web-wireshark-jwt-token-flow.md)** - JWT token transmission path in Web Wireshark
|
||||
- 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
|
||||
|
||||
### 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
|
||||
@@ -0,0 +1,25 @@
|
||||
# GNS3 uBridge Permission Issue
|
||||
|
||||
## Problem
|
||||
Docker container node fails to start with error: `uBridge requires root access or the capability to interact with network adapters`
|
||||
|
||||
## Root Cause
|
||||
- uBridge needs to create TAP network interfaces to connect Docker containers to GNS3 virtual network
|
||||
- This requires `CAP_NET_ADMIN` and `CAP_NET_RAW` capabilities
|
||||
- Other node types (like QEMU) may have their own network implementation and don't need uBridge
|
||||
|
||||
## Solution
|
||||
```bash
|
||||
sudo setcap cap_net_admin,cap_net_raw=eip /usr/bin/ubridge
|
||||
```
|
||||
|
||||
## Code Locations
|
||||
- uBridge path check: `gns3server/compute/base_manager.py:298` (`has_privileged_access`)
|
||||
- Permission check call: `gns3server/compute/base_node.py:851`
|
||||
- Error message definition: `gns3server/compute/base_node.py:852`
|
||||
|
||||
## Error Propagation Flow
|
||||
Error is propagated via `NodeError` exception:
|
||||
1. `NodeError` is caught at `gns3server/api/routes/compute/__init__.py:131-137`
|
||||
2. Returns HTTP 409 with JSON: `{"message": "...", "exception": "NodeError"}`
|
||||
3. Frontend should read error info from `message` field
|
||||
@@ -0,0 +1,53 @@
|
||||
# Web Wireshark JWT Token Flow
|
||||
|
||||
## Background
|
||||
|
||||
Question: What is the purpose of `jwt_token` parameter in UDPLink and how is it transmitted?
|
||||
|
||||
## JWT Token Complete Flow
|
||||
|
||||
1. **User initiates request** → HTTP request with `Authorization: Bearer <jwt_token>`
|
||||
|
||||
2. **API layer extracts token** → `gns3server/api/routes/controller/links.py`:
|
||||
```python
|
||||
auth_header = http_request.headers.get("Authorization", "")
|
||||
jwt_token = auth_header.replace("Bearer ", "") if auth_header else None
|
||||
```
|
||||
|
||||
3. **Pass to Link layer** → `Link.start_capture(wireshark=True, jwt_token=xxx)`
|
||||
|
||||
4. **UDPLink forwards** → `UDPLink.start_capture()` calls `super().start_capture(jwt_token=jwt_token)`
|
||||
|
||||
5. **Start Web Wireshark container** → `Link._start_web_wireshark(jwt_token)` calls management script
|
||||
|
||||
6. **Container uses token** → `manage_wireshark.py` executes inside container:
|
||||
```bash
|
||||
curl -N -H 'Authorization: Bearer {jwt_token}' \
|
||||
'http://controller:3080/v3/projects/{project_id}/links/{link_id}/capture/stream' | \
|
||||
wireshark -i - -k -display :{display}
|
||||
```
|
||||
|
||||
## Why More Verbose Than gns3-copilot?
|
||||
|
||||
**gns3-copilot approach**:
|
||||
- All code within the same process
|
||||
- Uses `contextvars` to store and retrieve token
|
||||
- Downstream code directly calls `get_current_jwt_token()`, no need to pass through layers
|
||||
|
||||
**Web Wireshark approach**:
|
||||
- Token needs to be passed across processes (management script is separate process)
|
||||
- Token needs to be passed across containers (Docker container isolation)
|
||||
- Cannot use `contextvars`, must pass through command line arguments
|
||||
|
||||
## Conclusion
|
||||
|
||||
UDPLink does not use this token, it only forwards it to the parent class. Ultimately used by the curl command inside the Web Wireshark container to authenticate with the GNS3 controller's capture stream API.
|
||||
|
||||
Token flow: **Client → GNS3 API → Link → UDPLink → Web Wireshark container → GNS3 capture stream API**
|
||||
|
||||
## Related Files
|
||||
|
||||
- `gns3server/api/routes/controller/links.py:114-115` - JWT token extraction
|
||||
- `gns3server/controller/link.py:321-360` - Web Wireshark startup logic
|
||||
- `gns3server/controller/udp_link.py:178` - UDPLink start_capture signature
|
||||
- `gns3server/agent/web_wireshark/manage_wireshark.py:527-529` - Container curl command with JWT
|
||||
@@ -0,0 +1,148 @@
|
||||
# Xpra HTML5 Client Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
Web Wireshark integration uses xpra's built-in HTML5 client to provide browser-based packet capture viewing. The xpra HTML5 client is located at `/usr/share/xpra/www` in the container.
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
### 1. URL Parameters (Per-Session)
|
||||
|
||||
Control toolbar menu items via URL query parameters (default: true):
|
||||
|
||||
| Parameter | Menu Item | Description |
|
||||
|-----------|-----------|-------------|
|
||||
| xpramenu | Xpra Menu | Main menu (Server, Information submenus) |
|
||||
| open_windows | Open Windows | List of open windows |
|
||||
| fullscreen_button | Fullscreen | Fullscreen toggle button |
|
||||
| keyboard_button | Keyboard | Keyboard layout/shortcuts |
|
||||
| clipboard_button | Clipboard Copy | Clipboard copy functionality |
|
||||
| sound_button | Audio | Audio toggle |
|
||||
| cursor_lock_button | Lock Cursor | Game cursor lock mode |
|
||||
|
||||
```bash
|
||||
# Examples
|
||||
?fullscreen_button=false&sound_button=false
|
||||
?xpramenu=false&keyboard_button=false&clipboard_button=false&sound_button=false&cursor_lock_button=false
|
||||
```
|
||||
|
||||
### 2. default-settings.txt (Recommended - Global/Persistent)
|
||||
|
||||
Modify `html5/default-settings.txt` (INI format) for all users.
|
||||
|
||||
**Features:**
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| keyboard | auto-detect | Enable keyboard input |
|
||||
| keyboard_layout | us | Keyboard layout |
|
||||
| clipboard | yes | Clipboard sharing |
|
||||
| printing | yes | Printer forwarding |
|
||||
| file_transfer | yes | File transfer |
|
||||
| swap_keys | MacOS yes | Swap Command/Control keys |
|
||||
| scroll_reverse_x | no | Reverse mouse X-axis |
|
||||
| floating_menu | yes | Show floating menu |
|
||||
| toolbar_position | top-left | Toolbar position |
|
||||
| autohide | no | Auto-hide toolbar |
|
||||
| sound | yes | Audio forwarding |
|
||||
| video | 64-bit yes | Video decoding |
|
||||
|
||||
**Connection Options:**
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| server | Server address |
|
||||
| port | Port number |
|
||||
| username | Username |
|
||||
| password | Password |
|
||||
| ssl | Enable SSL |
|
||||
| encryption | Encryption type (AES-CBC/CTR/CFB) |
|
||||
| key | AES encryption key |
|
||||
| sharing | Allow session sharing |
|
||||
| steal | Steal session |
|
||||
| reconnect | Auto-reconnect |
|
||||
| bandwidth_limit | Bandwidth limit (bits/s) |
|
||||
| override_width | Client desktop width |
|
||||
|
||||
**Advanced Options:**
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| audio_codec | auto-detect | Audio codec |
|
||||
| encoding | auto | Image encoding (png/jpeg/webp/etc) |
|
||||
| remote_logging | yes | Send logs to server |
|
||||
| action | connect | Connection mode (start/shadow) |
|
||||
| submit | yes | Show diagnostics on disconnect |
|
||||
|
||||
### 3. default_settings in Code
|
||||
|
||||
Set directly in `<script>` tag in index.html:
|
||||
```javascript
|
||||
const default_settings = {};
|
||||
default_settings["xpramenu"] = false;
|
||||
```
|
||||
|
||||
### Priority Order
|
||||
|
||||
URL parameter > default_settings object > default-settings.txt > code default value
|
||||
|
||||
## Server-Controlled Submenu Items
|
||||
|
||||
The **Server submenu** items are controlled by server hello message:
|
||||
|
||||
| Menu Item ID | Content | Control Source |
|
||||
|--------------|---------|----------------|
|
||||
| clock_menu_entry | Clock | server-time in hello |
|
||||
| upload_menu_entry | Upload file | file-transfer or file.enabled |
|
||||
| download_menu_entry | Download file | file-transfer or file.enabled |
|
||||
| shutdown_menu_entry | Shutdown Server | client-shutdown in hello |
|
||||
|
||||
**Information submenu** (About Xpra, Session Info, Bug Report) and fixed items (Reload, Disconnect) are always displayed.
|
||||
|
||||
To hide "Shutdown Server", set environment variable before starting xpra:
|
||||
```bash
|
||||
export XPRA_CLIENT_CAN_SHUTDOWN=false
|
||||
xpra start :{display} ...
|
||||
```
|
||||
Code: `xpra/server/base.py:40` - `CLIENT_CAN_SHUTDOWN = envbool("XPRA_CLIENT_CAN_SHUTDOWN", True)`
|
||||
|
||||
## Customizing Background Image
|
||||
|
||||
Background image is defined in `html5/css/client.css`:
|
||||
```css
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #021d3a;
|
||||
background-image: url(../background.jpg);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
1. **Replace image file**: Create/replace `html5/background.jpg`
|
||||
2. **Modify CSS**: Change `html5/css/client.css` line 9:
|
||||
```css
|
||||
background-image: url(../your-image.jpg);
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Supported formats: jpg, png, svg, etc.
|
||||
- Recommended resolution: 1920x1080 or higher
|
||||
- `background-size: cover` auto-stretches to fill
|
||||
|
||||
### Temporary Modification (via URL)
|
||||
|
||||
Cannot modify background via URL directly, but can use browser DevTools:
|
||||
```css
|
||||
body { background-image: url(your-image-url); }
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- Container Dockerfile: `gns3server/agent/web_wireshark/docker/Dockerfile`
|
||||
- Container HTML5 client path: `/usr/share/xpra/www`
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: documentation
|
||||
description: Use this skill when creating or updating technical documentation under docs/ directory for GNS3 server
|
||||
version: 3.0.0
|
||||
---
|
||||
|
||||
# GNS3 Server Technical Documentation Standard
|
||||
|
||||
## Core Principle
|
||||
|
||||
Documentation answers: **What is this, how does it work at a high level.**
|
||||
|
||||
Code details are left to the codebase — readers can use AI to find implementation specifics.
|
||||
|
||||
---
|
||||
|
||||
## Document Structure
|
||||
|
||||
```markdown
|
||||
# Feature Name
|
||||
|
||||
## Overview
|
||||
[What it does, in 2-3 sentences]
|
||||
|
||||
## Architecture
|
||||
[Mermaid diagram: components and their relationships]
|
||||
|
||||
## Business Process
|
||||
[Mermaid sequence/flowchart: key flows]
|
||||
|
||||
## API Endpoints
|
||||
[Table: Method | Path | Description | Privilege]
|
||||
|
||||
## Notes
|
||||
[Known limitations, performance data, security — only when relevant]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Include
|
||||
|
||||
- Mermaid diagrams for architecture and flow (GitHub renders natively)
|
||||
- `graph` for component relationships
|
||||
- `sequenceDiagram` for request/response flows
|
||||
- `flowchart` for data processing steps
|
||||
- API endpoint tables
|
||||
- Request/response JSON examples (for interfaces)
|
||||
- Performance data (only measured numbers, no estimates)
|
||||
|
||||
## What to Skip
|
||||
|
||||
- Code snippets — let readers search the codebase
|
||||
- Verbose prose — use diagrams and tables
|
||||
- Implementation details — link file paths instead of pasting code
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: memory
|
||||
description: This skill should be used when the user asks to "record memory", "save to memory", "remember this", "create a memory", "add to memory", or discusses documenting technical decisions, architectural choices, or important case studies for the project. Use this skill to record project knowledge to the .claude/memory/ directory.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Memory Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Record project-related technical decisions, important case studies, and lessons learned to the project memory directory, ensuring critical knowledge is managed with the code repository.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
Activate this skill when user requests involve:
|
||||
- "Record to memory", "save to memory", "add to memory"
|
||||
- "Remember this", "create a memory"
|
||||
- Documenting technical decisions, architectural choices
|
||||
- Recording problem solutions and lessons learned
|
||||
- Discussing API design, performance optimization, security implementations
|
||||
|
||||
## Memory Location
|
||||
|
||||
Project memory is stored in: `.claude/memory/`
|
||||
|
||||
## Memory Structure
|
||||
|
||||
```
|
||||
.claude/
|
||||
├── memory/
|
||||
│ ├── MEMORY.md # Main index file
|
||||
│ └── <topic>.md # Detailed records for specific topics
|
||||
└── skills/
|
||||
└── memory/
|
||||
└── SKILL.md # This file
|
||||
```
|
||||
|
||||
## What to Record
|
||||
|
||||
### ✅ Should Record
|
||||
|
||||
- Important architectural decisions and rationale
|
||||
- Solutions to complex problems
|
||||
- Key considerations for API design
|
||||
- Performance optimization experiences
|
||||
- Security-related implementation details
|
||||
- Integration approaches with other systems
|
||||
- Common errors and solutions
|
||||
- Design changes based on user feedback
|
||||
|
||||
### ❌ Should Not Record
|
||||
|
||||
- Temporary debugging information
|
||||
- Session-specific state
|
||||
- Obvious code implementation details (code is documentation)
|
||||
- Content already documented elsewhere
|
||||
|
||||
## Memory Template
|
||||
|
||||
```markdown
|
||||
# <Topic Title>
|
||||
|
||||
## Background
|
||||
<Why this record exists>
|
||||
|
||||
## Decision/Implementation
|
||||
<What was done or how it was implemented>
|
||||
|
||||
## Rationale
|
||||
<Why this approach was chosen, what alternatives were considered>
|
||||
|
||||
## Related Files
|
||||
<Code files involved, use file_path:line_number format>
|
||||
|
||||
## Examples
|
||||
<If there are code examples or other examples>
|
||||
```
|
||||
|
||||
## Recording Process
|
||||
|
||||
1. **Identify content to record**
|
||||
2. **Determine topic title** (concise, descriptive)
|
||||
3. **Create record using template**
|
||||
4. **Update MEMORY.md index**
|
||||
5. **Save to .claude/memory/<topic>.md**
|
||||
|
||||
## Examples
|
||||
|
||||
- `web-wireshark-jwt-token-flow.md` - JWT token flow in Web Wireshark integration
|
||||
- `docker-api-version-detection.md` - Docker API version dynamic detection implementation
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use clear, descriptive filenames
|
||||
- Include references to related code files (with line numbers)
|
||||
- Document "why" not just "what"
|
||||
- Keep records updated, remove outdated ones
|
||||
- Link to related memory files
|
||||
@@ -16,14 +16,14 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
ref: "gh-pages"
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.12
|
||||
- name: Merge changes from 3.0 branch
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install .[dev]
|
||||
python -m pip install .[ai-copilot,dev]
|
||||
|
||||
- name: Install Windows specific dependencies
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
+18
@@ -67,3 +67,21 @@ env
|
||||
venv
|
||||
*venv
|
||||
.ropeproject
|
||||
|
||||
# Claude Code settings (may contain API keys)
|
||||
.claude/settings*.json
|
||||
.claude/minmax-settings.json
|
||||
.claude/zhipu-settings.json
|
||||
.claude/settings.local.json
|
||||
.claude/deepseek-settings.json
|
||||
.claude/claude-settings.json
|
||||
.claude/gemini-settings.json
|
||||
.claude/grok-settings.json
|
||||
!.claude/development.md # Exception: allow development docs
|
||||
!.claude/skills/ # Exception: allow project skills
|
||||
!.claude/memory/ # Exception: allow project memory
|
||||
# PROJECT_CONTEXT.md # Commented out: allow tracking project context
|
||||
|
||||
# Tiktoken cache files
|
||||
gns3server/agent/gns3_copilot/cache/tiktoken/
|
||||
|
||||
@@ -1,5 +1,185 @@
|
||||
# Change Log
|
||||
|
||||
## 3.1.0a2 12/05/2026
|
||||
|
||||
* Bundle web-ui v3.1.0a2
|
||||
* fix: surface tshark stderr errors to LLM, not just log them
|
||||
* fix: hint LLM about -c behavior when tshark returns empty with -c
|
||||
* fix: update search_fields examples to use existing field names
|
||||
* fix: reject multi-keyword search_fields, single keyword only
|
||||
* fix: document action/query params in PacketAnalysisTool description
|
||||
* feat: add search_fields action to PacketAnalysisTool for real-time field lookup
|
||||
* feat: validate tshark -e field names before running analysis
|
||||
* fix: document -c behavior caveat in PacketAnalysisTool description
|
||||
* fix: demote loader-level load logs to DEBUG, info already reported by manager
|
||||
* fix: promote skills/prompts load logs to INFO level
|
||||
* fix: load packet analysis protocols during skills initialization
|
||||
* feat: add PacketAnalysisSkillsTool
|
||||
* fix: remove remaining quoted example from tool input docstring
|
||||
* fix: use shlex.split for tshark_args parsing
|
||||
* feat: add packet analysis workflow to tool description
|
||||
* refactor: remove old PacketCaptureTool, replaced by PacketAnalysisTool
|
||||
* feat: add protocol-oriented packet analysis tool
|
||||
* docs: add roadmap docs for planned features
|
||||
* feat: require superadmin for /reload/skills endpoint
|
||||
* fix: remove background init timeout and allow skills reload retry
|
||||
* deps: add PyYAML and GitPython to ai-requirements.txt
|
||||
* feat: add fault injection system and external skills repository
|
||||
* fix: Disable DeepSeek thinking mode to avoid reasoning_content errors
|
||||
* fix: Skip offline compute nodes during project import
|
||||
* fix: Import status module in project.py
|
||||
* fix: Handle connection errors when stopping node console
|
||||
* refactor: Remove MD5 calculation from node files listing
|
||||
* fix: Export NodeFile schema to schemas module
|
||||
* feat: Add detailed metadata to node files listing
|
||||
* feat: Add API endpoint to list node files
|
||||
* Fix snapshot restore does not work after export/import of project
|
||||
* Warn to use 64-bit IOU images. Fixes #2716 32-bit IOU image support has been removed from the GNS3 VM.
|
||||
* Fix: Improve Docker container deletion error logging
|
||||
* Fix: Add exponential backoff to compute reconnection attempts
|
||||
* Fix: Check compute connectivity before node creation in open()
|
||||
* Fix: Check compute connectivity before open() during project deletion
|
||||
* Fix: Add error logging when closing/deleting projects on computes
|
||||
* Fix: Skip add_compute for existing computes when opening projects
|
||||
* feat: add AI provider config files to .gitignore
|
||||
* Fix: Use _computes instead of _project_created_on_compute for deletion check
|
||||
* Fix: Check compute connection status before project deletion
|
||||
* fix: extend remote compute image path conversion to all node types
|
||||
* fix: send relative image path to remote compute nodes
|
||||
* fix: handle directory access for static web-ui routes
|
||||
* fix: add duplicate name validation when updating templates
|
||||
* fix: avoid AttributeError when password is not provided in compute update
|
||||
* fix: improve hostname validation error messages with allowed characters
|
||||
* feat(compute): Fix Dynamips to listen on the internal port when ssh is used for auxiliary console
|
||||
* feat(compute): Fix ambiguous log message related to the active console transport.
|
||||
* feat(compute): Updated server host key to use the secure ssh-ed25519 instead of old ssh-rsa algorithm
|
||||
* feat(compute): Fix missing SSH wrapping for auxiliary console
|
||||
* feat(compute): Added AsyncioSSHServer dedicated tests
|
||||
* feat(compute): add SSH console type support
|
||||
* fix: include error details in LLM model config failure response
|
||||
* Fix telnet console silent-proxy hang on non-ConnectionError exit (#2344)
|
||||
|
||||
## 2.2.59 08/05/2026
|
||||
|
||||
* Sync appliances
|
||||
* Fix snapshot restore does not work after export/import of project
|
||||
* Warn to use 64-bit IOU images. Fixes #2716 32-bit IOU image support has been removed from the GNS3 VM.
|
||||
* Fix telnet console silent-proxy hang on non-ConnectionError exit (#2344)
|
||||
|
||||
## 3.1.0a1 25/04/2026
|
||||
|
||||
* New completely revamped Web interface.
|
||||
* Add GNS3 Copilot AI agent back-end
|
||||
* Web-wireshark: packet capture and live traffic analysis in the browser
|
||||
* Fix using SSL with remote computes. Fixes https://github.com/GNS3/gns3-gui/issues/3733
|
||||
* Add --url and --branch parameters to update-bundled-web-ui.sh
|
||||
* Fix bug when custom_adapters is None in port_factory.py
|
||||
* Have optional body in start node API endpoint
|
||||
* Fix snapshots after merging
|
||||
* Potential fix for pull request finding 'CodeQL / Information exposure through an exception'
|
||||
* Refactored the way snapshots are handled in the server
|
||||
* fix(symbols): add authentication to symbol raw endpoint
|
||||
* fix(symbols): add CORS headers to symbol file responses
|
||||
* Allow to find new project in sub dirs
|
||||
* Enhancement: Refresh projects list without restarting the GNS3 server
|
||||
* Fix accessing username in authentication.py
|
||||
* Improved exception handling and telnet client disconnect
|
||||
* Use get method for registry_version to avoid KeyError
|
||||
* Add support for appliance format version 8
|
||||
* Convert database template fields from PickleType to JSON
|
||||
* feat(statistics): distinguish open vs closed project nodes
|
||||
* fix(statistics): handle closed projects where nodes/links are dicts
|
||||
* feat(controller): extend /statistics API with project, node and link stats
|
||||
* Use FastAPI Lifespan Events
|
||||
* feat(link): add control_offset field to LinkStyle
|
||||
* feat(symbols): add DELETE API for removing custom symbol files
|
||||
* Enhancement: Re-write current Telnet server implementation using telnetlib3 library
|
||||
* feat: improve error handling for API configuration errors
|
||||
* Enhancement Added link type support on the server side
|
||||
* Ignore HTTPException received when closing a project
|
||||
* Prevent router to be duplicated when running and add tests
|
||||
* Fixed issue #1605 regarding Cisco slots causing configs to break
|
||||
* feat: add VNC WebSocket console support for Docker and QEMU nodes
|
||||
* Return 405 error for unsupported actions for other node types
|
||||
* feat(telnet_server): improve error handling and connection management
|
||||
* fix(telnet): handle connection race condition during broadcast
|
||||
* Change first() to one_or_none() for image query
|
||||
* Fix for duplicating Qemu nodes
|
||||
* Minor optimizations to logic
|
||||
* Fixed an issue with the server causing errors when uploading large images + Preparation for a smarter check when uploading images
|
||||
* Implemented a smarter node_type system
|
||||
* docs: restructure copilot docs and update node tools
|
||||
* fix(acl): correct endpoint paths for users, groups, and roles
|
||||
* fix(iou): return 405 error for unsupported suspend operation
|
||||
* feat(api): enhance max_tokens field with robust null handling
|
||||
* feat(docs): add AI prompting guide for configuration templates
|
||||
* Deactivate 'use default IOU values' by default and update RAM/NVRAM values
|
||||
* Make sure the node shows as stopped when the wrap console cannot be stopped. Ref https://github.com/GNS3/gns3-registry/pull/1010
|
||||
* chore: update author name and copyright headers
|
||||
* feat(docs): enhance AI chat API documentation with examples and details
|
||||
* feat: add .claude directory to gitignore for API key security
|
||||
* Revert "Fix QEMU serial console artifacts by filtering ANSI CPR responses in …"
|
||||
* docs: add troubleshooting guide for datetime timezone issue
|
||||
* Drop support for Python 3.9
|
||||
* feat: add multi-user concurrency control design document
|
||||
* docs: add troubleshooting guide for force kill residual processes
|
||||
* Fixed the path traversal check
|
||||
* Fix image handling
|
||||
* Qemu VMs: only compute MD5 checksums for existing disks
|
||||
* Copy config files and database from a previous version if it exists
|
||||
* Add enable_http_auth in gns3_server.conf sample
|
||||
* feat(compute): option to disable compute authentication
|
||||
* tests(controller,port): short_name finishes successfully when port name is None
|
||||
* fix(controller,port): handle None port name to prevent TypeError in short_name method
|
||||
* fix(docker): handle container name conflict automatically
|
||||
* feat(virtualbox): add fallback for VBoxManage executable detection
|
||||
* feat: simplify port name assignment logic
|
||||
* feat(ports): add null checks for port name in short name generation
|
||||
* Remove unneeded rbac_repo in get_templates
|
||||
* Use lists for tags instead of dicts
|
||||
* Fix for resizing Qemu VM disks
|
||||
* feat(templates): safely access settings dict keys
|
||||
* feat(api): fix ETag handling and JSON serialization in template endpoint
|
||||
* feat(api): add tag filtering to nodes and templates endpoints
|
||||
* Update project schema and fix tests
|
||||
* Add 'created_by' field to track project creator
|
||||
* Fix updating packet filters
|
||||
* Add vendor, model and netmiko_device_type fields to templates
|
||||
* fix: handle RuntimeError in notification_manager emit()
|
||||
* fix: busybox static link detection on Alpine/musl
|
||||
* Fix bug when updating compute password to be empty
|
||||
* Cancel tasks if controller cannot be started
|
||||
* Enable parallel compression with zstandard. Fixes #2324
|
||||
* Add Dynamips to Dockerfile dependencies
|
||||
* Take populated disks into consideration when calculating PCI device ID
|
||||
* Fix telnet keepalive options on macOS
|
||||
|
||||
## 2.2.58.1 12/04/2026
|
||||
|
||||
* Sync appliances
|
||||
|
||||
## 2.2.58 10/04/2026
|
||||
|
||||
* Bundle web-ui v2.2.58
|
||||
* Sync appliances
|
||||
* Snapshot refactoring
|
||||
|
||||
## 2.2.57 23/03/2026
|
||||
|
||||
* feat(telnet_server): improve error handling and connection management
|
||||
* Deactivate 'use default IOU values' by default and update RAM/NVRAM values
|
||||
* Make sure the node shows as stopped when the wrap console cannot be stopped
|
||||
* tests(controller,port): short_name finishes successfully when port name is None
|
||||
* fix(controller,port): handle None port name to prevent TypeError in short_name method
|
||||
* fix: busybox static link detection on Alpine/musl
|
||||
* Take populated disks into consideration when calculating PCI device ID
|
||||
|
||||
## 2.2.56.1 28/01/2026
|
||||
|
||||
* Fix telnet keepalive options on macOS
|
||||
* Upgrade dependencies
|
||||
* Drop Python 3.8 support
|
||||
|
||||
## 3.0.6 28/01/2026
|
||||
|
||||
* Sync appliances
|
||||
|
||||
+1
-1
@@ -34,4 +34,4 @@ COPY . /gns3server
|
||||
RUN mkdir -p ~/.config/GNS3/3.0/
|
||||
RUN cp scripts/gns3_server.conf ~/.config/GNS3/3.0/
|
||||
|
||||
RUN python3 -m pip install --break-system-packages .
|
||||
RUN python3 -m pip install --break-system-packages --ignore-installed .
|
||||
@@ -0,0 +1,370 @@
|
||||
# GNS3 Server - Project Context for Claude Code
|
||||
|
||||
> This document provides project context information for the Claude Code AI assistant to work more efficiently.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Project Name**: GNS3 Server
|
||||
**Description**: Network simulation server supporting the GNS3 network virtualization platform
|
||||
**Primary Language**: Python
|
||||
**Current Branch**: `feature/ai-copilot-bridge`
|
||||
**Main Branch**: `master`
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gns3server/
|
||||
├── agent/ # AI Copilot related modules
|
||||
│ └── gns3_copilot/ # GNS3-Copilot AI Assistant
|
||||
│ ├── agent/ # LangGraph Agent implementation
|
||||
│ ├── tools_v2/ # Tool collection (device config, display commands, etc.)
|
||||
│ ├── utils/ # Utility functions and helper modules
|
||||
│ ├── gns3_client/ # GNS3 API client
|
||||
│ ├── prompts/ # AI prompt templates
|
||||
│ ├── agent_service.py # Agent service layer
|
||||
│ └── project_agent_manager.py # Project Agent manager
|
||||
│
|
||||
├── api/ # API routes
|
||||
│ └── routes/controller/ # Controller API
|
||||
│ └── chat.py # AI Chat API endpoint
|
||||
│
|
||||
├── controller/ # Core controller
|
||||
│ ├── project.py # Project management
|
||||
│ └── ...
|
||||
│
|
||||
├── schemas/ # Pydantic data models
|
||||
│ └── controller/
|
||||
│ └── chat.py # Chat API Schema
|
||||
│
|
||||
├── db/ # Database module
|
||||
│ └── tasks.py # Database tasks
|
||||
│
|
||||
└── docs/ # Documentation
|
||||
└── gns3-copilot/ # AI Copilot documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Module Descriptions
|
||||
|
||||
### 1. AI Copilot Data Flow
|
||||
|
||||
```
|
||||
Tool Layer → Agent Layer → Service Layer → API Layer → Frontend
|
||||
```
|
||||
|
||||
**Tool Output Format**: All tools return `dict` or `list[dict]`, automatically serialized to JSON in the Service layer
|
||||
|
||||
**Key Files**:
|
||||
- `agent/gns3_copilot/tools_v2/*.py` - Tool implementations
|
||||
- `agent/gns3_copilot/agent_service.py:460-473` - Event conversion logic
|
||||
- `agent/gns3_copilot/utils/parse_tool_content.py` - Tool result parsing
|
||||
|
||||
### 2. SSE Event Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `content` | AI text streaming output |
|
||||
| `tool_call` | LLM tool invocation (progressive parameters) |
|
||||
| `tool_start` | Tool execution started |
|
||||
| `tool_end` | Tool execution completed (output as JSON) |
|
||||
| `error` | Error message |
|
||||
| `done` | Stream ended |
|
||||
|
||||
### 3. Important Tools
|
||||
|
||||
| Tool Name | File | Function |
|
||||
|-----------|------|----------|
|
||||
| `ExecuteMultipleDeviceCommands` | `display_tools_nornir.py` | Read-only diagnostic commands |
|
||||
| `ExecuteMultipleDeviceConfigCommands` | `config_tools_nornir.py` | Configuration commands |
|
||||
| `VPCSMultiCommands` | `vpcs_tools_telnetlib3.py` | VPCS virtual PC commands |
|
||||
| `GNS3TemplateTool` | `gns3_get_node_temp.py` | Get GNS3 templates |
|
||||
| `GNS3CreateNodeTool` | `gns3_create_node.py` | Create nodes |
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Static Code Analysis (Flake8)
|
||||
|
||||
The project uses **flake8** for static code analysis. You must run checks before committing code.
|
||||
|
||||
#### Running Flake8
|
||||
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Check a single file
|
||||
flake8 path/to/file.py
|
||||
|
||||
# Check the entire project
|
||||
flake8 gns3server/
|
||||
|
||||
# Check a specific directory
|
||||
flake8 gns3server/utils/
|
||||
```
|
||||
|
||||
#### Common Flake8 Error Codes
|
||||
|
||||
| Code | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| **F401** | Module imported but unused | `import os` never used |
|
||||
| **F841** | Local variable assigned but never used | `x = 1` not used later |
|
||||
| **F824** | `global` declaration never assigned | `global _bar` never assigned in scope |
|
||||
| **E501** | Line too long (>79 characters) | Single line exceeds 79 characters |
|
||||
|
||||
#### Fix Examples
|
||||
|
||||
```python
|
||||
# F401: Remove unused imports
|
||||
# Bad
|
||||
import os # Never used
|
||||
# Good: Delete this line
|
||||
|
||||
# F841: Mark as intentionally unused
|
||||
# Bad
|
||||
def foo():
|
||||
x = 1 # Never used
|
||||
|
||||
# Good: Use or mark as intentionally unused
|
||||
def foo():
|
||||
x = 1
|
||||
_ = x # Mark as intentionally unused
|
||||
|
||||
# F824: Remove unnecessary global declaration
|
||||
# Bad
|
||||
def foo():
|
||||
global _fernet # Never assigned in this scope
|
||||
|
||||
# Good: Remove global statement (if only reading)
|
||||
def foo():
|
||||
# Just read the global variable, no global declaration needed
|
||||
pass
|
||||
|
||||
# E501: Break long lines
|
||||
# Bad
|
||||
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
|
||||
|
||||
# Good
|
||||
raise RuntimeError(
|
||||
"Encryption not initialized. Call init_encryption() first."
|
||||
)
|
||||
```
|
||||
|
||||
#### Ruff (Alternative)
|
||||
|
||||
[Ruff](https://docs.astral.sh/ruff/) is a faster Python linter that can replace flake8, isort, black, and more.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install ruff
|
||||
|
||||
# Usage
|
||||
ruff check gns3server/ # Check code
|
||||
ruff check --fix gns3server/ # Auto-fix
|
||||
ruff format gns3server/ # Format code
|
||||
```
|
||||
|
||||
### Python Code Style
|
||||
|
||||
```python
|
||||
# 1. Type annotations
|
||||
def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
pass
|
||||
|
||||
# 2. Error handling
|
||||
try:
|
||||
result = tool.invoke(args)
|
||||
except Exception as e:
|
||||
logger.error("Tool failed: %s", e, exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
# 3. JSON serialization
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False, indent=2)
|
||||
```
|
||||
|
||||
### Import Order
|
||||
|
||||
```python
|
||||
# 1. Standard library
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
# 2. Third-party libraries
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
# 3. Local modules
|
||||
from gns3server.agent.gns3_copilot.agent import agent_builder
|
||||
from gns3server.controller import Controller
|
||||
```
|
||||
|
||||
### Copyright Header
|
||||
|
||||
```python
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Commit Standards
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Type Categories
|
||||
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `feat` | New feature | `feat(copilot): add user authentication` |
|
||||
| `fix` | Bug fix | `fix(api): correct JSON serialization` |
|
||||
| `docs` | Documentation update | `docs: update API design doc` |
|
||||
| `refactor` | Refactoring | `refactor(agent): simplify state management` |
|
||||
| `chore` | Build/toolchain | `chore: update dependencies` |
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
fix(copilot): serialize tool output to standard JSON format
|
||||
|
||||
Changed tool output serialization in AgentService._convert_event_to_chunk()
|
||||
from str() to json.dumps() to ensure structured data is properly formatted.
|
||||
|
||||
Changes:
|
||||
- Added json import to agent_service.py
|
||||
- Modified on_tool_end event handling
|
||||
|
||||
Benefits:
|
||||
- Frontend can parse tool results with JSON.parse()
|
||||
- Chinese characters are preserved (not escaped)
|
||||
|
||||
Co-Authored-By: YueGuobin <yueguobin@outlook.com>
|
||||
```
|
||||
|
||||
### IMPORTANT: Use Your Own Git Account for Commits
|
||||
|
||||
When creating git commits, **ALWAYS** use your own git account information for authorship.
|
||||
|
||||
**Before committing**, verify your git configuration:
|
||||
|
||||
```bash
|
||||
# Check current git user configuration
|
||||
git config user.name
|
||||
git config user.email
|
||||
|
||||
# If incorrect, set your own information
|
||||
git config user.name "Your Name"
|
||||
git config user.email "your.email@example.com"
|
||||
```
|
||||
|
||||
**For this project**, the current configured user is:
|
||||
```
|
||||
Name: YueGuobin
|
||||
Email: yueguobin@outlook.com
|
||||
```
|
||||
|
||||
You may use `Co-Authored-By` to credit contributors, but the `Author` field should always be **your own account**:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add new feature
|
||||
|
||||
Co-Authored-By: ContributorName <contributor@example.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
### 1. When Modifying Tool Output Format
|
||||
|
||||
**Must update the following locations**:
|
||||
- `agent/gns3_copilot/tools_v2/*.py` - Tool implementation
|
||||
- `agent/gns3_copilot/agent_service.py` - Event conversion
|
||||
- `docs/gns3-copilot/ai-chat-api-design.md` - API documentation
|
||||
|
||||
### 2. Data Serialization Principles
|
||||
|
||||
- **Tool Layer**: Return Python `dict`/`list`
|
||||
- **Service Layer**: Use `json.dumps()` for serialization
|
||||
- **API Layer**: Use Pydantic validation then serialize to SSE
|
||||
|
||||
### 3. Logging
|
||||
|
||||
```python
|
||||
logger.info("Operation started: project_id=%s", project_id)
|
||||
logger.debug("Detailed info: data=%s", data)
|
||||
logger.error("Error occurred: %s", error, exc_info=True)
|
||||
```
|
||||
|
||||
### 4. Security Considerations
|
||||
|
||||
- Prohibit dangerous commands (reload, erase startup-config, etc.)
|
||||
- Use `filter_forbidden_commands()` to filter commands
|
||||
- Validate user input (project_id format, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pytest tests/
|
||||
|
||||
# Static code analysis (must run before committing)
|
||||
source venv/bin/activate
|
||||
flake8 gns3server/
|
||||
|
||||
# Check a single file
|
||||
flake8 path/to/file.py
|
||||
|
||||
# Syntax check
|
||||
python3 -m py_compile gns3server/agent/gns3_copilot/agent_service.py
|
||||
|
||||
# Check git status
|
||||
git status
|
||||
|
||||
# View git history
|
||||
git log --oneline -15
|
||||
|
||||
# View file modification history
|
||||
git blame file.py -L 100,120
|
||||
|
||||
# Commit code (verify your git user info first!)
|
||||
git config user.name && git config user.email
|
||||
git add files...
|
||||
git commit -m "type(scope): description"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [AI Chat API Design](./docs/gns3-copilot/ai-chat-api-design.md)
|
||||
- [Command Security Policy](./docs/gns3-copilot/command-security.md)
|
||||
- [LLM Model Configuration](./docs/gns3-copilot/llm-model-configs-api.md)
|
||||
- [Context Window Management](./docs/gns3-copilot/context-window-management.md)
|
||||
|
||||
---
|
||||
|
||||
**Document Maintenance**: Please update this document promptly when project structure or development standards change.
|
||||
@@ -54,6 +54,51 @@ python3 -m pip install gns3-gui
|
||||
python3 -m pip install gns3-server
|
||||
```
|
||||
|
||||
#### Optional Features
|
||||
|
||||
GNS3 server supports optional features that can be installed as needed:
|
||||
|
||||
**AI Copilot** (Optional):
|
||||
```shell
|
||||
python3 -m pip install gns3-server[ai-copilot]
|
||||
```
|
||||
AI-powered assistant for network topology design and automation.
|
||||
|
||||
**Development** (For contributors):
|
||||
```shell
|
||||
python3 -m pip install gns3-server[dev]
|
||||
```
|
||||
|
||||
**Web Wireshark** (Optional):
|
||||
```shell
|
||||
pip install gns3-server && gns3server-web-wireshark-setup
|
||||
```
|
||||
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]
|
||||
```
|
||||
|
||||
**Why optional?**
|
||||
- Reduces installation size for users who don't need specific features
|
||||
- Supports restricted environments (government, education, corporate) where certain libraries may not be allowed
|
||||
- Faster installation for basic GNS3 usage
|
||||
- Allows users to choose only the features they need
|
||||
|
||||
**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:**
|
||||
|
||||
To remove AI Copilot dependencies:
|
||||
|
||||
```shell
|
||||
gns3server-uninstall-ai-copilot
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
Please see our [documentation](https://docs.gns3.com/docs/getting-started/installation/linux) for more details.
|
||||
@@ -77,7 +122,7 @@ Note that Docker needs the script program (`bsdutils` or `util-linux` package),
|
||||
|
||||
### Setting up
|
||||
|
||||
These commands will install the server as well as all Python dependencies:
|
||||
These commands will install the server with core Python dependencies:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/GNS3/gns3-server
|
||||
@@ -89,6 +134,18 @@ python3 -m pip install .
|
||||
python3 -m gns3server
|
||||
```
|
||||
|
||||
**For AI Copilot development**, install with additional dependencies:
|
||||
|
||||
```shell
|
||||
python3 -m pip install .[ai-copilot,dev]
|
||||
```
|
||||
|
||||
**For development (tests and linting)**:
|
||||
|
||||
```shell
|
||||
python3 -m pip install .[dev]
|
||||
```
|
||||
|
||||
You will have to manually install other software dependencies (see above), for Dynamips, VPCS and uBridge the easiest is to install from our PPA.
|
||||
|
||||
### Docker container
|
||||
@@ -137,4 +194,4 @@ Bug fixes or small improvements pull requests go here.
|
||||
|
||||
3.x development brand for the next major release.
|
||||
|
||||
**Never** use this branch for production. Pull requests for major new features go here.
|
||||
**Never** use this branch for production. Pull requests for major new features go here.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ==============================================================================
|
||||
# GNS3 Copilot AI Agent Dependencies
|
||||
# ==============================================================================
|
||||
# Install with: pip install gns3-server[ai-copilot]
|
||||
# Or directly: pip install -r ai-requirements.txt
|
||||
# ==============================================================================
|
||||
|
||||
# Core AI and Automation Framework
|
||||
# Note: aiosqlite is in core requirements.txt
|
||||
langchain>=1.2.10
|
||||
langchain-core>=1.2.16
|
||||
langgraph>=1.0.9
|
||||
langgraph-checkpoint>=4.0.0
|
||||
langgraph-checkpoint-sqlite>=3.0.3
|
||||
langgraph-checkpoint-postgres>=3.0.4
|
||||
|
||||
# Model Providers
|
||||
langchain-openai>=1.1.10
|
||||
langchain-anthropic>=1.3.4
|
||||
langchain-google-genai>=4.2.1
|
||||
langchain-aws>=1.3.1
|
||||
langchain-ollama>=1.0.1
|
||||
langchain-deepseek>=1.0.1
|
||||
langchain-xai>=1.2.2
|
||||
|
||||
# Token Counting
|
||||
tiktoken>=0.8.0
|
||||
|
||||
# LangSmith SDK
|
||||
langsmith>=0.7.7
|
||||
|
||||
# Network Automation
|
||||
netmiko>=4.6.0
|
||||
nornir>=3.5.0
|
||||
nornir-netmiko>=1.0.1
|
||||
nornir-utils>=0.2.0
|
||||
nornir-salt>=0.23.0
|
||||
|
||||
# Environment & Configuration
|
||||
python-dotenv>=1.2.1
|
||||
|
||||
# Authentication
|
||||
PyJWT>=2.10.1
|
||||
psycopg-pool>=3.1.0
|
||||
|
||||
# Skills Repository Management
|
||||
PyYAML>=6.0.0
|
||||
GitPython>=3.1.0
|
||||
|
||||
# Note: httpx is only for testing (in dev-requirements.txt)
|
||||
@@ -1,7 +1,7 @@
|
||||
pytest==8.4.2 # version 8.4.2 is the last one supporting Python 3.9
|
||||
pytest==9.0.3 # fix CVE-2025-71176; Python 3.10+ required
|
||||
flake8==7.3.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest-asyncio==0.26.0 # upgrading leads to stuck / failed tests
|
||||
requests==2.32.5
|
||||
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'
|
||||
httpx==0.28.1
|
||||
httpx_ws==0.7.1 # upgrading leads to failures in tests
|
||||
httpx_ws==0.7.2 # upgrading leads to failures in tests
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
Copyright (c) 2026 GNS3 Web UI Project
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
Copyright (C) 2025 Creative Commons Corporation
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||
License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Section 1 – Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
publication, distribution, public performance, public display, and
|
||||
adaptation rights, and rights in database extensions and
|
||||
collections.
|
||||
|
||||
d. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
e. Licensed Rights means rights granted to You subject to the terms
|
||||
and conditions of this Public License, which are limited to all
|
||||
Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
f. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
g. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
h. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and
|
||||
of the Council of 11 March 1996 on the legal protection of
|
||||
databases, as amended and/or succeeded, as well as other
|
||||
essentially equivalent rights anywhere in the world.
|
||||
|
||||
i. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
Section 2 – Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt,
|
||||
where Exceptions and Limitations apply to Your use, this
|
||||
Public License does not apply, and You do not need to
|
||||
comply with its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in
|
||||
Section 6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor – Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor – Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You
|
||||
apply.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights. Moral rights, such as the right of integrity, are
|
||||
not licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to the
|
||||
extent possible, the Licensor waives and/or agrees not to assert
|
||||
any such rights held by the Licensor to the limited extent
|
||||
necessary to allow You to exercise the Licensed Rights, but not
|
||||
otherwise.
|
||||
|
||||
c. Patent and trademark rights are not licensed under this Public
|
||||
License.
|
||||
|
||||
d. To the extent possible, the Licensor waives any right to collect
|
||||
royalties from You for the exercise of the Licensed Rights,
|
||||
whether directly or through a collecting society under any
|
||||
voluntary or waivable statutory or compulsory licensing scheme.
|
||||
In all other cases the Licensor expressly reserves any right to
|
||||
collect such royalties.
|
||||
|
||||
Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to
|
||||
the following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the
|
||||
Licensor with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the
|
||||
Licensed Material and any others designated to
|
||||
receive attribution, in any reasonable manner
|
||||
requested by the Licensor (including by
|
||||
pseudonym if designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to
|
||||
the extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI
|
||||
or hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may
|
||||
be reasonable to satisfy the conditions by providing a URI
|
||||
or hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the
|
||||
Adapted Material from complying with this Public License.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to,
|
||||
the Adapter's License You apply. You may satisfy this
|
||||
condition in any reasonable manner based on the medium,
|
||||
means, and context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. Unless otherwise separately undertaken by the Licensor, to the
|
||||
extent possible, the Licensor offers the Licensed Material as-is
|
||||
and as-available, and makes no representations or warranties of
|
||||
any kind concerning the Licensed Material, whether express,
|
||||
implied, statutory, or other. This includes, without limitation,
|
||||
warranties of title, merchantability, fitness for a particular
|
||||
purpose, non-infringement, absence of latent or other defects,
|
||||
accuracy, or the presence or absence of errors, whether or not
|
||||
known or discoverable. Where disclaimers of warranties are not
|
||||
allowed in full or in part, this disclaimer may not apply to You.
|
||||
|
||||
b. To the extent possible, in no event will the Licensor be liable
|
||||
to You on any legal theory (including, without limitation,
|
||||
negligence) or otherwise for any direct, special, indirect,
|
||||
incidental, consequential, punitive, exemplary, or other losses,
|
||||
costs, expenses, or damages that arise out of this Public License
|
||||
or use of the Licensed Material, even if the Licensor has been
|
||||
advised of the possibility of such losses, costs, expenses, or
|
||||
damages. Where a limitation of liability is not allowed in full
|
||||
or in part, this limitation may not apply to You.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
Section 6 – Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply
|
||||
with this Public License, then Your rights under this Public
|
||||
License terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated
|
||||
under Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured,
|
||||
provided it is cured within 30 days of Your discovery of
|
||||
the violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
Section 7 – Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
Section 8 – Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License
|
||||
is deemed unenforceable, it shall be automatically reformed to
|
||||
the minimum extent necessary to make it enforceable. If the
|
||||
provision cannot be reformed, it shall be severed from this
|
||||
Public License without affecting the enforceability of the
|
||||
remaining terms and conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and
|
||||
no failure to comply consented to unless expressly agreed to by
|
||||
the Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding,
|
||||
Creative Commons may elect to apply one of its public licenses to
|
||||
material it publishes and in those instances will be considered the
|
||||
"Licensor." The text of the Creative Commons public licenses is
|
||||
dedicated to the public domain under the CC0 Public Domain Dedication.
|
||||
The text of the Creative Commons public licenses is dedicated to the
|
||||
public domain under the CC0 Public Domain Dedication. Except for the
|
||||
limited purpose of indicating that material is shared under a Creative
|
||||
Commons public license or as otherwise permitted by the Creative Commons
|
||||
policies published at creativecommons.org/policies, Creative Commons
|
||||
does not authorize the use of the trademark "Creative Commons" or any
|
||||
other trademark or logo of Creative Commons without its prior written
|
||||
consent including, without limitation, in connection with any
|
||||
unauthorized modifications to any of its public licenses or any other
|
||||
arrangements, understandings, or agreements concerning use of licensed
|
||||
material. For the avoidance of doubt, this paragraph does not form part
|
||||
of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
|
||||
=======================================================================
|
||||
|
||||
For the GNS3 Web UI documentation:
|
||||
|
||||
- Documentation License: CC BY-SA 4.0
|
||||
- Project License: GPLv3 (see root LICENSE file)
|
||||
- License URL: https://creativecommons.org/licenses/by-sa/4.0/
|
||||
|
||||
All documentation files in this directory are licensed under the
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License
|
||||
(CC BY-SA 4.0).
|
||||
|
||||
=======================================================================
|
||||
|
||||
IMPORTANT: ShareAlike Requirements for Software Derivatives
|
||||
|
||||
Under the CC BY-SA 4.0 ShareAlike condition, if you create derivative
|
||||
works based on this documentation, including software that is considered
|
||||
a derivative of the documentation, you must:
|
||||
|
||||
1. **License your derivative work under CC BY-SA 4.0 or a compatible
|
||||
license** (such as GPLv3, which is compatible with CC BY-SA 4.0)
|
||||
|
||||
2. **Attribute the original documentation** to the GNS3 Web UI Project
|
||||
|
||||
3. **Indicate if you modified the documentation** and retain
|
||||
indications of previous modifications
|
||||
|
||||
4. **Include the license notice** and link to the CC BY-SA 4.0 license
|
||||
|
||||
What constitutes a derivative work:
|
||||
- Translations of the documentation
|
||||
- Adaptations, modifications, or enhancements to the documentation
|
||||
- Software that incorporates substantial portions of the documentation
|
||||
- Works based on the documentation's structure, organization, or content
|
||||
|
||||
Compatible licenses include:
|
||||
- CC BY-SA 4.0 or later
|
||||
- GPLv3 (GNU General Public License version 3)
|
||||
- AGPLv3 (GNU Affero General Public License version 3)
|
||||
- Other licenses approved as compatible by Creative Commons
|
||||
|
||||
For more information on CC BY-SA compatibility, see:
|
||||
https://creativecommons.org/compatiblelicenses
|
||||
|
||||
If you are unsure whether your use constitutes a derivative work or
|
||||
which license to apply, please consult the full CC BY-SA 4.0 license
|
||||
text above or seek legal advice.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
# GNS3 Server Documentation
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This documentation is licensed under the **Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA 4.0)**.
|
||||
|
||||
⚠️ **Important - ShareAlike Requirement**: If you create derivative works based on this documentation (including software that incorporates substantial portions of the documentation), your work must also be licensed under **CC BY-SA 4.0 or a compatible license** (such as GPLv3).
|
||||
|
||||
- 📄 **Full License Text**: See [docs/LICENSE](./LICENSE)
|
||||
- 🔗 **License URL**: https://creativecommons.org/licenses/by-sa/4.0/
|
||||
- 📖 **Compatibility**: https://creativecommons.org/compatiblelicenses
|
||||
|
||||
**Dual License Structure**:
|
||||
- 📚 **Documentation**: CC BY-SA 4.0 (this directory)
|
||||
- 💻 **Software Code**: GPLv3 (see root [LICENSE](../LICENSE))
|
||||
|
||||
---
|
||||
|
||||
Technical documentation for the GNS3 server project, covering features, AI Copilot, development setup, and known issues.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md # This file
|
||||
├── development-setup.md # Ubuntu 24.04 development environment setup
|
||||
├── openapi.json # OpenAPI specification
|
||||
├── features/ # Feature documentation
|
||||
│ ├── compute-controller-setup.md # Controller + Compute architecture & configuration
|
||||
│ ├── statistics-api.md # Aggregated statistics API for monitoring
|
||||
│ ├── vnc-websocket-console.md # Browser-based VNC console via WebSocket
|
||||
│ └── web-wireshark-business-process.md # Web Wireshark (Docker + xpra packet capture)
|
||||
├── gns3-copilot/ # AI Copilot feature documentation
|
||||
│ ├── netmiko_devices.md # Netmiko supported devices (366 types)
|
||||
│ ├── template-based-configuration-roadmap.md # Future: template-based config with HITL
|
||||
│ └── implemented/ # Implemented features
|
||||
│ ├── chat-api.md # Chat API (SSE, session management)
|
||||
│ ├── llm-model-configs.md # LLM model configuration system
|
||||
│ ├── command-security.md # Command security and filtering
|
||||
│ ├── context-window-management.md # Context window optimization
|
||||
│ ├── node-control-tools.md # Node start/stop/suspend tools
|
||||
│ └── multi-vendor-device-support.md # Multi-vendor device support
|
||||
└── bugs/ # Known issues & bug reports
|
||||
└── telnet-server-connection-race-condition.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Controller + Compute Setup (`features/compute-controller-setup.md`)
|
||||
Architecture and minimum configuration for setting up GNS3 Controller with remote Compute nodes. Covers compute node config, controller registration, and multi-compute deployment.
|
||||
|
||||
### Statistics API (`features/statistics-api.md`)
|
||||
Aggregated server statistics API (`GET /v3/statistics`) for monitoring dashboards. Collects compute resources, project/node/link counts, and Web Wireshark container status in a single request.
|
||||
|
||||
### VNC WebSocket Console (`features/vnc-websocket-console.md`)
|
||||
Browser-based VNC console access via WebSocket. The Controller acts as WebSocket-to-WebSocket relay, and Compute bridges WebSocket to TCP for QEMU/Docker VMs. Supports noVNC clients.
|
||||
|
||||
### API Error Responses (`features/api-error-responses.md`)
|
||||
Unified error response format across all GNS3 API endpoints. Documents HTTP status codes, error types, and client-side error handling patterns.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## GNS3 AI Copilot (`gns3-copilot/`)
|
||||
|
||||
### Implemented Features
|
||||
|
||||
| Feature | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| [Chat API](gns3-copilot/implemented/chat-api.md) | SSE streaming, session management, token statistics | Implemented |
|
||||
| [LLM Model Configs](gns3-copilot/implemented/llm-model-configs.md) | Multi-level model config (system/group/user) | Implemented |
|
||||
| [Command Security](gns3-copilot/implemented/command-security.md) | Command filtering, dangerous operation detection, HITL | Implemented |
|
||||
| [Context Window Management](gns3-copilot/implemented/context-window-management.md) | Token optimization, content filtering, compression | Implemented |
|
||||
| [Node Control Tools](gns3-copilot/implemented/node-control-tools.md) | Start/stop/suspend with batch ops and progress tracking | Implemented |
|
||||
| [Multi-Vendor Support](gns3-copilot/implemented/multi-vendor-device-support.md) | Cisco, Huawei, Ruijie, VPCS with custom Netmiko drivers | Implemented |
|
||||
|
||||
### Reference
|
||||
|
||||
- [Netmiko Supported Devices](gns3-copilot/netmiko_devices.md) — 366 device types (154 SSH, 55 Telnet, 3 custom GNS3 drivers)
|
||||
|
||||
### Roadmap
|
||||
|
||||
- [Template-Based Configuration with HITL](gns3-copilot/template-based-configuration-roadmap.md) — Jinja2 templates with human-in-the-loop confirmations for device configuration and node creation
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
## Development Setup (`development-setup.md`)
|
||||
|
||||
Quick-start guide for Ubuntu 24.04: install via PPA, set up dependencies, and run gns3-server from source.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [GNS3 Server API Documentation](https://api.gns3.com/) — Interactive API docs (also available locally via `redoc.html`)
|
||||
- [GNS3 Web UI Documentation](https://docs.gns3.com/)
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-04-20_
|
||||
@@ -0,0 +1,316 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Telnet Server Connection Race Condition Bug
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Date**: 2026-03-14
|
||||
**Severity**: High
|
||||
**Status**: Open
|
||||
**Component**: Telnet Server (`gns3server/utils/asyncio/telnet_server.py`)
|
||||
|
||||
## Error Logs
|
||||
|
||||
```
|
||||
2026-03-14 15:21:31 ERROR asyncio:1879 Unhandled exception in client_connected_cb
|
||||
transport: <_SelectorSocketTransport fd=67 read=polling write=<idle, bufsize=0>>
|
||||
Traceback (most recent call last):
|
||||
File "/home/yueguobin/myCode/GNS3/gns3-server/gns3server/utils/asyncio/telnet_server.py", line 215, in run
|
||||
await self._process(network_reader, network_writer, connection)
|
||||
File "/home/yueguobin/myCode/GNS3/gns3-server/gns3server/utils/asyncio/telnet_server.py", line 305, in _process
|
||||
client_info = connection_key.get_extra_info("socket").getpeername()
|
||||
File "/usr/lib64/python3.13/asyncio/trsock.py", line 77, in getpeername
|
||||
return self._sock.getpeername()
|
||||
~~~~~~~~~~~~~~~~~~~~~~^^
|
||||
OSError: [Errno 107] Transport endpoint is not connected
|
||||
```
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Architecture
|
||||
|
||||
The GNS3 Telnet server architecture supports multiple concurrent client connections to a single node console:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Node (VPCS/Docker/Router/Switch, etc.) │
|
||||
│ - Independent process │
|
||||
│ - Normal operation, processing business logic │
|
||||
└────────────────────┬─────────────────────────────────────┘
|
||||
│ stdout (device output)
|
||||
│
|
||||
┌────────────▼─────────────────────────────────────┐
|
||||
│ AsyncioTelnetServer (Telnet Proxy Server) │
|
||||
│ - Reads output from Node │
|
||||
│ - Broadcasts to all connected clients │
|
||||
│ - ← ← Bug occurs at this layer ← ← │
|
||||
└────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┼────────────┬──────────────────┐
|
||||
│ │ │ │
|
||||
Web Console Auto Script Client 1 Client 2
|
||||
(long-lived) (quick disco) (normal) (normal)
|
||||
```
|
||||
|
||||
### The Race Condition
|
||||
|
||||
The bug occurs in the broadcast logic when a client disconnects while the server is iterating through connections:
|
||||
|
||||
**Timeline**:
|
||||
```
|
||||
t1: Clients A, B, C connect to the same Telnet port
|
||||
t2: Device has output (e.g., log message)
|
||||
t3: Client A receives data and script immediately disconnects (FIN sent)
|
||||
t4: Server hasn't read EOF yet (event loop hasn't checked this connection)
|
||||
t5: Server iterates through connections to broadcast data
|
||||
t6: When iterating to Client A, getpeername() is called → OSError!
|
||||
```
|
||||
|
||||
### Code Location
|
||||
|
||||
**File**: `gns3server/utils/asyncio/telnet_server.py`
|
||||
**Line**: 305
|
||||
|
||||
**Problematic Code**:
|
||||
```python
|
||||
# Line 304-305
|
||||
for connection_key in list(self._connections.keys()):
|
||||
client_info = connection_key.get_extra_info("socket").getpeername() # ← OSError here
|
||||
connection = self._connections[connection_key]
|
||||
|
||||
try:
|
||||
connection.writer.write(data)
|
||||
await asyncio.wait_for(connection.writer.drain(), timeout=10)
|
||||
except:
|
||||
log.debug(f"Timeout while sending data to client: {client_info}, closing and removing from connection table.")
|
||||
connection.close()
|
||||
del self._connections[connection_key]
|
||||
```
|
||||
|
||||
### The Core Issue
|
||||
|
||||
The `getpeername()` call is **outside** the try-except block, so any OSError from it is not caught.
|
||||
|
||||
Additionally, the top-level exception handler at line 216 only catches `ConnectionError`:
|
||||
|
||||
```python
|
||||
# Line 212-227
|
||||
try:
|
||||
await self._write_intro(network_writer, echo=self._echo, binary=self._binary, naws=self._naws)
|
||||
await connection.connected()
|
||||
await self._process(network_reader, network_writer, connection)
|
||||
except ConnectionError: # ← Only catches ConnectionError
|
||||
async with self._lock:
|
||||
network_writer.close()
|
||||
if self._reader_process == network_reader:
|
||||
self._reader_process = None
|
||||
if self._current_read is not None:
|
||||
self._current_read.cancel()
|
||||
|
||||
await connection.disconnected()
|
||||
del self._connections[network_writer]
|
||||
```
|
||||
|
||||
**Python Exception Hierarchy**:
|
||||
```
|
||||
BaseException
|
||||
└─ Exception
|
||||
├─ ConnectionError ← Only this is caught
|
||||
│ ├─ ConnectionResetError
|
||||
│ ├─ BrokenPipeError
|
||||
│ └─ ...
|
||||
└─ OSError ← Actually thrown! (not a subclass of ConnectionError)
|
||||
└─ [Errno 107] Transport endpoint is not connected
|
||||
```
|
||||
|
||||
Since `OSError` is **not** a subclass of `ConnectionError`, it propagates uncaught to the asyncio event loop.
|
||||
|
||||
## Impact Assessment
|
||||
|
||||
### Immediate Effects
|
||||
|
||||
| Impact | Severity | Description |
|
||||
|--------|----------|-------------|
|
||||
| Connection interrupted | 🔴 High | The connection triggering the exception is terminated |
|
||||
| Resource leak | 🟠 Medium | socket/connection not properly cleaned up |
|
||||
| Other clients affected | 🟡 Low | Other clients on same port may miss broadcast data |
|
||||
| Service stability | 🟡 Low | Long-running may accumulate zombie connections |
|
||||
|
||||
### User-Reported Symptoms
|
||||
|
||||
After this error occurs, users report:
|
||||
|
||||
1. **Cannot open the affected node** - Clicking on the node fails
|
||||
2. **Cannot close the node** - Close button doesn't work
|
||||
3. **"Node not found" errors** - Operations on the node return 404
|
||||
4. **Refresh fixes it temporarily** - Reloading the page restores functionality
|
||||
|
||||
### Why This Happens
|
||||
|
||||
When the uncaught `OSError` occurs, the cleanup code at lines 217-227 **never executes**:
|
||||
|
||||
```python
|
||||
except ConnectionError:
|
||||
async with self._lock:
|
||||
network_writer.close() # ✗ Not executed
|
||||
if self._reader_process == network_reader:
|
||||
self._reader_process = None # ✗ Not executed
|
||||
if self._current_read is not None:
|
||||
self._current_read.cancel() # ✗ Not executed
|
||||
await connection.disconnected() # ✗ Not executed
|
||||
del self._connections[network_writer] # ✗ Not executed
|
||||
```
|
||||
|
||||
This leads to:
|
||||
- **Resource leaks**: socket and writer not closed, file descriptors leaked
|
||||
- **State inconsistency**: `_connections` dictionary retains disconnected connections
|
||||
- **Potential deadlocks**: Locks may not be released if exception occurs while holding them
|
||||
- **Subsequent operation failures**: Future operations may access zombie connections
|
||||
|
||||
### Effect on Node Process
|
||||
|
||||
**The Node process itself is NOT affected**:
|
||||
- Node continues running normally
|
||||
- Node's stdout has already been read by the proxy
|
||||
- The bug occurs during the broadcast phase, after data has been read
|
||||
|
||||
The issue is in the **Telnet Proxy layer**, not the node itself.
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
This error is more likely to occur with:
|
||||
|
||||
| Scenario | Probability | Reason |
|
||||
|----------|-------------|--------|
|
||||
| **Automated scripts** | 🔴 High | Fast connect → execute → disconnect, small time window |
|
||||
| **Manual operation** | 🟡 Medium | Can occur (e.g., closing terminal, network fluctuation) |
|
||||
| **Normal usage** | 🟢 Low | Human operations slower, server usually detects EOF first |
|
||||
|
||||
### Typical Scenario
|
||||
|
||||
1. User opens Web Console (long-lived connection)
|
||||
2. Automated script connects → executes command → quickly disconnects
|
||||
3. While script disconnects, device has output that needs broadcasting
|
||||
4. During connection iteration, script connection already closed
|
||||
5. `getpeername()` call fails with OSError
|
||||
|
||||
## Related Issues
|
||||
|
||||
A secondary issue was found in the error handler:
|
||||
|
||||
**File**: `gns3server/api/server.py`
|
||||
**Line**: 162
|
||||
|
||||
```python
|
||||
@app.exception_handler(ControllerNotFoundError)
|
||||
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
|
||||
log.error(f"Controller not found error in {request.url.path} ({request.method}): {exc}")
|
||||
# ^^^^^^^^^^^^^^^
|
||||
return JSONResponse(...)
|
||||
```
|
||||
|
||||
**Problem**: `request.method` only exists in HTTP requests, not WebSocket connections.
|
||||
|
||||
When a WebSocket request triggers this exception handler:
|
||||
```
|
||||
ControllerNotFoundError: Node ID xxx doesn't exist
|
||||
↓
|
||||
Attempt to log error
|
||||
↓
|
||||
AttributeError: 'WebSocket' object has no attribute 'method'
|
||||
```
|
||||
|
||||
This masks the original error with an attribute error.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Primary Fix (Telnet Server)
|
||||
|
||||
Move `getpeername()` inside the try block and catch OSError:
|
||||
|
||||
```python
|
||||
# Lines 303-314
|
||||
for connection_key in list(self._connections.keys()):
|
||||
connection = self._connections[connection_key]
|
||||
client_info = None
|
||||
|
||||
try:
|
||||
client_info = connection_key.get_extra_info("socket").getpeername()
|
||||
connection.writer.write(data)
|
||||
await asyncio.wait_for(connection.writer.drain(), timeout=10)
|
||||
except (OSError, ConnectionError, asyncio.TimeoutError) as e:
|
||||
log.debug(f"Error sending data to client {client_info}: {e}, closing and removing from connection table.")
|
||||
connection.close()
|
||||
del self._connections[connection_key]
|
||||
```
|
||||
|
||||
### Secondary Fix (Top-level Exception Handler)
|
||||
|
||||
Catch OSError in the main handler to ensure cleanup:
|
||||
|
||||
```python
|
||||
# Lines 212-227
|
||||
try:
|
||||
await self._write_intro(network_writer, echo=self._echo, binary=self._binary, naws=self._naws)
|
||||
await connection.connected()
|
||||
await self._process(network_reader, network_writer, connection)
|
||||
except (ConnectionError, OSError): # ← Add OSError
|
||||
async with self._lock:
|
||||
network_writer.close()
|
||||
if self._reader_process == network_reader:
|
||||
self._reader_process = None
|
||||
if self._current_read is not None:
|
||||
self._current_read.cancel()
|
||||
|
||||
await connection.disconnected()
|
||||
del self._connections[network_writer]
|
||||
```
|
||||
|
||||
### Tertiary Fix (API Error Handler)
|
||||
|
||||
Fix the WebSocket error handler:
|
||||
|
||||
```python
|
||||
@app.exception_handler(ControllerNotFoundError)
|
||||
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
|
||||
method = getattr(request, 'method', 'WebSocket')
|
||||
log.error(f"Controller not found error in {request.url.path} ({method}): {exc}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={"message": str(exc)},
|
||||
)
|
||||
```
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
**To be documented** after testing.
|
||||
|
||||
Potential reproduction scenario:
|
||||
1. Start a GNS3 node with console enabled (e.g., VPCS)
|
||||
2. Open web console to keep a long-lived connection
|
||||
3. Run automated script that:
|
||||
- Connects to the same console port
|
||||
- Executes a command
|
||||
- Immediately disconnects
|
||||
4. While script is disconnecting, trigger device output
|
||||
5. Observe the error in logs
|
||||
|
||||
## References
|
||||
|
||||
- **Files**:
|
||||
- `gns3server/utils/asyncio/telnet_server.py:305` (primary issue)
|
||||
- `gns3server/utils/asyncio/telnet_server.py:216` (exception handler)
|
||||
- `gns3server/api/server.py:162` (secondary issue)
|
||||
|
||||
- **Related Commits**:
|
||||
- Recent telnet-related work on feature branch
|
||||
|
||||
- **Error Patterns**:
|
||||
- Race condition in connection management
|
||||
- Incomplete exception handling in asyncio code
|
||||
@@ -0,0 +1,146 @@
|
||||
# Ubuntu 24.04 GNS3 Development Environment Setup
|
||||
|
||||
## Simplified Development Environment Setup
|
||||
|
||||
### 1. Add PPA
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:gns3/ppa
|
||||
sudo apt update
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
Binary tools (Dynamips, VPCS, uBridge, etc.) are automatically installed as gns3-server dependencies
|
||||
|
||||
```bash
|
||||
sudo apt install gns3-server tshark
|
||||
```
|
||||
|
||||
### 3. Remove gns3-server While Keeping Dependencies
|
||||
|
||||
If you only want to run the server from source:
|
||||
|
||||
```bash
|
||||
# Remove main package only, keep dependencies
|
||||
sudo apt remove gns3-server
|
||||
|
||||
# Mark dependencies as manual to prevent autoremove deletion
|
||||
sudo apt-mark manual ubridge dynamips vpcs libvirt
|
||||
```
|
||||
|
||||
### 4. Install and Start Docker
|
||||
|
||||
```bash
|
||||
sudo apt install docker.io
|
||||
sudo systemctl enable --now docker
|
||||
```
|
||||
|
||||
#### Configure Docker Mirror Accelerator (China Mainland)
|
||||
|
||||
If Docker Hub is inaccessible, configure mirror accelerators:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/docker
|
||||
sudo tee /etc/docker/daemon.json <<'EOF'
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.1ms.run",
|
||||
"https://docker.xuanyuan.me"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker info | grep -i mirror
|
||||
```
|
||||
|
||||
### 5. Add User to Groups
|
||||
|
||||
```bash
|
||||
# If running as gns3 user (created by PPA install):
|
||||
sudo usermod -aG ubridge,docker,kvm gns3
|
||||
|
||||
# If running as your own user, add yourself to these groups:
|
||||
sudo usermod -aG ubridge,docker,kvm $USER
|
||||
|
||||
# Then logout and login again for group membership to take effect
|
||||
```
|
||||
|
||||
### 6. Install Python venv
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12-venv
|
||||
```
|
||||
|
||||
### 7. Run Development Version from Source
|
||||
|
||||
Clone the repository (official or your fork):
|
||||
|
||||
```bash
|
||||
# Official repository
|
||||
git clone https://github.com/GNS3/gns3-server.git
|
||||
|
||||
# Or your forked repository
|
||||
git clone https://github.com/yourname/gns3-server.git
|
||||
cd gns3-server
|
||||
git checkout your-branch
|
||||
```
|
||||
|
||||
Setup virtual environment and install dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# For China mainland users, use mirror:
|
||||
# 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 .[dev]
|
||||
```
|
||||
|
||||
Run the server:
|
||||
|
||||
```bash
|
||||
python3 -m gns3server
|
||||
```
|
||||
|
||||
## Optional: Install AI Copilot Development Dependencies
|
||||
|
||||
```bash
|
||||
python3 -m pip install .[ai-copilot,dev]
|
||||
```
|
||||
|
||||
## Optional: Expand LVM Root Partition
|
||||
|
||||
If the root partition is low on space but the volume group has unallocated space:
|
||||
|
||||
Check LVM status:
|
||||
|
||||
```bash
|
||||
sudo lvm pvdisplay
|
||||
sudo lvm vgdisplay
|
||||
sudo lvm lvdisplay
|
||||
```
|
||||
|
||||
Extend the root LV using all free space:
|
||||
|
||||
```bash
|
||||
sudo lvm lvextend -l +100%FREE /dev/mapper/ubuntu--vg-ubuntu--lv
|
||||
sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
df -h
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# API Error Response Format
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Server uses a unified error response format across all API endpoints. All error responses return a JSON object with a `message` field containing the error details.
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Error description here"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Types
|
||||
|
||||
| Error Type | HTTP Status | Description |
|
||||
|------------|-------------|-------------|
|
||||
| `ControllerBadRequestError` | 400 | Invalid request parameters (e.g., validation failure) |
|
||||
| `ControllerUnauthorizedError` | 401 | Missing or invalid authentication |
|
||||
| `ControllerForbiddenError` | 403 | User lacks required privileges |
|
||||
| `ControllerNotFoundError` | 404 | Resource not found |
|
||||
| `ControllerError` | 409 | General conflict (e.g., duplicate name) |
|
||||
| `ControllerTimeoutError` | 408 | Operation timed out |
|
||||
| `ComputeConflictError` | 409 | Compute node returned a conflict |
|
||||
| `RequestValidationError` | 422 | Pydantic/FastAPI validation error (field missing or type mismatch) |
|
||||
| `SQLAlchemyError` | 500 | Database error |
|
||||
|
||||
## Error Response Examples
|
||||
|
||||
### Template Already Exists (409)
|
||||
```json
|
||||
{
|
||||
"message": "A template with name 'my-template' already exists"
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Error (422)
|
||||
```json
|
||||
{
|
||||
"message": "image field is required"
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Denied (403)
|
||||
```json
|
||||
{
|
||||
"message": "Permission denied (privilege Template.Allocate is required)"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Template Type (400)
|
||||
```json
|
||||
{
|
||||
"message": "JSON schema error received while creating new template: ..."
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Error Handling
|
||||
|
||||
When handling errors in the GNS3 Web UI or API clients:
|
||||
|
||||
```javascript
|
||||
// Extract error message from response
|
||||
function extractErrorMessage(error) {
|
||||
if (error.error && error.error.message) {
|
||||
return error.error.message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Exception handlers: `gns3server/api/server.py`
|
||||
- Template validation: `gns3server/schemas/controller/templates/`
|
||||
- Error classes: `gns3server/controller/controller_error.py`
|
||||
@@ -0,0 +1,146 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Controller + Compute Setup
|
||||
|
||||
This document describes the minimum configuration required to set up a GNS3 Controller with remote Compute nodes.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Compute**: Runs individual nodes (QEMU, Docker, etc.) and provides resource monitoring
|
||||
- **Controller**: Manages multiple computes, projects, and provides the REST API
|
||||
- **Database**: Controller uses SQLite to store projects, nodes, and compute registration
|
||||
|
||||
## Minimum Configuration
|
||||
|
||||
### 1. Compute Node Configuration
|
||||
|
||||
Create the configuration file at `~/.config/GNS3/3.1/gns3_server.conf`:
|
||||
|
||||
```ini
|
||||
[Server]
|
||||
host = 0.0.0.0
|
||||
port = 3080
|
||||
compute_username = gns3
|
||||
compute_password = gns3
|
||||
```
|
||||
|
||||
Start the Compute:
|
||||
```bash
|
||||
gns3server
|
||||
```
|
||||
|
||||
### 2. Controller Node Configuration
|
||||
|
||||
Create the configuration file at `~/.config/GNS3/3.1/gns3_server.conf`:
|
||||
|
||||
```ini
|
||||
[Server]
|
||||
host = 192.168.1.140
|
||||
port = 3080
|
||||
compute_username = gns3
|
||||
compute_password = gns3
|
||||
```
|
||||
|
||||
Start the Controller:
|
||||
```bash
|
||||
gns3server
|
||||
```
|
||||
|
||||
### 3. Register Compute with Controller
|
||||
|
||||
Use the API to register a remote compute:
|
||||
|
||||
```bash
|
||||
POST /v3/computes
|
||||
{
|
||||
"protocol": "http",
|
||||
"host": "192.168.1.x",
|
||||
"port": 3080,
|
||||
"user": "gns3",
|
||||
"password": "gns3"
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Host Configuration
|
||||
|
||||
- **Controller `host`**: If set to `0.0.0.0`, the controller will register itself as `127.0.0.1`, which breaks remote compute connections
|
||||
- **Symptom**: Compute nodes report "No common subnet for compute X (controller) and Y" even when on the same network
|
||||
- **Solution**: Always use the actual LAN IP address for the Controller's `host` field (e.g., `host = 192.168.1.104`)
|
||||
- **Dynamic IP**: If the controller machine uses DHCP, set a static lease on the router or use mDNS (`.local` domain)
|
||||
|
||||
### Password Configuration
|
||||
|
||||
- If `compute_password` is not set, a random 16-character password is auto-generated on startup
|
||||
- The Controller must use the same credentials as the Compute's configuration
|
||||
|
||||
### Network Requirements
|
||||
|
||||
- Controller and Compute must be on the same LAN for cross-compute links to work
|
||||
- UDP tunnel is used for cross-compute links, requiring network connectivity on UDP ports
|
||||
|
||||
### Configuration File Location
|
||||
|
||||
- Default location: `~/.config/GNS3/3.1/gns3_server.conf`
|
||||
- Version `3.0` uses `~/.config/GNS3/3.0/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 401 Unauthorized on Compute Connect
|
||||
|
||||
1. Check that Compute's `compute_username` and `compute_password` match what was passed to the API
|
||||
2. Verify the Compute's configuration file is correctly loaded
|
||||
3. Ensure the `[Server]` section is used (not `[Controller]`)
|
||||
|
||||
### No Common Subnet Error
|
||||
|
||||
If compute nodes report:
|
||||
```
|
||||
Cannot get an IP address on same subnet: No common subnet for compute X (controller) and Y
|
||||
```
|
||||
|
||||
1. **Primary cause**: Controller's `host` is set to `0.0.0.0` - it registers as `127.0.0.1` which is unreachable from compute nodes
|
||||
2. Check the controller's `/v3/version` endpoint - if `controller_host` shows `127.0.0.1`, this is the issue
|
||||
3. Set the Controller's `host` to its actual LAN IP address (e.g., `192.168.1.104`)
|
||||
4. Verify both machines are on the same network and can ping each other
|
||||
5. Check firewall rules allow TCP/UDP communication on required ports
|
||||
|
||||
### WebSocket Console Authentication Failed
|
||||
|
||||
When connecting to a remote compute node's console via WebSocket, the connection may fail with errors in the logs:
|
||||
|
||||
**Controller log:**
|
||||
```
|
||||
New client 192.168.1.104:33268 has connected to controller console WebSocket
|
||||
Forwarding console WebSocket to 'ws://192.168.1.3:3080/v3/compute/projects/.../console/ws'
|
||||
Client 192.168.1.104:33268 has disconnected from controller console WebSocket
|
||||
```
|
||||
|
||||
**Compute log:**
|
||||
```
|
||||
WebSocket /v3/compute/projects/.../console/ws" [accepted]
|
||||
ERROR gns3server.api.routes.compute.dependencies.authentication:103 Could not authenticate while connecting to compute WebSocket: Could not validate credentials
|
||||
```
|
||||
|
||||
**Cause**: The Controller forwards its own `compute_username` and `compute_password` credentials when connecting to the Compute's WebSocket endpoint. If the Compute node does not have matching credentials configured, authentication fails.
|
||||
|
||||
**Solution**: Ensure the Compute node's configuration file has matching credentials:
|
||||
|
||||
```ini
|
||||
[Server]
|
||||
enable_http_auth = True
|
||||
compute_username = gns3
|
||||
compute_password = your_password
|
||||
```
|
||||
|
||||
The Controller automatically uses its configured `compute_username` and `compute_password` when establishing WebSocket connections to remote computes (see `gns3server/api/routes/controller/nodes.py` lines 627-628).
|
||||
|
||||
**Note**: If `compute_password` is not explicitly set in the Compute's config, a random 16-character password is auto-generated at startup, making authentication impossible for any external Controller.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Statistics API
|
||||
|
||||
## Overview
|
||||
|
||||
Aggregated server statistics API for monitoring dashboards. Collects compute resources, project/node/link counts, and Web Wireshark container status in a single request.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Client["Client (Dashboard)"]
|
||||
|
||||
subgraph Controller["GET /v3/statistics"]
|
||||
direction TB
|
||||
F1["1. Compute Resources<br/>GET compute/statistics"]
|
||||
F2["2. Project/Node/Link<br/>Iterate in memory"]
|
||||
F3["3. Web Wireshark<br/>Docker API + docker stats"]
|
||||
end
|
||||
|
||||
Compute["Compute Nodes<br/>(psutil)"]
|
||||
Memory["Controller<br/>(in-memory data)"]
|
||||
Docker["Docker Daemon"]
|
||||
|
||||
Client -->|GET /v3/statistics| Controller
|
||||
F1 -->|HTTP| Compute
|
||||
F2 -->|read| Memory
|
||||
F3 -->|Docker API| Docker
|
||||
```
|
||||
|
||||
## Data Collection Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Controller as Controller API
|
||||
participant Compute as Compute Nodes
|
||||
participant Docker as Docker Daemon
|
||||
|
||||
Client->>Controller: GET /v3/statistics
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over Controller: 1. Compute Resources
|
||||
loop For each compute
|
||||
Controller->>Compute: GET /statistics
|
||||
Compute-->>Controller: psutil data
|
||||
Note over Controller: skip on error
|
||||
end
|
||||
end
|
||||
|
||||
rect rgb(240, 255, 240)
|
||||
Note over Controller: 2. Projects / Nodes / Links
|
||||
Note over Controller: Count projects by status (opened/closed)
|
||||
Note over Controller: Count nodes: open→by_type+by_status, closed→by_type only
|
||||
Note over Controller: Count links: total + capturing
|
||||
end
|
||||
|
||||
rect rgb(255, 248, 240)
|
||||
Note over Controller: 3. Web Wireshark
|
||||
loop For opened projects only
|
||||
Controller->>Docker: Query container status
|
||||
Controller->>Docker: docker stats (2s timeout)
|
||||
Docker-->>Controller: Container resource usage
|
||||
end
|
||||
end
|
||||
|
||||
Controller-->>Client: JSON response
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/v3/statistics` | Aggregated server statistics | Session |
|
||||
| GET | `/v3/compute/statistics` | Single compute resource stats | Basic Auth |
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"computes": [
|
||||
{
|
||||
"compute_id": "local",
|
||||
"compute_name": "Local",
|
||||
"statistics": {
|
||||
"memory_total": 16777216000,
|
||||
"memory_free": 8000000000,
|
||||
"memory_used": 8777216000,
|
||||
"swap_total": 2147479552,
|
||||
"swap_free": 1500000000,
|
||||
"swap_used": 647279552,
|
||||
"cpu_usage_percent": 45,
|
||||
"memory_usage_percent": 52,
|
||||
"swap_usage_percent": 30,
|
||||
"disk_usage_percent": 67,
|
||||
"load_average_percent": [12, 8, 5]
|
||||
}
|
||||
}
|
||||
],
|
||||
"projects": {
|
||||
"total": 5,
|
||||
"opened": 3,
|
||||
"closed": 2
|
||||
},
|
||||
"nodes": {
|
||||
"total": 42,
|
||||
"open_project_nodes": 30,
|
||||
"closed_project_nodes": 12,
|
||||
"by_type": { "qemu": 20, "docker": 12, "dynamips": 6, "vpcs": 4 },
|
||||
"by_status": { "started": 25, "stopped": 12, "suspended": 5 }
|
||||
},
|
||||
"links": {
|
||||
"total": 38,
|
||||
"capturing": 2
|
||||
},
|
||||
"webwireshark": {
|
||||
"total_containers": 1,
|
||||
"running_containers": 1,
|
||||
"active_sessions": 2,
|
||||
"containers": [
|
||||
{
|
||||
"project_id": "e16e2b51-9ba9-403b-9df4-b2915d7508a3",
|
||||
"project_name": "test-project",
|
||||
"container_id": "6edc9029bac0",
|
||||
"status": "running",
|
||||
"running": true,
|
||||
"active_sessions": 2,
|
||||
"memory_limit": "2.0 GB",
|
||||
"cpu_limit": "1.0",
|
||||
"pids_limit": 1000,
|
||||
"memory": "272.5MiB / 4GiB",
|
||||
"cpu": "0.23%",
|
||||
"pids": 69
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
### `computes[]`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `compute_id` | string | Compute identifier |
|
||||
| `compute_name` | string | Display name |
|
||||
| `statistics` | object | Resource usage (see below) |
|
||||
|
||||
**`statistics` fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `memory_total` | int | Total RAM (bytes) |
|
||||
| `memory_free` | int | Available RAM (bytes) |
|
||||
| `memory_used` | int | Used RAM (bytes) |
|
||||
| `swap_total` | int | Total swap (bytes) |
|
||||
| `swap_free` | int | Free swap (bytes) |
|
||||
| `swap_used` | int | Used swap (bytes) |
|
||||
| `cpu_usage_percent` | int | CPU usage 0-100 |
|
||||
| `memory_usage_percent` | int | RAM usage 0-100 |
|
||||
| `swap_usage_percent` | int | Swap usage 0-100 |
|
||||
| `disk_usage_percent` | int | Project dir disk usage 0-100 |
|
||||
| `load_average_percent` | int[] | Load avg per core (1/5/15 min) |
|
||||
|
||||
### `projects`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `total` | int | All projects |
|
||||
| `opened` | int | Currently opened |
|
||||
| `closed` | int | Currently closed |
|
||||
|
||||
### `nodes`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `total` | int | All nodes across projects |
|
||||
| `open_project_nodes` | int | Nodes in opened projects (has runtime status) |
|
||||
| `closed_project_nodes` | int | Nodes in closed projects (from topology JSON, no status) |
|
||||
| `by_type` | object | Count by node type (qemu, docker, etc.) |
|
||||
| `by_status` | object | Count by status — **open project nodes only** |
|
||||
|
||||
### `links`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `total` | int | All links across projects |
|
||||
| `capturing` | int | Links currently capturing |
|
||||
|
||||
### `webwireshark`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `total_containers` | int | All Wireshark containers |
|
||||
| `running_containers` | int | Currently running |
|
||||
| `active_sessions` | int | Active capture sessions |
|
||||
| `containers` | array | Per-container details (opened projects only) |
|
||||
|
||||
**`containers[]` fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `project_id` | string | Project UUID |
|
||||
| `project_name` | string | Project name |
|
||||
| `container_id` | string | Docker container ID (12 chars) |
|
||||
| `status` | string | Container status (running, exited, etc.) |
|
||||
| `running` | bool | Is running |
|
||||
| `active_sessions` | int | Active captures in this project |
|
||||
| `memory_limit` | string | Configured memory limit (e.g. `"2.0 GB"`, `"unlimited"`) |
|
||||
| `cpu_limit` | string | Configured CPU limit (e.g. `"1.0"`, `"unlimited"`) |
|
||||
| `pids_limit` | int/string | Process limit (e.g. `1000`, `"unlimited"`) |
|
||||
| `memory` | string | Live memory usage (conditional, see notes) |
|
||||
| `cpu` | string | Live CPU usage (conditional, see notes) |
|
||||
| `pids` | int | Live process count (conditional, see notes) |
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | Unauthorized — invalid or missing session |
|
||||
| 500 | Internal server error |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Compute stats are best-effort**: if a compute is unreachable, it is skipped (logged as error) and other data is still returned
|
||||
- **`by_status` only reflects open project nodes**: closed projects store topology in JSON without runtime status
|
||||
- **Live container stats** (`memory`, `cpu`, `pids`) are only present when `docker stats` succeeds — may be absent if the Docker daemon is slow (2s timeout)
|
||||
- **Web Wireshark containers** are only queried for opened projects
|
||||
@@ -0,0 +1,402 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# VNC WebSocket Console
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 server supports WebSocket-based VNC console access, enabling browser-based graphical console connections to QEMU and Docker VMs without requiring standalone VNC client applications.
|
||||
|
||||
This implementation uses GNS3's API layer as a transparent WebSocket-to-TCP proxy, forwarding binary VNC protocol data between the browser and the VM's VNC server.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connection Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Browser noVNC] -->|WebSocket binary| B[GNS3 Controller]
|
||||
B -->|WebSocket + BasicAuth + SSL| C[GNS3 Compute]
|
||||
C -->|TCP| D[QEMU/Docker VNC :5900]
|
||||
```
|
||||
|
||||
**Controller** acts as a WebSocket-to-WebSocket relay (JWT auth, IPv6 handling).
|
||||
**Compute** acts as a WebSocket-to-TCP bridge (validates node state, opens VNC TCP connection).
|
||||
|
||||
### Components
|
||||
|
||||
1. **Browser (noVNC)**
|
||||
- HTML5 VNC client running in the browser
|
||||
- Connects via WebSocket using `binary` subprotocol
|
||||
- Handles RFB protocol (Remote Frame Buffer) for VNC
|
||||
|
||||
2. **GNS3 Controller API**
|
||||
- WebSocket endpoint: `/v3/projects/{project_id}/nodes/{node_id}/console/vnc`
|
||||
- Authentication: JWT token via query parameter
|
||||
- Authorization: RBAC privilege check via `has_privilege_on_websocket("Node.Console")`
|
||||
- Proxies WebSocket to compute node (WebSocket-to-WebSocket relay)
|
||||
- Handles IPv6 addresses by wrapping in brackets
|
||||
|
||||
3. **GNS3 Compute API**
|
||||
- WebSocket endpoint: `/v3/compute/projects/{project_id}/{node_type}/nodes/{node_id}/console/vnc`
|
||||
- Authentication: HTTP Basic Auth via `ws_compute_authentication()`
|
||||
- Establishes TCP connection to VNC server, bridges WebSocket ↔ TCP
|
||||
|
||||
4. **Node (QEMU/Docker)**
|
||||
- VNC server listening on configured port (default: 5900+)
|
||||
- RFB protocol for remote display
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Controller WebSocket Endpoint
|
||||
|
||||
**URL**: `ws://{controller_host}:{port}/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={jwt_token}`
|
||||
|
||||
**Authentication**:
|
||||
- JWT token via query parameter
|
||||
- User must have `Node.Console` privilege
|
||||
|
||||
**WebSocket Subprotocols**:
|
||||
- Accepts: `binary`
|
||||
- Used by noVNC for binary data transfer
|
||||
|
||||
**Request Example**:
|
||||
```javascript
|
||||
const token = "eyJ0eXAiOiJKV1QiLCJhbGc...";
|
||||
const wsUrl = `ws://localhost:3080/v3/projects/${projectId}/nodes/${nodeId}/console/vnc?token=${token}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl, 'binary');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
```
|
||||
|
||||
### Compute WebSocket Endpoint
|
||||
|
||||
**URL**: `ws://{compute_host}:{port}/v3/compute/projects/{project_id}/{node_type}/nodes/{node_id}/console/vnc`
|
||||
|
||||
**Authentication**:
|
||||
- HTTP Basic Auth via `ws_compute_authentication()` dependency
|
||||
- Configured via `settings.Server.compute_username` (default: `gns3`) and `settings.Server.compute_password` (default: empty)
|
||||
|
||||
**Response**:
|
||||
- Bidirectional binary WebSocket connection
|
||||
- Transparent VNC protocol forwarding
|
||||
|
||||
## Supported Node Types
|
||||
|
||||
### QEMU VMs
|
||||
|
||||
**Console Type Configuration**:
|
||||
```json
|
||||
{
|
||||
"console_type": "vnc",
|
||||
"console": 5900,
|
||||
"console_resolution": "1024x768"
|
||||
}
|
||||
```
|
||||
|
||||
**QEMU Parameters**:
|
||||
```bash
|
||||
-vnc :0 # VNC on display 0 (port = 5900 + display)
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- File: `gns3server/api/routes/compute/qemu_nodes.py`
|
||||
- Endpoint: `/{node_id}/console/vnc`
|
||||
- Method: `start_vnc_websocket_console(websocket)`
|
||||
|
||||
### Docker Containers
|
||||
|
||||
**Console Type Configuration**:
|
||||
```json
|
||||
{
|
||||
"console_type": "vnc",
|
||||
"console": 5900,
|
||||
"console_resolution": "1024x768",
|
||||
"console_http_port": 8080,
|
||||
"console_http_path": "/"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
- File: `gns3server/api/routes/compute/docker_nodes.py`
|
||||
- Endpoint: `/{node_id}/console/vnc`
|
||||
- Method: `start_vnc_websocket_console(websocket)`
|
||||
|
||||
## WebSocket Data Forwarding
|
||||
|
||||
### Compute Layer (WebSocket ↔ TCP)
|
||||
|
||||
**Location**: `gns3server/compute/base_node.py` — `start_vnc_websocket_console()`
|
||||
|
||||
1. Validates node is started and `console_type == "vnc"`; closes WebSocket with code 1000 otherwise
|
||||
2. Opens TCP connection to VNC server at `console_host:console_port`
|
||||
3. Runs two concurrent tasks via `asyncio.wait(FIRST_COMPLETED)`:
|
||||
- `ws_forward()`: WebSocket → TCP (catches `WebSocketDisconnect`)
|
||||
- `vnc_forward()`: TCP → WebSocket (reads 65536-byte buffer)
|
||||
4. Cancels pending tasks, closes TCP writer on completion
|
||||
|
||||
### Controller Layer (WebSocket ↔ WebSocket)
|
||||
|
||||
**Location**: `gns3server/api/routes/controller/nodes.py` — `vnc_console()`
|
||||
|
||||
1. Authenticates user via `has_privilege_on_websocket("Node.Console")` dependency
|
||||
2. Constructs compute URL with IPv6 bracket handling
|
||||
3. Connects to compute WebSocket using `aiohttp.ws_connect()` with HTTP Basic Auth and SSL context
|
||||
4. Uses `asyncio.ensure_future()` for client→compute forwarding, `async for msg` iteration for compute→client
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (noVNC)
|
||||
participant C as Controller
|
||||
participant W as Compute
|
||||
participant V as VNC Server
|
||||
|
||||
B->>C: WebSocket connect (binary, JWT token)
|
||||
C->>W: WebSocket connect (binary, BasicAuth, SSL)
|
||||
W->>V: TCP connect (asyncio.open_connection)
|
||||
|
||||
Note over B,V: Bidirectional binary forwarding active
|
||||
|
||||
B->>C: WebSocket binary frame
|
||||
C->>W: aiohttp send_bytes()
|
||||
W->>V: TCP write(data)
|
||||
|
||||
V->>W: TCP data (read 65536)
|
||||
W->>C: WebSocket binary frame
|
||||
C->>B: send_bytes()
|
||||
```
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### Controller Layer
|
||||
|
||||
1. **Authentication**:
|
||||
- JWT token validation via `has_privilege_on_websocket("Node.Console")` dependency
|
||||
- Token passed as query parameter: `?token={jwt}`
|
||||
|
||||
2. **Authorization**:
|
||||
- RBAC privilege check: `Node.Console`
|
||||
- Per-node access control
|
||||
|
||||
3. **WebSocket Subprotocol**:
|
||||
- Client requests: `binary`
|
||||
- Server accepts: `binary` (if requested)
|
||||
|
||||
### Compute Layer
|
||||
|
||||
1. **Authentication**:
|
||||
- HTTP Basic Auth
|
||||
- Credentials from controller config
|
||||
- Username: `settings.Server.compute_username`
|
||||
- Password: `settings.Server.compute_password`
|
||||
|
||||
2. **Node Validation**:
|
||||
- Check node exists
|
||||
- Check node is started
|
||||
- Check console type is `vnc`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Settings
|
||||
|
||||
**Controller Configuration** (`gns3-server.conf`):
|
||||
```ini
|
||||
[Server]
|
||||
host = 0.0.0.0
|
||||
port = 3080
|
||||
```
|
||||
|
||||
**Compute Configuration** (same file):
|
||||
```ini
|
||||
[Server]
|
||||
compute_username = gns3
|
||||
compute_password = # empty by default, must be set for compute auth
|
||||
```
|
||||
|
||||
### VNC Port Range
|
||||
|
||||
VNC console ports are allocated from a configurable range (`gns3server/schemas/config.py`):
|
||||
|
||||
| Setting | Default | Range |
|
||||
|---------|---------|-------|
|
||||
| `vnc_console_start_port_range` | 5900 | 5900–65535 |
|
||||
| `vnc_console_end_port_range` | 10000 | 5900–65535 |
|
||||
|
||||
Validation: `vnc_console_end_port_range` must be greater than `vnc_console_start_port_range`.
|
||||
|
||||
### Node Settings
|
||||
|
||||
**QEMU VM Example**:
|
||||
```json
|
||||
{
|
||||
"name": "vm-1",
|
||||
"node_type": "qemu",
|
||||
"console_type": "vnc",
|
||||
"console": 5900,
|
||||
"console_resolution": "1280x720",
|
||||
"properties": {
|
||||
"qemu_path": "/usr/bin/qemu-system-x86_64"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Docker Container Example**:
|
||||
```json
|
||||
{
|
||||
"name": "container-1",
|
||||
"node_type": "docker",
|
||||
"console_type": "vnc",
|
||||
"console": 5900,
|
||||
"console_resolution": "1024x768",
|
||||
"console_http_port": 8080
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**1. "Node is not started"**
|
||||
- **Symptom**: WebSocket closes immediately
|
||||
- **Solution**: Start the VM before opening console
|
||||
- **API Check**: `GET /v3/projects/{project_id}/nodes/{node_id}` → verify `status == "started"`
|
||||
|
||||
**2. "Console type is not vnc"**
|
||||
- **Symptom**: WebSocket closes with error
|
||||
- **Solution**: Set node console_type to "vnc"
|
||||
- **API Update**: `PUT /v3/projects/{project_id}/nodes/{node_id}` with `{"console_type": "vnc"}`
|
||||
|
||||
**3. "Cannot connect to VNC server"**
|
||||
- **Symptom**: Connection timeout
|
||||
- **Possible Causes**:
|
||||
- VNC server not listening
|
||||
- Port conflict
|
||||
- Firewall blocking connection
|
||||
- **Verification**:
|
||||
```bash
|
||||
# Check if VNC is listening
|
||||
netstat -tlnp | grep 5900
|
||||
|
||||
# Check QEMU process
|
||||
ps aux | grep qemu
|
||||
```
|
||||
|
||||
**4. WebSocket Subprotocol Negotiation Failed**
|
||||
- **Symptom**: Connection rejected during handshake
|
||||
- **Solution**: Ensure noVNC sends `binary` subprotocol
|
||||
- **Browser Console Check**:
|
||||
```javascript
|
||||
console.log(ws.protocol); // Should be "binary"
|
||||
```
|
||||
|
||||
**5. Authentication Errors**
|
||||
- **Symptom**: HTTP 401/403 responses
|
||||
- **Controller**: Check JWT token is valid and not expired
|
||||
- **Compute**: Verify compute username/password in config
|
||||
|
||||
### Debug Logging
|
||||
|
||||
**Enable Detailed Logging**:
|
||||
```ini
|
||||
[gns3server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
**Check Controller Logs**:
|
||||
```bash
|
||||
# Look for WebSocket connection messages
|
||||
grep "VNC console WebSocket" /var/log/gns3/gns3.log
|
||||
```
|
||||
|
||||
**Check Compute Logs**:
|
||||
```bash
|
||||
# Look for VNC forwarding messages
|
||||
grep "Connected to VNC server" /var/log/gns3/gns3.log
|
||||
```
|
||||
|
||||
**Browser Console**:
|
||||
```javascript
|
||||
// Enable noVNC debugging
|
||||
RFB.messages.log = function(msg) { console.log(msg); };
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
|
||||
1. **Controller → Client**
|
||||
- JWT token with expiration
|
||||
- RBAC authorization
|
||||
- Privilege: `Node.Console`
|
||||
|
||||
2. **Controller → Compute**
|
||||
- HTTP Basic Auth via `aiohttp.BasicAuth`
|
||||
- SSL context from `Controller.instance().ssl_context()`
|
||||
- Raises `ControllerForbiddenError` if `compute_username` is not set
|
||||
|
||||
3. **VNC Server**
|
||||
- Optional VNC password (QEMU only)
|
||||
- Configured via node properties
|
||||
|
||||
### Network Security
|
||||
|
||||
**Recommendations**:
|
||||
1. Use HTTPS/WSS for production deployments
|
||||
2. Firewall compute API ports
|
||||
3. Use short-lived JWT tokens
|
||||
4. Enable VNC password for sensitive VMs
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# Controller with TLS
|
||||
gns3server --ssl --cert /path/to/cert.pem --key /path/to/key.pem
|
||||
|
||||
# VNC with password
|
||||
qemu-system-x86_64 -vnc :0,password
|
||||
```
|
||||
|
||||
### WebSocket Security
|
||||
|
||||
**Subprotocol Validation**:
|
||||
- Only `binary` subprotocol is accepted
|
||||
- Prevents protocol downgrade attacks
|
||||
|
||||
**Origin Validation**:
|
||||
- Browser enforces same-origin policy
|
||||
- Configure CORS if using separate domains
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Single Client per VNC Port**
|
||||
- VNC protocol supports one client at a time
|
||||
- New connections disconnect existing clients
|
||||
|
||||
2. **No Audio Redirection**
|
||||
- VNC protocol does not support audio
|
||||
|
||||
3. **No USB Redirection**
|
||||
- VNC protocol does not support device redirection
|
||||
|
||||
4. **Performance Overhead**
|
||||
- WebSocket-to-TCP bridging adds processing overhead compared to native VNC clients
|
||||
|
||||
## References
|
||||
|
||||
- [RFB Protocol 3.8](https://tools.ietf.org/html/rfc6143) - VNC Protocol Specification
|
||||
- [noVNC Documentation](https://github.com/novnc/noVNC) - HTML5 VNC Client
|
||||
- [FastAPI WebSocket](https://fastapi.tiangolo.com/advanced/websockets/) - WebSocket Implementation
|
||||
- [GNS3 Documentation](https://docs.gns3.com/) - General GNS3 Documentation
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.1 | 2026-04-19 | Review against code: fix buffer size, auth details, add port range config, remove unverified content |
|
||||
| 1.0 | 2026-03-17 | Initial VNC WebSocket documentation |
|
||||
@@ -0,0 +1,620 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Web Wireshark Feature - Business Process Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The **Web Wireshark** feature enables users to run Wireshark packet capture analysis directly in a web browser without requiring a desktop environment or VNC connection. This is achieved through an **xpra** (persistent remote applications) HTML5 client running inside a Docker container.
|
||||
|
||||
## Feature Summary
|
||||
|
||||
- **Core Capability**: Web-based packet capture visualization using Wireshark
|
||||
- **Technology Stack**: Docker container + xpra + HTML5 WebSocket proxy
|
||||
- **Target Users**: Network engineers and students who need to analyze network traffic in GNS3 topologies
|
||||
- **Key Benefit**: Zero-install, browser-based packet capture analysis
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Before using Web Wireshark, install the GNS3 server and set up the Docker image:
|
||||
|
||||
```bash
|
||||
# Development install
|
||||
pip install -e . && gns3server-web-wireshark-setup
|
||||
|
||||
# Production install
|
||||
pip install gns3-server && gns3server-web-wireshark-setup
|
||||
```
|
||||
|
||||
This command will:
|
||||
1. Install the gns3-server package
|
||||
2. Pull the `gns3/web-wireshark:latest` image from Docker Hub
|
||||
3. If pull fails, build the image locally using the included Dockerfile
|
||||
|
||||
The `wireshark` command shows the raw output from `docker pull` or `docker build`, allowing you to see the full progress.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph GNS3Server["GNS3 Server"]
|
||||
WebUI["Web UI<br/>(Browser)"]
|
||||
Controller["GNS3 Controller"]
|
||||
LinkCtrl["Link Controller<br/>(start_capture / stop_capture)"]
|
||||
Manager["WebWiresharkManager<br/>(called directly, not via CLI)"]
|
||||
DockerClient["DockerHTTPClient<br/>(HTTP via Unix Socket)"]
|
||||
end
|
||||
|
||||
subgraph DockerDaemon["Docker Daemon"]
|
||||
Container["gns3-wireshark-{project_id}"]
|
||||
Xvfb["Xvfb (virtual framebuffer)"]
|
||||
Xpra["xpra server"]
|
||||
Wireshark["Wireshark"]
|
||||
end
|
||||
|
||||
ClientBrowser["Client Browser<br/>(HTML5 Client)"]
|
||||
|
||||
ClientBrowser -->|REST API| WebUI
|
||||
WebUI -->|capture/start| Controller
|
||||
Controller --> LinkCtrl
|
||||
LinkCtrl -->|start/stop/restart session| Manager
|
||||
Manager -->|Docker API<br/>/var/run/docker.sock| DockerClient
|
||||
DockerClient -->|container lifecycle| Container
|
||||
Container --- Xvfb
|
||||
Container --- Xpra
|
||||
Container --- Wireshark
|
||||
ClientBrowser -.->|WebSocket proxy| Xpra
|
||||
Xpra -.->|xpra HTML5| ClientBrowser
|
||||
|
||||
style GNS3Server fill:#e8f4fd,stroke:#2196f3
|
||||
style DockerDaemon fill:#fff3e0,stroke:#ff9800
|
||||
style ClientBrowser fill:#e8f5e9,stroke:#4caf50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Description
|
||||
|
||||
### 1. Web UI (Client Browser)
|
||||
- User interface for starting/stopping packet capture
|
||||
- Receives WebSocket URL for connecting to xpra HTML5 client
|
||||
- No plugins required - pure HTML5/JavaScript
|
||||
|
||||
### 2. GNS3 Controller
|
||||
- Orchestrates the capture workflow
|
||||
- Validates user permissions (RBAC)
|
||||
- Manages link capture state
|
||||
|
||||
### 3. manage_wireshark.py (Management CLI)
|
||||
- Command-line interface for container and session management
|
||||
- **For manual debugging and testing only** — the server calls `WebWiresharkManager` directly at runtime
|
||||
- Handles Docker container lifecycle
|
||||
- Manages xpra sessions per link
|
||||
|
||||
### 4. WebWiresharkManager
|
||||
- Core business logic for Web Wireshark
|
||||
- Handles container creation, session startup/shutdown
|
||||
- Deterministic port allocation based on link_id
|
||||
|
||||
### 5. DockerHTTPClient
|
||||
- Async HTTP client for Docker API
|
||||
- Communicates via Unix socket (/var/run/docker.sock)
|
||||
- Manages container lifecycle
|
||||
|
||||
### 6. Docker Container (gns3-wireshark-{project_id})
|
||||
- Runs xpra server with HTML5 support
|
||||
- Contains Xvfb (virtual framebuffer) for headless Wireshark
|
||||
- Streams display to browser via WebSocket
|
||||
|
||||
---
|
||||
|
||||
## Business Processes
|
||||
|
||||
### Process 1: Start Packet Capture with Web Wireshark
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant WebUI as Web UI
|
||||
participant Controller as Controller
|
||||
participant LinkCtrl as Link Controller
|
||||
participant Manager as WebWiresharkManager
|
||||
|
||||
User->>WebUI: 1. Start Capture
|
||||
WebUI->>Controller: 2. POST /capture/start
|
||||
Controller->>LinkCtrl: 3. _start_web_wireshark(jwt_token)
|
||||
LinkCtrl->>Manager: 4. start_wireshark_session()
|
||||
Note right of Manager: 5. Ensure network exists
|
||||
Note right of Manager: 6. Get/create container
|
||||
Note right of Manager: 7. Start xpra session<br/>(display + port)
|
||||
Note right of Manager: 8. Start Wireshark<br/>(curl | wireshark -i - -k)
|
||||
Manager-->>LinkCtrl: ws_url
|
||||
LinkCtrl-->>Controller: ws_url
|
||||
Controller-->>WebUI: 10. {capturing: true, ws_url: "ws://..."}
|
||||
WebUI-->>User: 9. ws_url
|
||||
Note over User,Manager: 11-12. Browser connects via WebSocket<br/>Server proxies to container xpra
|
||||
```
|
||||
|
||||
**API Endpoint**: `POST /v3/projects/{project_id}/links/{link_id}/capture/start`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"wireshark": true,
|
||||
"data_link_type": "DLT_EN10MB",
|
||||
"capture_file_name": "capture.pcap"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "link-uuid",
|
||||
"capturing": true,
|
||||
"ws_url": "ws://192.168.1.100:14500"
|
||||
}
|
||||
```
|
||||
|
||||
### Process 2: Container Lifecycle (Per Project)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Project Open"] --> B["First Link Capture"]
|
||||
B --> C["Container Created<br/>(one per project)"]
|
||||
C --> D["Container Running"]
|
||||
|
||||
subgraph SessionArchitecture["Container Session Architecture"]
|
||||
direction TB
|
||||
D --> E["gns3-wireshark-{project_id}"]
|
||||
|
||||
subgraph Link1Session["Link 1 Session"]
|
||||
L1Xpra["xpra :14503"]
|
||||
L1Xvfb["Xvfb :14503<br/>(1920x1080x24)"]
|
||||
L1WS["Wireshark<br/>(curl | wireshark -i -)"]
|
||||
L1Bind["bind-ws=0.0.0.0:14503"]
|
||||
L1Xpra --- L1Xvfb
|
||||
L1Xpra --- L1WS
|
||||
L1Xpra --- L1Bind
|
||||
end
|
||||
|
||||
subgraph Link2Session["Link 2 Session"]
|
||||
L2Xpra["xpra :11024"]
|
||||
L2Xvfb["Xvfb :11024<br/>(1920x1080x24)"]
|
||||
L2WS["Wireshark<br/>(curl | wireshark -i -)"]
|
||||
L2Bind["bind-ws=0.0.0.0:11024"]
|
||||
L2Xpra --- L2Xvfb
|
||||
L2Xpra --- L2WS
|
||||
L2Xpra --- L2Bind
|
||||
end
|
||||
|
||||
E --> Link1Session
|
||||
E --> Link2Session
|
||||
|
||||
Note1["display = port = 10000 + hash(link_id) % 10000<br/>Each xpra creates its own Xvfb (NOT shared)"]
|
||||
end
|
||||
|
||||
D --> F["Project Close"]
|
||||
F --> G["Stop Container<br/>(all sessions terminate)"]
|
||||
G --> H["Container Stopped<br/>(preserved for reuse)"]
|
||||
H -->|Project Reopened| D
|
||||
|
||||
I["Project Delete"] --> J["Delete Container"]
|
||||
J --> K["Container Removed"]
|
||||
|
||||
style SessionArchitecture fill:#fff8e1,stroke:#ffa000
|
||||
style Link1Session fill:#e3f2fd,stroke:#1976d2
|
||||
style Link2Session fill:#e8f5e9,stroke:#388e3c
|
||||
```
|
||||
|
||||
### Process 3: WebSocket Connection Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser as Client Browser
|
||||
participant Server as GNS3 Server (API Route)
|
||||
participant Container as Docker Container (xpra)
|
||||
|
||||
Browser->>Server: 1. WSS Connect (ws://.../web-wireshark?token=jwt)
|
||||
Note right of Server: 2. Validate JWT<br/>(RBAC: Link.Capture)
|
||||
Note right of Server: 3. Get container IP<br/>from Docker API
|
||||
Server-->>Browser: 4. Accept connection
|
||||
Note right of Server: 5. Start WebSocket proxy
|
||||
Server->>Container: 6. Connect to xpra<br/>ws://container:port
|
||||
Container-->>Server: 7. xpra validates subprotocol
|
||||
Note over Browser,Container: 8-9. Bidirectional WebSocket proxy<br/>Browser ⟺ Server ⟺ Container
|
||||
Note left of Browser: 10. HTML5 client renders<br/>Wireshark window
|
||||
```
|
||||
|
||||
**WebSocket Endpoint**: `ws://host/v3/projects/{project_id}/links/{link_id}/capture/web-wireshark?token=<jwt_token>`
|
||||
|
||||
### Process 4: Capture Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph GNS3Node["GNS3 Node"]
|
||||
Router["Router / Switch"]
|
||||
end
|
||||
|
||||
subgraph ComputeNode["Compute Node"]
|
||||
Capture["Link Capture Buffer"]
|
||||
end
|
||||
|
||||
Router -->|"TAP (raw packets)"| Capture
|
||||
|
||||
subgraph GNS3Server["GNS3 Server"]
|
||||
APIRoute["GET /capture/stream<br/>Proxies pcap stream"]
|
||||
end
|
||||
|
||||
Capture -->|"pcap stream"| APIRoute
|
||||
|
||||
subgraph DockerContainer["Docker Container"]
|
||||
direction TB
|
||||
Curl["curl -N -H 'Authorization: Bearer {jwt}'<br/>'{server}/capture/stream'"]
|
||||
Wireshark["wireshark -i - -k"]
|
||||
Xvfb["Xvfb :display<br/>(1920x1080x24)"]
|
||||
Xpra["xpra :display<br/>ws://0.0.0.0:{port}"]
|
||||
|
||||
Curl -->|"stdin pipe"| Wireshark
|
||||
Wireshark -->|"renders to"| Xvfb
|
||||
Xvfb -->|"X11 display"| Xpra
|
||||
end
|
||||
|
||||
APIRoute -.->|"pcap stream<br/>(curl fetches from container)"| Curl
|
||||
|
||||
subgraph Browser["Client Browser"]
|
||||
HTML5Client["HTML5/xpra Client"]
|
||||
WSUI["Wireshark Web Interface"]
|
||||
HTML5Client --> WSUI
|
||||
end
|
||||
|
||||
Xpra <-->|"WebSocket"| HTML5Client
|
||||
|
||||
style GNS3Node fill:#f3e5f5,stroke:#7b1fa2
|
||||
style ComputeNode fill:#e8eaf6,stroke:#283593
|
||||
style GNS3Server fill:#e8f4fd,stroke:#2196f3
|
||||
style DockerContainer fill:#fff3e0,stroke:#ff9800
|
||||
style Browser fill:#e8f5e9,stroke:#4caf50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Allocation Strategy
|
||||
|
||||
### Deterministic Port Mapping
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
LinkID["link_id (UUID)"] --> Hash["MD5 Hash"] --> Modulo["hash % 10000"]
|
||||
Modulo --> Offset["+ 10000"]
|
||||
Offset --> Result["display = port<br/>Range: 10000 - 19999"]
|
||||
|
||||
style LinkID fill:#e3f2fd,stroke:#1976d2
|
||||
style Result fill:#e8f5e9,stroke:#388e3c
|
||||
```
|
||||
|
||||
| link_id | port/display |
|
||||
|---------|-------------|
|
||||
| `f233f27f-7432-49c3-9aa2-50e326a10eec` | 14503 |
|
||||
| `a1b2c3d4-1234-5678-90ab-cdef12345678` | 11024 |
|
||||
| `12345678-90ab-cdef-1234-567890abcdef` | 17892 |
|
||||
|
||||
**Benefits**:
|
||||
- Same link always gets same port (deterministic)
|
||||
- Display number = port number (same hash)
|
||||
- No port conflicts between sessions
|
||||
- Easy to predict and debug
|
||||
|
||||
---
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph HostMachine["Host Machine"]
|
||||
subgraph GNS3ServerHost["GNS3 Server<br/>192.168.1.100:3080"]
|
||||
WSProxy["WebSocket Proxy"]
|
||||
end
|
||||
|
||||
subgraph DockerNetwork["Docker Bridge: gns3-wireshark<br/>Subnet: 172.31.0.0/22"]
|
||||
Gateway["Bridge Gateway<br/>172.31.0.1"]
|
||||
Container["gns3-wireshark-{project_id}<br/>172.31.0.x"]
|
||||
Gateway --- Container
|
||||
end
|
||||
end
|
||||
|
||||
Client["Client Browser"] -->|"ws://192.168.1.100:3080<br/>/capture/web-wireshark"| WSProxy
|
||||
WSProxy -->|"via gateway 172.31.0.1<br/>ws://172.31.0.x:{port}"| Container
|
||||
|
||||
style HostMachine fill:#fafafa,stroke:#9e9e9e
|
||||
style DockerNetwork fill:#e3f2fd,stroke:#1976d2
|
||||
style GNS3ServerHost fill:#e8f5e9,stroke:#388e3c
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Management Commands
|
||||
|
||||
> **Note**: These commands are for manual debugging and testing only. The GNS3 server calls `WebWiresharkManager` directly at runtime.
|
||||
|
||||
### start
|
||||
|
||||
Starts Web Wireshark session for a specific link.
|
||||
|
||||
| Argument | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `--project-id` | Yes | - | Project UUID |
|
||||
| `--link-id` | Yes | - | Link UUID |
|
||||
| `--jwt-token` | Yes | - | JWT authentication token |
|
||||
| `--capture-url` | No | auto-detected | PCAP stream URL |
|
||||
| `--image` | No | `gns3/web-wireshark:latest` | Docker image |
|
||||
| `--memory` | No | `2g` | Memory limit |
|
||||
| `--cpus` | No | `1.0` | CPU cores |
|
||||
| `--pids-limit` | No | `1000` | Process limit |
|
||||
|
||||
```bash
|
||||
python manage_wireshark.py start \
|
||||
--project-id "5af0fe00-..." \
|
||||
--link-id "f233f27f-..." \
|
||||
--jwt-token "eyJhbG..."
|
||||
```
|
||||
|
||||
### Other Commands
|
||||
|
||||
| Command | Description | Key Arguments |
|
||||
|---------|-------------|---------------|
|
||||
| `stop` | Stop session for a specific link | `--project-id`, `--link-id` |
|
||||
| `restart` | Restart session (reopens Wireshark window) | `--project-id`, `--link-id`, `--jwt-token` |
|
||||
| `stop-all` | Stop all sessions for a project | `--project-id` |
|
||||
| `delete` | Delete container (alias for delete-container) | `--project-id` |
|
||||
| `stop-container` | Stop container without deleting | `--project-id` |
|
||||
| `delete-container` | Delete the container | `--project-id` |
|
||||
|
||||
---
|
||||
|
||||
## Project Close/Delete Workflow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Project Close"] --> B["_stop_web_wireshark_container()"]
|
||||
B --> C["WebWiresharkManager.stop_container(project_id)"]
|
||||
C --> D["docker stop<br/>(all xpra/Xvfb/Wireshark terminate)"]
|
||||
D --> E["Container Stopped<br/>(preserved for reuse)"]
|
||||
|
||||
E -->|Project Reopened| F["First Capture Start"]
|
||||
F --> G["docker start<br/>(container already exists, fast)"]
|
||||
G --> H["New xpra sessions created"]
|
||||
|
||||
I["Project Delete"] --> J["_cleanup_web_wireshark_container()"]
|
||||
J --> K["WebWiresharkManager.delete_container(project_id)"]
|
||||
K --> L["docker rm<br/>(container removed)"]
|
||||
|
||||
style A fill:#e3f2fd,stroke:#1976d2
|
||||
style I fill:#ffebee,stroke:#c62828
|
||||
style E fill:#fff8e1,stroke:#ffa000
|
||||
style L fill:#ffebee,stroke:#c62828
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Method | Endpoint | Description | Privilege |
|
||||
|--------|----------|-------------|-----------|
|
||||
| POST | `/v3/projects/{id}/links/{id}/capture/start` | Start capture with Web Wireshark | Link.Capture |
|
||||
| POST | `/v3/projects/{id}/links/{id}/capture/stop` | Stop capture | Link.Capture |
|
||||
| POST | `/v3/projects/{id}/links/{id}/capture/wireshark/restart` | Restart Wireshark window | Link.Capture |
|
||||
| GET | `/v3/projects/{id}/links/{id}/capture/stream` | Stream PCAP data | Link.Capture |
|
||||
| GET | `/v3/projects/{id}/links/{id}/capture/file` | Download PCAP file | Link.Capture |
|
||||
| WS | `/v3/projects/{id}/links/{id}/capture/web-wireshark` | WebSocket proxy for xpra | Link.Capture |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **RBAC Authentication**: All endpoints require `Link.Capture` privilege
|
||||
2. **JWT Token Validation**: WebSocket connections validate JWT token
|
||||
3. **WebSocket Subprotocol Negotiation**: Proper xpra subprotocol handling
|
||||
4. **Container Isolation**: Each project gets its own container with isolated resources
|
||||
5. **Network Segmentation**: Container runs on isolated Docker network (not host network)
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Startup & Shutdown Performance
|
||||
|
||||
| Step | Before Optimization | After Optimization | Improvement |
|
||||
|------|---------------------|-------------------|-------------|
|
||||
| **Health Check** | ~1.0s | ~0s | Docker native status |
|
||||
| **Gateway Detection** | 0.85s | ~0.001s | Docker API vs exec |
|
||||
| **Process Cleanup** | ~850ms | ~40ms | Host perspective recursive tree walk |
|
||||
| **Xpra Startup** | 6.4s | ~3s | HTML5 client disabled |
|
||||
| **Wireshark Launch** | ~1s | ~1s | No change |
|
||||
| **Total Startup** | **~15s** | **~5-6s** | **67% faster** |
|
||||
|
||||
| Step | Before Optimization | After Optimization | Improvement |
|
||||
|------|---------------------|-------------------|-------------|
|
||||
| **Process Termination** | ~8s | ~20-40ms | Recursive tree walk, no orphans |
|
||||
| **File Cleanup** | ~850ms | ~850ms | Docker exec (safety) |
|
||||
| **Total Shutdown** | **~9s** | **~2s** | **78% faster** |
|
||||
|
||||
#### Startup Breakdown: First vs Subsequent
|
||||
|
||||
| Phase | First Startup (Container stopped) | Subsequent Startup (Container running) |
|
||||
|-------|-----------------------------------|----------------------------------------|
|
||||
| Container startup | ~1-2s | ~0s (already running) |
|
||||
| Container health check | ~1s (unhealthy→healthy) | ~0s (already healthy) |
|
||||
| Gateway detection | ~0.001s | ~0.001s |
|
||||
| Process cleanup | ~40ms | ~40ms |
|
||||
| Xpra startup | ~3s | ~3s |
|
||||
| Wireshark launch | ~1s | ~1s |
|
||||
| **Total** | **~6s** | **~5s** |
|
||||
|
||||
#### Measured Performance Data
|
||||
|
||||
**Startup (from production logs):**
|
||||
```
|
||||
13:46:53 → 13:46:59 = 6s (first startup with container start)
|
||||
13:47:38 → 13:47:43 = 5s (subsequent startup, container running)
|
||||
```
|
||||
|
||||
**Shutdown (from production logs):**
|
||||
```
|
||||
13:48:15 → 13:48:17 = 2s (complete cleanup, no orphan processes)
|
||||
13:48:50 → 13:48:52 = 2s (complete cleanup, no orphan processes)
|
||||
```
|
||||
|
||||
#### Key Optimizations
|
||||
|
||||
- **Complete Process Cleanup**: Recursive process tree traversal eliminates orphaned processes (Xvfb, pulseaudio, ibus-daemon)
|
||||
- **Fast Gateway Detection**: Docker API query instead of container exec
|
||||
- **Smart Health Check**: Trust Docker built-in status, no manual ping
|
||||
- **Xpra Optimization**: Disabled unnecessary HTML5 client (`--html=off`)
|
||||
- **Reduced Docker Exec Calls**: Combined X lock and xpra socket cleanup into single exec call
|
||||
|
||||
### Docker Exec Performance Limitations
|
||||
|
||||
**Important**: Docker daemon has internal queuing for concurrent exec requests to the same container.
|
||||
|
||||
#### Test Results (Same Container)
|
||||
|
||||
| Test Scenario | Execution Time | Avg Per Exec |
|
||||
|--------------|----------------|--------------|
|
||||
| Single docker exec | 0.854s | 0.854s |
|
||||
| Serial 7 docker exec | 7.225s | 1.032s |
|
||||
| Parallel 7 docker exec | 10.253s | 1.465s |
|
||||
|
||||
**Key Finding**: Parallel execution is **42% slower** than serial execution.
|
||||
|
||||
```
|
||||
Serial: 7.225s (7 requests processed sequentially)
|
||||
Parallel: 10.253s (Docker daemon still processes sequentially + context switch overhead)
|
||||
```
|
||||
|
||||
#### Impact on Web Wireshark
|
||||
|
||||
When stopping multiple capture sessions quickly:
|
||||
- Each stop requires 1 docker exec (cleanup files)
|
||||
- Docker daemon processes exec requests sequentially
|
||||
- 7 sessions × ~1s each = ~7-10 seconds total
|
||||
- User requests appear to "queue" even though they're concurrent
|
||||
|
||||
#### Optimization Strategy
|
||||
|
||||
Since Docker exec cannot be parallelized effectively:
|
||||
1. **Minimize exec calls** - Already implemented: 2 calls → 1 call
|
||||
2. **Accept serial processing** - No benefit to parallel execution
|
||||
3. **Focus on fast exec content** - Use simple `rm -f` commands
|
||||
|
||||
#### Future Optimization Options
|
||||
|
||||
- Use Docker API instead of exec (requires container filesystem access)
|
||||
- Delay cleanup to next startup (increases startup complexity)
|
||||
- Batch multiple stops into single operation (requires API changes)
|
||||
|
||||
### Resource Usage (Per Wireshark Instance)
|
||||
| Resource | Typical Usage |
|
||||
|----------|---------------|
|
||||
| Memory | 150-250 MB |
|
||||
| CPU | 0.5-2% (idle to active) |
|
||||
| Threads | ~30 threads |
|
||||
| Disk I/O | Minimal |
|
||||
|
||||
### Container Configuration
|
||||
|
||||
Configured via `WebWiresharkSettings` in `gns3server/schemas/config.py`:
|
||||
|
||||
| Parameter | Default | Recommended | Description |
|
||||
|-----------|---------|-------------|-------------|
|
||||
| enabled | true | - | Enable/disable Web Wireshark feature |
|
||||
| image | gns3/web-wireshark:latest | - | Docker image name |
|
||||
| network_subnet | 172.31.0.0/22 | - | Docker bridge network subnet |
|
||||
| Memory | 2GB | 2-4GB | Container memory limit |
|
||||
| CPUs | 1.0 | 1.0-2.0 | Container CPU limit |
|
||||
| PIDs Limit | 1000 | 1000 | Container process limit |
|
||||
|
||||
### Scaling Guidelines
|
||||
| Instances | Memory | Use Case |
|
||||
|-----------|--------|----------|
|
||||
| 1-3 | 450-750 MB | Light projects |
|
||||
| 4-6 | 600-1.5 GB | Medium projects |
|
||||
| 7-10 | 1-2.5 GB | Large projects |
|
||||
| 10+ | >2.5 GB | Increase memory |
|
||||
|
||||
---
|
||||
|
||||
## Docker API Compatibility
|
||||
|
||||
### Version Negotiation Mechanism
|
||||
|
||||
The Web Wireshark feature implements automatic Docker API version negotiation to ensure compatibility across different Docker versions:
|
||||
|
||||
1. **Primary**: Try API version 1.44 first
|
||||
- Supports Docker 29.3+ (API 1.54+), which requires minimum API 1.44
|
||||
|
||||
2. **Fallback**: If server rejects 1.44 with 400 error, downgrade to API 1.40
|
||||
- Supports Docker 20.10 (API 1.41)
|
||||
|
||||
### Tested Configurations
|
||||
|
||||
| Docker Version | API Version | API 1.40 | API 1.44 | Solution |
|
||||
|----------------|-------------|----------|----------|----------|
|
||||
| 20.10 | 1.41 | ✓ | ✗ | Fallback to 1.40 |
|
||||
| 29.3+ | 1.54+ | ✗ | ✓ | Use 1.44 |
|
||||
|
||||
### Container IP Retrieval
|
||||
|
||||
The `get_container_ip()` method implements a dual-strategy approach for retrieving container IP addresses:
|
||||
|
||||
1. **Primary Method**: Query Docker Container API
|
||||
- Uses `docker inspect` via HTTP API
|
||||
- Safe access to `NetworkSettings.Networks` field using `.get()`
|
||||
- Handles missing fields gracefully (fixes KeyError bug)
|
||||
|
||||
2. **Fallback Method**: Execute `hostname -I` command inside container
|
||||
- Works when Docker API response lacks network information
|
||||
- Compatible with containers without `ip` command
|
||||
- Returns first IP address from `hostname -I` output
|
||||
|
||||
This dual approach ensures compatibility across different Docker API versions that may have varying response formats.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **JWT Token Visibility**: Token passed via command-line arguments (visible in `/proc/<pid>/cmdline`)
|
||||
2. **Single Container**: All Wireshark instances run in a single container per project
|
||||
3. **Docker Dependency**: Requires Docker daemon running on the server
|
||||
4. **Browser Support**: Requires modern browser with WebSocket support
|
||||
5. **Port Range**: Limited to 10,000 unique ports (10000-19999)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
gns3server/
|
||||
├── controller/
|
||||
│ ├── link.py # Link capture lifecycle
|
||||
│ └── project.py # Project cleanup hooks
|
||||
├── api/routes/controller/
|
||||
│ └── links.py # REST/WebSocket API endpoints
|
||||
└── agent/web_wireshark/
|
||||
├── setup_wireshark_image.py # Docker image setup tool
|
||||
├── manage_wireshark.py # CLI management tool (manual/debug use only)
|
||||
├── manager.py # Session management logic (called by server)
|
||||
├── docker_client.py # Docker API client
|
||||
├── stats.py # Container statistics collection
|
||||
├── docker/
|
||||
│ └── Dockerfile # Container image definition
|
||||
└── WEB_WIRESHARK.md # Technical documentation
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,224 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Command Security Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3-Copilot includes multiple security layers to prevent execution of commands that may cause issues in the lab environment:
|
||||
|
||||
- **Command Filtering**: Prevents commands that may timeout or lock up the console
|
||||
- **Configuration Safety**: Restricts dangerous configuration changes (AAA, passwords, etc.)
|
||||
- **Multi-line Command Handling**: Properly processes commands with embedded newlines (banner, etc.)
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Tool Request] --> B{Command Filter}
|
||||
B -->|allowed| C[Nornir/Netmiko Execution]
|
||||
B -->|blocked| D[blocked_commands_info]
|
||||
C --> E[Process Results]
|
||||
D --> E
|
||||
E --> F[Return Result]
|
||||
|
||||
subgraph Command Filter
|
||||
B1[Load forbidden_commands.txt]
|
||||
B2[Prefix match per command]
|
||||
B1 --> B2
|
||||
end
|
||||
|
||||
subgraph Security Layers
|
||||
S1[Layer 1: Command Filter<br/>prefix-based blocking]
|
||||
S2[Layer 2: AI Prompt Rules<br/>AAA/password guidance only]
|
||||
S3[Layer 3: Multi-line Expansion<br/>banner/ACL handling]
|
||||
S1 --> S2 --> S3
|
||||
end
|
||||
```
|
||||
|
||||
## Business Process
|
||||
|
||||
### Command Filtering Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Tool receives commands]) --> Load[Load forbidden patterns<br/>from config file]
|
||||
Load --> Loop{For each command}
|
||||
Loop -->|next command| Prefix{Command starts with<br/>forbidden pattern?}
|
||||
Prefix -->|yes| Block[Add to blocked_commands_info]
|
||||
Prefix -->|no| Allow[Add to allowed list]
|
||||
Block --> Loop
|
||||
Allow --> Loop
|
||||
Loop -->|all checked| Empty{Allowed list empty?}
|
||||
Empty -->|yes| Return1[Return partial_success<br/>with blocked info only]
|
||||
Empty -->|no| Exec[Execute allowed commands<br/>via Nornir/Netmiko]
|
||||
Exec --> HasBlocked{Has blocked commands?}
|
||||
HasBlocked -->|yes| Return2[Return partial_success<br/>with output + blocked info]
|
||||
HasBlocked -->|no| Return3[Return success<br/>with output]
|
||||
```
|
||||
|
||||
### Multi-line Command Expansion Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[config_tools receives commands] --> B{Command contains \n?}
|
||||
B -->|yes| C[Split by newline]
|
||||
C --> D[Filter empty lines]
|
||||
D --> E[Add expanded lines to command list]
|
||||
B -->|no| F[Keep command as-is]
|
||||
E --> G[Execute via netmiko_send_config]
|
||||
F --> G
|
||||
```
|
||||
|
||||
## Tools Using Command Security
|
||||
|
||||
| Tool | File | Filter | Multi-line Expansion | Result Fields |
|
||||
|------|------|--------|---------------------|---------------|
|
||||
| `ExecuteMultipleDeviceCommands` | `display_tools_nornir.py` | Yes | No | `diagnostic_commands` |
|
||||
| `ExecuteMultipleDeviceConfigCommands` | `config_tools_nornir.py` | Yes | Yes | `config_commands` |
|
||||
|
||||
## Command Filtering System
|
||||
|
||||
### 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`.
|
||||
|
||||
**Format:**
|
||||
- One command pattern per line
|
||||
- **Prefix matching** (case-insensitive) — matches the beginning of each command
|
||||
- Empty lines and lines starting with `#` are ignored
|
||||
|
||||
**Default patterns (used when skills repository is unavailable):**
|
||||
|
||||
| Pattern | Reason |
|
||||
|---------|--------|
|
||||
| `traceroute` | Can run 30+ seconds, exceeds tool timeout |
|
||||
| `tracepath` | Similar to traceroute, long execution time |
|
||||
| `tracert` | Windows traceroute, same timeout issues |
|
||||
| `ping -f` | Flood ping can overwhelm lab devices |
|
||||
| `debug` | Can produce overwhelming output and destabilize devices |
|
||||
| `test` | May affect device stability |
|
||||
|
||||
### Result Format
|
||||
|
||||
**Status values:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| `"success"` | All commands executed, none blocked |
|
||||
| `"partial_success"` | Some commands blocked (including all blocked + execution succeeded) |
|
||||
| `"failed"` | Execution failed (device not found, connection error, etc.) |
|
||||
|
||||
**Example — partial success (display tool):**
|
||||
|
||||
```json
|
||||
{
|
||||
"device_name": "R-1",
|
||||
"status": "partial_success",
|
||||
"output": "...",
|
||||
"diagnostic_commands": ["show version", "show ip int brief"],
|
||||
"blocked_commands": ["traceroute 8.8.8.8"],
|
||||
"blocked_info": {
|
||||
"traceroute 8.8.8.8": "Command 'traceroute 8.8.8.8' is not allowed because it matches the forbidden pattern 'traceroute'. ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `diagnostic_commands` is specific to display tools. Config tools use `config_commands` instead.
|
||||
|
||||
## Module Structure
|
||||
|
||||
**Command Filter:** `gns3server/agent/gns3_copilot/utils/command_filter.py`
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `filter_forbidden_commands(commands)` | Returns `(allowed_commands, blocked_commands_info)` |
|
||||
| `is_command_forbidden(command)` | Check if a single command matches a forbidden pattern |
|
||||
| `get_forbidden_commands()` | Get current forbidden patterns list |
|
||||
| `reload_forbidden_commands()` | Directly load and cache commands from skills repository |
|
||||
|
||||
**Integration points:**
|
||||
- `display_tools_nornir.py` — `_filter_forbidden_commands_from_device_configs()`
|
||||
- `config_tools_nornir.py` — `_filter_forbidden_commands_from_device_configs()` + `_expand_multiline_commands()`
|
||||
|
||||
## Configuration Safety (AI Prompt Level)
|
||||
|
||||
In addition to the command filter, the AI agent is instructed via system prompt (`lab_automation_assistant_prompt.py`) to handle sensitive configuration commands with caution:
|
||||
|
||||
**Prompt-enforced rules:**
|
||||
- **FORBIDDEN (guidance only):** `enable secret`, `username`, `aaa new-model`, `service password-encryption`, `line vty` — AI provides configuration guidance instead of executing
|
||||
- **Caution required:** `reload`, `erase`, `format` — AI warns user before executing destructive operations
|
||||
|
||||
> These are prompt-level rules, not code-level enforcement. Users can always execute commands directly via device console or SSH/Telnet.
|
||||
|
||||
## Multi-line Command Handling
|
||||
|
||||
Commands containing `\n` are automatically split before execution by `config_tools_nornir.py:_expand_multiline_commands()`.
|
||||
|
||||
**Example:** `["banner motd #\nWelcome\n#"]` becomes `["banner motd #", "Welcome", "#"]`
|
||||
|
||||
Applies to any command with embedded newlines: `banner`, multi-line ACLs, route-maps, etc.
|
||||
|
||||
## Configuration
|
||||
|
||||
### 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.
|
||||
|
||||
## Implementation Verification
|
||||
|
||||
### Test Results (Live GNS3 Environment)
|
||||
|
||||
**Scenario:** IOU-L2-1 (Cisco IOS L3 switch), mixed allowed/forbidden commands.
|
||||
|
||||
```json
|
||||
{
|
||||
"device_name": "IOU-L2-1",
|
||||
"status": "partial_success",
|
||||
"diagnostic_commands": [
|
||||
"show ip route", "show ip interface brief",
|
||||
"ping 10.0.0.1", "ping 10.0.0.2", "ping 10.0.0.4"
|
||||
],
|
||||
"blocked_commands": ["traceroute 10.0.0.2"],
|
||||
"blocked_info": {
|
||||
"traceroute 10.0.0.2": "Command 'traceroute 10.0.0.2' is not allowed because it matches the forbidden pattern 'traceroute'. ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Prefix match filtering | Verified | `traceroute` correctly matched and blocked |
|
||||
| Partial execution | Verified | Other commands executed successfully |
|
||||
| `partial_success` status | Verified | Set correctly when blocked + succeeded |
|
||||
| Multi-device support | Verified | Each device filtered independently |
|
||||
| Non-blocking behavior | Verified | No tool timeouts or console issues |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Regex support** for more sophisticated pattern matching
|
||||
- **Per-project override files** for forbidden commands
|
||||
- **Web UI configuration** for managing forbidden commands
|
||||
- **Audit logging** for blocked command analysis
|
||||
- **Per-command timeouts** instead of blocking
|
||||
- **Interrupt mechanism** (Ctrl+C) for long-running commands
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Command blocked unexpectedly | Check `blocked_commands` in result, identify matching pattern, edit `forbidden_commands.txt` |
|
||||
| "File not found, using defaults" | Verify `config/forbidden_commands.txt` exists and is readable |
|
||||
| Changes not taking effect | Call `POST /copilot/reload/skills` or restart server |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Skills Repository](skills-repository.md)
|
||||
- [Fault Injection](fault-injection.md)
|
||||
- [Chat API](chat-api.md)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# LLM Context Window Management
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3 Copilot's context window management prevents LLM context overflow by trimming conversation history to fit within configured token limits. It uses tiktoken for accurate token counting, supports three context strategies (conservative/balanced/aggressive), and automatically injects GNS3 topology information into the system prompt before each LLM call.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "context_manager.py"
|
||||
A[create_pre_model_hook] --> B[_inject_topology_into_system]
|
||||
A --> C[estimate_tool_tokens]
|
||||
A --> D[trim_messages via LangChain]
|
||||
E[count_tokens via tiktoken]
|
||||
E --> C
|
||||
E --> F[_count_tokens_for_message]
|
||||
F --> D
|
||||
end
|
||||
|
||||
subgraph "gns3_copilot.py"
|
||||
G[llm_call node] --> H[Get topology via GNS3TopologyTool]
|
||||
H --> A
|
||||
A --> I[model_with_tools.invoke]
|
||||
end
|
||||
|
||||
style A fill:#4a9eff,color:#fff
|
||||
style D fill:#ff6b6b,color:#fff
|
||||
style E fill:#51cf66,color:#fff
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
| Function | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| `create_pre_model_hook()` | `context_manager.py` | Factory that creates the preprocessing hook |
|
||||
| `_inject_topology_into_system()` | `context_manager.py` | Injects topology into system prompt via `{{topology_info}}` placeholder |
|
||||
| `count_tokens()` | `context_manager.py` | Accurate token counting using tiktoken (`cl100k_base`) |
|
||||
| `estimate_tool_tokens()` | `context_manager.py` | Estimates token cost of tool definitions (JSON serialization) |
|
||||
| `_count_tokens_for_message()` | `context_manager.py` | Token counter adapter for LangChain's `trim_messages` |
|
||||
| `llm_call()` | `gns3_copilot.py` | StateGraph node that orchestrates the full LLM call pipeline |
|
||||
|
||||
**Token Counting**: Uses tiktoken `cl100k_base` encoding (GPT-4 compatible, 95%+ accuracy for most modern LLMs).
|
||||
|
||||
**tiktoken Cache**: Encoding files are cached locally at `<agent_package>/cache/tiktoken/` to avoid repeated downloads. This is configured before tiktoken import via `TIKTOKEN_CACHE_DIR` environment variable.
|
||||
|
||||
**Required Dependency**: `tiktoken>=0.8.0` — if not installed, `ModuleNotFoundError` is raised at startup.
|
||||
|
||||
## Trimming Process
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[llm_call node invoked] --> B[Get topology via GNS3TopologyTool]
|
||||
B --> C[Create pre_model_hook]
|
||||
C --> D[Inject topology into system prompt]
|
||||
D --> E{topology data available?}
|
||||
E -->|Yes| F[Replace placeholder with topology content]
|
||||
E -->|No| G[Replace placeholder with 'No topology information available']
|
||||
F --> H[Estimate tool definition tokens]
|
||||
G --> H
|
||||
H --> I[Calculate trim_messages budget]
|
||||
I --> J[trim_messages with strategy='last']
|
||||
J --> K[Return prepared messages]
|
||||
K --> L[model.invoke with prepared messages]
|
||||
```
|
||||
|
||||
### Token Budget Calculation
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["context_limit<br/>(e.g. 128K)"] -->|"× strategy_ratio"| B["Input Budget<br/>(e.g. 96K @ balanced)"]
|
||||
B -->|"− tool_tokens"| C["trim_messages Budget<br/>(includes system message)"]
|
||||
C --> D["trim_messages<br/>max_tokens parameter"]
|
||||
```
|
||||
|
||||
**Key**: `max_tokens` passed to `trim_messages` **includes** the system message. System tokens are NOT subtracted separately — `trim_messages` handles system message preservation via `include_system=True`.
|
||||
|
||||
### Budget Calculation Example
|
||||
|
||||
| Step | Calculation | Result |
|
||||
|------|------------|--------|
|
||||
| Context limit | 128 (configured) | 128K tokens |
|
||||
| Strategy ratio | balanced = 0.75 | 128 × 1000 × 0.75 = 96,000 |
|
||||
| Tool definitions | estimated from JSON schema | ~1,725 tokens |
|
||||
| trim_messages budget | 96,000 − 1,725 | **94,275 tokens** (includes system) |
|
||||
|
||||
### Trimming Priority
|
||||
|
||||
| Priority | Content | Behavior |
|
||||
|----------|---------|----------|
|
||||
| 1 | System Message (system prompt + topology) | Always kept (`include_system=True`) |
|
||||
| 2 | Latest conversation messages | Kept from newest to oldest |
|
||||
| 3 | Older conversation history | Discarded first when exceeding budget |
|
||||
|
||||
**Note**: System prompt and topology are merged into a single `SystemMessage` via the `{{topology_info}}` template variable and cannot be trimmed independently.
|
||||
|
||||
### Edge Case Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| System + tools > input budget | ERROR log with recommendations (reduce prompt/tools, use larger model, switch to conservative). `trim_messages` is still called — `include_system=True` ensures system message is kept, but no history messages will fit. |
|
||||
| Trim budget < system × 1.5 | WARNING log: minimal room for conversation history |
|
||||
| Invalid context_strategy | Falls back to `balanced` with WARNING log |
|
||||
| `trim_messages` throws exception | Returns untrimmed messages (graceful degradation) |
|
||||
| Missing `{{topology_info}}` placeholder | Returns original messages without injection, WARNING log |
|
||||
| Topology retrieval fails | Injects `"(No topology information available)"` placeholder |
|
||||
|
||||
## Integration with GNS3 Copilot
|
||||
|
||||
**File**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
|
||||
|
||||
### Why Direct Call, Not Config-based?
|
||||
|
||||
The agent uses a **custom StateGraph** (`StateGraph(MessagesState)`), not LangGraph's pre-built agent. LangGraph's `pre_model_hook` config parameter only works with pre-built agents like `create_react_agent`.
|
||||
|
||||
| Agent Type | pre_model_hook Support |
|
||||
|------------|------------------------|
|
||||
| `create_react_agent` | Via `config={"configurable": {"pre_model_hook": ...}}` |
|
||||
| `chat_agent_executor` | Via `config={"configurable": {"pre_model_hook": ...}}` |
|
||||
| **Custom StateGraph** | **Not supported** — must call directly |
|
||||
|
||||
### Execution Flow
|
||||
|
||||
In the `llm_call` node of `gns3_copilot.py`:
|
||||
|
||||
1. Get `llm_config` from request-scoped context variable
|
||||
2. Get `project_id` from `config["configurable"]`
|
||||
3. Retrieve topology via `GNS3TopologyTool._run(project_id)`
|
||||
4. Select tools based on `copilot_mode` (`teaching_assistant` vs `lab_automation_assistant`)
|
||||
5. Load system prompt and create `pre_model_hook`
|
||||
6. **Directly call** `pre_hook({"messages": messages, "topology_info": topology_info})` to prepare messages
|
||||
7. Invoke `model_with_tools.invoke(prepared_messages)`
|
||||
|
||||
> **Note**: The actual `llm_call` function also includes defensive checks for missing config, empty messages, and topology retrieval errors — see source code for full details.
|
||||
|
||||
## Strategy Implementation
|
||||
|
||||
### Context Strategy Ratios
|
||||
|
||||
Defined as `CONTEXT_STRATEGY_RATIOS` in `context_manager.py`, with default `DEFAULT_CONTEXT_STRATEGY = "balanced"`.
|
||||
|
||||
| Strategy | Input Ratio | Output Reserved | Calculation |
|
||||
|----------|-------------|-----------------|-------------|
|
||||
| Conservative | 60% | 40% | `context_limit × 1000 × 0.60` |
|
||||
| Balanced | 75% | 25% | `context_limit × 1000 × 0.75` |
|
||||
| Aggressive | 85% | 15% | `context_limit × 1000 × 0.85` |
|
||||
|
||||
## Log Output
|
||||
|
||||
### Normal Case (topology injected)
|
||||
|
||||
```
|
||||
INFO: Calling pre_hook to prepare 1 messages
|
||||
INFO: ✓ Topology injected: 7722 chars, nodes: ['netshoot-1', 'R1', 'R2', 'IOU-L3-1', 'IOU-L3-2']
|
||||
INFO: Context ready: 2 msgs, ~3815 tokens + 1725 tools = 5540 / 128K (4.3%), strategy=conservative
|
||||
INFO: Messages prepared: 1 → 2
|
||||
INFO: LLM call completed: tool_calls=0
|
||||
```
|
||||
|
||||
### When Trimming Occurs
|
||||
|
||||
```
|
||||
INFO: Calling pre_hook to prepare 50 messages
|
||||
INFO: ✓ Topology injected: 8500 chars, nodes: ['R1', 'R2', ...]
|
||||
INFO: Messages trimmed: 50 → 25 msgs. Total: ~82000 tokens + 1725 tools = 83725 / 128K (65.4%), strategy=balanced
|
||||
INFO: Messages prepared: 50 → 25
|
||||
```
|
||||
|
||||
### When Topology is None
|
||||
|
||||
```
|
||||
INFO: Calling pre_hook to prepare 1 messages
|
||||
WARNING: ✗ Topology data is None, injecting placeholder
|
||||
INFO: Context ready: 2 msgs, ~800 tokens + 1725 tools = 2525 / 128K (2.0%), strategy=balanced
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Behavior | Resolution |
|
||||
|-------|----------|------------|
|
||||
| tiktoken not installed | `ModuleNotFoundError` at startup | `pip install tiktoken>=0.8.0` |
|
||||
| `context_limit` missing | `ValueError` raised | Add `context_limit` to LLM config |
|
||||
| Invalid `context_limit` | `ValueError` raised | Must be positive integer |
|
||||
| Invalid `context_strategy` | WARNING, fallback to `balanced` | Use `conservative`/`balanced`/`aggressive` |
|
||||
| `trim_messages` failure | ERROR log, returns untrimmed messages | Check message format compatibility |
|
||||
|
||||
## Deprecated API
|
||||
|
||||
`prepare_context_messages()` is deprecated. It remains in `context_manager.py` for backward compatibility but emits `DeprecationWarning`. Use `create_pre_model_hook()` instead.
|
||||
|
||||
## Related Source Files
|
||||
|
||||
- `gns3server/agent/gns3_copilot/agent/context_manager.py` — Context management core logic
|
||||
- `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` — LLM call node (StateGraph)
|
||||
- `gns3server/agent/gns3_copilot/agent/model_factory.py` — Model creation and tool binding
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Fault Injection Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The fault injection feature enables GNS3 Copilot to automatically inject realistic network faults into GNS3 labs for troubleshooting training. The agent analyzes the topology, selects an appropriate fault based on the configured protocols, injects it, and documents the results.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Agent Workflow"
|
||||
TG[Topology Gathering]
|
||||
CFG[Config Collection]
|
||||
FA[Fault Analysis]
|
||||
FI[Fault Injection]
|
||||
FD[Fault Documentation]
|
||||
end
|
||||
|
||||
subgraph "Skills Repository"
|
||||
IS[INJECTION_SKILLS_REGISTRY<br/>39 categories, 368 issues]
|
||||
SK[injection_skills Tool]
|
||||
end
|
||||
|
||||
subgraph "Tools"
|
||||
DC[execute_multiple_device_commands]
|
||||
CC[execute_multiple_device_config_commands]
|
||||
GK[GNS3TopologyTool]
|
||||
end
|
||||
|
||||
TG -->|Get topology| GK
|
||||
CFG -->|Get configs| DC
|
||||
FA -->|Query filtered skills| IS
|
||||
FA -->|Select fault| SK
|
||||
FI -->|Inject config changes| CC
|
||||
FD -->|Output report| FD
|
||||
```
|
||||
|
||||
## Injection Skills Tool
|
||||
|
||||
The `InjectionSkillsTool` (LangChain `BaseTool`) is the primary interface for querying available faults.
|
||||
|
||||
### Listing Faults (with context filter)
|
||||
|
||||
The LLM MUST pass topology context when listing faults:
|
||||
|
||||
```json
|
||||
{"action": "list", "context": ["ospf", "bgp", "vlan"]}
|
||||
```
|
||||
|
||||
This returns only faults matching the protocols found in the topology. The tool rejects calls without `context`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "context parameter is required when action='list'",
|
||||
"hint": "Analyze the topology and device configurations first...",
|
||||
"available_categories": ["bgp", "interface", "mpls", "ospf", ...]
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Fault Details
|
||||
|
||||
Token-efficient usage pattern:
|
||||
|
||||
```json
|
||||
// Step 1: List issue names only (~300 tokens)
|
||||
{"device_type": "injection_ospf", "detail": "index"}
|
||||
|
||||
// Step 2: Get single issue detail (~500 tokens)
|
||||
{"device_type": "injection_ospf", "issue": "ospf_hello_dead_mismatch"}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `action` | No (`"get"`) | `"list"` to browse, `"get"` for details |
|
||||
| `context` | **Yes** for `action="list"` | Protocols found in topology: `["ospf", "bgp"]` |
|
||||
| `device_type` | Yes for `action="get"` | e.g. `"injection_ospf"` |
|
||||
| `detail` | No (`"full"`) | `"index"` (names), `"summary"` (+desc), `"full"` (all) |
|
||||
| `issue` | No | Single issue key for targeted detail |
|
||||
|
||||
## Fault Injection Workflow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant LLM
|
||||
participant InjectionSkillsTool
|
||||
participant TopologyTool
|
||||
participant DeviceCommands
|
||||
|
||||
LLM->>TopologyTool: Get topology info
|
||||
TopologyTool-->>LLM: Topology + device types
|
||||
|
||||
LLM->>DeviceCommands: Get device configs
|
||||
DeviceCommands-->>LLM: Running configurations
|
||||
|
||||
Note over LLM: Analyze which protocols are in use
|
||||
|
||||
LLM->>InjectionSkillsTool: {"action": "list", "context": ["ospf", "vlan"]}
|
||||
InjectionSkillsTool-->>LLM: Matching fault categories
|
||||
|
||||
LLM->>InjectionSkillsTool: {"device_type": "injection_ospf", "detail": "index"}
|
||||
InjectionSkillsTool-->>LLM: Issue names (token-efficient)
|
||||
|
||||
LLM->>InjectionSkillsTool: {"device_type": "injection_ospf", "issue": "ospf_hello_dead_mismatch"}
|
||||
InjectionSkillsTool-->>LLM: Full fault detail + config commands
|
||||
|
||||
LLM->>DeviceCommands: Inject fault configuration
|
||||
DeviceCommands-->>LLM: Execution result
|
||||
|
||||
Note over LLM: Document fault in response
|
||||
```
|
||||
|
||||
## Injection Skills Repository
|
||||
|
||||
Skills are organized by protocol/category in the external [GNS3-Skills](https://github.com/yueguobin/GNS3-Skills) repository:
|
||||
|
||||
| Category | File | Example Issues |
|
||||
|----------|------|----------------|
|
||||
| OSPF | `injection/ospf_issues.yaml` | Hello/Dead mismatch, MTU mismatch, area mismatch |
|
||||
| BGP | `injection/bgp_issues.yaml` | AS-path prepend, next-hop unreachable, route filtering |
|
||||
| VLAN | `injection/vlan_issues.yaml` | Trunk allowed mismatch, native VLAN mismatch |
|
||||
| STP | `injection/stp_issues.yaml` | Root guard, loop guard, port priority |
|
||||
| MPLS | `injection/mpls_issues.yaml` | LDP session down, label binding failure |
|
||||
| ... | 34 more files | 368 issues total |
|
||||
|
||||
## Recovery
|
||||
|
||||
Each injected fault includes restore commands in the documentation. The LLM always provides commands to fully revert all changes.
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### POST /copilot/projects/{project_id}/chat/inject
|
||||
|
||||
Dedicated endpoint for fault injection. Internally sets `copilot_mode` to `troubleshooting_injection` and runs the agent.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"message": "Inject a network fault for troubleshooting practice",
|
||||
"session_id": "optional-session-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Server-Sent Events (SSE) stream identical to the chat stream endpoint.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Skills Repository](skills-repository.md)
|
||||
- [Command Security](command-security.md)
|
||||
- [Chat API](chat-api.md)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,772 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Multi-Vendor Network Device Support
|
||||
|
||||
## Overview
|
||||
|
||||
GNS3-Copilot supports network devices from multiple vendors through Netmiko and Nornir integration. The system includes a custom Netmiko driver for Huawei devices in GNS3 emulation environments and supports dynamic device type detection.
|
||||
|
||||
## Supported Vendors
|
||||
|
||||
| Vendor | Device Type | Protocol | Status |
|
||||
|--------|-------------|----------|--------|
|
||||
| **Cisco** | `cisco_ios_telnet` | Telnet | ✅ Tested |
|
||||
| **Huawei** | `gns3_huawei_telnet_ce` | Telnet | ✅ Tested (Custom Driver) |
|
||||
| **Ruijie (锐捷)** | `gns3_ruijie_telnet` | Telnet | ✅ Tested (Custom Driver) |
|
||||
| **VPCS** | `gns3_vpcs_telnet` | Telnet | ✅ Tested (Custom Driver, Simulator) |
|
||||
|
||||
## Custom VPCS Driver (`VPCSTelnet`)
|
||||
|
||||
### Problem Statement
|
||||
|
||||
VPCS (Virtual PC Simulator) is a lightweight virtual PC simulator used in GNS3 lab environments. Unlike network devices (routers/switches), VPCS devices:
|
||||
|
||||
1. **No authentication** - Direct console access without username/password
|
||||
2. **Simple command interface** - No configuration modes
|
||||
3. **Simple prompt pattern** - `PC1>`, `PC2>`, etc.
|
||||
|
||||
### Solution: Lightweight Custom Driver
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[BaseConnection<br/>Netmiko base class] --> B[VPCSTelnet<br/>Custom GNS3 driver]
|
||||
B --> C[No authentication]
|
||||
B --> D[Simple prompt: PC\d+]
|
||||
B --> E[No config mode]
|
||||
B --> F[ANSI stripping]
|
||||
```
|
||||
|
||||
**Why Not Use Standard Telnet Driver?**
|
||||
- Standard drivers attempt authentication (times out)
|
||||
- No support for VPCS-specific prompt patterns (`PC\d+>`)
|
||||
- No need for configuration mode handling
|
||||
|
||||
### VPCSTelnet Implementation
|
||||
|
||||
#### Location
|
||||
```
|
||||
gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py
|
||||
```
|
||||
|
||||
#### Key Features
|
||||
|
||||
**1. No Authentication**
|
||||
```python
|
||||
def telnet_login(self, pri_prompt_terminator=r"PC\d+>", ...):
|
||||
# Send returns until VPCS prompt detected
|
||||
for i in range(max_loops):
|
||||
self.write_channel(self.RETURN)
|
||||
output = self.read_channel()
|
||||
|
||||
if re.search(pri_prompt_terminator, output):
|
||||
return output # Success - VPCS prompt detected
|
||||
```
|
||||
|
||||
**2. Simple Prompt Recognition**
|
||||
```
|
||||
PC1> ip 10.10.0.12/24 10.10.0.254
|
||||
PC1> ping 10.10.0.254
|
||||
```
|
||||
|
||||
**3. No Configuration Mode**
|
||||
```python
|
||||
def check_config_mode(self) -> bool:
|
||||
return False # VPCS has no config mode
|
||||
|
||||
def config_mode(self) -> str:
|
||||
return "" # No config mode to enter
|
||||
|
||||
def exit_config_mode(self) -> str:
|
||||
return "" # No config mode to exit
|
||||
```
|
||||
|
||||
**4. No Paging**
|
||||
```python
|
||||
def disable_paging(self) -> str:
|
||||
return "" # VPCS doesn't use paging
|
||||
```
|
||||
|
||||
**5. Custom `send_command`**
|
||||
VPCS overrides `send_command` with non-standard defaults:
|
||||
- `strip_prompt=False`, `strip_command=False`, `normalize=False` — VPCS output doesn't need standard Netmiko processing
|
||||
- Uses `_strip_ansi_codes()` to clean terminal escape sequences from VPCS output
|
||||
|
||||
**6. ANSI Escape Code Stripping**
|
||||
VPCS output often contains ANSI terminal codes. The `_strip_ansi_codes()` method strips bold, underline, reset, and color codes before returning output to callers.
|
||||
|
||||
### VPCS Tool Usage
|
||||
|
||||
The VPCS driver is used by the `execute_vpcs_commands` tool:
|
||||
|
||||
> File: `gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py`
|
||||
|
||||
**VPCS Connection Parameters:**
|
||||
|
||||
VPCS devices use additional Netmiko parameters for reliable connections:
|
||||
| Parameter | Value | Reason |
|
||||
|-----------|-------|--------|
|
||||
| `fast_cli` | `False` | VPCS is slow, disable fast CLI mode |
|
||||
| `global_delay_factor` | `2.0` | Double the delay between commands |
|
||||
|
||||
```python
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {
|
||||
"device_type": "gns3_vpcs_telnet",
|
||||
"fast_cli": False,
|
||||
"global_delay_factor": 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### VPCS Built-in Template Configuration
|
||||
|
||||
**✨ Automatic Tags - No Manual Configuration Required**
|
||||
|
||||
VPCS nodes created from the built-in template automatically include the necessary tags:
|
||||
|
||||
| Tag | Value | Purpose |
|
||||
|-----|-------|---------|
|
||||
| `device_type` | `gns3_vpcs_telnet` | Netmiko driver selection |
|
||||
|
||||
**Built-in Template Definition:**
|
||||
> File: `gns3server/services/templates.py`
|
||||
|
||||
The VPCS built-in template includes `"tags": ["device_type:gns3_vpcs_telnet"]`, which is automatically applied to all VPCS nodes created from the template.
|
||||
|
||||
**User Benefits:**
|
||||
- ✅ **No manual tagging required** — Tags are applied automatically when creating VPCS nodes
|
||||
- ✅ **Automatic driver selection** — Copilot tools automatically use the correct Netmiko driver
|
||||
- ✅ **Consistent behavior** — All VPCS nodes from the built-in template work identically
|
||||
- ✅ **Zero configuration** — Users don't need to understand device_type tags
|
||||
|
||||
**How It Works:**
|
||||
1. User creates a VPCS node from the built-in "VPCS" template
|
||||
2. Node automatically inherits the tag: `device_type:gns3_vpcs_telnet`
|
||||
3. Copilot tools read the tag and select the appropriate VPCS Netmiko driver
|
||||
4. Commands execute using the VPCS-optimized driver (no authentication, simple prompts)
|
||||
|
||||
### Supported VPCS Commands
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `ip` | Configure/show IP address | `ip 10.10.0.12/24 10.10.0.254` |
|
||||
| `ping` | Test connectivity | `ping 10.10.0.254` |
|
||||
| `arp` | Display ARP table | `arp` |
|
||||
| `show ip` | Show IP configuration | `show ip` |
|
||||
| `version` | Show VPCS version | `version` |
|
||||
| `save` | Save configuration | `save` |
|
||||
| `load` | Load configuration | `load` |
|
||||
|
||||
### Architecture Benefits
|
||||
|
||||
**Unified Tool Architecture:**
|
||||
- ✅ Uses Nornir for connection management (same as network device tools)
|
||||
- ✅ Uses Netmiko for command execution (consistent with other tools)
|
||||
- ✅ Follows same patterns as `config_tools_nornir.py` and `display_tools_nornir.py`
|
||||
- ✅ Simplified codebase - no need for separate telnetlib3 implementation
|
||||
|
||||
**Migration from telnetlib3:**
|
||||
| Aspect | Old (telnetlib3) | New (Netmiko + Nornir) |
|
||||
|--------|-----------------|-------------------------|
|
||||
| Library | telnetlib3 | Netmiko |
|
||||
| Framework | Manual threading | Nornir |
|
||||
| Code Lines | ~490 lines | ~580 lines (with better structure) |
|
||||
| Consistency | Unique implementation | Same as other tools |
|
||||
| Maintenance | Separate code path | Unified architecture |
|
||||
|
||||
---
|
||||
|
||||
## Custom Huawei Driver (`GNS3HuaweiTelnetCE`)
|
||||
|
||||
### Problem Statement
|
||||
|
||||
GNS3-emulated Huawei devices connect via console **without requiring authentication**. Standard Netmiko drivers attempt username/password authentication, causing connection timeouts.
|
||||
|
||||
**Standard Driver Behavior:**
|
||||
```
|
||||
Telnet Connection → Wait for username prompt → Send username → Wait for password → Send password → Access
|
||||
^ Times out after 20 seconds
|
||||
```
|
||||
|
||||
**GNS3 Huawei Device:**
|
||||
```
|
||||
Telnet Connection → Direct access to command line (no login prompts)
|
||||
<HUAWEI>
|
||||
```
|
||||
|
||||
### Solution: Custom Driver Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[BaseConnection] --> B[CiscoBaseConnection]
|
||||
B --> C[HuaweiBase<br/>VRP support, system-view,<br/>prompt patterns, paging]
|
||||
C --> D[GNS3HuaweiTelnetCE<br/>Overrides telnet_login only]
|
||||
D --> E[Skip authentication]
|
||||
D --> F[Auto-commit before exit]
|
||||
D --> G[y/n auto-confirm]
|
||||
```
|
||||
|
||||
**Why Inherit from HuaweiBase?**
|
||||
- ✅ Built-in VRP (Versatile Routing Platform) command handling
|
||||
- ✅ Huawei-specific configuration mode (`system-view`)
|
||||
- ✅ Huawei prompt patterns (`<...>`, `[...]`)
|
||||
- ✅ Huawei paging disable (`screen-length 0 temporary`)
|
||||
- ✅ Minimal code changes - only override authentication
|
||||
|
||||
### GNS3HuaweiTelnetCE Implementation
|
||||
|
||||
#### Location
|
||||
```
|
||||
gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py
|
||||
```
|
||||
|
||||
**Package Structure:**
|
||||
```
|
||||
custom_netmiko/
|
||||
├── __init__.py # Package initialization, auto-registers all drivers
|
||||
├── huawei_ce.py # Huawei CloudEngine custom driver
|
||||
├── README.md # Driver development guide
|
||||
└── tests/ # Unit tests
|
||||
├── __init__.py
|
||||
└── test_huawei_ce.py # Huawei CE driver tests
|
||||
```
|
||||
|
||||
#### Key Features
|
||||
|
||||
1. **Skip Authentication**
|
||||
- Directly detect Huawei prompt patterns
|
||||
- No username/password prompts
|
||||
- Connection ready in < 1 second
|
||||
|
||||
2. **VRP Prompt Recognition**
|
||||
```
|
||||
User view: <HUAWEI>
|
||||
System view: [HUAWEI]
|
||||
Interface: [HUAWEI-GigabitEthernet0/0/1]
|
||||
```
|
||||
|
||||
3. **Automatic Confirmation Handling**
|
||||
- Detects and responds to `[y/n]` prompts
|
||||
- Example: `return` command asks "Return to user view? [y/n]:"
|
||||
- Automatically sends `y` to confirm
|
||||
|
||||
4. **Proper Output Collection**
|
||||
- Uses Netmiko's `read_channel_timing()` for reliable output
|
||||
- Waits for command completion (2s no new data = done)
|
||||
- 30-second absolute timeout prevents hanging
|
||||
|
||||
5. **Auto-Commit Before Exit**
|
||||
- Automatically sends `commit` command before exiting config mode
|
||||
- Prevents "Uncommitted configurations [Y/N/C]" prompt
|
||||
- Ensures configuration changes are saved
|
||||
|
||||
#### Limitations
|
||||
|
||||
**Why Not Use the Standard `huawei_telnet` Driver?**
|
||||
- The standard Netmiko Huawei driver (`huawei_telnet`) has known issues in GNS3 emulation environments
|
||||
- This is the primary reason the custom `gns3_huawei_telnet_ce` driver was developed
|
||||
- The custom driver is designed for GNS3 devices **without authentication**
|
||||
- If your GNS3 Huawei device has been configured with authentication, remove it for GNS3 testing to use the custom driver
|
||||
|
||||
**When to Use:**
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|----------|---------------|
|
||||
| GNS3 Huawei device | Always use `gns3_huawei_telnet_ce` |
|
||||
| GNS3 device has auth configured | Remove auth, then use `gns3_huawei_telnet_ce` |
|
||||
|
||||
#### Method Overrides
|
||||
|
||||
**1. `telnet_login` - Skip Authentication**
|
||||
```python
|
||||
def telnet_login(self, pri_prompt_terminator=r"<\S+>|>\s*$",
|
||||
alt_prompt_terminator=r"\[\S+\]", ...) -> str:
|
||||
# Clear buffer
|
||||
self.read_channel()
|
||||
|
||||
# Send returns until prompt detected
|
||||
for i in range(max_loops):
|
||||
self.write_channel(self.RETURN)
|
||||
output = self.read_channel()
|
||||
|
||||
# Check for Huawei prompts
|
||||
if re.search(pri_prompt_terminator, output):
|
||||
return output # Success!
|
||||
|
||||
return output # Best effort
|
||||
```
|
||||
|
||||
**2. `send_config_set` - Configuration Commands**
|
||||
```python
|
||||
def send_config_set(self, config_commands, **kwargs) -> str:
|
||||
# Enter config mode
|
||||
output += self.config_mode(config_command="system-view")
|
||||
|
||||
# Send all commands
|
||||
for cmd in config_commands:
|
||||
self.write_channel(f"{cmd}{self.RETURN}")
|
||||
time.sleep(delay_factor * 0.05)
|
||||
|
||||
# Collect output using Netmiko standard method
|
||||
output += self.read_channel_timing(read_timeout=30, last_read=2.0)
|
||||
|
||||
# Auto-commit before exit (prevents [Y/N/C] prompt)
|
||||
self.write_channel(f"commit{self.RETURN}")
|
||||
time.sleep(0.5 * self.global_delay_factor)
|
||||
output += self.read_channel()
|
||||
|
||||
# Exit config mode
|
||||
output += self.exit_config_mode()
|
||||
|
||||
return output
|
||||
```
|
||||
|
||||
**3. `exit_config_mode` - Handle Confirmation**
|
||||
```python
|
||||
def exit_config_mode(self, exit_config="return", pattern=r"<\S+>|>\s*$") -> str:
|
||||
self.write_channel(f"return{self.RETURN}")
|
||||
|
||||
# Look for confirmation prompt
|
||||
for _ in range(20):
|
||||
new_output = self.read_channel()
|
||||
|
||||
if re.search(r"\[y/n\]", new_output):
|
||||
self.write_channel(f"y{self.RETURN}") # Auto-confirm
|
||||
|
||||
if re.search(pattern, new_output):
|
||||
return output # Back to user view
|
||||
|
||||
return output
|
||||
```
|
||||
|
||||
### Device Type Registration
|
||||
|
||||
The custom driver must be registered with Netmiko's global mappings:
|
||||
|
||||
```python
|
||||
def register_custom_device_type() -> None:
|
||||
import importlib
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Register in CLASS_MAPPER (for ConnectHandler)
|
||||
sd.CLASS_MAPPER["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
|
||||
sd.CLASS_MAPPER["huawei_ce"] = GNS3HuaweiTelnetCE
|
||||
|
||||
# Register in CLASS_MAPPER_BASE (for base class definitions)
|
||||
sd.CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
|
||||
sd.CLASS_MAPPER_BASE["huawei_ce"] = GNS3HuaweiTelnetCE
|
||||
|
||||
# CRITICAL: Rebuild static lists
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
```
|
||||
|
||||
**Important: Static List Problem**
|
||||
- `ssh_dispatcher.platforms` is computed at module import time
|
||||
- Modifying `CLASS_MAPPER` doesn't automatically update `platforms`
|
||||
- Must manually rebuild the list after registration
|
||||
|
||||
**Auto-Registration**
|
||||
```python
|
||||
# Automatically runs on module import
|
||||
try:
|
||||
register_custom_device_type()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register custom device type: {e}")
|
||||
```
|
||||
|
||||
## Custom Ruijie Driver (`RuijieTelnetEnhanced`)
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Ruijie (锐捷) network devices exhibit Cisco-like command syntax but have interactive prompts during configuration that can cause standard Netmiko drivers to fail.
|
||||
|
||||
**Common Issue - OSPF Router-ID:**
|
||||
```
|
||||
Router(config-router)#router-id 10.0.0.1
|
||||
Change router-id and update OSPF process! [yes/no]:
|
||||
```
|
||||
|
||||
Standard Netmiko's `send_config_set()` waits for a prompt pattern, but the `[yes/no]:` prompt doesn't match the expected config mode pattern, causing:
|
||||
1. **ReadTimeout**: Netmiko times out waiting for the prompt
|
||||
2. **Command Failure**: Subsequent commands are not executed
|
||||
3. **Device Lockup**: Console remains in the waiting state
|
||||
|
||||
### Solution: Hybrid Strategy with Interactive Prompt Handling
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[BaseConnection] --> B[CiscoBaseConnection]
|
||||
B --> C[RuijieOSBase<br/>Netmiko built-in]
|
||||
C --> D[RuijieTelnetEnhanced<br/>Interactive prompt handling]
|
||||
D --> E[Preprocessing<br/>Insert yes after known commands]
|
||||
D --> F[Batch send<br/>Fast path 2-3s]
|
||||
D --> G[Fallback one-by-one<br/>Slow but reliable ~10s]
|
||||
```
|
||||
|
||||
**Why Hybrid Strategy?**
|
||||
1. **Preprocessing**: Automatically insert `yes` after known interactive commands
|
||||
2. **Fast Path**: Batch send for most commands (2-3 seconds for 13 commands)
|
||||
3. **Fallback**: One-by-one send with real-time detection (reliable but slower)
|
||||
|
||||
### RuijieTelnetEnhanced Implementation
|
||||
|
||||
#### Location
|
||||
```
|
||||
gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py
|
||||
```
|
||||
|
||||
#### Key Features
|
||||
|
||||
**1. Preprocessing - Known Interactive Commands**
|
||||
```python
|
||||
INTERACTIVE_PATTERNS = [
|
||||
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF/EIGRP/BGP router-id
|
||||
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
|
||||
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
|
||||
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
|
||||
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
|
||||
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
|
||||
]
|
||||
```
|
||||
|
||||
**2. Hybrid Send Strategy**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Input Configuration Commands] --> B[Preprocessing]
|
||||
B --> B1[Detect interactive commands]
|
||||
B1 --> B2[Insert 'yes' after them]
|
||||
B2 --> C[Try Batch Send - Fast]
|
||||
C --> C1[Write all commands rapidly]
|
||||
C1 --> C2[Read output once, last_read=2.0s]
|
||||
C2 --> D{Success?}
|
||||
D -->|Yes| E[Return output]
|
||||
D -->|No| F[Fallback: One-by-One]
|
||||
F --> F1[Send each command individually]
|
||||
F1 --> F2[Detect prompts after each]
|
||||
F2 --> F3[Send 'yes' when needed, last_read=0.5s]
|
||||
F3 --> E
|
||||
```
|
||||
|
||||
**3. Batch Send Performance**
|
||||
- **13 commands** with 1 interactive command
|
||||
- **Fast path**: ~2-3 seconds (includes device processing time)
|
||||
- **Fallback**: ~10 seconds (if batch fails)
|
||||
|
||||
**4. Real-time Detection (Fallback)**
|
||||
```python
|
||||
# After each command
|
||||
new_output = self.read_channel_timing(read_timeout=10, last_read=0.5)
|
||||
|
||||
# Check for [yes/no] prompt
|
||||
if re.search(r"\[yes/no\]", new_output, re.IGNORECASE):
|
||||
self.write_channel(f"yes{self.RETURN}")
|
||||
output += self.read_channel_timing(read_timeout=30, last_read=0.5)
|
||||
```
|
||||
|
||||
#### GNS3 Node Tag Configuration
|
||||
|
||||
**For Ruijie devices in GNS3:**
|
||||
```
|
||||
device_type:gns3_ruijie_telnet
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```python
|
||||
from netmiko import ConnectHandler
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko
|
||||
|
||||
device = {
|
||||
"device_type": "gns3_ruijie_telnet",
|
||||
"host": "127.0.0.1",
|
||||
"port": 5000,
|
||||
}
|
||||
|
||||
with ConnectHandler(**device) as conn:
|
||||
# These commands include router-id (interactive)
|
||||
config = [
|
||||
"router ospf 1",
|
||||
"router-id 10.0.0.1", # Triggers [yes/no] prompt
|
||||
"network 192.168.1.0 0.0.0.255 area 0",
|
||||
]
|
||||
# Automatically handles the [yes/no] prompt
|
||||
output = conn.send_config_set(config)
|
||||
```
|
||||
|
||||
#### Limitations
|
||||
|
||||
**Interactive Command Coverage:**
|
||||
- **Covered**: `router-id`, `erase`, `delete`, `format`, `reload`, `boot system`
|
||||
- **Not Covered**: Unknown or vendor-specific interactive prompts
|
||||
- **Fallback**: If batch fails, falls back to one-by-one with real-time detection
|
||||
|
||||
**When to Use:**
|
||||
|
||||
| Scenario | Use Driver |
|
||||
|----------|------------|
|
||||
| GNS3 Ruijie (known interactive commands) | `gns3_ruijie_telnet` (batch + auto-fallback) |
|
||||
| GNS3 Ruijie (unknown interactive prompts) | `gns3_ruijie_telnet` (auto-fallback to one-by-one) |
|
||||
|
||||
## Dynamic Device Type Detection
|
||||
|
||||
### GNS3 Node Tags
|
||||
|
||||
Device type is extracted from GNS3 node tags:
|
||||
|
||||
```
|
||||
device_type:gns3_huawei_telnet_ce → Netmiko driver selection
|
||||
```
|
||||
|
||||
**Tag Examples:**
|
||||
|
||||
| Vendor | Device Type Tag | Template Source |
|
||||
|--------|----------------|-----------------|
|
||||
| Cisco IOS | `device_type:cisco_ios_telnet` | User appliance |
|
||||
| Huawei CE | `device_type:gns3_huawei_telnet_ce` | User appliance |
|
||||
| Ruijie | `device_type:gns3_ruijie_telnet` | User appliance |
|
||||
| **VPCS** | `device_type:gns3_vpcs_telnet` | **Built-in ✅** |
|
||||
|
||||
**VPCS Built-in Template:**
|
||||
- VPCS has a **built-in template** with pre-configured `device_type` tag
|
||||
- The tag is **automatically applied** when creating VPCS nodes
|
||||
- No manual configuration required — works out of the box
|
||||
- Other devices require users to import appliances and configure tags manually
|
||||
|
||||
### Nornir Best Practice: Host-Level Connection Configuration
|
||||
|
||||
The system uses **Nornir's configuration priority** (host > group > defaults) to handle multi-vendor environments efficiently:
|
||||
|
||||
```python
|
||||
# From get_gns3_device_port.py
|
||||
hosts_data[device_name] = {
|
||||
"port": console_port,
|
||||
"groups": ["network_devices"], # All devices share one group
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {"device_type": device_type} # Device-specific driver
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Why Host-Level `connection_options`?**
|
||||
- ✅ Each device has its own `device_type` (host-level config)
|
||||
- ✅ All devices share common settings via group inheritance (`hostname`, `timeout`)
|
||||
- ✅ No need to dynamically create multiple groups for each device type
|
||||
- ✅ Cleaner code structure — single generic group for all devices
|
||||
- ✅ Follows Nornir best practice: "configuration proximity"
|
||||
|
||||
**Configuration Priority:**
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Host Level<br/>connection_options.device_type<br/>(gns3_huawei_telnet_ce, cisco_ios_telnet, etc.)"]
|
||||
B["Group Level<br/>hostname=127.0.0.1, timeout=120<br/>username, password"]
|
||||
C["Defaults Level<br/>data.location=gns3"]
|
||||
A -->|overrides| B
|
||||
B -->|overrides| C
|
||||
```
|
||||
|
||||
| Aspect | Old: Dynamic Groups | New: Host-Level Config |
|
||||
|--------|--------------------|-----------------------|
|
||||
| Group count | One per vendor type | Single `network_devices` group |
|
||||
| Device type location | Group `connection_options` | Host `connection_options` |
|
||||
| Complexity | Dynamic group creation logic | No dynamic group logic |
|
||||
|
||||
## Architecture Evolution
|
||||
|
||||
### Problem: Multi-Vendor Device Support
|
||||
|
||||
**Initial Challenge:**
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Topology: Cisco R1 + Huawei SW1 + Juniper SRX] --> B[Need: Different Netmiko drivers per device]
|
||||
B --> C[Question: How to configure Nornir for multiple device types?]
|
||||
```
|
||||
|
||||
### Solution Evolution
|
||||
|
||||
| Approach | Strategy | Issue |
|
||||
|----------|----------|-------|
|
||||
| ❌ Approach 1 | Single group with first device's `device_type` | All devices use wrong driver |
|
||||
| ❌ Approach 2 | Dynamic groups per vendor (`huawei_telnet`, `cisco_telnet`, ...) | Complex logic, code duplication, not Nornir best practice |
|
||||
| ✅ Approach 3 (Current) | **Host-level `connection_options`** + single generic group | Clean, follows Nornir host > group > defaults priority |
|
||||
|
||||
> See `get_gns3_device_port.py:get_device_ports_from_topology()` for the current implementation.
|
||||
|
||||
### Configuration Priority Demonstration
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Host Level (highest)<br/>device_type: gns3_huawei_telnet_ce"]
|
||||
B["Group Level (middle)<br/>hostname: 127.0.0.1, timeout: 120"]
|
||||
C["Defaults Level (lowest)<br/>data.location: gns3"]
|
||||
A -->|overrides| B -->|overrides| C
|
||||
D[Result] --> D1["Each device uses its own device_type"]
|
||||
D --> D2["All share hostname, timeout from group"]
|
||||
D --> D3["All share data.location from defaults"]
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Direct Netmiko Usage
|
||||
|
||||
**Custom driver auto-registers on import. No username/password needed for GNS3 devices.**
|
||||
|
||||
| Device Type | `device_type` | Auth | Notes |
|
||||
|-------------|---------------|------|-------|
|
||||
| Huawei CE | `gns3_huawei_telnet_ce` | None | Custom driver, skip auth |
|
||||
| Ruijie | `gns3_ruijie_telnet` | None | Custom driver, interactive prompts handled |
|
||||
| VPCS | `gns3_vpcs_telnet` | None | Custom driver, no config mode |
|
||||
| Cisco IOS | `cisco_ios_telnet` | Required | Standard Netmiko driver |
|
||||
|
||||
### GNS3 Copilot Tool Usage
|
||||
|
||||
> Tools: `tools_v2/display_tools_nornir.py`, `tools_v2/config_tools_nornir.py`, `tools_v2/vpcs_tools_netmiko.py`
|
||||
|
||||
Each tool reads node tags to determine `device_type`, builds host-level Nornir inventory, and dispatches commands to the appropriate Netmiko driver. Multi-vendor topologies are handled transparently — each device uses its own driver.
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
gns3server/agent/gns3_copilot/
|
||||
├── utils/
|
||||
│ ├── custom_netmiko/ # Custom Netmiko drivers package
|
||||
│ │ ├── __init__.py # Auto-registers all drivers on import
|
||||
│ │ ├── huawei_ce.py # Huawei CloudEngine driver
|
||||
│ │ ├── ruijie_telnet.py # Ruijie enhanced driver
|
||||
│ │ ├── vpcs_telnet.py # VPCS simulator driver
|
||||
│ │ ├── README.md # Driver development guide
|
||||
│ │ └── tests/ # Unit tests (52 total)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── test_huawei_ce.py # Huawei CE driver tests (9)
|
||||
│ │ ├── test_ruijie_telnet.py # Ruijie driver tests (15)
|
||||
│ │ └── test_vpcs_telnet.py # VPCS driver tests (28)
|
||||
│ └── get_gns3_device_port.py # Device port extraction
|
||||
│ └── get_device_ports_from_topology() # Extract ports + device_type from tags
|
||||
├── tools_v2/
|
||||
│ ├── display_tools_nornir.py # Multi-vendor display commands
|
||||
│ ├── config_tools_nornir.py # Multi-vendor config commands
|
||||
│ │ └── _expand_multiline_commands() # Expand banner commands
|
||||
│ └── vpcs_tools_netmiko.py # VPCS commands using Nornir + Netmiko
|
||||
│ └── VPCSCommands # VPCS tool class
|
||||
```
|
||||
|
||||
**Key Architectural Changes (2026-03-14):**
|
||||
- ❌ Removed: `vpcs_tools_telnetlib3.py` - Replaced with Netmiko implementation
|
||||
- ✅ Added: `vpcs_telnet.py` - Custom VPCS driver for Netmiko
|
||||
- ✅ Added: `vpcs_tools_netmiko.py` - VPCS tool using Nornir + Netmiko
|
||||
- ✅ Simplified: Unified tool architecture - all tools use Nornir + Netmiko
|
||||
- ✅ Updated: Module structure - all tools follow same pattern
|
||||
|
||||
**Key Architectural Changes (2026-03-13):**
|
||||
- ❌ Removed: `_get_nornir_groups_config()` - No longer needed
|
||||
- ❌ Removed: `_get_nornir_group()` - No longer needed
|
||||
- ✅ Simplified: `_initialize_nornir()` - Uses single generic group
|
||||
- ✅ Updated: `get_gns3_device_port.py()` - Returns host-level `connection_options`
|
||||
- ✅ Added: `ruijie_telnet.py` - Custom Ruijie driver with interactive prompt handling
|
||||
- ✅ Added: `_expand_multiline_commands()` - Auto-expands banner and multi-line commands
|
||||
- ✅ Added: device_type tag validation with error feedback (inline in `_run`)
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### Test Coverage
|
||||
|
||||
> Test files located at: `gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/`
|
||||
|
||||
**Total: 52 tests across 3 test files**
|
||||
|
||||
| Test File | Tests | Coverage Focus |
|
||||
|-----------|-------|----------------|
|
||||
| `test_huawei_ce.py` | 9 | Registration, inheritance, VRP methods, mock connection |
|
||||
| `test_ruijie_telnet.py` | 15 | Registration, interactive patterns, preprocessing, batch/fallback |
|
||||
| `test_vpcs_telnet.py` | 28 | Registration, login, command sending, ANSI stripping, config mode |
|
||||
|
||||
#### Huawei CE Tests (`TestHuaweiTelnetCEDriver` + `TestHuaweiTelnetCEIntegration`)
|
||||
- Device type registration in CLASS_MAPPER
|
||||
- Inheritance from HuaweiBase
|
||||
- VRP-specific methods (`config_mode`, `check_config_mode`, `exit_config_mode`)
|
||||
- Prompt pattern constants
|
||||
- Mock telnet connection
|
||||
|
||||
#### Ruijie Tests (`TestRuijieTelnetEnhancedDriver` + `TestRuijieTelnetEnhancedIntegration`)
|
||||
- Device type registration
|
||||
- Inheritance from RuijieOSBase
|
||||
- Interactive pattern matching (`router-id`, `erase`, `delete`, etc.)
|
||||
- Preprocessing of interactive commands
|
||||
- Batch and fallback send strategies
|
||||
|
||||
#### VPCS Tests (`TestVPCSTelnetDriver` + 5 more test classes)
|
||||
- Device type registration
|
||||
- Inheritance from BaseConnection
|
||||
- No-auth login with `PC\d+>` prompt
|
||||
- `send_command` / `send_command_timing` overrides
|
||||
- ANSI escape code stripping (8 dedicated tests)
|
||||
- Config mode always returns empty/false
|
||||
|
||||
**Running Tests:**
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python -m pytest gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/
|
||||
```
|
||||
|
||||
**Current Test Status:** ✅ All 52 tests passing
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Custom Netmiko README](../../../../../gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md) - Driver development guide
|
||||
- [Node Control Tools](./node-control-tools.md) - Device lifecycle management
|
||||
- [Command Security](./command-security.md) - Command filtering and validation
|
||||
- [Chat API](./chat-api.md) - Session management and SSE
|
||||
|
||||
## References
|
||||
|
||||
- [Netmiko Documentation](https://ktbyers.github.io/netmiko/)
|
||||
- [Netmiko PLATFORMS.md](https://github.com/ktbyers/netmiko/blob/master/PLATFORMS.md)
|
||||
- [Nornir Documentation](https://nornir.readthedocs.io/)
|
||||
|
||||
---
|
||||
|
||||
_Implementation Date: 2026-03-12_
|
||||
|
||||
_Last Updated: 2026-04-20 (Documentation review: fixed VPCS template tags, updated test coverage, added Mermaid diagrams, removed code bloat)_
|
||||
|
||||
_Status: ✅ Implemented - Custom drivers for Huawei, Ruijie, and VPCS; multi-vendor support with Cisco IOS, Huawei, Ruijie, and VPCS tested_
|
||||
|
||||
_Architecture: Nornir best practice - host-level connection_options with single generic group_
|
||||
|
||||
_Unit Tests: ✅ 52/52 passing (9 Huawei + 15 Ruijie + 28 VPCS)_
|
||||
|
||||
_Changelog:_
|
||||
- **2026-03-14**: Added VPCS support and unified tool architecture
|
||||
- Implemented `VPCSTelnet` custom Netmiko driver for VPCS simulator
|
||||
- Replaced `vpcs_tools_telnetlib3.py` with `vpcs_tools_netmiko.py`
|
||||
- Unified all tools to use Nornir + Netmiko architecture
|
||||
- Removed dependency on telnetlib3 for VPCS devices
|
||||
- Improved code consistency and maintainability
|
||||
- **2026-03-13 (Evening)**: Added Ruijie enhanced driver and interactive command handling
|
||||
- Implemented `RuijieTelnetEnhanced` with hybrid batch/fallback strategy
|
||||
- Added automatic `yes` insertion for known interactive commands (`router-id`, `erase`, etc.)
|
||||
- Achieves ~2-3 seconds for 13 commands (vs 10+ seconds for one-by-one)
|
||||
- Auto-fallback to real-time detection for unknown interactive prompts
|
||||
- Registered `gns3_ruijie_telnet` device type
|
||||
- **2026-03-13 (Afternoon)**: Enhanced configuration safety and command handling
|
||||
- Added AAA/password configuration prohibition in system prompts
|
||||
- Implemented multi-line command expansion (`_expand_multiline_commands()`)
|
||||
- Added `device_type` tag validation with error feedback
|
||||
- Prevents execution of commands that could lock users out of devices
|
||||
- Properly handles banner and other multi-line configuration commands
|
||||
- **2026-03-13 (Morning)**: Refactored to use host-level `connection_options` instead of dynamic groups
|
||||
- Removed `_get_nornir_groups_config()` and `_get_nornir_group()` helper functions
|
||||
- Simplified `_initialize_nornir()` to use single generic group
|
||||
- Updated `get_gns3_device_port.py()` to return host-level configuration
|
||||
- **2026-03-12**: Initial implementation with custom Huawei driver
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# External Skills Repository
|
||||
|
||||
## 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.
|
||||
|
||||
The repository provides:
|
||||
- **Injection skills** (39 categories): Network fault scenarios for troubleshooting practice
|
||||
- **Device skills**: Device-specific command knowledge (VPCS, etc.)
|
||||
- **Feature skills**: Topology planning, network design
|
||||
- **System prompts**: Agent personality and behavior definitions
|
||||
- **Forbidden commands**: Security rules for command filtering
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "GNS3-Skills Repository"
|
||||
YAML[injection/*.yaml<br/>device/*.yaml<br/>feature/*.yaml]
|
||||
MD[prompts/*.md]
|
||||
CFG[config/forbidden_commands.txt]
|
||||
end
|
||||
|
||||
subgraph "GNS3 Server"
|
||||
SM[SkillsManager]
|
||||
SL[SkillsLoader]
|
||||
REG[Registry<br/>SKILLS_REGISTRY<br/>INJECTION_SKILLS_REGISTRY]
|
||||
PROMPT[PROMPTS_CACHE]
|
||||
FC[command_filter]
|
||||
end
|
||||
|
||||
YAML --> SL
|
||||
MD --> SL
|
||||
CFG --> FC
|
||||
SL --> REG
|
||||
SL --> PROMPT
|
||||
SM --> SL
|
||||
SM -->|git pull| YAML
|
||||
```
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
GNS3-Skills/
|
||||
├── injection/ # 39 YAML files, one per protocol/category
|
||||
│ ├── ospf_issues.yaml
|
||||
│ ├── bgp_issues.yaml
|
||||
│ ├── vlan_issues.yaml
|
||||
│ └── ...
|
||||
├── device/ # Device-specific skills
|
||||
│ └── vpcs.yaml
|
||||
├── feature/ # Feature skills
|
||||
│ └── topology_planner.yaml
|
||||
├── prompts/ # System prompts (Markdown)
|
||||
│ ├── teaching_assistant.md
|
||||
│ ├── lab_automation_assistant.md
|
||||
│ ├── troubleshooting_injection.md
|
||||
│ └── title.md
|
||||
└── config/ # Security configuration
|
||||
└── forbidden_commands.txt
|
||||
```
|
||||
|
||||
## 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_branch = main
|
||||
skills_auto_update = true
|
||||
```
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `skills_repo_url` | `https://github.com/yueguobin/GNS3-Skills.git` | Git repository URL |
|
||||
| `skills_repo_branch` | `main` | Git branch to track |
|
||||
| `skills_auto_update` | `true` | Automatically pull on reload |
|
||||
|
||||
## Initialization Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Server
|
||||
participant agent/__init__.py
|
||||
participant SkillsManager
|
||||
participant Git
|
||||
|
||||
Server->>agent/__init__.py: Import module
|
||||
agent/__init__.py->>SkillsManager: Start background init thread
|
||||
Note over SkillsManager: Thread.join(5s timeout)
|
||||
|
||||
SkillsManager->>Git: Check local repo exists?
|
||||
alt No local repo
|
||||
Git->>SkillsManager: git clone (with timeout env)
|
||||
else Local repo exists
|
||||
SkillsManager->>Git: Check uncommitted changes?
|
||||
alt Has uncommitted changes
|
||||
Git-->>SkillsManager: Warn, skip pull
|
||||
else No changes
|
||||
SkillsManager->>Git: git fetch (timeout 10s)
|
||||
SkillsManager->>Git: Behind remote?
|
||||
alt Behind
|
||||
Git->>SkillsManager: git pull
|
||||
else Up to date
|
||||
Git-->>SkillsManager: Nothing to do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SkillsManager->>SkillsManager: reload_skills() - load YAML files
|
||||
SkillsManager->>SkillsManager: reload_prompts() - load Markdown files
|
||||
```
|
||||
|
||||
### Git Timeout Configuration
|
||||
|
||||
Git operations use per-command environment variables to prevent hanging:
|
||||
|
||||
```python
|
||||
_GIT_TIMEOUT_ENV = {
|
||||
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
|
||||
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
|
||||
'GIT_HTTP_LOW_SPEED_LIMIT': '1000', # < 1 KB/s = slow → abort
|
||||
}
|
||||
```
|
||||
|
||||
These apply only to the specific `clone`/`fetch`/`pull` subprocess, not to the global environment.
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### POST /copilot/reload/skills
|
||||
|
||||
Triggers a full reload of the skills repository. Performs one git update check, then reloads all skills, prompts, and forbidden commands from local files.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"skills": true,
|
||||
"skill_count": 39,
|
||||
"prompts": true,
|
||||
"prompt_count": 4,
|
||||
"forbidden_commands": 6,
|
||||
"version": "abc123def456..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `success` | Overall success (true if skills or prompts loaded) |
|
||||
| `skills` | Skills reload result |
|
||||
| `skill_count` | Number of injection skills loaded |
|
||||
| `prompts` | Prompts reload result |
|
||||
| `prompt_count` | Number of prompts loaded |
|
||||
| `forbidden_commands` | Number of forbidden command patterns |
|
||||
| `version` | Git commit hash of the repository |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Fault Injection](fault-injection.md)
|
||||
- [Command Security](command-security.md)
|
||||
- [Chat API](chat-api.md)
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# Injection Fault Tracking — Roadmap
|
||||
|
||||
## Problem
|
||||
|
||||
When using the AI Copilot in troubleshooting injection mode across multiple chat sessions within the same project, there is no mechanism to prevent the same fault from being injected repeatedly. Each new session starts with a clean slate, unaware of what faults have already been used. This leads to:
|
||||
|
||||
- Duplicate injection scenarios across different sessions
|
||||
- No visibility for instructors into which faults have been used
|
||||
- No record of which devices were affected or whether faults were resolved
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Track injected faults per chat session and feed the history into the LLM's system prompt as context, so the model actively avoids repeating scenarios.
|
||||
|
||||
### 1. Storage
|
||||
|
||||
The existing `chat_sessions` table has `metadata` and `stats` JSON columns. After each fault injection, the agent records:
|
||||
|
||||
```json
|
||||
// stored in chat_session.metadata
|
||||
{
|
||||
"injected_fault": {
|
||||
"fault_type": "injection_ospf",
|
||||
"issue_key": "ospf_hello_dead_mismatch",
|
||||
"issue_name": "OSPF Hello/Dead Interval Mismatch",
|
||||
"device": "R1",
|
||||
"severity": "major",
|
||||
"injected_at": "2026-05-11T23:00:00Z",
|
||||
"resolved": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Context Injection
|
||||
|
||||
When starting a new troubleshooting session, query all previous sessions for the project, filter by `copilot_mode = "troubleshooting_injection"`, extract `injected_fault` data, and inject a summary into the system prompt via a placeholder (similar to the existing `{{topology_info}}` mechanism):
|
||||
|
||||
```
|
||||
Previously injected faults in this project:
|
||||
1. OSPF Hello/Dead Interval Mismatch on R1 (2026-05-11) — resolved
|
||||
2. BGP Route Reflector misconfiguration on R3 (2026-05-10) — resolved
|
||||
|
||||
Select a fault that has NOT been used before.
|
||||
```
|
||||
|
||||
### 3. Prompt Update
|
||||
|
||||
The `troubleshooting_injection.md` prompt in the GNS3-Skills repository gains an `{{injection_history}}` placeholder, and the agent is instructed to choose a fault not in the history list.
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] GNS3-Skills: add `{{injection_history}}` placeholder to `troubleshooting_injection.md`
|
||||
- [ ] gns3-server: implement injection fault recording in agent chat session flow
|
||||
- [ ] gns3-server: implement injection history query across sessions per project
|
||||
- [ ] gns3-server: inject history into system prompt via `context_manager.py`
|
||||
- [ ] gns3-server: add `exclude` parameter to `InjectionSkillsTool` for filtering used faults
|
||||
@@ -0,0 +1,255 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
|
||||
# Netmiko Supported Devices
|
||||
|
||||
**Netmiko Version:** 4.6.0
|
||||
**Generated:** 2026-03-14 13:10:00
|
||||
**Last Updated:** 2026-03-14 (Added VPCS support)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Device Types:** 366
|
||||
- **SSH Devices:** 154
|
||||
- **Telnet Devices:** 55
|
||||
- **Custom Devices:** 3
|
||||
|
||||
## Custom GNS3 Drivers
|
||||
|
||||
GNS3-Copilot includes custom Netmiko drivers optimized for GNS3 emulation:
|
||||
|
||||
| Platform | Device Type | Description |
|
||||
|----------|-------------|-------------|
|
||||
| Huawei CE | `gns3_huawei_telnet_ce` | Huawei CloudEngine driver with no authentication |
|
||||
| Ruijie | `gns3_ruijie_telnet` | Ruijie OS enhanced driver with interactive command handling |
|
||||
| VPCS | `gns3_vpcs_telnet` | VPCS simulator driver with ANSI code stripping |
|
||||
|
||||
## SSH Supported Devices
|
||||
|
||||
| Platform | Device Type | Source |
|
||||
|----------|-------------|--------|
|
||||
| A10 | `a10_ssh` | Netmiko |
|
||||
| Accedian | `accedian_ssh` | Netmiko |
|
||||
| Adtran | `adtran_os_ssh` | Netmiko |
|
||||
| Adva | `adva_fsp150f2_ssh` | Netmiko |
|
||||
| Adva | `adva_fsp150f3_ssh` | Netmiko |
|
||||
| Alaxala | `alaxala_ax26s_ssh` | Netmiko |
|
||||
| Alaxala | `alaxala_ax36s_ssh` | Netmiko |
|
||||
| Alcatel | `alcatel_aos_ssh` | Netmiko |
|
||||
| Alcatel | `alcatel_sros_ssh` | Netmiko |
|
||||
| Allied | `allied_telesis_awplus_ssh` | Netmiko |
|
||||
| Apresia | `apresia_aeos_ssh` | Netmiko |
|
||||
| Arista | `arista_eos_ssh` | Netmiko |
|
||||
| Arris | `arris_cer_ssh` | Netmiko |
|
||||
| Aruba | `aruba_aoscx_ssh` | Netmiko |
|
||||
| Aruba | `aruba_os_ssh` | Netmiko |
|
||||
| Aruba | `aruba_osswitch_ssh` | Netmiko |
|
||||
| Aruba | `aruba_procurve_ssh` | Netmiko |
|
||||
| Asterfusion | `asterfusion_asternos_ssh` | Netmiko |
|
||||
| Audiocode | `audiocode_66_ssh` | Netmiko |
|
||||
| Audiocode | `audiocode_72_ssh` | Netmiko |
|
||||
| Audiocode | `audiocode_shell_ssh` | Netmiko |
|
||||
| Avaya | `avaya_ers_ssh` | Netmiko |
|
||||
| Avaya | `avaya_vsp_ssh` | Netmiko |
|
||||
| Bintec | `bintec_boss_ssh` | Netmiko |
|
||||
| Broadcom | `broadcom_icos_ssh` | Netmiko |
|
||||
| Brocade | `brocade_fastiron_ssh` | Netmiko |
|
||||
| Brocade | `brocade_fos_ssh` | Netmiko |
|
||||
| Brocade | `brocade_netiron_ssh` | Netmiko |
|
||||
| Brocade | `brocade_nos_ssh` | Netmiko |
|
||||
| Brocade | `brocade_vdx_ssh` | Netmiko |
|
||||
| Brocade | `brocade_vyos_ssh` | Netmiko |
|
||||
| Calix | `calix_b6_ssh` | Netmiko |
|
||||
| Casa | `casa_cmts_ssh` | Netmiko |
|
||||
| Cdot | `cdot_cros_ssh` | Netmiko |
|
||||
| Centec | `centec_os_ssh` | Netmiko |
|
||||
| Check Point | `checkpoint_gaia_ssh` | Netmiko |
|
||||
| Ciena | `ciena_saos10_ssh` | Netmiko |
|
||||
| Ciena | `ciena_saos_ssh` | Netmiko |
|
||||
| Ciena | `ciena_waveserver_ssh` | Netmiko |
|
||||
| Cisco | `cisco_apic_ssh` | Netmiko |
|
||||
| Cisco | `cisco_asa_ssh` | Netmiko |
|
||||
| Cisco | `cisco_ftd_ssh` | Netmiko |
|
||||
| Cisco | `cisco_ios_ssh` | Netmiko |
|
||||
| Cisco | `cisco_nxos_ssh` | Netmiko |
|
||||
| Cisco | `cisco_s200_ssh` | Netmiko |
|
||||
| Cisco | `cisco_s300_ssh` | Netmiko |
|
||||
| Cisco | `cisco_tp_ssh` | Netmiko |
|
||||
| Cisco | `cisco_viptela_ssh` | Netmiko |
|
||||
| Cisco | `cisco_wlc_ssh` | Netmiko |
|
||||
| Cisco | `cisco_xe_ssh` | Netmiko |
|
||||
| Cisco | `cisco_xr_ssh` | Netmiko |
|
||||
| Cloudgenix | `cloudgenix_ion_ssh` | Netmiko |
|
||||
| Corelight | `corelight_linux_ssh` | Netmiko |
|
||||
| Coriant | `coriant_ssh` | Netmiko |
|
||||
| Cumulus | `cumulus_linux_ssh` | Netmiko |
|
||||
| Dell | `dell_dnos9_ssh` | Netmiko |
|
||||
| Dell | `dell_force10_ssh` | Netmiko |
|
||||
| Dell | `dell_isilon_ssh` | Netmiko |
|
||||
| Dell | `dell_os10_ssh` | Netmiko |
|
||||
| Dell | `dell_os6_ssh` | Netmiko |
|
||||
| Dell | `dell_os9_ssh` | Netmiko |
|
||||
| Dell | `dell_powerconnect_ssh` | Netmiko |
|
||||
| Dell | `dell_sonic_ssh` | Netmiko |
|
||||
| Digi | `digi_transport_ssh` | Netmiko |
|
||||
| Dlink | `dlink_ds_ssh` | Netmiko |
|
||||
| Edgecore | `edgecore_sonic_ssh` | Netmiko |
|
||||
| Ekinops | `ekinops_ek360_ssh` | Netmiko |
|
||||
| Eltex | `eltex_esr_ssh` | Netmiko |
|
||||
| Eltex | `eltex_ssh` | Netmiko |
|
||||
| Endace | `endace_ssh` | Netmiko |
|
||||
| Enterasys | `enterasys_ssh` | Netmiko |
|
||||
| Ericsson | `ericsson_ipos_ssh` | Netmiko |
|
||||
| Ericsson | `ericsson_mltn63_ssh` | Netmiko |
|
||||
| Ericsson | `ericsson_mltn66_ssh` | Netmiko |
|
||||
| Extreme | `extreme_ers_ssh` | Netmiko |
|
||||
| Extreme | `extreme_exos_ssh` | Netmiko |
|
||||
| Extreme | `extreme_netiron_ssh` | Netmiko |
|
||||
| Extreme | `extreme_nos_ssh` | Netmiko |
|
||||
| Extreme | `extreme_slx_ssh` | Netmiko |
|
||||
| Extreme | `extreme_ssh` | Netmiko |
|
||||
| Extreme | `extreme_tierra_ssh` | Netmiko |
|
||||
| Extreme | `extreme_vdx_ssh` | Netmiko |
|
||||
| Extreme | `extreme_vsp_ssh` | Netmiko |
|
||||
| Extreme | `extreme_wing_ssh` | Netmiko |
|
||||
| F5 | `f5_linux_ssh` | Netmiko |
|
||||
| F5 | `f5_ltm_ssh` | Netmiko |
|
||||
| F5 | `f5_tmsh_ssh` | Netmiko |
|
||||
| Fiberstore | `fiberstore_fsos_ssh` | Netmiko |
|
||||
| Fiberstore | `fiberstore_fsosv2_ssh` | Netmiko |
|
||||
| Fiberstore | `fiberstore_networkos_ssh` | Netmiko |
|
||||
| Flexvnf | `flexvnf_ssh` | Netmiko |
|
||||
| Fortinet | `fortinet_ssh` | Netmiko |
|
||||
| Garderos | `garderos_grs_ssh` | Netmiko |
|
||||
| Generic | `generic_ssh` | Netmiko |
|
||||
| Generic | `generic_termserver_ssh` | Netmiko |
|
||||
| H3C (华三) | `h3c_comware_ssh` | Netmiko |
|
||||
| HP | `hp_comware_ssh` | Netmiko |
|
||||
| HP | `hp_procurve_ssh` | Netmiko |
|
||||
| Hillstone | `hillstone_stoneos_ssh` | Netmiko |
|
||||
| Huawei | `huawei_olt_ssh` | Netmiko |
|
||||
| Huawei | `huawei_smartax_ssh` | Netmiko |
|
||||
| Huawei | `huawei_smartaxmmi_ssh` | Netmiko |
|
||||
| Huawei | `huawei_ssh` | Netmiko |
|
||||
| Huawei | `huawei_vrp_ssh` | Netmiko |
|
||||
| Huawei | `huawei_vrpv8_ssh` | Netmiko |
|
||||
| Infinera | `infinera_packet_ssh` | Netmiko |
|
||||
| Ipinfusion | `ipinfusion_ocnos_ssh` | Netmiko |
|
||||
| Juniper | `juniper_junos_ssh` | Netmiko |
|
||||
| Juniper | `juniper_screenos_ssh` | Netmiko |
|
||||
| Juniper | `juniper_ssh` | Netmiko |
|
||||
| Keymile | `keymile_nos_ssh` | Netmiko |
|
||||
| Keymile | `keymile_ssh` | Netmiko |
|
||||
| Lancom | `lancom_lcossx4_ssh` | Netmiko |
|
||||
| Linux | `linux_ssh` | Netmiko |
|
||||
| Maipu (迈普) | `maipu_ssh` | Netmiko |
|
||||
| Mellanox | `mellanox_mlnxos_ssh` | Netmiko |
|
||||
| Mellanox | `mellanox_ssh` | Netmiko |
|
||||
| Mikrotik | `mikrotik_routeros_ssh` | Netmiko |
|
||||
| Mikrotik | `mikrotik_switchos_ssh` | Netmiko |
|
||||
| Mrv | `mrv_lx_ssh` | Netmiko |
|
||||
| Mrv | `mrv_optiswitch_ssh` | Netmiko |
|
||||
| Nec | `nec_ix_ssh` | Netmiko |
|
||||
| Netapp | `netapp_cdot_ssh` | Netmiko |
|
||||
| Netgear | `netgear_prosafe_ssh` | Netmiko |
|
||||
| Netscaler | `netscaler_ssh` | Netmiko |
|
||||
| Nokia | `nokia_srl_ssh` | Netmiko |
|
||||
| Nokia | `nokia_sros_ssh` | Netmiko |
|
||||
| Oneaccess | `oneaccess_oneos_ssh` | Netmiko |
|
||||
| Ovs | `ovs_linux_ssh` | Netmiko |
|
||||
| Palo Alto | `paloalto_panos_ssh` | Netmiko |
|
||||
| Pluribus | `pluribus_ssh` | Netmiko |
|
||||
| Quanta | `quanta_mesh_ssh` | Netmiko |
|
||||
| Rad | `rad_etx_ssh` | Netmiko |
|
||||
| Raisecom | `raisecom_roap_ssh` | Netmiko |
|
||||
| Ruckus | `ruckus_fastiron_ssh` | Netmiko |
|
||||
| Ruijie (锐捷) | `ruijie_os_ssh` | Netmiko |
|
||||
| Silverpeak | `silverpeak_vxoa_ssh` | Netmiko |
|
||||
| Sixwind | `sixwind_os_ssh` | Netmiko |
|
||||
| Sophos | `sophos_sfos_ssh` | Netmiko |
|
||||
| Supermicro | `supermicro_smis_ssh` | Netmiko |
|
||||
| Telcosystems | `telcosystems_binos_ssh` | Netmiko |
|
||||
| Teldat | `teldat_cit_ssh` | Netmiko |
|
||||
| Tplink | `tplink_jetstream_ssh` | Netmiko |
|
||||
| Ubiquiti | `ubiquiti_edge_ssh` | Netmiko |
|
||||
| Ubiquiti | `ubiquiti_edgerouter_ssh` | Netmiko |
|
||||
| Ubiquiti | `ubiquiti_edgeswitch_ssh` | Netmiko |
|
||||
| Ubiquiti | `ubiquiti_unifiswitch_ssh` | Netmiko |
|
||||
| Vertiv | `vertiv_mph_ssh` | Netmiko |
|
||||
| Vyatta | `vyatta_vyos_ssh` | Netmiko |
|
||||
| Vyos | `vyos_ssh` | Netmiko |
|
||||
| Watchguard | `watchguard_fireware_ssh` | Netmiko |
|
||||
| Yamaha | `yamaha_ssh` | Netmiko |
|
||||
| ZTE (中兴) | `zte_zxros_ssh` | Netmiko |
|
||||
| Zyxel | `zyxel_os_ssh` | Netmiko |
|
||||
|
||||
## Telnet Supported Devices
|
||||
|
||||
| Platform | Device Type | Source |
|
||||
|----------|-------------|--------|
|
||||
| Adtran | `adtran_os_telnet` | Netmiko |
|
||||
| Apresia | `apresia_aeos_telnet` | Netmiko |
|
||||
| Arista | `arista_eos_telnet` | Netmiko |
|
||||
| Aruba | `aruba_procurve_telnet` | Netmiko |
|
||||
| Audiocode | `audiocode_66_telnet` | Netmiko |
|
||||
| Audiocode | `audiocode_72_telnet` | Netmiko |
|
||||
| Audiocode | `audiocode_shell_telnet` | Netmiko |
|
||||
| Bintec | `bintec_boss_telnet` | Netmiko |
|
||||
| Brocade | `brocade_fastiron_telnet` | Netmiko |
|
||||
| Brocade | `brocade_netiron_telnet` | Netmiko |
|
||||
| Calix | `calix_b6_telnet` | Netmiko |
|
||||
| Centec | `centec_os_telnet` | Netmiko |
|
||||
| Ciena | `ciena_saos_telnet` | Netmiko |
|
||||
| Cisco | `cisco_ios_telnet` | Netmiko |
|
||||
| Cisco | `cisco_nxos_telnet` | Netmiko |
|
||||
| Cisco | `cisco_s200_telnet` | Netmiko |
|
||||
| Cisco | `cisco_s300_telnet` | Netmiko |
|
||||
| Cisco | `cisco_xr_telnet` | Netmiko |
|
||||
| Dell | `dell_dnos6_telnet` | Netmiko |
|
||||
| Dell | `dell_powerconnect_telnet` | Netmiko |
|
||||
| Dlink | `dlink_ds_telnet` | Netmiko |
|
||||
| Extreme | `extreme_exos_telnet` | Netmiko |
|
||||
| Extreme | `extreme_netiron_telnet` | Netmiko |
|
||||
| Extreme | `extreme_telnet` | Netmiko |
|
||||
| Fiberstore | `fiberstore_fsosv2_telnet` | Netmiko |
|
||||
| Generic | `generic_telnet` | Netmiko |
|
||||
| Generic | `generic_termserver_telnet` | Netmiko |
|
||||
| Genexis | `genexis_solt33_telnet` | Netmiko |
|
||||
| HP | `hp_comware_telnet` | Netmiko |
|
||||
| HP | `hp_procurve_telnet` | Netmiko |
|
||||
| Huawei | `huawei_olt_telnet` | Netmiko |
|
||||
| Huawei | `huawei_telnet` | Netmiko |
|
||||
| Huawei | `gns3_huawei_telnet_ce` | Custom ✨ (GNS3) |
|
||||
| Infinera | `infinera_packet_telnet` | Netmiko |
|
||||
| Ipinfusion | `ipinfusion_ocnos_telnet` | Netmiko |
|
||||
| Juniper | `juniper_junos_telnet` | Netmiko |
|
||||
| Maipu (迈普) | `maipu_telnet` | Netmiko |
|
||||
| Nec | `nec_ix_telnet` | Netmiko |
|
||||
| Nokia | `nokia_sros_telnet` | Netmiko |
|
||||
| Oneaccess | `oneaccess_oneos_telnet` | Netmiko |
|
||||
| Optilink | `optilink_eolt11444_telnet` | Netmiko |
|
||||
| Optilink | `optilink_eolt9702_telnet` | Netmiko |
|
||||
| Palo Alto | `paloalto_panos_telnet` | Netmiko |
|
||||
| Rad | `rad_etx_telnet` | Netmiko |
|
||||
| Raisecom | `raisecom_telnet` | Netmiko |
|
||||
| Ruckus | `ruckus_fastiron_telnet` | Netmiko |
|
||||
| Ruijie (锐捷) | `ruijie_os_telnet` | Netmiko |
|
||||
| Ruijie (锐捷) | `gns3_ruijie_telnet` | Custom ✨ (GNS3 Enhanced) |
|
||||
| Supermicro | `supermicro_smis_telnet` | Netmiko |
|
||||
| VPCS | `gns3_vpcs_telnet` | Custom ✨ (GNS3 VPCS Simulator) |
|
||||
| Telcosystems | `telcosystems_binos_telnet` | Netmiko |
|
||||
| Teldat | `teldat_cit_telnet` | Netmiko |
|
||||
| Tplink | `tplink_jetstream_telnet` | Netmiko |
|
||||
| Yamaha | `yamaha_telnet` | Netmiko |
|
||||
| ZTE (中兴) | `zte_zxros_telnet` | Netmiko |
|
||||
|
||||
---
|
||||
|
||||
*This document was generated automatically by the Netmiko device list script.*
|
||||
@@ -0,0 +1,92 @@
|
||||
<!--
|
||||
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,67 @@
|
||||
<!--
|
||||
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 it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
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.
|
||||
|
||||
|
||||
# User Preferences API — Roadmap
|
||||
|
||||
## Problem
|
||||
|
||||
Currently, user-specific preferences (UI theme, language, AI Copilot settings, workspace layout, etc.) can only be stored in browser `localStorage`. This has several drawbacks:
|
||||
|
||||
1. **localStorage clearing** — clearing browser data or switching devices loses all settings
|
||||
2. **No cross-device sync** — users must reconfigure preferences on each device
|
||||
3. **No API access** — CLI tools and third-party clients cannot read/write preferences
|
||||
4. **Fragile persistence** — localStorage can be cleared by browser maintenance or incognito mode
|
||||
|
||||
## Proposed API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/v3/access/users/me/preferences` | Get all preferences for current user |
|
||||
| `PUT` | `/v3/access/users/me/preferences` | Merge preferences (top-level merge, not replace) |
|
||||
| `DELETE` | `/v3/access/users/me/preferences` | Clear all preferences |
|
||||
|
||||
### Storage
|
||||
|
||||
A `preferences` JSON column on the existing `users` table:
|
||||
|
||||
- Type: `JSON`, non-nullable, default `'{}'`
|
||||
- No schema enforcement — clients can store arbitrary key-value pairs
|
||||
- Examples: `{"theme": "dark", "language": "zh-CN", "copilot_default_mode": "teaching_assistant"}`
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
1. Add `preferences` JSON column to `User` ORM model
|
||||
2. Create Alembic migration
|
||||
3. Add `UserPreferencesUpdate` Pydantic schema with `extra="allow"`
|
||||
4. Add `get_user_preferences()` and `update_user_preferences()` to `UsersRepository`
|
||||
5. Add three endpoints to `users.py` router: `GET/PUT/DELETE /me/preferences`
|
||||
|
||||
### Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `gns3server/db/models/user.py` | User ORM model |
|
||||
| `gns3server/db/repositories/users.py` | UsersRepository with database operations |
|
||||
| `gns3server/api/routes/controller/users.py` | User API router |
|
||||
| `gns3server/schemas/` | Pydantic schemas |
|
||||
| `gns3server/db/versions/` | Alembic migrations |
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Add `preferences` JSON column to User ORM model
|
||||
- [ ] Create Alembic migration
|
||||
- [ ] Add `UserPreferencesUpdate` schema
|
||||
- [ ] Add repository methods
|
||||
- [ ] Add API endpoints to users.py router
|
||||
@@ -1,3 +1,7 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
+24
-12
@@ -1,14 +1,12 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>GNS3 controller API - ReDoc</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
|
||||
<link type="text/css" rel="stylesheet" href="/static/swagger-ui.css">
|
||||
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
|
||||
<!--
|
||||
ReDoc doesn't change outer page styles
|
||||
@@ -21,11 +19,25 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
ReDoc requires Javascript to function. Please enable it to browse the documentation.
|
||||
</noscript>
|
||||
<redoc spec-url="openapi.json"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"> </script>
|
||||
<div id="swagger-ui">
|
||||
</div>
|
||||
<script src="/static/swagger-ui-bundle.js"></script>
|
||||
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: 'openapi.json',
|
||||
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#
|
||||
# Copyright (C) 2025 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Agent module with optional AI Copilot 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.
|
||||
|
||||
Installation:
|
||||
pip install gns3-server[ai-copilot]
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
# Feature flag: AI Copilot is available
|
||||
AI_COPILOT_AVAILABLE = False
|
||||
|
||||
# Try to import AI Copilot components
|
||||
try:
|
||||
from .gns3_copilot.project_agent_manager import get_project_agent_manager
|
||||
from .gns3_copilot.project_agent_manager import ProjectAgentManager
|
||||
AI_COPILOT_AVAILABLE = True
|
||||
|
||||
# Start skills repository initialization in background.
|
||||
# Clones/pulls GNS3-Skills during server startup.
|
||||
# Git network timeouts are configured in SkillsManager (_GIT_TIMEOUT_ENV).
|
||||
# The daemon thread auto-terminates when the process exits.
|
||||
from gns3server.agent.gns3_copilot.skills.registry import _ensure_skills_manager
|
||||
|
||||
threading.Thread(target=_ensure_skills_manager, daemon=True).start()
|
||||
|
||||
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_COPILOT_AVAILABLE = False
|
||||
|
||||
# Provide stub functions that raise helpful errors
|
||||
async def get_project_agent_manager():
|
||||
"""
|
||||
Get the global ProjectAgentManager singleton instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If AI Copilot dependencies are not installed
|
||||
"""
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
)
|
||||
|
||||
class ProjectAgentManager:
|
||||
"""
|
||||
Stub class for ProjectAgentManager when AI dependencies are not installed.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AI_COPILOT_AVAILABLE",
|
||||
"get_project_agent_manager",
|
||||
"ProjectAgentManager",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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-Copilot - AI-powered network automation assistant for GNS3.
|
||||
|
||||
This package provides a command-line interface for launching the GNS3-Copilot
|
||||
Streamlit application with support for Streamlit parameter passthrough.
|
||||
"""
|
||||
|
||||
# Dynamic version management
|
||||
__version__: str = "unknown"
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = str(version("gns3-copilot"))
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Yue Guobin (岳国宾)"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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-Copilot Agent Package
|
||||
|
||||
This package contains the main GNS3-Copilot agent implementation for
|
||||
network automation tasks using LangGraph workflow orchestration.
|
||||
|
||||
"""
|
||||
|
||||
from .gns3_copilot import agent_builder
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("gns3-copilot")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Yue Guobin (岳国宾)"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
__all__ = [
|
||||
"agent_builder",
|
||||
]
|
||||
@@ -0,0 +1,641 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
Context Manager for GNS3-Copilot - Using LangGraph pre_model_hook
|
||||
|
||||
This module provides context window management using LangGraph's recommended
|
||||
pre_model_hook approach with accurate token counting:
|
||||
|
||||
Key Features:
|
||||
- Accurate token counting using tiktoken (95%+ accuracy)
|
||||
- Template variable injection for topology info
|
||||
- Context strategy ratios (conservative/balanced/aggressive)
|
||||
- Message trimming using LangChain's native trim_messages
|
||||
- Tool definition token estimation
|
||||
- Detailed token breakdown logging
|
||||
|
||||
Requirements:
|
||||
- tiktoken>=0.8.0 (required)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
# Configure tiktoken cache directory (must be set before importing tiktoken)
|
||||
_cache_dir = Path(__file__).parent.parent / "cache" / "tiktoken"
|
||||
_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.environ["TIKTOKEN_CACHE_DIR"] = str(_cache_dir)
|
||||
|
||||
import tiktoken
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.messages import trim_messages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Token Counter Setup
|
||||
# ============================================================================
|
||||
|
||||
# Initialize tiktoken encoding (required dependency)
|
||||
import time
|
||||
logger.debug("Initializing tiktoken encoding (cl100k_base)...")
|
||||
logger.debug(f"Cache directory: {_cache_dir}")
|
||||
logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)")
|
||||
start_time = time.time()
|
||||
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
|
||||
elapsed = time.time() - start_time
|
||||
logger.debug(f"✓ tiktoken encoding loaded successfully (took {elapsed:.2f}s)")
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
# Context strategy ratios (percentage of context limit to use for input)
|
||||
CONTEXT_STRATEGY_RATIOS = {
|
||||
"conservative": 0.60,
|
||||
"balanced": 0.75,
|
||||
"aggressive": 0.85,
|
||||
}
|
||||
|
||||
DEFAULT_CONTEXT_STRATEGY = "balanced"
|
||||
|
||||
# Token unit conversion
|
||||
TOKENS_PER_K = 1000
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Token Counting Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
"""
|
||||
Count tokens in text using tiktoken.
|
||||
|
||||
Uses cl100k_base encoding (GPT-4) which provides accurate counting
|
||||
for most modern LLMs (OpenAI, Anthropic, DeepSeek, etc.).
|
||||
|
||||
Args:
|
||||
text: Text to count tokens for
|
||||
|
||||
Returns:
|
||||
Exact token count (not estimated)
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
return len(_tiktoken_encoding.encode(text))
|
||||
|
||||
|
||||
def estimate_tool_tokens(tools: list[Any]) -> int:
|
||||
"""
|
||||
Estimate token consumption for tool definitions.
|
||||
|
||||
Tool definitions are serialized as JSON and sent to LLM with each request.
|
||||
This function estimates their token cost.
|
||||
|
||||
Args:
|
||||
tools: List of LangChain tool objects
|
||||
|
||||
Returns:
|
||||
Estimated token count for all tool definitions
|
||||
"""
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
total_tokens = 0
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
# Build tool schema in OpenAI format
|
||||
tool_schema = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
},
|
||||
}
|
||||
|
||||
# Add parameters schema if available
|
||||
if hasattr(tool, "args_schema") and tool.args_schema:
|
||||
try:
|
||||
# Try Pydantic v2 method (model_json_schema)
|
||||
tool_schema["function"]["parameters"] = (
|
||||
tool.args_schema.model_json_schema()
|
||||
)
|
||||
except AttributeError:
|
||||
# Fallback to Pydantic v1 method (schema)
|
||||
try:
|
||||
tool_schema["function"]["parameters"] = (
|
||||
tool.args_schema.schema()
|
||||
)
|
||||
except Exception:
|
||||
# Both methods failed, use empty schema
|
||||
tool_name = getattr(tool, "name", "unknown")
|
||||
logger.debug(
|
||||
"Failed to get schema for tool %s, using empty "
|
||||
"parameters",
|
||||
tool_name,
|
||||
)
|
||||
tool_schema["function"]["parameters"] = {}
|
||||
except Exception as e:
|
||||
# model_json_schema() raised an exception
|
||||
tool_name = getattr(tool, "name", "unknown")
|
||||
logger.debug(
|
||||
"model_json_schema() failed for tool %s: %s, "
|
||||
"trying v1 fallback",
|
||||
tool_name,
|
||||
e,
|
||||
)
|
||||
try:
|
||||
tool_schema["function"]["parameters"] = (
|
||||
tool.args_schema.schema()
|
||||
)
|
||||
except Exception:
|
||||
tool_schema["function"]["parameters"] = {}
|
||||
|
||||
# Serialize to JSON and count tokens
|
||||
schema_str = json.dumps(tool_schema, ensure_ascii=False)
|
||||
tokens = count_tokens(schema_str)
|
||||
total_tokens += tokens
|
||||
|
||||
except Exception as e:
|
||||
tool_name = getattr(tool, "name", "unknown")
|
||||
logger.debug(
|
||||
"Failed to estimate tokens for tool %s: %s",
|
||||
tool_name,
|
||||
e,
|
||||
)
|
||||
# Rough fallback: 1000 tokens per tool
|
||||
total_tokens += 1000
|
||||
|
||||
return total_tokens
|
||||
|
||||
|
||||
def _count_tokens_for_message(message: BaseMessage) -> int:
|
||||
"""
|
||||
Token counter for LangChain's trim_messages.
|
||||
|
||||
This function is called by trim_messages for each message.
|
||||
|
||||
Args:
|
||||
message: A single message (HumanMessage, AIMessage, SystemMessage,
|
||||
etc.)
|
||||
|
||||
Returns:
|
||||
Estimated token count for the message
|
||||
"""
|
||||
content = ""
|
||||
if hasattr(message, "content") and message.content:
|
||||
content = str(message.content)
|
||||
|
||||
return count_tokens(content)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pre-Model Hook Factory
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_pre_model_hook(
|
||||
system_prompt: str,
|
||||
get_topology_func: Callable[[dict], Any] | None = None,
|
||||
get_llm_config_func: Callable[[], dict[str, Any] | None] | None = None,
|
||||
get_tools_func: Callable[[], list[Any]] | None = None,
|
||||
) -> Callable[[dict], dict]:
|
||||
"""
|
||||
Create a pre_model_hook function for LangGraph agent.
|
||||
|
||||
This hook will be automatically called before each LLM invocation,
|
||||
handling topology injection, tool token estimation, and message
|
||||
trimming.
|
||||
|
||||
IMPORTANT USAGE REQUIREMENTS:
|
||||
This hook MUST be passed via model.invoke() config, NOT used as
|
||||
a Node return value.
|
||||
|
||||
CORRECT Usage:
|
||||
model.invoke(messages,
|
||||
config={"configurable": {"pre_model_hook": pre_hook}})
|
||||
|
||||
INCORRECT Usage:
|
||||
# ❌ Don't use as Node return value
|
||||
def my_node(state):
|
||||
return pre_hook(state) # Wrong!
|
||||
|
||||
# ❌ Don't use with StateGraph if state uses add_messages reducer
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, add_messages] # Incompatible!
|
||||
|
||||
COMPATIBILITY:
|
||||
- Works with: model.invoke(config={"configurable":
|
||||
{"pre_model_hook": ...}})
|
||||
- Does NOT work as: StateGraph Node return value
|
||||
- Does NOT work with: Annotated[list, add_messages] state reducers
|
||||
|
||||
The hook returns a complete message list that overwrites the model
|
||||
input, which is correct for invoke() but wrong for state updates with
|
||||
add_messages.
|
||||
|
||||
Args:
|
||||
system_prompt: System prompt template (must contain
|
||||
{{topology_info}} placeholder)
|
||||
get_topology_func: Function to extract topology from state
|
||||
get_llm_config_func: Function to get LLM config from context
|
||||
get_tools_func: Optional function to get tools list for token
|
||||
estimation
|
||||
|
||||
Returns:
|
||||
A pre_model_hook function for use with model.invoke(config=...)
|
||||
|
||||
Thread Safety:
|
||||
Thread-safe if get_llm_config_func uses request-scoped context
|
||||
(e.g., contextvars)
|
||||
|
||||
Performance Notes:
|
||||
- SystemMessage is reconstructed on each LLM call (intentional
|
||||
design)
|
||||
- Overhead: ~1-2ms per call (negligible compared to LLM latency)
|
||||
- Trade-off: Simplicity > micro-optimization
|
||||
- In ReAct loops with multiple LLM calls, topology is re-injected
|
||||
each time
|
||||
- This is acceptable for GNS3-Copilot's usage patterns (low
|
||||
concurrency, short conversations)
|
||||
"""
|
||||
|
||||
def pre_model_hook(state: dict) -> dict:
|
||||
"""
|
||||
LangGraph pre_model_hook - called before each LLM invocation.
|
||||
|
||||
This function is NOT a StateGraph Node. It is a preprocessing hook
|
||||
that modifies the input to the LLM, not the agent state.
|
||||
|
||||
Usage Context:
|
||||
Called automatically by LangChain when passed via:
|
||||
model.invoke(messages,
|
||||
config={"configurable":
|
||||
{"pre_model_hook": this}})
|
||||
|
||||
Args:
|
||||
state: Current agent state containing messages and topology_info
|
||||
|
||||
Returns:
|
||||
dict with 'messages' key containing prepared and trimmed messages
|
||||
Note: This return value is used by LangChain to replace the model
|
||||
input, NOT to update the agent state.
|
||||
|
||||
Raises:
|
||||
ValueError: If context_limit is missing or invalid
|
||||
"""
|
||||
logger.debug(
|
||||
"pre_model_hook invoked: messages=%d",
|
||||
len(state.get("messages", [])),
|
||||
)
|
||||
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
logger.debug("No messages in state, returning empty list")
|
||||
return {"messages": []}
|
||||
|
||||
# Get LLM config
|
||||
llm_config = get_llm_config_func() if get_llm_config_func else None
|
||||
|
||||
if not llm_config:
|
||||
logger.error("LLM config not found. context_limit is required.")
|
||||
raise ValueError(
|
||||
"LLM config not found. context_limit is required."
|
||||
)
|
||||
|
||||
if "context_limit" not in llm_config:
|
||||
logger.error(
|
||||
"context_limit not found in LLM config. "
|
||||
"This is a required field. Please configure context_limit."
|
||||
)
|
||||
raise ValueError("context_limit is required in LLM config")
|
||||
|
||||
limit = llm_config["context_limit"]
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
logger.error("Invalid context_limit: %s", limit)
|
||||
raise ValueError(f"Invalid context_limit: {limit}")
|
||||
|
||||
context_limit_k = limit
|
||||
strategy = llm_config.get("context_strategy", DEFAULT_CONTEXT_STRATEGY)
|
||||
|
||||
if strategy not in CONTEXT_STRATEGY_RATIOS:
|
||||
logger.warning(
|
||||
"Invalid context_strategy '%s', using '%s'",
|
||||
strategy,
|
||||
DEFAULT_CONTEXT_STRATEGY,
|
||||
)
|
||||
strategy = DEFAULT_CONTEXT_STRATEGY
|
||||
|
||||
# Step 1: Estimate tool tokens
|
||||
tool_tokens = 0
|
||||
if get_tools_func:
|
||||
try:
|
||||
tools = get_tools_func()
|
||||
tool_tokens = estimate_tool_tokens(tools)
|
||||
logger.debug(
|
||||
"Tool definitions estimated at ~%d tokens (%d tools)",
|
||||
tool_tokens,
|
||||
len(tools),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to estimate tool tokens: %s", e)
|
||||
|
||||
# Step 2: Inject topology into system prompt
|
||||
messages_with_system = _inject_topology_into_system(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
state=state,
|
||||
get_topology_func=get_topology_func,
|
||||
)
|
||||
|
||||
# Step 3: Calculate token breakdown for logging and validation
|
||||
system_message = messages_with_system[0]
|
||||
system_tokens = _count_tokens_for_message(system_message)
|
||||
|
||||
# Calculate tokens for messages_with_system (including system)
|
||||
messages_with_system_tokens = sum(
|
||||
_count_tokens_for_message(m) for m in messages_with_system
|
||||
)
|
||||
|
||||
# Calculate available budget
|
||||
model_limit_tokens = context_limit_k * TOKENS_PER_K
|
||||
strategy_ratio = CONTEXT_STRATEGY_RATIOS[strategy]
|
||||
max_input_tokens = int(model_limit_tokens * strategy_ratio)
|
||||
|
||||
# Calculate max tokens for trim_messages
|
||||
# Important: trim_messages counts ALL messages (including system),
|
||||
# so we only subtract tool_tokens here, NOT system_tokens.
|
||||
# Let trim_messages handle system.
|
||||
max_tokens_for_trim = max_input_tokens - tool_tokens
|
||||
|
||||
# Validate budget and provide actionable warnings
|
||||
if system_tokens + tool_tokens > max_input_tokens: # noqa: E501
|
||||
logger.error(
|
||||
"System prompt (%d tokens) + tools (%d tokens) EXCEED input "
|
||||
"budget (%d tokens). This will likely cause LLM call failures. " # noqa: E501
|
||||
"Recommendations: 1) Reduce system prompt length, 2) Reduce "
|
||||
"number of tools, 3) Use a model with larger context window, "
|
||||
"or 4) Switch to 'conservative' strategy.",
|
||||
system_tokens,
|
||||
tool_tokens,
|
||||
max_input_tokens,
|
||||
)
|
||||
elif max_tokens_for_trim < system_tokens * 1.5:
|
||||
# Less than 1.5x system tokens means very little room for history
|
||||
logger.warning(
|
||||
"System prompt (%d tokens) + tools (%d tokens) leave minimal "
|
||||
"room for conversation history (%d tokens remaining). "
|
||||
"Consider reducing system prompt length or number of tools.",
|
||||
system_tokens,
|
||||
tool_tokens,
|
||||
max_tokens_for_trim - system_tokens,
|
||||
)
|
||||
|
||||
# Debug log with clear terminology
|
||||
logger.debug(
|
||||
"Token breakdown: system=%d, all_messages=%d (system+history), "
|
||||
"tools=%d, trim_budget=%d (limit=%dK, strategy=%s)",
|
||||
system_tokens,
|
||||
messages_with_system_tokens,
|
||||
tool_tokens,
|
||||
max_tokens_for_trim,
|
||||
context_limit_k,
|
||||
strategy,
|
||||
)
|
||||
|
||||
# Step 4: Trim messages to fit
|
||||
# Note: max_tokens_for_trim INCLUDES system message, trim_messages
|
||||
# will handle it
|
||||
try:
|
||||
trimmed = trim_messages(
|
||||
messages=messages_with_system,
|
||||
max_tokens=max_tokens_for_trim,
|
||||
strategy="last",
|
||||
token_counter=_count_tokens_for_message,
|
||||
include_system=True,
|
||||
)
|
||||
|
||||
# Calculate final token counts
|
||||
final_total = sum(_count_tokens_for_message(m) for m in trimmed)
|
||||
usage_percent = (
|
||||
(final_total + tool_tokens) / model_limit_tokens * 100
|
||||
)
|
||||
|
||||
if len(trimmed) < len(messages_with_system):
|
||||
logger.info(
|
||||
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d "
|
||||
"tools = %d / %dK (%.1f%%), strategy=%s",
|
||||
len(messages_with_system),
|
||||
len(trimmed),
|
||||
final_total,
|
||||
tool_tokens,
|
||||
final_total + tool_tokens,
|
||||
context_limit_k,
|
||||
usage_percent,
|
||||
strategy,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK "
|
||||
"(%.1f%%), strategy=%s",
|
||||
len(trimmed),
|
||||
final_total,
|
||||
tool_tokens,
|
||||
final_total + tool_tokens,
|
||||
context_limit_k,
|
||||
usage_percent,
|
||||
strategy,
|
||||
)
|
||||
|
||||
return {"messages": trimmed}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to trim messages: %s", e)
|
||||
logger.warning("Returning original messages due to trimming error")
|
||||
return {"messages": messages_with_system}
|
||||
|
||||
return pre_model_hook
|
||||
|
||||
|
||||
def _inject_topology_into_system(
|
||||
messages: list,
|
||||
system_prompt: str,
|
||||
state: dict,
|
||||
get_topology_func: Callable[[dict], Any] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Inject topology information into system prompt.
|
||||
|
||||
Args:
|
||||
messages: Message list
|
||||
system_prompt: System prompt template
|
||||
state: Current state
|
||||
get_topology_func: Optional function to extract topology
|
||||
|
||||
Returns:
|
||||
Message list with system message prepended
|
||||
"""
|
||||
if "{{topology_info}}" not in system_prompt:
|
||||
logger.warning("system_prompt missing {{topology_info}} placeholder")
|
||||
return messages
|
||||
|
||||
topology_data = None
|
||||
if get_topology_func:
|
||||
try:
|
||||
topology_data = get_topology_func(state)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get topology: %s", e)
|
||||
|
||||
if topology_data:
|
||||
topology_str = str(topology_data)
|
||||
formatted_prompt = system_prompt.replace(
|
||||
"{{topology_info}}", f"\n\n## Current Topology\n{topology_str}"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Topology injected: %d chars, nodes: %s",
|
||||
len(topology_str),
|
||||
list(topology_data.get("nodes", {}).keys())[:5],
|
||||
) # Show first 5 node names
|
||||
logger.debug(
|
||||
"Full topology data: %s", topology_str[:500]
|
||||
) # First 500 chars
|
||||
else:
|
||||
formatted_prompt = system_prompt.replace(
|
||||
"{{topology_info}}", "(No topology information available)"
|
||||
)
|
||||
logger.warning("✗ Topology data is None, injecting placeholder")
|
||||
|
||||
# Filter out existing SystemMessage instances
|
||||
non_system_messages = [
|
||||
m for m in messages if not isinstance(m, SystemMessage)
|
||||
]
|
||||
|
||||
filtered_count = len(messages) - len(non_system_messages)
|
||||
if filtered_count > 0:
|
||||
logger.debug(
|
||||
"Filtered out %d existing SystemMessage(s)", filtered_count
|
||||
)
|
||||
|
||||
return [SystemMessage(content=formatted_prompt)] + non_system_messages
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Legacy Compatibility (Deprecated)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def prepare_context_messages(
|
||||
state_messages: list[Any],
|
||||
system_prompt: str,
|
||||
topology_context: str | None = None,
|
||||
model_name: str = "gpt-4o",
|
||||
llm_config: dict[str, Any] | None = None,
|
||||
tools: list[Any] | None = None,
|
||||
) -> list[Any]:
|
||||
"""
|
||||
**DEPRECATED**: Use create_pre_model_hook() instead.
|
||||
"""
|
||||
warnings.warn(
|
||||
"prepare_context_messages() is deprecated. Use "
|
||||
"create_pre_model_hook() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if "{{topology_info}}" not in system_prompt:
|
||||
formatted_prompt = system_prompt
|
||||
elif topology_context:
|
||||
formatted_prompt = system_prompt.replace(
|
||||
"{{topology_info}}", f"\n\n## Current Topology\n{topology_context}"
|
||||
)
|
||||
else:
|
||||
formatted_prompt = system_prompt.replace(
|
||||
"{{topology_info}}", "(No topology information available)"
|
||||
)
|
||||
|
||||
return [SystemMessage(content=formatted_prompt)] + state_messages
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Module Test
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
print("=== Context Manager Module Test ===\n")
|
||||
|
||||
# Test token counting
|
||||
print("Test 1: Token Counting")
|
||||
print(" tiktoken encoding: cl100k_base")
|
||||
|
||||
test_text = "Hello world 你好世界"
|
||||
tokens = count_tokens(test_text)
|
||||
print(f" '{test_text}' → {tokens} tokens")
|
||||
|
||||
# Test hook creation
|
||||
print("\nTest 2: Create pre_model_hook")
|
||||
system_prompt = "You are GNS3 Copilot.\n\n{{topology_info}}"
|
||||
|
||||
def mock_get_topology(state):
|
||||
return {"project_id": "test", "nodes": 5}
|
||||
|
||||
def mock_get_config():
|
||||
return {"context_limit": 8, "context_strategy": "conservative"}
|
||||
|
||||
def mock_get_tools():
|
||||
return [] # No tools for test
|
||||
|
||||
hook = create_pre_model_hook(
|
||||
system_prompt=system_prompt,
|
||||
get_topology_func=mock_get_topology,
|
||||
get_llm_config_func=mock_get_config,
|
||||
get_tools_func=mock_get_tools,
|
||||
)
|
||||
print("✓ pre_model_hook created successfully")
|
||||
|
||||
# Test invocation
|
||||
print("\nTest 3: Invoke pre_model_hook")
|
||||
test_state = {
|
||||
"messages": [
|
||||
HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)
|
||||
],
|
||||
"topology_info": {"project_id": "test123", "nodes": 3},
|
||||
}
|
||||
|
||||
result = hook(test_state)
|
||||
print(f"✓ Hook invoked: {len(result['messages'])} messages returned")
|
||||
|
||||
print("\n=== All Tests Passed ===")
|
||||
@@ -0,0 +1,713 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
|
||||
"""
|
||||
GNS3 Network Automation Assistant - LangGraph Agent
|
||||
|
||||
This module implements the core LangGraph agent workflow for GNS3-Copilot,
|
||||
an AI-powered assistant for GNS3 network automation and management.
|
||||
|
||||
The agent provides:
|
||||
- LangGraph-based state management and workflow
|
||||
- Mode-aware tool orchestration for GNS3 operations
|
||||
- Context-aware conversation handling
|
||||
- Automatic conversation title generation
|
||||
- Integration with GNS3 topology management
|
||||
|
||||
Copilot Modes:
|
||||
- "teaching_assistant" (default): Diagnostic tools only, no configuration
|
||||
changes
|
||||
- "lab_automation_assistant": Full diagnostic and configuration tools
|
||||
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from typing import Literal
|
||||
|
||||
# Third-party imports
|
||||
from langchain.messages import AnyMessage
|
||||
from langchain.messages import SystemMessage
|
||||
from langchain.messages import ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.graph import END
|
||||
from langgraph.graph import START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.managed.is_last_step import RemainingSteps
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
# Local imports
|
||||
from gns3server.agent.gns3_copilot.agent.context_manager import (
|
||||
create_pre_model_hook,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.agent.model_factory import (
|
||||
create_base_model_with_tools,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.agent.model_factory import (
|
||||
create_title_model,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
get_current_llm_config,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.prompts import load_system_prompt
|
||||
from gns3server.agent.gns3_copilot.skills.registry import get_prompt
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
ExecuteMultipleDeviceCommands,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
ExecuteMultipleDeviceConfigCommands,
|
||||
)
|
||||
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 GNS3StartNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import PacketAnalysisTool
|
||||
from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool
|
||||
from gns3server.agent.gns3_copilot.skills import InjectionSkillsTool
|
||||
from gns3server.agent.gns3_copilot.skills import PacketAnalysisSkillsTool
|
||||
|
||||
# Set up logger for GNS3-Copilot
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Note: LLM model configuration is now managed by the new llm_model_configs
|
||||
# system. The model_factory module handles model creation with configuration
|
||||
# from the database.
|
||||
|
||||
# Define tools for different copilot modes
|
||||
# Teaching assistant mode: READ-ONLY diagnostic tools only
|
||||
TEACHING_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes
|
||||
GNS3UpdateNodeNameTool(), # Update node name
|
||||
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands
|
||||
# (READ-ONLY)
|
||||
PacketAnalysisTool(), # Protocol-oriented packet analysis with tshark
|
||||
PacketAnalysisSkillsTool(), # Query packet analysis protocol definitions
|
||||
DeviceSkillsTool(), # Get device-specific skills and command knowledge
|
||||
]
|
||||
|
||||
# Lab automation assistant mode: Full diagnostic AND configuration tools
|
||||
LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes
|
||||
GNS3StopNodeTool(), # Stop GNS3 nodes
|
||||
GNS3SuspendNodeTool(), # Suspend GNS3 nodes (preserve state)
|
||||
GNS3UpdateNodeNameTool(), # Update node name
|
||||
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands
|
||||
# (READ-ONLY)
|
||||
ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands
|
||||
VPCSCommands(), # Execute VPCS commands using Netmiko
|
||||
PacketAnalysisTool(), # Protocol-oriented packet analysis with tshark
|
||||
PacketAnalysisSkillsTool(), # Query packet analysis protocol definitions
|
||||
DeviceSkillsTool(), # Get device-specific skills and command knowledge
|
||||
]
|
||||
|
||||
# Troubleshooting injection mode: Tools for injecting network faults
|
||||
# Focused on configuration commands and injection skills for fault injection
|
||||
TROUBLESHOOTING_INJECTION_MODE_TOOLS = [
|
||||
ExecuteMultipleDeviceCommands(), # Get device configurations (READ-ONLY)
|
||||
ExecuteMultipleDeviceConfigCommands(), # Inject configuration changes
|
||||
InjectionSkillsTool(), # Query injection skills and fault types
|
||||
GNS3TopologyTool(), # Get topology information
|
||||
]
|
||||
|
||||
# Default tools (legacy support - will be overridden by mode-specific tools)
|
||||
tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
|
||||
|
||||
# Create combined tool lookup for tool_node (supports all modes)
|
||||
# tool_node will receive tool calls based on mode-specific tools bound to the
|
||||
# model
|
||||
# Combine all tools from all modes and deduplicate by tool name
|
||||
ALL_TOOLS = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
|
||||
tools_by_name = {tool.name: tool for tool in ALL_TOOLS}
|
||||
|
||||
# Add troubleshooting injection tools to the global tool registry
|
||||
for tool in TROUBLESHOOTING_INJECTION_MODE_TOOLS:
|
||||
if tool.name not in tools_by_name:
|
||||
tools_by_name[tool.name] = tool
|
||||
|
||||
# Log application startup
|
||||
logger.info("GNS3-Copilot application starting up")
|
||||
|
||||
# Constants for conversation title management
|
||||
DEFAULT_CONVERSATION_TITLE = "New Conversation"
|
||||
UNTITLED_SESSION_FALLBACK = "Untitled Session"
|
||||
TITLE_MAX_LENGTH = 40
|
||||
|
||||
# Abort flags storage for session-level abort tracking
|
||||
# This is checked by conditional edge functions to stop the graph
|
||||
_abort_flags: dict[str, bool] = {}
|
||||
|
||||
|
||||
def check_abort_flag(session_id: str) -> bool:
|
||||
"""
|
||||
Check if abort flag is set for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
True if abort is requested, False otherwise
|
||||
"""
|
||||
return _abort_flags.get(session_id, False)
|
||||
|
||||
|
||||
def set_abort_flag(session_id: str):
|
||||
"""
|
||||
Set abort flag for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
"""
|
||||
_abort_flags[session_id] = True
|
||||
logger.debug("Abort flag set for session: %s", session_id)
|
||||
|
||||
|
||||
def clear_abort_flag(session_id: str):
|
||||
"""
|
||||
Clear abort flag for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
"""
|
||||
_abort_flags[session_id] = False
|
||||
logger.debug("Abort flag cleared for session: %s", session_id)
|
||||
|
||||
|
||||
# Define state
|
||||
class MessagesState(TypedDict):
|
||||
"""
|
||||
GNS3-Copilot conversation state management class.
|
||||
|
||||
Maintains the conversation state for the LangGraph workflow, including
|
||||
message history, call counters, and session titles for comprehensive
|
||||
dialogue management.
|
||||
|
||||
Attributes:
|
||||
messages: List of conversation messages with cumulative updates using
|
||||
operator.add
|
||||
llm_calls: Counter for tracking the number of LLM invocations
|
||||
remaining_steps: Is automatically managed by LangGraph's RemainingSteps
|
||||
to track and limit recursion depth.
|
||||
conversation_title: Optional conversation title for session
|
||||
identification and management
|
||||
topology_info: Dictionary containing GNS3 project topology information
|
||||
session_id: Session identifier for abort tracking
|
||||
abort: Flag to signal abort request
|
||||
"""
|
||||
|
||||
messages: Annotated[list[AnyMessage], operator.add]
|
||||
|
||||
llm_calls: int
|
||||
|
||||
remaining_steps: RemainingSteps
|
||||
|
||||
# Optional conversation title
|
||||
conversation_title: str | None
|
||||
|
||||
# Store GNS3 topology information
|
||||
topology_info: dict | None
|
||||
|
||||
# Session identifier for abort tracking
|
||||
session_id: str | None
|
||||
|
||||
# Abort flag to signal stop request
|
||||
abort: bool
|
||||
|
||||
|
||||
# Define llm call node
|
||||
def llm_call(state: dict, config: RunnableConfig | None = None):
|
||||
"""
|
||||
LLM decides whether to call a tool or not.
|
||||
|
||||
Uses pre_model_hook pattern for automatic topology injection and
|
||||
message trimming, ensuring separation of concerns and complete
|
||||
history preservation in state["messages"].
|
||||
"""
|
||||
|
||||
logger.debug("LLM call node invoked")
|
||||
|
||||
# Get llm_config from request-scoped context variable
|
||||
llm_config = get_current_llm_config()
|
||||
|
||||
if not llm_config:
|
||||
logger.error("LLM config not found in context")
|
||||
return {
|
||||
"messages": [],
|
||||
"llm_calls": state.get("llm_calls", 0),
|
||||
"topology_info": None,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"LLM config retrieved from context: provider=%s, model=%s",
|
||||
llm_config.get("provider"),
|
||||
llm_config.get("model"),
|
||||
)
|
||||
|
||||
# Defensive check: skip LLM call if no user messages
|
||||
messages = state.get("messages", [])
|
||||
if not messages or len(messages) == 0:
|
||||
logger.warning("No messages in state, skipping LLM call")
|
||||
return {
|
||||
"messages": [],
|
||||
"llm_calls": state.get("llm_calls", 0),
|
||||
"topology_info": None,
|
||||
}
|
||||
|
||||
# Get project_id from config configurable (set when starting the chat)
|
||||
project_id = None
|
||||
topology_info = None
|
||||
if config and config.get("configurable"):
|
||||
project_id = config["configurable"].get("project_id")
|
||||
|
||||
# Retrieve topology information if available
|
||||
if project_id:
|
||||
try:
|
||||
topology_tool = GNS3TopologyTool()
|
||||
topology = topology_tool._run(project_id=project_id)
|
||||
|
||||
if topology and "error" not in topology:
|
||||
topology_info = topology
|
||||
logger.info(
|
||||
"Successfully retrieved topology for project_id: %s, "
|
||||
"name: %s",
|
||||
project_id,
|
||||
topology.get("name"),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to retrieve topology for project_id %s: %s",
|
||||
project_id,
|
||||
topology.get("error", "Unknown error")
|
||||
if topology
|
||||
else "No result",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error retrieving topology for project_id %s: %s",
|
||||
project_id,
|
||||
e,
|
||||
)
|
||||
|
||||
# Store topology_info in state for pre_model_hook to access
|
||||
state["topology_info"] = topology_info
|
||||
|
||||
# Select tools based on copilot_mode
|
||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
if copilot_mode == "lab_automation_assistant":
|
||||
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
|
||||
logger.info(
|
||||
"Using LAB_AUTOMATION_ASSISTANT mode tools (includes "
|
||||
"configuration tools)"
|
||||
)
|
||||
elif copilot_mode == "troubleshooting_injection":
|
||||
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
|
||||
logger.info(
|
||||
"Using TROUBLESHOOTING_INJECTION mode tools (fault injection)"
|
||||
)
|
||||
else: # teaching_assistant mode (default)
|
||||
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
|
||||
logger.info(
|
||||
"Using TEACHING_ASSISTANT mode tools (diagnostic tools only)"
|
||||
)
|
||||
|
||||
# Create pre_model_hook for automatic topology injection and trimming
|
||||
# Load system prompt based on copilot_mode configuration
|
||||
system_prompt = load_system_prompt(llm_config)
|
||||
pre_hook = create_pre_model_hook(
|
||||
system_prompt=system_prompt,
|
||||
get_topology_func=lambda s: s.get("topology_info"),
|
||||
get_llm_config_func=get_current_llm_config,
|
||||
get_tools_func=lambda: mode_tools, # Pass mode-specific tools for
|
||||
# token estimation
|
||||
)
|
||||
|
||||
# Create fresh model with tools for each LLM call
|
||||
logger.debug(
|
||||
"Creating model with tools: provider=%s, model=%s, mode=%s, tools=%d",
|
||||
llm_config.get("provider"),
|
||||
llm_config.get("model"),
|
||||
copilot_mode,
|
||||
len(mode_tools),
|
||||
)
|
||||
model_with_tools = create_base_model_with_tools(
|
||||
mode_tools, llm_config=llm_config
|
||||
)
|
||||
|
||||
# Call pre_hook directly to prepare messages (topology injection +
|
||||
# trimming)
|
||||
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not
|
||||
# custom StateGraph
|
||||
logger.debug("Calling pre_hook to prepare %d messages", len(messages))
|
||||
prepared_state = pre_hook(
|
||||
{"messages": messages, "topology_info": topology_info}
|
||||
)
|
||||
prepared_messages = prepared_state["messages"]
|
||||
logger.info(
|
||||
"Messages prepared: %d → %d", len(messages), len(prepared_messages)
|
||||
)
|
||||
|
||||
# Invoke model with prepared messages
|
||||
response = model_with_tools.invoke(prepared_messages)
|
||||
|
||||
# Add metadata with created_at timestamp to AI response
|
||||
if hasattr(response, "metadata"):
|
||||
existing_metadata = response.metadata or {}
|
||||
response.metadata = {
|
||||
**existing_metadata,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
else:
|
||||
# LangChain messages should have metadata attribute, but defensive
|
||||
# fallback
|
||||
try:
|
||||
response.metadata = {"created_at": datetime.utcnow().isoformat()}
|
||||
except Exception:
|
||||
logger.warning("Could not add metadata to AI response")
|
||||
|
||||
logger.info(
|
||||
"LLM call completed: tool_calls=%d",
|
||||
len(response.tool_calls) if hasattr(response, "tool_calls") else 0,
|
||||
)
|
||||
|
||||
return {
|
||||
"messages": [response],
|
||||
"llm_calls": state.get("llm_calls", 0) + 1,
|
||||
"topology_info": topology_info,
|
||||
"session_id": state.get("session_id"),
|
||||
}
|
||||
|
||||
|
||||
# Define generate title node
|
||||
def generate_title(
|
||||
state: MessagesState, config: RunnableConfig | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a conversation title using a lightweight assistant LLM
|
||||
(title_model). This node is only executed when no title has been set yet
|
||||
(first round only).
|
||||
"""
|
||||
|
||||
# Get llm_config from request-scoped context variable
|
||||
llm_config = get_current_llm_config()
|
||||
|
||||
if not llm_config:
|
||||
logger.error("LLM config not found in context, cannot generate title")
|
||||
return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")}
|
||||
|
||||
# Only generate a title if it hasn't been set yet
|
||||
current_title = state.get("conversation_title")
|
||||
if current_title in [None, "New Conversation"]:
|
||||
logger.debug("Title generation triggered for session")
|
||||
messages = state["messages"]
|
||||
|
||||
# Load title prompt from external repository
|
||||
title_prompt = get_prompt("title")
|
||||
if not title_prompt:
|
||||
logger.error("Title prompt not found in external repository")
|
||||
return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")}
|
||||
|
||||
# Build the prompt for title generation
|
||||
title_prompt_messages = [
|
||||
SystemMessage(content=title_prompt),
|
||||
messages[0], # User's first message
|
||||
messages[-1], # Assistant's final response in this turn
|
||||
]
|
||||
|
||||
# Call the title generation model (create fresh instance for each call)
|
||||
try:
|
||||
title_model = create_title_model(llm_config=llm_config)
|
||||
response = title_model.invoke(
|
||||
title_prompt_messages,
|
||||
config={"configurable": {"foo_temperature": 1.0}},
|
||||
)
|
||||
raw_content = response.content
|
||||
|
||||
new_title = raw_content.strip()
|
||||
|
||||
# Validate the generated title
|
||||
if not new_title or len(new_title) < 3:
|
||||
raise ValueError(
|
||||
f"Generated title too short or empty: '{new_title}'"
|
||||
)
|
||||
|
||||
if new_title in [
|
||||
"New Conversation",
|
||||
"Untitled Session",
|
||||
"GNS3 Session",
|
||||
]:
|
||||
raise ValueError(
|
||||
f"Generated title is a default value: '{new_title}'"
|
||||
)
|
||||
|
||||
# Safety: truncate long titles and avoid line breaks
|
||||
if len(new_title) > TITLE_MAX_LENGTH:
|
||||
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
|
||||
|
||||
# Remove unwanted characters
|
||||
new_title = (
|
||||
new_title.replace("\n", " ").replace('"', "").replace("'", "")
|
||||
)
|
||||
|
||||
logger.debug("Generated new title: %s", new_title)
|
||||
return {"conversation_title": new_title, "session_id": state.get("session_id")}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Title generation failed: {e}, using fallback")
|
||||
|
||||
# Improved fallback: Use user's first message content
|
||||
if messages and len(messages) > 0:
|
||||
first_message = messages[0]
|
||||
if hasattr(first_message, "content"):
|
||||
fallback_title = first_message.content[:30].strip()
|
||||
# Remove newlines and extra spaces
|
||||
fallback_title = fallback_title.replace("\n", " ").strip()
|
||||
# Collapse multiple spaces
|
||||
while " " in fallback_title:
|
||||
fallback_title = fallback_title.replace(" ", " ")
|
||||
|
||||
# Truncate if needed
|
||||
if len(fallback_title) > 28:
|
||||
fallback_title = fallback_title[:28] + ".."
|
||||
|
||||
if fallback_title:
|
||||
logger.info(
|
||||
"Using fallback title from user message: '%s'",
|
||||
fallback_title,
|
||||
)
|
||||
return {"conversation_title": fallback_title, "session_id": state.get("session_id")}
|
||||
|
||||
# Final fallback
|
||||
logger.info(
|
||||
"Using final fallback title: '%s'",
|
||||
UNTITLED_SESSION_FALLBACK,
|
||||
)
|
||||
return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")}
|
||||
|
||||
# Title already exists → no update needed
|
||||
return {}
|
||||
|
||||
|
||||
# Define tool node
|
||||
def tool_node(state: dict, config: RunnableConfig | None = None):
|
||||
"""Performs the tool call"""
|
||||
|
||||
tool_calls = state["messages"][-1].tool_calls
|
||||
logger.debug("Tool node invoked: tool_calls=%d", len(tool_calls))
|
||||
|
||||
result = []
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
logger.debug(
|
||||
"Executing tool: %s with args: %s", tool_name, tool_call["args"]
|
||||
)
|
||||
tool = tools_by_name[tool_name]
|
||||
try:
|
||||
observation = tool.invoke(tool_call["args"])
|
||||
logger.debug(
|
||||
"Tool %s completed: output_length=%d",
|
||||
tool_name,
|
||||
len(str(observation)) if observation else 0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
|
||||
observation = f"Error: {str(e)}"
|
||||
|
||||
# Serialize observation to JSON string if it's not already a string
|
||||
# This ensures ToolMessage.content is always JSON format, not Python
|
||||
# str()
|
||||
if not isinstance(observation, str):
|
||||
observation = json.dumps(observation, ensure_ascii=False, indent=2)
|
||||
|
||||
# Create ToolMessage with metadata including created_at
|
||||
tool_msg = ToolMessage(
|
||||
content=observation,
|
||||
tool_call_id=tool_call["id"],
|
||||
name=tool_call["name"],
|
||||
metadata={"created_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
result.append(tool_msg)
|
||||
|
||||
return {"messages": result, "session_id": state.get("session_id")}
|
||||
|
||||
|
||||
# Abort handler node - provides tool results when aborting
|
||||
def abort_handler_node(state: dict) -> dict:
|
||||
"""
|
||||
Handles abort by providing tool result messages for pending tool_calls.
|
||||
This ensures message history consistency and prevents checkpoint corruption.
|
||||
"""
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"session_id": state.get("session_id")}
|
||||
|
||||
last_message = messages[-1]
|
||||
if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
|
||||
return {"session_id": state.get("session_id")}
|
||||
|
||||
result = []
|
||||
for tool_call in last_message.tool_calls:
|
||||
tool_msg = ToolMessage(
|
||||
content=json.dumps({
|
||||
"status": "aborted",
|
||||
"message": "Tool execution was aborted by user",
|
||||
"tool_call_id": tool_call["id"],
|
||||
}),
|
||||
tool_call_id=tool_call["id"],
|
||||
name=tool_call["name"],
|
||||
metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True},
|
||||
)
|
||||
result.append(tool_msg)
|
||||
|
||||
logger.info("Abort handler: generated %d aborted tool messages", len(result))
|
||||
return {"messages": result, "session_id": state.get("session_id")}
|
||||
|
||||
|
||||
# Routing logic after the LLM node
|
||||
def should_continue(
|
||||
state: MessagesState,
|
||||
) -> Literal["tool_node", "title_generator_node", "abort_handler_node", END]:
|
||||
"""
|
||||
Determine the next step after the LLM has produced a response.
|
||||
|
||||
- If abort flag is set → end the conversation
|
||||
- If the LLM requested any tool calls → route to tool_node
|
||||
- If this is the first complete turn (llm_calls == 1) and no title
|
||||
exists → generate a title
|
||||
- Otherwise → conversation is complete, go to END
|
||||
"""
|
||||
# Check abort flag first
|
||||
session_id = state.get("session_id")
|
||||
if session_id and check_abort_flag(session_id):
|
||||
last_message = state["messages"][-1]
|
||||
# If there's a tool_calls message, we need to handle it properly
|
||||
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
|
||||
return "abort_handler_node"
|
||||
return END
|
||||
|
||||
last_message = state["messages"][-1]
|
||||
current_title = state.get("conversation_title")
|
||||
|
||||
# LLM requested one or more tool executions
|
||||
if last_message.tool_calls:
|
||||
return "tool_node"
|
||||
|
||||
# First full interaction completed and title not yet generated
|
||||
if current_title in [None, "New Conversation"]:
|
||||
return "title_generator_node"
|
||||
|
||||
# Normal completion (multi-turn conversation or title already exists)
|
||||
return END
|
||||
|
||||
|
||||
# Routing logic after the tool node, Check remaining_steps
|
||||
def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]:
|
||||
"""
|
||||
Routing logic after tool execution to prevent infinite recursion.
|
||||
|
||||
Determines whether to continue with another LLM call or end the
|
||||
conversation based on remaining steps and message type.
|
||||
|
||||
Args:
|
||||
state: Current conversation state with messages and remaining steps
|
||||
|
||||
Returns:
|
||||
"llm_call" to continue processing, END to terminate conversation
|
||||
|
||||
Logic:
|
||||
- If abort flag is set → end the conversation
|
||||
- If the last message is ToolMessage and steps >= 4: continue to LLM
|
||||
- Otherwise: end the conversation to prevent infinite loops
|
||||
"""
|
||||
# Check abort flag first
|
||||
session_id = state.get("session_id")
|
||||
if session_id and check_abort_flag(session_id):
|
||||
return END
|
||||
|
||||
last_message = state["messages"][-1]
|
||||
if isinstance(last_message, ToolMessage):
|
||||
if state["remaining_steps"] < 4:
|
||||
return END
|
||||
return "llm_call"
|
||||
|
||||
return END
|
||||
|
||||
|
||||
# Build and compile the agent
|
||||
# Build workflow
|
||||
agent_builder = StateGraph(MessagesState)
|
||||
|
||||
# Add nodes
|
||||
agent_builder.add_node("llm_call", llm_call)
|
||||
agent_builder.add_node("tool_node", tool_node)
|
||||
agent_builder.add_node("title_generator_node", generate_title)
|
||||
agent_builder.add_node("abort_handler_node", abort_handler_node)
|
||||
|
||||
# Add edges to connect nodes
|
||||
agent_builder.add_edge(START, "llm_call")
|
||||
# Conditional routing after LLM response
|
||||
# Determines the next step based on whether LLM needs to call tools or
|
||||
# generate title
|
||||
agent_builder.add_conditional_edges(
|
||||
"llm_call",
|
||||
should_continue,
|
||||
{
|
||||
"tool_node": "tool_node", # Route to tool execution if LLM requested
|
||||
# tools
|
||||
"title_generator_node": "title_generator_node", # Generate title on
|
||||
# first interaction
|
||||
"abort_handler_node": "abort_handler_node", # Handle abort with tool calls
|
||||
END: END, # End conversation if no tools needed
|
||||
},
|
||||
)
|
||||
# Conditional routing after tool execution
|
||||
# Prevents infinite recursion by checking remaining steps before continuing
|
||||
agent_builder.add_conditional_edges(
|
||||
"tool_node",
|
||||
recursion_limit_continue,
|
||||
{
|
||||
"llm_call": "llm_call", # Continue to LLM if tools executed and steps
|
||||
# remain
|
||||
END: END, # End conversation to prevent infinite loops
|
||||
},
|
||||
)
|
||||
|
||||
agent_builder.add_edge("title_generator_node", END)
|
||||
agent_builder.add_edge("abort_handler_node", END)
|
||||
@@ -0,0 +1,258 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Model Factory for GNS3-Copilot Agent
|
||||
|
||||
This module provides factory functions to create fresh LLM model instances.
|
||||
Configuration is passed directly from the database.
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_llm_config(
|
||||
llm_config: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Convert llm_config dict to model factory format.
|
||||
|
||||
Args:
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Dictionary containing model configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is missing or invalid.
|
||||
"""
|
||||
if not llm_config:
|
||||
raise ValueError("LLM configuration is required")
|
||||
|
||||
logger.info(
|
||||
"Using LLM config: provider=%s, model=%s",
|
||||
llm_config.get("provider"),
|
||||
llm_config.get("model"),
|
||||
)
|
||||
|
||||
return {
|
||||
"model_name": llm_config.get("model", ""),
|
||||
"model_provider": llm_config.get("provider", ""),
|
||||
"api_key": llm_config.get("api_key", ""),
|
||||
"base_url": llm_config.get("base_url", ""),
|
||||
"temperature": str(llm_config.get("temperature", "0")),
|
||||
}
|
||||
|
||||
|
||||
def create_base_model(
|
||||
llm_config: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a fresh base LLM model instance.
|
||||
|
||||
Args:
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new LLM model instance configured with current settings.
|
||||
The actual type depends on the provider (e.g., ChatOpenAI, etc.).
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration fields are missing or invalid.
|
||||
RuntimeError: If model creation fails.
|
||||
"""
|
||||
config_vars = _load_llm_config(llm_config)
|
||||
|
||||
# Log the loaded configuration (mask sensitive data)
|
||||
logger.info(
|
||||
"Creating base model: name=%s, provider=%s, base_url=%s, "
|
||||
"temperature=%s",
|
||||
config_vars["model_name"],
|
||||
config_vars["model_provider"],
|
||||
config_vars["base_url"] if config_vars["base_url"] else "default",
|
||||
config_vars["temperature"],
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
if not config_vars["model_name"]:
|
||||
raise ValueError("LLM configuration requires 'model' field")
|
||||
|
||||
if not config_vars["model_provider"]:
|
||||
raise ValueError("LLM configuration requires 'provider' field")
|
||||
|
||||
try:
|
||||
# Prepare parameters for init_chat_model
|
||||
init_params = {
|
||||
"model": config_vars["model_name"],
|
||||
"model_provider": config_vars["model_provider"],
|
||||
"api_key": config_vars["api_key"],
|
||||
"base_url": config_vars["base_url"],
|
||||
"temperature": config_vars["temperature"],
|
||||
"configurable_fields": "any",
|
||||
"config_prefix": "foo",
|
||||
}
|
||||
|
||||
# Disable DeepSeek thinking mode to avoid reasoning_content handling issues
|
||||
# DeepSeek models enable thinking mode by default, which returns reasoning_content
|
||||
# that must be passed back to the API in subsequent requests. To simplify
|
||||
# message handling and avoid 400 errors, we disable it here.
|
||||
if config_vars["model_provider"] == "deepseek":
|
||||
init_params["extra_body"] = {"thinking": {"type": "disabled"}}
|
||||
logger.debug("DeepSeek thinking mode disabled")
|
||||
|
||||
model = init_chat_model(**init_params)
|
||||
|
||||
logger.debug("Base model created successfully")
|
||||
return model
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create base model: %s", e)
|
||||
raise RuntimeError(f"Failed to create base model: {e}") from e
|
||||
|
||||
|
||||
def create_title_model(
|
||||
llm_config: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a fresh title generation model instance.
|
||||
|
||||
This creates a model instance suitable for generating conversation titles.
|
||||
It uses the same configuration as the base model but with a higher
|
||||
temperature for more creative output.
|
||||
|
||||
Args:
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new LLM model instance for title generation.
|
||||
The actual type depends on the provider.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration fields are missing or invalid.
|
||||
RuntimeError: If model creation fails.
|
||||
"""
|
||||
config_vars = _load_llm_config(llm_config)
|
||||
|
||||
logger.info(
|
||||
"Creating title model: name=%s, provider=%s, base_url=%s, "
|
||||
"temperature=1.0",
|
||||
config_vars["model_name"],
|
||||
config_vars["model_provider"],
|
||||
config_vars["base_url"] if config_vars["base_url"] else "default",
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
if not config_vars["model_name"]:
|
||||
raise ValueError("LLM configuration requires 'model' field")
|
||||
|
||||
if not config_vars["model_provider"]:
|
||||
raise ValueError("LLM configuration requires 'provider' field")
|
||||
|
||||
try:
|
||||
# Prepare parameters for init_chat_model
|
||||
init_params = {
|
||||
"model": config_vars["model_name"],
|
||||
"model_provider": config_vars["model_provider"],
|
||||
"api_key": config_vars["api_key"],
|
||||
"base_url": config_vars["base_url"],
|
||||
"temperature": "1.0", # Higher temperature for more creative titles
|
||||
"configurable_fields": "any",
|
||||
"config_prefix": "foo",
|
||||
}
|
||||
|
||||
# Disable DeepSeek thinking mode to avoid reasoning_content handling issues
|
||||
# DeepSeek models enable thinking mode by default, which returns reasoning_content
|
||||
# that must be passed back to the API in subsequent requests. To simplify
|
||||
# message handling and avoid 400 errors, we disable it here.
|
||||
if config_vars["model_provider"] == "deepseek":
|
||||
init_params["extra_body"] = {"thinking": {"type": "disabled"}}
|
||||
logger.debug("DeepSeek thinking mode disabled for title model")
|
||||
|
||||
model = init_chat_model(**init_params)
|
||||
|
||||
logger.debug("Title model created successfully")
|
||||
return model
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create title model: %s", e)
|
||||
raise RuntimeError(f"Failed to create title model: {e}") from e
|
||||
|
||||
|
||||
def create_model_with_tools(
|
||||
model: Any,
|
||||
tools: list[Any],
|
||||
) -> Any:
|
||||
"""
|
||||
Bind tools to a model instance.
|
||||
|
||||
Args:
|
||||
model: The base model instance.
|
||||
tools: List of tools to bind to the model.
|
||||
|
||||
Returns:
|
||||
Any: A model instance with tools bound (type varies by provider).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If tool binding fails.
|
||||
"""
|
||||
try:
|
||||
model_with_tools = model.bind_tools(tools)
|
||||
logger.debug("Model bound with %d tools successfully", len(tools))
|
||||
return model_with_tools
|
||||
except Exception as e:
|
||||
logger.error("Failed to bind tools to model: %s", e)
|
||||
raise RuntimeError(f"Failed to bind tools to model: {e}") from e
|
||||
|
||||
|
||||
def create_base_model_with_tools(
|
||||
tools: list[Any],
|
||||
llm_config: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create a fresh base model instance with tools bound.
|
||||
|
||||
This is a convenience function that combines creating the base model
|
||||
and binding tools to it.
|
||||
|
||||
Args:
|
||||
tools: List of tools to bind to the model.
|
||||
llm_config: Configuration dictionary from database
|
||||
|
||||
Returns:
|
||||
Any: A new model instance with tools bound (type varies by provider).
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration fields are missing.
|
||||
RuntimeError: If model creation or tool binding fails.
|
||||
"""
|
||||
base_model = create_base_model(llm_config)
|
||||
return create_model_with_tools(base_model, tools)
|
||||
@@ -0,0 +1,782 @@
|
||||
# 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 Copilot Agent Service
|
||||
|
||||
Provides project-level Agent instances with SQLite checkpoint management.
|
||||
Each project has its own AgentService with a dedicated checkpoint database
|
||||
in the project directory.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
from gns3server.agent.gns3_copilot.agent.gns3_copilot import agent_builder
|
||||
from gns3server.agent.gns3_copilot.chat_sessions_repository import (
|
||||
ChatSessionsRepository,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
set_current_jwt_token,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
set_current_llm_config,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.utils.error_handler import format_error_message
|
||||
from gns3server.agent.gns3_copilot.utils.message_converters import (
|
||||
convert_langchain_to_openai,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.utils.tool_call_stream import (
|
||||
ToolCallStreamAccumulator,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""
|
||||
Project-level Agent Service with async checkpoint management.
|
||||
|
||||
Manages a LangGraph agent instance with SQLite-based state persistence
|
||||
for a single GNS3 project.
|
||||
"""
|
||||
|
||||
def __init__(self, project_path: str):
|
||||
"""
|
||||
Initialize AgentService for a project.
|
||||
|
||||
Args:
|
||||
project_path: Path to the GNS3 project directory
|
||||
"""
|
||||
self.project_path = project_path
|
||||
self._checkpointer: Optional[AsyncSqliteSaver] = None
|
||||
self._checkpointer_conn: Optional[aiosqlite.Connection] = None
|
||||
self._checkpointer_path: Optional[str] = None
|
||||
self._graph = None
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._initialized = False
|
||||
|
||||
def abort_session(self, session_id: str):
|
||||
"""
|
||||
Signal abort for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
|
||||
set_abort_flag,
|
||||
)
|
||||
|
||||
set_abort_flag(session_id)
|
||||
log.info("Abort signal set for session: %s", session_id)
|
||||
|
||||
def _get_checkpoint_dir(self) -> str:
|
||||
"""Get or create the checkpoint directory for this project."""
|
||||
checkpoint_dir = os.path.join(self.project_path, "gns3-copilot")
|
||||
os.makedirs(checkpoint_dir, exist_ok=True)
|
||||
return checkpoint_dir
|
||||
|
||||
async def _get_checkpointer(self) -> AsyncSqliteSaver:
|
||||
"""
|
||||
Get or create the SQLite checkpointer for this project.
|
||||
|
||||
Returns:
|
||||
AsyncSqliteSaver instance
|
||||
"""
|
||||
async with self._init_lock:
|
||||
if self._checkpointer is not None:
|
||||
return self._checkpointer
|
||||
|
||||
checkpoint_dir = self._get_checkpoint_dir()
|
||||
checkpointer_path = os.path.join(
|
||||
checkpoint_dir, "copilot_checkpoints.db"
|
||||
)
|
||||
|
||||
log.debug("Creating checkpointer at: %s", checkpointer_path)
|
||||
|
||||
# Close existing connection if switching projects
|
||||
if self._checkpointer_conn:
|
||||
try:
|
||||
await self._checkpointer_conn.close()
|
||||
log.debug("Closed previous checkpointer connection")
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
"Error closing old checkpointer connection: %s", e
|
||||
)
|
||||
|
||||
# Create new connection
|
||||
conn = await aiosqlite.connect(checkpointer_path)
|
||||
# Enable WAL mode for better concurrent performance
|
||||
await conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._checkpointer_conn = (
|
||||
conn # Save connection reference to prevent GC
|
||||
)
|
||||
self._checkpointer = AsyncSqliteSaver(conn)
|
||||
|
||||
# CRITICAL: Initialize database schema
|
||||
await self._checkpointer.setup()
|
||||
|
||||
# Create chat_sessions table in the same database
|
||||
await self._create_chat_sessions_table(conn)
|
||||
|
||||
self._checkpointer_path = checkpointer_path
|
||||
self._initialized = True
|
||||
|
||||
log.info("Project checkpointer created at: %s", checkpointer_path)
|
||||
return self._checkpointer
|
||||
|
||||
async def _create_chat_sessions_table(self, conn: aiosqlite.Connection):
|
||||
"""
|
||||
Create the chat_sessions table in the checkpoint database.
|
||||
|
||||
Args:
|
||||
conn: aiosqlite connection
|
||||
"""
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT UNIQUE NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT DEFAULT 'New Conversation',
|
||||
|
||||
-- Statistics
|
||||
message_count INTEGER DEFAULT 0,
|
||||
llm_calls_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
last_message_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Reserved fields (JSON strings)
|
||||
metadata TEXT DEFAULT '{}',
|
||||
stats TEXT DEFAULT '{}',
|
||||
|
||||
-- Pin feature
|
||||
pinned BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_thread_id ON "
|
||||
"chat_sessions(thread_id)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_project ON "
|
||||
"chat_sessions(user_id, project_id)"
|
||||
)
|
||||
|
||||
# Check if pinned column exists, add it if not (migration for existing
|
||||
# databases)
|
||||
cursor = await conn.execute("PRAGMA table_info(chat_sessions)")
|
||||
columns = await cursor.fetchall()
|
||||
column_names = [col[1] for col in columns]
|
||||
|
||||
if "pinned" not in column_names:
|
||||
log.debug("Adding pinned column to existing chat_sessions table")
|
||||
await conn.execute(
|
||||
"ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT "
|
||||
"FALSE"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Create pinned index (after column is guaranteed to exist)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON "
|
||||
"chat_sessions(pinned DESC, updated_at DESC)"
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
log.debug("chat_sessions table created in checkpoint database")
|
||||
|
||||
async def _get_graph(self):
|
||||
"""Get or compile the LangGraph agent."""
|
||||
if self._graph is None:
|
||||
checkpointer = await self._get_checkpointer()
|
||||
self._graph = agent_builder.compile(checkpointer=checkpointer)
|
||||
log.info(
|
||||
"LangGraph agent compiled for project: %s", self.project_path
|
||||
)
|
||||
return self._graph
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
message: str,
|
||||
session_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
jwt_token: Optional[str] = None,
|
||||
mode: str = "text",
|
||||
llm_config: Optional[Dict[str, Any]] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream chat responses from the agent.
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
session_id: Session/thread ID for conversation continuity
|
||||
project_id: GNS3 project ID (optional, for context)
|
||||
user_id: User ID for metadata tracking
|
||||
jwt_token: JWT token for API authentication (optional)
|
||||
mode: Interaction mode (default: "text")
|
||||
llm_config: LLM configuration dict (provider, model, api_key,
|
||||
etc.)
|
||||
|
||||
Yields:
|
||||
Dict containing SSE-compatible response chunks
|
||||
"""
|
||||
log.info(
|
||||
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, "
|
||||
"mode=%s",
|
||||
project_id,
|
||||
user_id,
|
||||
session_id,
|
||||
mode,
|
||||
)
|
||||
|
||||
# Ensure checkpointer is initialized
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
|
||||
# Get or create chat session
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
session = await repo.get_session_by_thread(session_id)
|
||||
is_new_session = session is None
|
||||
|
||||
if is_new_session:
|
||||
# Create new session
|
||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
session = await repo.create_session(
|
||||
thread_id=session_id,
|
||||
user_id=user_id or "",
|
||||
project_id=project_id or "",
|
||||
title="New Conversation",
|
||||
copilot_mode=copilot_mode,
|
||||
)
|
||||
log.debug(
|
||||
"Created new chat session: thread_id=%s, copilot_mode=%s",
|
||||
session_id,
|
||||
copilot_mode
|
||||
)
|
||||
|
||||
# Set request-scoped context variables (memory-only, not persisted)
|
||||
if jwt_token:
|
||||
set_current_jwt_token(jwt_token)
|
||||
log.debug("JWT token set in context")
|
||||
if llm_config:
|
||||
set_current_llm_config(llm_config)
|
||||
log.debug(
|
||||
"LLM config set in context: provider=%s, model=%s",
|
||||
llm_config.get("provider"),
|
||||
llm_config.get("model"),
|
||||
)
|
||||
|
||||
# Build config - only thread-safe identifiers
|
||||
# Determine recursion_limit based on copilot_mode
|
||||
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
if copilot_mode == "troubleshooting_injection":
|
||||
recursion_limit = 100 # Need more recursion depth for fault injection workflow
|
||||
log.debug("Using extended recursion_limit for troubleshooting_injection mode: 100")
|
||||
else:
|
||||
recursion_limit = 25 # Default recursion limit
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": session_id,
|
||||
"project_id": project_id,
|
||||
},
|
||||
"metadata": {
|
||||
"user_id": user_id,
|
||||
"copilot_mode": copilot_mode, # Store mode in checkpoint metadata
|
||||
},
|
||||
"recursion_limit": recursion_limit,
|
||||
}
|
||||
|
||||
# Build inputs
|
||||
# Determine remaining_steps based on copilot_mode
|
||||
# Troubleshooting injection mode needs more steps for multi-tool workflow
|
||||
if copilot_mode == "troubleshooting_injection":
|
||||
remaining_steps = 50 # Need more steps for fault injection workflow
|
||||
log.debug("Using extended remaining_steps for troubleshooting_injection mode: 50")
|
||||
else:
|
||||
remaining_steps = 20 # Default for other modes
|
||||
|
||||
inputs = {
|
||||
"messages": [
|
||||
HumanMessage(
|
||||
content=message,
|
||||
id=str(uuid4()),
|
||||
metadata={"created_at": datetime.utcnow().isoformat()},
|
||||
)
|
||||
],
|
||||
"llm_calls": 0,
|
||||
"remaining_steps": remaining_steps,
|
||||
"session_id": session_id,
|
||||
"abort": False,
|
||||
}
|
||||
|
||||
# Clear abort flag for this session at the start of stream
|
||||
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
|
||||
clear_abort_flag,
|
||||
)
|
||||
|
||||
clear_abort_flag(session_id)
|
||||
log.debug("Abort flag cleared for session: %s", session_id)
|
||||
|
||||
# Get the compiled graph
|
||||
graph = await self._get_graph()
|
||||
log.debug("LangGraph graph obtained, starting stream")
|
||||
|
||||
# Track statistics for session update
|
||||
message_count = 1 # User message
|
||||
llm_calls_count = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
last_message_at = datetime.utcnow().isoformat()
|
||||
|
||||
# Track if we've counted the AI response for this turn
|
||||
ai_response_counted = False
|
||||
tool_messages_counted = 0
|
||||
|
||||
# Initialize tool call stream accumulator for handling progressive
|
||||
# tool call arguments
|
||||
tool_call_accumulator = ToolCallStreamAccumulator()
|
||||
|
||||
# Track if stream was aborted
|
||||
stream_aborted = False
|
||||
|
||||
# Stream events
|
||||
try:
|
||||
async for event in graph.astream_events(
|
||||
inputs, config=config, version="v2"
|
||||
):
|
||||
event_type = event.get("event", "")
|
||||
data = event.get("data", {})
|
||||
|
||||
# Track LLM calls and tokens
|
||||
if event_type == "on_chat_model_start":
|
||||
# Filter out title_generator_node from statistics
|
||||
langgraph_node = event.get("metadata", {}).get(
|
||||
"langgraph_node", ""
|
||||
)
|
||||
if langgraph_node != "title_generator_node":
|
||||
llm_calls_count += 1
|
||||
log.debug(
|
||||
"LLM call started, count=%d", llm_calls_count
|
||||
)
|
||||
else:
|
||||
log.debug(
|
||||
"Skipping LLM call count for internal node: "
|
||||
"title_generator_node"
|
||||
)
|
||||
|
||||
elif event_type == "on_chat_model_end":
|
||||
# Filter out title_generator_node from token counting
|
||||
langgraph_node = event.get("metadata", {}).get(
|
||||
"langgraph_node", ""
|
||||
)
|
||||
if langgraph_node == "title_generator_node":
|
||||
log.debug(
|
||||
"Skipping token counting for internal node: "
|
||||
"title_generator_node"
|
||||
)
|
||||
else:
|
||||
# Extract token usage from response metadata
|
||||
# Try multiple possible locations where token usage
|
||||
# might be stored
|
||||
token_info_found = False
|
||||
|
||||
# Method 1: response.usage_metadata
|
||||
response = data.get("response", {})
|
||||
if hasattr(response, "usage_metadata"):
|
||||
usage = response.usage_metadata
|
||||
if usage:
|
||||
input_tokens += usage.get("input_tokens", 0)
|
||||
output_tokens += usage.get("output_tokens", 0)
|
||||
token_info_found = True
|
||||
|
||||
# Method 2: output.usage_metadata
|
||||
if not token_info_found:
|
||||
output_msg = data.get("output", {})
|
||||
if hasattr(output_msg, "usage_metadata"):
|
||||
usage = output_msg.usage_metadata
|
||||
if usage:
|
||||
input_tokens += usage.get(
|
||||
"input_tokens", 0
|
||||
)
|
||||
output_tokens += usage.get(
|
||||
"output_tokens", 0
|
||||
)
|
||||
token_info_found = True
|
||||
|
||||
# Method 3: Check data directly for token usage fields
|
||||
if not token_info_found:
|
||||
if "input_tokens" in data:
|
||||
input_tokens += data.get("input_tokens", 0)
|
||||
if "output_tokens" in data:
|
||||
output_tokens += data.get("output_tokens", 0)
|
||||
if (
|
||||
"input_tokens" in data
|
||||
or "output_tokens" in data
|
||||
):
|
||||
token_info_found = True
|
||||
|
||||
# Count AI response as one message (only once per turn)
|
||||
if not ai_response_counted:
|
||||
message_count += 1
|
||||
ai_response_counted = True
|
||||
|
||||
# Track tool messages
|
||||
elif event_type == "on_tool_end":
|
||||
message_count += 1 # Tool result message
|
||||
tool_messages_counted += 1
|
||||
log.debug(
|
||||
"Tool message counted, message_count=%d", message_count
|
||||
)
|
||||
|
||||
# Convert event to chunk for SSE streaming
|
||||
# Use accumulator for on_chat_model_stream events to handle
|
||||
# progressive tool calls
|
||||
|
||||
# Filter out internal nodes (title_generator_node) from
|
||||
# streaming to frontend
|
||||
langgraph_node = event.get("metadata", {}).get(
|
||||
"langgraph_node", ""
|
||||
)
|
||||
if langgraph_node == "title_generator_node":
|
||||
# Skip all events from the title_generator_node (internal
|
||||
# use only)
|
||||
log.debug(
|
||||
"Skipping event from internal node: "
|
||||
"title_generator_node"
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == "on_chat_model_stream":
|
||||
chunks = tool_call_accumulator.process_event(event)
|
||||
for chunk in chunks:
|
||||
# Add session_id to each chunk
|
||||
chunk["session_id"] = session_id
|
||||
log.debug(
|
||||
"Yielding accumulated chunk: type=%s",
|
||||
chunk.get("type"),
|
||||
)
|
||||
yield chunk
|
||||
else:
|
||||
# Use stateless converter for other events
|
||||
chunk = self._convert_event_to_chunk(event, session_id)
|
||||
if chunk:
|
||||
log.debug("Yielding chunk: type=%s", chunk.get("type"))
|
||||
yield chunk
|
||||
|
||||
# Check if stream was aborted and yield tool_end events for aborted tools
|
||||
from gns3server.agent.gns3_copilot.agent.gns3_copilot import (
|
||||
check_abort_flag,
|
||||
)
|
||||
|
||||
if check_abort_flag(session_id):
|
||||
log.info("Stream aborted, yielding tool_end events: session_id=%s", session_id)
|
||||
# Get final state to find aborted tool messages
|
||||
final_state = await graph.aget_state(config)
|
||||
if final_state and "messages" in final_state.values:
|
||||
for msg in final_state.values["messages"]:
|
||||
# Check if this is an aborted tool message
|
||||
if hasattr(msg, "metadata") and msg.metadata.get("aborted"):
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": getattr(msg, "name", "unknown"),
|
||||
"tool_output": msg.content if hasattr(msg, "content") else "",
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
# Update session statistics after successful stream
|
||||
await repo.update_session(
|
||||
thread_id=session_id,
|
||||
message_count=message_count,
|
||||
llm_calls_count=llm_calls_count,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=input_tokens + output_tokens,
|
||||
last_message_at=last_message_at,
|
||||
)
|
||||
log.info(
|
||||
"Session statistics updated: thread_id=%s, messages=%d, "
|
||||
"llm_calls=%d, tokens=%d+%d=%d",
|
||||
session_id,
|
||||
message_count,
|
||||
llm_calls_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
input_tokens + output_tokens,
|
||||
)
|
||||
|
||||
# Sync auto-generated title from checkpoint state
|
||||
final_state = await graph.aget_state(config)
|
||||
if final_state and "conversation_title" in final_state.values:
|
||||
generated_title = final_state.values["conversation_title"]
|
||||
current_session = await repo.get_session_by_thread(session_id)
|
||||
if (
|
||||
current_session
|
||||
and current_session.title != generated_title
|
||||
):
|
||||
await repo.update_session(
|
||||
thread_id=session_id, title=generated_title
|
||||
)
|
||||
log.info(
|
||||
"Auto-generated title synced: thread_id=%s, title=%s",
|
||||
session_id,
|
||||
generated_title,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error("Error in stream_chat: %s", e, exc_info=True)
|
||||
yield {
|
||||
"type": "error",
|
||||
"error": format_error_message(e),
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
def _convert_event_to_chunk(
|
||||
self, event: Dict[str, Any], session_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Convert LangGraph event to API response chunk.
|
||||
|
||||
Args:
|
||||
event: LangGraph event from astream_events
|
||||
session_id: Session ID for the response
|
||||
|
||||
Returns:
|
||||
Dict for SSE response or None if event should be filtered
|
||||
|
||||
Note:
|
||||
on_chat_model_stream events are handled by
|
||||
ToolCallStreamAccumulator before calling this method, so they are
|
||||
not processed here.
|
||||
"""
|
||||
event_type = event.get("event", "")
|
||||
data = event.get("data", {})
|
||||
|
||||
if event_type == "on_tool_start":
|
||||
# Tool execution started
|
||||
# Extract tool_call_id from event metadata to associate with
|
||||
# tool_call event
|
||||
tool_call_id = event.get("metadata", {}).get("tool_call_id", "")
|
||||
return {
|
||||
"type": "tool_start",
|
||||
"tool_name": event.get("name", ""),
|
||||
"tool_call_id": tool_call_id,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
elif event_type == "on_tool_end":
|
||||
# Tool execution completed
|
||||
# Extract tool output and convert to JSON string
|
||||
output = data.get("output", "")
|
||||
# Convert output to JSON string if it's not already a string
|
||||
# This ensures dict/list outputs are properly serialized for
|
||||
# frontend parsing
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output, ensure_ascii=False, indent=2)
|
||||
return {
|
||||
"type": "tool_end",
|
||||
"tool_name": event.get("name", ""),
|
||||
"tool_output": output,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def get_history(
|
||||
self, session_id: str, limit: int = 100
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session/thread ID
|
||||
limit: Maximum number of messages to retrieve
|
||||
|
||||
Returns:
|
||||
Dict containing thread_id, title, and messages
|
||||
"""
|
||||
config = {"configurable": {"thread_id": session_id}}
|
||||
|
||||
try:
|
||||
graph = await self._get_graph()
|
||||
state = await graph.aget_state(config)
|
||||
|
||||
if state and "messages" in state.values:
|
||||
messages = []
|
||||
for msg in state.values["messages"][-limit:]:
|
||||
messages.append(self._convert_message_to_dict(msg))
|
||||
|
||||
title = state.values.get(
|
||||
"conversation_title", "New Conversation"
|
||||
)
|
||||
|
||||
return {
|
||||
"thread_id": session_id,
|
||||
"title": title,
|
||||
"messages": messages,
|
||||
}
|
||||
except Exception as e:
|
||||
log.error("Error getting history: %s", e, exc_info=True)
|
||||
|
||||
return {
|
||||
"thread_id": session_id,
|
||||
"title": "New Conversation",
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
def _convert_message_to_dict(self, msg) -> Dict[str, Any]:
|
||||
"""Convert a LangChain message to OpenAI-compatible dict format."""
|
||||
return convert_langchain_to_openai(msg)
|
||||
|
||||
async def list_sessions(
|
||||
self, user_id: Optional[str] = None, copilot_mode: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List chat sessions for this project.
|
||||
|
||||
Args:
|
||||
user_id: Filter by user ID (optional)
|
||||
copilot_mode: Filter by copilot mode (optional)
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of session dictionaries
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
sessions = await repo.list_sessions(user_id=user_id, copilot_mode=copilot_mode, limit=limit)
|
||||
return [s.to_dict() for s in sessions]
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
"""
|
||||
Delete a chat session and its checkpoints.
|
||||
|
||||
Args:
|
||||
session_id: Thread ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
return await repo.delete_session(session_id)
|
||||
|
||||
async def rename_session(
|
||||
self, session_id: str, new_title: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Rename a chat session.
|
||||
|
||||
Args:
|
||||
session_id: Thread ID
|
||||
new_title: New title
|
||||
|
||||
Returns:
|
||||
Updated session dictionary or None
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
session = await repo.update_session(
|
||||
thread_id=session_id, title=new_title
|
||||
)
|
||||
return session.to_dict() if session else None
|
||||
|
||||
async def pin_session(
|
||||
self, session_id: str, pinned: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Pin or unpin a chat session.
|
||||
|
||||
Args:
|
||||
session_id: Thread ID
|
||||
pinned: True to pin, False to unpin
|
||||
|
||||
Returns:
|
||||
Updated session dictionary or None
|
||||
"""
|
||||
if not self._checkpointer_conn:
|
||||
await self._get_checkpointer()
|
||||
|
||||
repo = ChatSessionsRepository(self._checkpointer_conn)
|
||||
session = await repo.pin_session(thread_id=session_id, pinned=pinned)
|
||||
return session.to_dict() if session else None
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Close the checkpointer connection and cleanup resources.
|
||||
"""
|
||||
async with self._init_lock:
|
||||
if self._checkpointer_conn:
|
||||
try:
|
||||
# Add timeout to prevent blocking on database close
|
||||
await asyncio.wait_for(
|
||||
self._checkpointer_conn.close(),
|
||||
timeout=5.0
|
||||
)
|
||||
log.debug(
|
||||
"Checkpointer connection closed for: %s",
|
||||
self.project_path,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning(
|
||||
"Checkpointer connection close timeout for: %s (forcing cleanup)",
|
||||
self.project_path,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("Error closing checkpointer connection: %s", e)
|
||||
finally:
|
||||
self._checkpointer_conn = None
|
||||
self._checkpointer = None
|
||||
self._graph = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,447 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
Chat Sessions Repository for managing chat session data.
|
||||
|
||||
Provides CRUD operations for the chat_sessions table in the project's
|
||||
checkpoint database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatSession:
|
||||
"""Chat session model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: Optional[int] = None,
|
||||
thread_id: str = "",
|
||||
user_id: str = "",
|
||||
project_id: str = "",
|
||||
title: str = "New Conversation",
|
||||
message_count: int = 0,
|
||||
llm_calls_count: int = 0,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
total_tokens: int = 0,
|
||||
last_message_at: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
updated_at: Optional[str] = None,
|
||||
metadata: str = "{}",
|
||||
stats: str = "{}",
|
||||
pinned: bool = False,
|
||||
):
|
||||
self.id = id
|
||||
self.thread_id = thread_id
|
||||
self.user_id = user_id
|
||||
self.project_id = project_id
|
||||
self.title = title
|
||||
self.message_count = message_count
|
||||
self.llm_calls_count = llm_calls_count
|
||||
self.input_tokens = input_tokens
|
||||
self.output_tokens = output_tokens
|
||||
self.total_tokens = total_tokens
|
||||
self.last_message_at = last_message_at
|
||||
self.created_at = created_at
|
||||
self.updated_at = updated_at
|
||||
self.metadata = metadata
|
||||
self.stats = stats
|
||||
self.pinned = pinned
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"thread_id": self.thread_id,
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"message_count": self.message_count,
|
||||
"llm_calls_count": self.llm_calls_count,
|
||||
"input_tokens": self.input_tokens,
|
||||
"output_tokens": self.output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"last_message_at": self.last_message_at,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"metadata": json.loads(self.metadata) if self.metadata else {},
|
||||
"stats": json.loads(self.stats) if self.stats else {},
|
||||
"pinned": self.pinned,
|
||||
}
|
||||
|
||||
|
||||
class ChatSessionsRepository:
|
||||
"""
|
||||
Repository for managing chat sessions in the checkpoint database.
|
||||
"""
|
||||
|
||||
def __init__(self, conn: aiosqlite.Connection):
|
||||
"""
|
||||
Initialize repository with a database connection.
|
||||
|
||||
Args:
|
||||
conn: aiosqlite connection to the checkpoint database
|
||||
"""
|
||||
self.conn = conn
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
title: str = "New Conversation",
|
||||
copilot_mode: Optional[str] = None,
|
||||
) -> ChatSession:
|
||||
"""
|
||||
Create a new chat session.
|
||||
|
||||
Args:
|
||||
thread_id: Unique thread identifier
|
||||
user_id: User ID
|
||||
project_id: Project ID
|
||||
title: Session title
|
||||
copilot_mode: Copilot mode (optional)
|
||||
|
||||
Returns:
|
||||
Created ChatSession
|
||||
"""
|
||||
now = datetime.utcnow().isoformat()
|
||||
# Build metadata JSON
|
||||
metadata = {"copilot_mode": copilot_mode} if copilot_mode else {}
|
||||
metadata_json = json.dumps(metadata)
|
||||
|
||||
cursor = await self.conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_sessions (
|
||||
thread_id, user_id, project_id, title,
|
||||
metadata, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(thread_id, user_id, project_id, title, metadata_json, now, now),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
session_id = cursor.lastrowid
|
||||
log.info(
|
||||
"Created chat session: id=%s, thread_id=%s, copilot_mode=%s",
|
||||
session_id, thread_id, copilot_mode
|
||||
)
|
||||
|
||||
return await self.get_session_by_id(session_id)
|
||||
|
||||
async def get_session_by_id(
|
||||
self, session_id: int
|
||||
) -> Optional[ChatSession]:
|
||||
"""
|
||||
Get a session by its database ID.
|
||||
|
||||
Args:
|
||||
session_id: Database row ID
|
||||
|
||||
Returns:
|
||||
ChatSession or None
|
||||
"""
|
||||
cursor = await self.conn.execute(
|
||||
"SELECT * FROM chat_sessions WHERE id = ?", (session_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return self._row_to_session(row)
|
||||
return None
|
||||
|
||||
async def get_session_by_thread(
|
||||
self, thread_id: str
|
||||
) -> Optional[ChatSession]:
|
||||
"""
|
||||
Get a session by thread_id.
|
||||
|
||||
Args:
|
||||
thread_id: Thread identifier
|
||||
|
||||
Returns:
|
||||
ChatSession or None
|
||||
"""
|
||||
cursor = await self.conn.execute(
|
||||
"SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return self._row_to_session(row)
|
||||
return None
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
copilot_mode: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ChatSession]:
|
||||
"""
|
||||
List sessions with optional filters.
|
||||
|
||||
Args:
|
||||
user_id: Filter by user ID
|
||||
project_id: Filter by project ID
|
||||
copilot_mode: Filter by copilot mode (metadata field)
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of ChatSession
|
||||
"""
|
||||
query = "SELECT * FROM chat_sessions"
|
||||
params = []
|
||||
|
||||
conditions = []
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
if project_id:
|
||||
conditions.append("project_id = ?")
|
||||
params.append(project_id)
|
||||
if copilot_mode:
|
||||
# Filter by JSON metadata field
|
||||
conditions.append("json_extract(metadata, '$.copilot_mode') = ?")
|
||||
params.append(copilot_mode)
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
# Sort by pinned status first, then by updated_at
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = await self.conn.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [self._row_to_session(row) for row in rows]
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
thread_id: str,
|
||||
title: Optional[str] = None,
|
||||
message_count: Optional[int] = None,
|
||||
llm_calls_count: Optional[int] = None,
|
||||
input_tokens: Optional[int] = None,
|
||||
output_tokens: Optional[int] = None,
|
||||
total_tokens: Optional[int] = None,
|
||||
last_message_at: Optional[str] = None,
|
||||
) -> Optional[ChatSession]:
|
||||
"""
|
||||
Update a session.
|
||||
|
||||
Args:
|
||||
thread_id: Thread identifier
|
||||
title: New title
|
||||
message_count: Increment message count
|
||||
llm_calls_count: Increment LLM call count
|
||||
input_tokens: Add to input tokens
|
||||
output_tokens: Add to output tokens
|
||||
total_tokens: Add to total tokens
|
||||
last_message_at: Last message timestamp
|
||||
|
||||
Returns:
|
||||
Updated ChatSession or None
|
||||
"""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if title is not None:
|
||||
updates.append("title = ?")
|
||||
params.append(title)
|
||||
|
||||
if message_count is not None:
|
||||
updates.append("message_count = message_count + ?")
|
||||
params.append(message_count)
|
||||
|
||||
if llm_calls_count is not None:
|
||||
updates.append("llm_calls_count = llm_calls_count + ?")
|
||||
params.append(llm_calls_count)
|
||||
|
||||
if input_tokens is not None:
|
||||
updates.append("input_tokens = input_tokens + ?")
|
||||
params.append(input_tokens)
|
||||
|
||||
if output_tokens is not None:
|
||||
updates.append("output_tokens = output_tokens + ?")
|
||||
params.append(output_tokens)
|
||||
|
||||
if total_tokens is not None:
|
||||
updates.append("total_tokens = total_tokens + ?")
|
||||
params.append(total_tokens)
|
||||
|
||||
if last_message_at is not None:
|
||||
updates.append("last_message_at = ?")
|
||||
params.append(last_message_at)
|
||||
|
||||
if not updates:
|
||||
return await self.get_session_by_thread(thread_id)
|
||||
|
||||
updates.append("updated_at = ?")
|
||||
params.append(now)
|
||||
params.append(thread_id)
|
||||
|
||||
query = (
|
||||
f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id "
|
||||
f"= ?"
|
||||
)
|
||||
|
||||
await self.conn.execute(query, params)
|
||||
await self.conn.commit()
|
||||
|
||||
log.debug("Updated chat session: thread_id=%s", thread_id)
|
||||
return await self.get_session_by_thread(thread_id)
|
||||
|
||||
async def delete_session(self, thread_id: str) -> bool:
|
||||
"""
|
||||
Delete a session by thread_id.
|
||||
|
||||
Args:
|
||||
thread_id: Thread identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
# First, delete the checkpoint data
|
||||
await self.conn.execute(
|
||||
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
|
||||
)
|
||||
|
||||
# Then delete the session
|
||||
cursor = await self.conn.execute(
|
||||
"DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
deleted = cursor.rowcount > 0
|
||||
if deleted:
|
||||
log.info(
|
||||
"Deleted chat session and checkpoints: thread_id=%s", thread_id
|
||||
)
|
||||
|
||||
return deleted
|
||||
|
||||
async def delete_all_sessions(self, project_id: str) -> int:
|
||||
"""
|
||||
Delete all sessions for a project.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
|
||||
Returns:
|
||||
Number of sessions deleted
|
||||
"""
|
||||
# Get all thread_ids for this project
|
||||
cursor = await self.conn.execute(
|
||||
"SELECT thread_id FROM chat_sessions WHERE project_id = ?",
|
||||
(project_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
thread_ids = [row[0] for row in rows]
|
||||
|
||||
# Delete checkpoints and sessions
|
||||
for thread_id in thread_ids:
|
||||
await self.conn.execute(
|
||||
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
|
||||
)
|
||||
|
||||
cursor = await self.conn.execute(
|
||||
"DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
if deleted_count > 0:
|
||||
log.info(
|
||||
"Deleted %d sessions for project: %s",
|
||||
deleted_count,
|
||||
project_id,
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
async def pin_session(
|
||||
self, thread_id: str, pinned: bool = True
|
||||
) -> Optional[ChatSession]:
|
||||
"""
|
||||
Pin or unpin a session.
|
||||
|
||||
Args:
|
||||
thread_id: Thread identifier
|
||||
pinned: True to pin, False to unpin
|
||||
|
||||
Returns:
|
||||
Updated ChatSession or None
|
||||
"""
|
||||
now = datetime.utcnow().isoformat()
|
||||
await self.conn.execute(
|
||||
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE "
|
||||
"thread_id = ?",
|
||||
(1 if pinned else 0, now, thread_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
log.debug(
|
||||
"Session pin status updated: thread_id=%s, pinned=%s",
|
||||
thread_id,
|
||||
pinned,
|
||||
)
|
||||
return await self.get_session_by_thread(thread_id)
|
||||
|
||||
def _row_to_session(self, row) -> ChatSession:
|
||||
"""Convert database row to ChatSession object."""
|
||||
return ChatSession(
|
||||
id=row[0],
|
||||
thread_id=row[1],
|
||||
user_id=row[2],
|
||||
project_id=row[3],
|
||||
title=row[4],
|
||||
message_count=row[5],
|
||||
llm_calls_count=row[6],
|
||||
input_tokens=row[7],
|
||||
output_tokens=row[8],
|
||||
total_tokens=row[9],
|
||||
last_message_at=row[10],
|
||||
created_at=row[11],
|
||||
updated_at=row[12],
|
||||
metadata=row[13],
|
||||
stats=row[14],
|
||||
pinned=bool(row[15]) if len(row) > 15 else False,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin
|
||||
#
|
||||
|
||||
"""
|
||||
Configuration package for GNS3 Copilot.
|
||||
"""
|
||||
|
||||
from .skills_config import (
|
||||
SKILLS_CONFIG,
|
||||
get_skills_config,
|
||||
update_skills_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SKILLS_CONFIG",
|
||||
"get_skills_config",
|
||||
"update_skills_config",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Skills Configuration
|
||||
|
||||
This module contains configuration for the external skills repository.
|
||||
"""
|
||||
|
||||
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",
|
||||
|
||||
# Git branch to use
|
||||
"branch": "main",
|
||||
|
||||
# Automatically pull updates on reload
|
||||
"auto_update": True,
|
||||
|
||||
# Enable external skills loading
|
||||
# If False, use the built-in hardcoded skills
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def get_skills_config() -> dict:
|
||||
"""
|
||||
Get the skills configuration.
|
||||
|
||||
Priority:
|
||||
1. GNS3 server config (gns3_server.conf → [Server] → skills_*)
|
||||
2. Hardcoded defaults
|
||||
|
||||
Returns:
|
||||
Dictionary containing skills configuration
|
||||
"""
|
||||
config = SKILLS_CONFIG.copy()
|
||||
try:
|
||||
server = Config.instance().settings.Server
|
||||
if server.skills_repo_url:
|
||||
config["repo_url"] = server.skills_repo_url
|
||||
if server.skills_repo_branch:
|
||||
config["branch"] = server.skills_repo_branch
|
||||
config["auto_update"] = server.skills_auto_update
|
||||
except Exception:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def update_skills_config(**kwargs):
|
||||
"""
|
||||
Update skills configuration.
|
||||
|
||||
Args:
|
||||
**kwargs: Configuration key-value pairs to update
|
||||
"""
|
||||
for key, value in kwargs.items():
|
||||
if key in SKILLS_CONFIG:
|
||||
SKILLS_CONFIG[key] = value
|
||||
else:
|
||||
raise ValueError(f"Unknown configuration key: {key}")
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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 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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Upstream gns3fy: https://github.com/davidban77/gns3fy
|
||||
"""
|
||||
|
||||
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
|
||||
from .connector_factory import get_llm_config
|
||||
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
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("gns3-copilot")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Yue Guobin (岳国宾)"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
__all__ = [
|
||||
"Gns3Connector",
|
||||
"Project",
|
||||
"Node",
|
||||
"Link",
|
||||
"NODE_TYPES",
|
||||
"CONSOLE_TYPES",
|
||||
"LINK_TYPES",
|
||||
"GNS3TopologyTool",
|
||||
"GNS3ProjectInfoTool",
|
||||
"get_gns3_connector",
|
||||
"get_gns3_connector_with_llm_config",
|
||||
"get_gns3_server_host",
|
||||
"get_llm_config",
|
||||
"set_current_jwt_token",
|
||||
"get_current_jwt_token",
|
||||
"set_current_llm_config",
|
||||
"get_current_llm_config",
|
||||
]
|
||||
@@ -0,0 +1,472 @@
|
||||
# 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 Connector Factory Module
|
||||
|
||||
This module provides factory functions for creating Gns3Connector instances
|
||||
with JWT token authentication and context-aware configuration management.
|
||||
|
||||
Features:
|
||||
- Context variable based request-scoped data management (JWT tokens, LLM
|
||||
config)
|
||||
- Auto-detection of GNS3 server URL from Controller/Config
|
||||
- Fallback URL strategy for flexible deployment
|
||||
- LLM configuration retrieval for users
|
||||
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
get_current_jwt_token,
|
||||
)
|
||||
|
||||
# Local imports
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import (
|
||||
Gns3Connector,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Fallback default URL
|
||||
DEFAULT_GNS3_URL = "http://127.0.0.1:3080"
|
||||
|
||||
|
||||
def _get_url_from_controller() -> Optional[str]:
|
||||
"""Try to get GNS3 server URL from running Controller instance.
|
||||
|
||||
Returns:
|
||||
URL string if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
from gns3server.controller import Controller
|
||||
|
||||
controller = Controller.instance()
|
||||
local_compute = controller.get_compute("local")
|
||||
|
||||
url = (
|
||||
f"{local_compute.protocol}://{local_compute.host}:"
|
||||
f"{local_compute.port}"
|
||||
)
|
||||
logger.debug(
|
||||
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
|
||||
url,
|
||||
local_compute.protocol,
|
||||
local_compute.host,
|
||||
local_compute.port,
|
||||
)
|
||||
return url
|
||||
except ImportError as e:
|
||||
logger.debug("Cannot import Controller: %s", str(e))
|
||||
return None
|
||||
except AttributeError as e:
|
||||
logger.debug("Controller instance not available: %s", str(e))
|
||||
return None
|
||||
except KeyError as e:
|
||||
logger.debug("Local compute not found in Controller: %s", str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Unexpected error getting URL from Controller: %s", str(e)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _get_url_from_config() -> Optional[str]:
|
||||
"""Try to get GNS3 server URL from Config settings.
|
||||
|
||||
Returns:
|
||||
URL string if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
from gns3server.config import Config
|
||||
|
||||
server_config = Config.instance().settings.Server
|
||||
url = (
|
||||
f"{server_config.protocol.value}://{server_config.host}:"
|
||||
f"{server_config.port}"
|
||||
)
|
||||
logger.debug(
|
||||
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
|
||||
url,
|
||||
server_config.protocol.value,
|
||||
server_config.host,
|
||||
server_config.port,
|
||||
)
|
||||
return url
|
||||
except ImportError as e:
|
||||
logger.debug("Cannot import Config: %s", str(e))
|
||||
return None
|
||||
except AttributeError as e:
|
||||
logger.debug("Config settings not available: %s", str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error getting URL from Config: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_gns3_connector(
|
||||
jwt_token: Optional[str] = None, url: Optional[str] = None
|
||||
) -> Optional[Gns3Connector]:
|
||||
"""Create and return a Gns3Connector instance with JWT authentication.
|
||||
|
||||
URL Resolution Strategy (in order):
|
||||
1. Explicitly provided `url` parameter
|
||||
2. Runtime configuration from Controller.instance().compute("local")
|
||||
3. Static configuration from Config.instance().settings.Server
|
||||
4. Fallback to DEFAULT_GNS3_URL (http://127.0.0.1:3080)
|
||||
|
||||
Args:
|
||||
jwt_token: JWT token for authentication (optional, will be retrieved
|
||||
from context if not provided)
|
||||
url: GNS3 server URL (optional, auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
Gns3Connector instance if parameters are valid, None otherwise
|
||||
|
||||
Example:
|
||||
# Auto-detect URL from Controller or Config
|
||||
connector = get_gns3_connector(jwt_token="your_jwt_token")
|
||||
|
||||
# Or specify custom URL
|
||||
connector = get_gns3_connector(
|
||||
jwt_token="your_jwt_token",
|
||||
url="http://custom-server:3080"
|
||||
)
|
||||
|
||||
if connector:
|
||||
projects = connector.projects
|
||||
else:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
"""
|
||||
try:
|
||||
# Validate JWT token
|
||||
# If not provided, try to get from current request context
|
||||
if not jwt_token:
|
||||
jwt_token = get_current_jwt_token()
|
||||
if not jwt_token:
|
||||
logger.error("JWT token is required")
|
||||
return None
|
||||
|
||||
# Resolve URL with fallback strategy
|
||||
if url is None:
|
||||
logger.debug("No URL provided, attempting auto-detection...")
|
||||
|
||||
# Strategy 1: Try to get from running Controller
|
||||
url = _get_url_from_controller()
|
||||
if url:
|
||||
logger.info("Using GNS3 server URL from Controller: %s", url)
|
||||
else:
|
||||
# Strategy 2: Fall back to Config
|
||||
logger.debug("Controller not available, trying Config...")
|
||||
url = _get_url_from_config()
|
||||
if url:
|
||||
logger.info("Using GNS3 server URL from Config: %s", url)
|
||||
else:
|
||||
# Strategy 3: Use default fallback
|
||||
logger.debug("Config not available, using default URL")
|
||||
url = DEFAULT_GNS3_URL
|
||||
logger.warning(
|
||||
"Using fallback default URL: %s. This may not be "
|
||||
"correct if your GNS3 server is configured "
|
||||
"differently. Consider providing the URL explicitly.",
|
||||
url,
|
||||
)
|
||||
else:
|
||||
logger.info("Using explicitly provided GNS3 server URL: %s", url)
|
||||
|
||||
# Validate URL
|
||||
if not url:
|
||||
logger.error("Failed to resolve GNS3 server URL")
|
||||
return None
|
||||
|
||||
# Create connector
|
||||
logger.debug("Creating Gns3Connector with URL=%s", url)
|
||||
connector = Gns3Connector(
|
||||
url=url,
|
||||
jwt_token=jwt_token,
|
||||
api_version=3,
|
||||
)
|
||||
logger.info("Successfully created Gns3Connector for URL: %s", url)
|
||||
return connector
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create Gns3Connector: %s", str(e), exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def get_gns3_connector_with_llm_config(
|
||||
user_id, jwt_token: str, url: Optional[str] = None, app=None
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Create Gns3Connector and retrieve LLM model configuration for the user.
|
||||
|
||||
This is a convenience function that combines:
|
||||
1. get_gns3_connector() - Create GNS3 API connector
|
||||
2. get_llm_config() - Retrieve user's default LLM config with API key
|
||||
|
||||
Args:
|
||||
user_id: User UUID (can be string or UUID object)
|
||||
jwt_token: JWT token for authentication
|
||||
url: GNS3 server URL (optional, auto-detected if not provided)
|
||||
app: FastAPI application instance (optional, for direct database
|
||||
access)
|
||||
|
||||
Returns:
|
||||
Dictionary with keys:
|
||||
- connector: Gns3Connector instance
|
||||
- llm_config: Dict with provider, api_key, model, etc.
|
||||
Or None if failed
|
||||
|
||||
Example:
|
||||
result = await get_gns3_connector_with_llm_config(user_id, jwt_token)
|
||||
if result:
|
||||
connector = result["connector"]
|
||||
llm_config = result["llm_config"]
|
||||
|
||||
# Use connector for GNS3 operations
|
||||
projects = connector.projects
|
||||
|
||||
# Use LLM config for AI operations
|
||||
provider = llm_config["provider"]
|
||||
api_key = llm_config["api_key"]
|
||||
model = llm_config["model"]
|
||||
else:
|
||||
logger.error("Failed to initialize GNS3 connector or LLM config")
|
||||
"""
|
||||
try:
|
||||
# Convert user_id to UUID if it's a string
|
||||
if isinstance(user_id, str):
|
||||
user_id = UUID(user_id)
|
||||
|
||||
# Step 1: Create GNS3 connector
|
||||
connector = get_gns3_connector(jwt_token=jwt_token, url=url)
|
||||
if not connector:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return None
|
||||
|
||||
# Step 2: Detect URL if not provided
|
||||
if url is None:
|
||||
url = _detect_url_for_api()
|
||||
|
||||
# Step 3: Get LLM config
|
||||
llm_config = get_llm_config(
|
||||
user_id=user_id, jwt_token=jwt_token, app=app
|
||||
)
|
||||
|
||||
if not llm_config:
|
||||
logger.warning(f"No LLM config found for user {user_id}")
|
||||
# Still return result with connector only
|
||||
return {"connector": connector, "llm_config": None}
|
||||
|
||||
logger.info(
|
||||
f"Successfully initialized GNS3 connector and LLM config for "
|
||||
f"user {user_id}: connector_url={connector.url}, "
|
||||
f"llm_provider={llm_config.get('provider')}, "
|
||||
f"llm_model={llm_config.get('model')}"
|
||||
)
|
||||
|
||||
return {"connector": connector, "llm_config": llm_config}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_url_for_api() -> Optional[str]:
|
||||
"""
|
||||
Detect GNS3 server URL for API calls.
|
||||
|
||||
Uses the same priority order as get_gns3_connector:
|
||||
1. Controller.instance().compute("local")
|
||||
2. Config.instance().settings.Server
|
||||
3. Fallback to DEFAULT_GNS3_URL
|
||||
|
||||
Returns:
|
||||
URL string, or None if detection failed
|
||||
"""
|
||||
logger.debug("Detecting GNS3 server URL for API calls")
|
||||
|
||||
# Try Controller first
|
||||
url = _get_url_from_controller()
|
||||
if url:
|
||||
logger.debug("Using URL from Controller for API call: %s", url)
|
||||
return url
|
||||
|
||||
# Try Config
|
||||
url = _get_url_from_config()
|
||||
if url:
|
||||
logger.debug("Using URL from Config for API call: %s", url)
|
||||
return url
|
||||
|
||||
# Fallback
|
||||
logger.debug("Using fallback URL for API call: %s", DEFAULT_GNS3_URL)
|
||||
return DEFAULT_GNS3_URL
|
||||
|
||||
|
||||
def get_gns3_server_host() -> str:
|
||||
"""
|
||||
Get GNS3 server hostname from Controller or Config.
|
||||
|
||||
This is a convenience function for extracting the hostname only, useful
|
||||
for Nornir tools that need the GNS3 server address.
|
||||
|
||||
Uses the same priority order as get_gns3_connector:
|
||||
1. Controller.instance().compute("local")
|
||||
2. Config.instance().settings.Server
|
||||
3. Fallback to DEFAULT_GNS3_URL host
|
||||
|
||||
Returns:
|
||||
Hostname/IP address string
|
||||
|
||||
Example:
|
||||
from gns3server.agent.gns3_copilot.gns3_client import (
|
||||
get_gns3_server_host,
|
||||
)
|
||||
|
||||
host = get_gns3_server_host()
|
||||
print(f"GNS3 server host: {host}")
|
||||
"""
|
||||
url = _detect_url_for_api()
|
||||
|
||||
# Extract host from URL
|
||||
# URL format: protocol://host:port
|
||||
try:
|
||||
# Remove protocol prefix
|
||||
host_part = url.split("://")[1]
|
||||
# Extract host (before the port)
|
||||
host = host_part.split(":")[0]
|
||||
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
|
||||
return host
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to extract host from URL %s: %s, using fallback", url, e
|
||||
)
|
||||
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
|
||||
|
||||
|
||||
def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
|
||||
"""
|
||||
Get LLM model configuration for a user.
|
||||
|
||||
This function retrieves the user's LLM configuration from the database.
|
||||
When `app` is provided, it directly accesses the database (bypassing API
|
||||
restrictions). Otherwise, it falls back to API call (which may mask
|
||||
group config API keys).
|
||||
|
||||
Args:
|
||||
user_id: User UUID (can be string or UUID object)
|
||||
jwt_token: JWT token for authentication
|
||||
app: FastAPI application instance (optional, for direct database
|
||||
access)
|
||||
|
||||
Returns:
|
||||
Dictionary with LLM configuration keys (provider, model, api_key,
|
||||
etc.), or None if not found.
|
||||
|
||||
Example:
|
||||
from gns3server.agent.gns3_copilot.gns3_client import (
|
||||
get_llm_config,
|
||||
)
|
||||
|
||||
config = get_llm_config(user_id, jwt_token)
|
||||
if config:
|
||||
provider = config['provider']
|
||||
model = config['model']
|
||||
api_key = config['api_key']
|
||||
"""
|
||||
try:
|
||||
# Convert user_id to UUID if it's a string
|
||||
if isinstance(user_id, str):
|
||||
user_id = UUID(user_id)
|
||||
|
||||
# If app is provided, use direct database access (preferred)
|
||||
if app is not None:
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import (
|
||||
get_user_llm_config_with_app,
|
||||
)
|
||||
|
||||
# Run the async function in sync context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context with a running loop
|
||||
# This shouldn't happen since this is a sync function
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run, get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
except RuntimeError:
|
||||
# No running event loop - we're in a sync context
|
||||
pass
|
||||
|
||||
# Try to get an existing event loop, or create a new one
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Loop is running but not the running loop (edge case)
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run,
|
||||
get_user_llm_config_with_app(user_id, app),
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
else:
|
||||
# Loop exists but not running - use it
|
||||
return loop.run_until_complete(
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
except RuntimeError:
|
||||
# No event loop exists - create a new one
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(
|
||||
get_user_llm_config_with_app(user_id, app)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Fallback: No app provided, try API call (will mask group config API
|
||||
# keys)
|
||||
logger.warning(
|
||||
"No app provided for get_llm_config, group config API keys may be "
|
||||
"masked"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get LLM config for user %s: %s", user_id, e)
|
||||
return None
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Request-Scoped Context Helpers for GNS3-Copilot
|
||||
|
||||
This module provides context variable management for request-scoped data
|
||||
(JWT tokens and LLM configurations) using Python's contextvars module.
|
||||
|
||||
These functions are separated into their own module to avoid circular imports
|
||||
between agent_service.py, gns3_copilot.py, and gns3_client modules.
|
||||
|
||||
Key Features:
|
||||
- Thread-safe and async-safe request-scoped context management
|
||||
- Automatic cleanup when request context ends
|
||||
- No manual cleanup required
|
||||
|
||||
Usage:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
set_current_jwt_token,
|
||||
get_current_jwt_token,
|
||||
set_current_llm_config,
|
||||
get_current_llm_config,
|
||||
)
|
||||
|
||||
# In request handler
|
||||
set_current_jwt_token(token)
|
||||
set_current_llm_config(config)
|
||||
|
||||
# In downstream code
|
||||
token = get_current_jwt_token()
|
||||
config = get_current_llm_config()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Context variables for request-scoped data
|
||||
# Automatically cleaned up when request context ends
|
||||
_jwt_token_context: ContextVar[Optional[str]] = ContextVar(
|
||||
"_jwt_token_context", default=None
|
||||
)
|
||||
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
|
||||
"_llm_config_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_jwt_token(token: str) -> None:
|
||||
"""Set the JWT token for the current request context.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
"""
|
||||
_jwt_token_context.set(token)
|
||||
logger.debug("JWT token set in context")
|
||||
|
||||
|
||||
def get_current_jwt_token() -> Optional[str]:
|
||||
"""Get the JWT token for the current request context.
|
||||
|
||||
Returns:
|
||||
JWT token string if available, None otherwise
|
||||
"""
|
||||
token = _jwt_token_context.get()
|
||||
if token:
|
||||
logger.debug("JWT token retrieved from context")
|
||||
else:
|
||||
logger.warning("JWT token not found in context")
|
||||
return token
|
||||
|
||||
|
||||
def set_current_llm_config(config: dict) -> None:
|
||||
"""Set the LLM config for the current request context.
|
||||
|
||||
Args:
|
||||
config: LLM configuration dictionary with provider, model, api_key,
|
||||
etc.
|
||||
"""
|
||||
_llm_config_context.set(config)
|
||||
logger.debug(
|
||||
"LLM config set in context: provider=%s, model=%s",
|
||||
config.get("provider"),
|
||||
config.get("model"),
|
||||
)
|
||||
|
||||
|
||||
def get_current_llm_config() -> Optional[dict]:
|
||||
"""Get the LLM config for the current request context.
|
||||
|
||||
Returns:
|
||||
LLM configuration dict if available, None otherwise
|
||||
"""
|
||||
config = _llm_config_context.get()
|
||||
if config:
|
||||
logger.debug(
|
||||
"LLM config retrieved from context: provider=%s, model=%s",
|
||||
config.get("provider"),
|
||||
config.get("model"),
|
||||
)
|
||||
else:
|
||||
logger.warning("LLM config not found in context")
|
||||
return config
|
||||
|
||||
|
||||
__all__ = [
|
||||
"set_current_jwt_token",
|
||||
"get_current_jwt_token",
|
||||
"set_current_llm_config",
|
||||
"get_current_llm_config",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,178 @@
|
||||
# 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 Project Info Tool
|
||||
|
||||
This module provides a LangChain BaseTool to retrieve basic information of a
|
||||
specific GNS3 project by project ID. Returns project name, status, node count,
|
||||
and link count.
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pprint import pprint
|
||||
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
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3ProjectInfoTool(BaseTool):
|
||||
"""LangChain tool for retrieving GNS3 project basic information."""
|
||||
|
||||
name: str = "gns3_project_info"
|
||||
description: str = """
|
||||
Retrieves basic information of a GNS3 project including name, status,
|
||||
node count and link count.
|
||||
|
||||
Input: `project_id` (str, required): UUID of the GNS3 project.
|
||||
|
||||
Output: Dictionary with:
|
||||
- `project_id`: Project UUID
|
||||
- `name`: Project name
|
||||
- `status`: Project status (opened/closed)
|
||||
- `node_count`: Number of nodes in the project
|
||||
- `link_count`: Number of links in the project
|
||||
|
||||
Returns a tuple format: (name, project_id, node_count, link_count, status)
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: Any = None,
|
||||
run_manager: Any = None,
|
||||
project_id: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Synchronous method to retrieve basic information of a specific GNS3 project.
|
||||
|
||||
Args:
|
||||
tool_input : Input parameters, typically a dict or Pydantic model.
|
||||
run_manager : Callback manager for tool run.
|
||||
project_id : The UUID of the specific GNS3 project.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing project info (name, project_id, node_count,
|
||||
link_count, status), or an error dictionary if an exception occurs
|
||||
or project_id is not provided.
|
||||
"""
|
||||
|
||||
# Log received input
|
||||
logger.info(
|
||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate project_id parameter
|
||||
if not project_id:
|
||||
logger.error("project_id parameter is required.")
|
||||
return {
|
||||
"error": (
|
||||
"project_id parameter is required. Please provide a valid "
|
||||
"project UUID."
|
||||
)
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
|
||||
if server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": (
|
||||
"Failed to connect to GNS3 server. Please check your "
|
||||
"configuration."
|
||||
)
|
||||
}
|
||||
|
||||
# Use the provided project_id directly
|
||||
logger.info(
|
||||
f"Retrieving project info for project_id: {project_id}"
|
||||
)
|
||||
project = Project(project_id=project_id, connector=server)
|
||||
project.get() # Load project details
|
||||
|
||||
# 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
|
||||
|
||||
# Build result in tuple format consistent with GNS3ProjectList
|
||||
result = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"node_count": node_count,
|
||||
"link_count": link_count,
|
||||
"tuple": (
|
||||
project.name,
|
||||
project.project_id,
|
||||
node_count,
|
||||
link_count,
|
||||
project.status,
|
||||
),
|
||||
}
|
||||
|
||||
# Log result
|
||||
logger.info(
|
||||
"Project info retrieved: name=%s, status=%s, nodes=%d, links=%d",
|
||||
project.name,
|
||||
project.status,
|
||||
node_count,
|
||||
link_count,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error retrieving GNS3 project info: %s", str(e))
|
||||
return {"error": f"Failed to retrieve project info: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool
|
||||
tool = GNS3ProjectInfoTool()
|
||||
|
||||
# Example usage with project_id
|
||||
# Replace with an actual project UUID from your GNS3 server
|
||||
example_project_id = "0c0fde25-6ead-4413-a283-ea8fd2324291"
|
||||
|
||||
print("Testing GNS3ProjectInfoTool with project_id...")
|
||||
result = tool._run(project_id=example_project_id)
|
||||
pprint(result)
|
||||
|
||||
# Test without project_id (should return error)
|
||||
print("\nTesting without project_id (should return error)...")
|
||||
error_result = tool._run()
|
||||
pprint(error_result)
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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 Topology Reader Tool
|
||||
|
||||
This module provides a LangChain BaseTool to retrieve the topology of a
|
||||
specific GNS3 project by project ID. Returns nodes, links, and project
|
||||
metadata.
|
||||
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from pprint import pprint
|
||||
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
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Define LangChain tool class
|
||||
class GNS3TopologyTool(BaseTool):
|
||||
"""LangChain tool for retrieving GNS3 project topology information."""
|
||||
|
||||
name: str = "gns3_topology_reader"
|
||||
description: str = """
|
||||
Retrieves the topology of a GNS3 project including nodes and links.
|
||||
|
||||
Input: `project_id` (str, required): UUID of the GNS3 project.
|
||||
|
||||
Output: Dictionary with:
|
||||
- `project_id`, `name`, `status`: Project metadata
|
||||
- `nodes`: Dict of node details (node_id, name, ports, console_port,
|
||||
type, etc.)
|
||||
- `links`: List of link connections
|
||||
|
||||
Use this to understand network structure before making changes.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: Any = None,
|
||||
run_manager: Any = None,
|
||||
project_id: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Synchronous method to retrieve the topology of a specific GNS3 project.
|
||||
|
||||
Args:
|
||||
tool_input: Input parameters, typically a dict or Pydantic model
|
||||
containing server_url.
|
||||
run_manager: Callback manager for tool run.
|
||||
project_id: The UUID of the specific GNS3 project to retrieve
|
||||
topology from.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the project ID, name, status, nodes,
|
||||
and links, or an error dictionary if an exception occurs
|
||||
or project_id is not provided.
|
||||
"""
|
||||
|
||||
# Log received input
|
||||
logger.info(
|
||||
"Received tool_input: %s, project_id: %s", tool_input, project_id
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate project_id parameter
|
||||
if not project_id:
|
||||
logger.error("project_id parameter is required.")
|
||||
return {
|
||||
"error": "project_id parameter is required. "
|
||||
"Please provide a valid project UUID."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
|
||||
if server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. Please check "
|
||||
"your configuration."
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# Get topology JSON: includes nodes (devices), links, etc.
|
||||
topology = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"nodes": self._clean_nodes_ports(
|
||||
copy.deepcopy(project.nodes_inventory())
|
||||
),
|
||||
"links": project.links_summary(is_print=False),
|
||||
}
|
||||
|
||||
# Log topology result
|
||||
logger.info(
|
||||
"Topology retrieved: project_id=%s, name=%s, nodes=%d, "
|
||||
"links=%d",
|
||||
topology.get("project_id"),
|
||||
topology.get("name"),
|
||||
len(topology.get("nodes", {})),
|
||||
len(topology.get("links", [])),
|
||||
)
|
||||
logger.debug("Topology details: %s", topology)
|
||||
|
||||
return topology
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error retrieving GNS3 topology: %s", str(e))
|
||||
return {"error": f"Failed to retrieve topology: {str(e)}"}
|
||||
|
||||
def _clean_nodes_ports(self, data: dict) -> dict:
|
||||
"""
|
||||
Clean and simplify the nodes data structure.
|
||||
|
||||
Simplify each node's ports list to only keep name and short_name
|
||||
fields.
|
||||
"""
|
||||
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
|
||||
if "ports" in node and isinstance(node["ports"], list):
|
||||
node["ports"] = [
|
||||
{"name": port["name"], "short_name": port["short_name"]}
|
||||
for port in node["ports"]
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool
|
||||
tool = GNS3TopologyTool()
|
||||
|
||||
# Example usage with project_id
|
||||
# Replace with an actual project UUID from your GNS3 server
|
||||
example_project_id = "0c0fde25-6ead-4413-a283-ea8fd2324291"
|
||||
|
||||
print("Testing GNS3TopologyTool with project_id...")
|
||||
result = tool._run(project_id=example_project_id)
|
||||
pprint(result)
|
||||
|
||||
# Test without project_id (should return error)
|
||||
print("\nTesting without project_id (should return error)...")
|
||||
error_result = tool._run()
|
||||
pprint(error_result)
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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 Agent Manager
|
||||
|
||||
Manages AgentService instances for GNS3 projects using a singleton pattern.
|
||||
Each project has its own AgentService with a dedicated SQLite checkpoint
|
||||
database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from gns3server.agent.gns3_copilot.agent_service import AgentService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectAgentManager:
|
||||
"""
|
||||
Singleton manager for project-level Agent services.
|
||||
|
||||
Manages the lifecycle of AgentService instances, ensuring that each project
|
||||
has exactly one AgentService instance. Handles cleanup of resources when
|
||||
projects are closed.
|
||||
"""
|
||||
|
||||
_instance: Optional["ProjectAgentManager"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
_agents: Dict[str, AgentService] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._agents = {}
|
||||
cls._instance._lock = asyncio.Lock()
|
||||
return cls._instance
|
||||
|
||||
async def get_agent(
|
||||
self, project_id: str, project_path: str
|
||||
) -> AgentService:
|
||||
"""
|
||||
Get or create an AgentService for a project.
|
||||
|
||||
Args:
|
||||
project_id: GNS3 project ID
|
||||
project_path: Path to the GNS3 project directory
|
||||
|
||||
Returns:
|
||||
AgentService instance for the project
|
||||
"""
|
||||
async with self._lock:
|
||||
if project_id not in self._agents:
|
||||
log.info(
|
||||
"Creating new AgentService for project: %s at %s",
|
||||
project_id,
|
||||
project_path,
|
||||
)
|
||||
self._agents[project_id] = AgentService(project_path)
|
||||
return self._agents[project_id]
|
||||
|
||||
async def remove_agent(self, project_id: str):
|
||||
"""
|
||||
Remove and cleanup an AgentService for a project.
|
||||
|
||||
Should be called when a project is closed to free resources.
|
||||
|
||||
Args:
|
||||
project_id: GNS3 project ID
|
||||
"""
|
||||
async with self._lock:
|
||||
if project_id in self._agents:
|
||||
log.info("Removing AgentService for project: %s", project_id)
|
||||
agent = self._agents.pop(project_id)
|
||||
await agent.close()
|
||||
|
||||
async def close_all(self):
|
||||
"""
|
||||
Close all AgentService instances and cleanup resources.
|
||||
|
||||
Should be called on server shutdown.
|
||||
"""
|
||||
async with self._lock:
|
||||
log.info(
|
||||
"Closing all AgentService instances (%d projects)",
|
||||
len(self._agents),
|
||||
)
|
||||
for project_id, agent in self._agents.items():
|
||||
log.debug("Closing AgentService for project: %s", project_id)
|
||||
await agent.close()
|
||||
self._agents.clear()
|
||||
|
||||
def has_agent(self, project_id: str) -> bool:
|
||||
"""
|
||||
Check if an AgentService exists for a project.
|
||||
|
||||
Args:
|
||||
project_id: GNS3 project ID
|
||||
|
||||
Returns:
|
||||
True if AgentService exists, False otherwise
|
||||
"""
|
||||
return project_id in self._agents
|
||||
|
||||
@property
|
||||
def active_projects(self) -> list[str]:
|
||||
"""
|
||||
Get list of project IDs with active AgentService instances.
|
||||
|
||||
Returns:
|
||||
List of project IDs
|
||||
"""
|
||||
return list(self._agents.keys())
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_project_agent_manager: Optional[ProjectAgentManager] = None
|
||||
_manager_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_project_agent_manager() -> ProjectAgentManager:
|
||||
"""
|
||||
Get the global ProjectAgentManager singleton instance.
|
||||
|
||||
Returns:
|
||||
ProjectAgentManager instance
|
||||
"""
|
||||
global _project_agent_manager
|
||||
async with _manager_lock:
|
||||
if _project_agent_manager is None:
|
||||
_project_agent_manager = ProjectAgentManager()
|
||||
log.info("ProjectAgentManager singleton created")
|
||||
return _project_agent_manager
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
Available prompts (loaded from external repository):
|
||||
- lab_automation_assistant.md: Lab automation mode (diagnostics + config)
|
||||
- teaching_assistant.md: Teaching assistant mode (diagnostics only)
|
||||
- troubleshooting_injection.md: Fault injection specialist
|
||||
- title.md: Title generation prompt template
|
||||
|
||||
Use the SkillsManager to load these prompts:
|
||||
from gns3server.agent.gns3_copilot.skills.manager import skills_manager
|
||||
|
||||
# Load prompts
|
||||
lab_prompt = skills_manager.load_prompt("lab_automation_assistant")
|
||||
teaching_prompt = skills_manager.load_prompt("teaching_assistant")
|
||||
injection_prompt = skills_manager.load_prompt("troubleshooting_injection")
|
||||
title_prompt = skills_manager.load_prompt("title")
|
||||
"""
|
||||
|
||||
from .prompt_loader import load_system_prompt
|
||||
|
||||
__all__ = ["load_system_prompt"]
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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 (� Yue Guobin)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
Prompt Loader for GNS3-Copilot
|
||||
|
||||
This module provides utilities for loading system prompts from the external
|
||||
GNS3-Skills repository.
|
||||
Supports multiple prompt variants based on LLM model configuration.
|
||||
|
||||
Available Modes (controlled by config.copilot_mode in llm_model_configs):
|
||||
- "teaching_assistant" (default): Teaching assistant mode - diagnostics only,
|
||||
no configuration
|
||||
- "lab_automation_assistant": Full lab automation assistant mode - diagnostics
|
||||
and configuration enabled
|
||||
- "troubleshooting_injection": Troubleshooting issue injection mode
|
||||
|
||||
All prompts are loaded from the external GNS3-Skills repository via SkillsManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.agent.gns3_copilot.skills.registry import get_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_system_prompt(llm_config: dict | None = None) -> str:
|
||||
"""
|
||||
Load the system prompt for GNS3-Copilot from the external repository.
|
||||
|
||||
The prompt mode is controlled by the `copilot_mode` field in the LLM
|
||||
model config:
|
||||
- "teaching_assistant" (default): Teaching assistant mode - diagnostics
|
||||
only, no configuration
|
||||
- "lab_automation_assistant": Full lab automation assistant mode -
|
||||
diagnostics and configuration enabled
|
||||
- "troubleshooting_injection": Troubleshooting issue injection mode -
|
||||
inject network faults for practice
|
||||
|
||||
Prompts are loaded from the external GNS3-Skills repository via SkillsManager.
|
||||
|
||||
Args:
|
||||
llm_config: LLM model configuration dictionary (flattened structure
|
||||
from get_user_llm_config_full)
|
||||
|
||||
Returns:
|
||||
str: The system prompt string, or empty string if not found.
|
||||
"""
|
||||
if not llm_config:
|
||||
logger.debug(
|
||||
"No LLM config provided, using default TEACHING_ASSISTANT "
|
||||
"prompt mode"
|
||||
)
|
||||
return _load_prompt("teaching_assistant")
|
||||
|
||||
# llm_config is a flattened dict with copilot_mode at the top level
|
||||
# Example: {"provider": "...", "model": "...", "copilot_mode": "...", ...}
|
||||
mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
|
||||
|
||||
if mode == "lab_automation_assistant":
|
||||
logger.debug(
|
||||
"Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + "
|
||||
"configuration)"
|
||||
)
|
||||
return _load_prompt("lab_automation_assistant")
|
||||
elif mode == "troubleshooting_injection":
|
||||
logger.debug(
|
||||
"Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)"
|
||||
)
|
||||
return _load_prompt("troubleshooting_injection")
|
||||
else:
|
||||
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")
|
||||
return _load_prompt("teaching_assistant")
|
||||
|
||||
|
||||
def _load_prompt(prompt_name: str) -> str:
|
||||
"""
|
||||
Load a prompt from the external skills repository.
|
||||
|
||||
Args:
|
||||
prompt_name: Name of the prompt (e.g., "teaching_assistant")
|
||||
|
||||
Returns:
|
||||
Prompt content as string, or empty string if not found
|
||||
"""
|
||||
prompt = get_prompt(prompt_name)
|
||||
if prompt:
|
||||
logger.debug(f"Loaded prompt '{prompt_name}' from external repository")
|
||||
return prompt
|
||||
|
||||
logger.warning(f"Prompt '{prompt_name}' not found in external repository")
|
||||
return ""
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Skills Package
|
||||
|
||||
This package provides skills management for GNS3 Copilot.
|
||||
All skills are loaded from the external GNS3-Skills repository.
|
||||
|
||||
Directory Structure:
|
||||
- skills/
|
||||
- registry.py # SKILLS_REGISTRY and get_skill()
|
||||
- manager.py # SkillsManager - Git clone/pull and hot reload
|
||||
- loader.py # SkillsLoader - YAML/Markdown file loading
|
||||
"""
|
||||
|
||||
from .registry import (
|
||||
SKILLS_REGISTRY,
|
||||
INJECTION_SKILLS_REGISTRY,
|
||||
get_skill,
|
||||
get_injection_skill,
|
||||
DeviceSkillsTool,
|
||||
InjectionSkillsTool,
|
||||
PacketAnalysisSkillsTool,
|
||||
set_skills_manager,
|
||||
get_skills_manager,
|
||||
reload_injection_skills,
|
||||
reload_forbidden_commands,
|
||||
reload_skills_repository,
|
||||
get_skills_repository_info,
|
||||
)
|
||||
from .manager import SkillsManager
|
||||
from .loader import SkillsLoader
|
||||
|
||||
__all__ = [
|
||||
"SKILLS_REGISTRY",
|
||||
"INJECTION_SKILLS_REGISTRY",
|
||||
"get_skill",
|
||||
"get_injection_skill",
|
||||
"DeviceSkillsTool",
|
||||
"InjectionSkillsTool",
|
||||
"PacketAnalysisSkillsTool",
|
||||
"SkillsManager",
|
||||
"SkillsLoader",
|
||||
"set_skills_manager",
|
||||
"get_skills_manager",
|
||||
"reload_injection_skills",
|
||||
"reload_skills_repository",
|
||||
"get_skills_repository_info",
|
||||
]
|
||||
@@ -0,0 +1,279 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Skills Loader
|
||||
|
||||
This module provides functionality to load skills from YAML files.
|
||||
Supports loading injection skills and device/feature skills.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Load skills from YAML files in the skills directory.
|
||||
|
||||
This loader reads YAML files and converts them to the skill dictionary
|
||||
format expected by the skills registry.
|
||||
"""
|
||||
|
||||
def __init__(self, skills_dir: str):
|
||||
"""
|
||||
Initialize the skills loader.
|
||||
|
||||
Args:
|
||||
skills_dir: Path to the skills directory containing YAML files
|
||||
"""
|
||||
self.skills_dir = Path(skills_dir)
|
||||
if not self.skills_dir.exists():
|
||||
logger.warning(f"Skills directory does not exist: {self.skills_dir}")
|
||||
|
||||
def load_injection_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all injection skills from YAML files.
|
||||
|
||||
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 = {}
|
||||
injection_dir = self.skills_dir / "injection"
|
||||
|
||||
if not injection_dir.exists():
|
||||
logger.warning(f"Injection skills directory not found: {injection_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in injection_dir.glob("*.yaml"):
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
# Generate key from filename (e.g., "ospf_issues.yaml" -> "injection_ospf")
|
||||
skill_key = f"injection_{yaml_file.stem}"
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded injection skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} injection skills from {injection_dir}")
|
||||
return skills
|
||||
|
||||
def load_device_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all device/feature skills from YAML files.
|
||||
|
||||
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 = {}
|
||||
device_dir = self.skills_dir / "device"
|
||||
|
||||
if not device_dir.exists():
|
||||
logger.warning(f"Device skills directory not found: {device_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in device_dir.glob("*.yaml"):
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
# 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 isinstance(skill_data, dict) else None
|
||||
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}")
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} device skills from {device_dir}")
|
||||
return skills
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
"""
|
||||
Load a prompt from a markdown file.
|
||||
|
||||
Args:
|
||||
prompt_name: Name of the prompt file (without .md extension)
|
||||
|
||||
Returns:
|
||||
Prompt content as string, or empty string if not found
|
||||
"""
|
||||
prompts_dir = self.skills_dir / "prompts"
|
||||
prompt_file = prompts_dir / f"{prompt_name}.md"
|
||||
|
||||
if not prompt_file.exists():
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(prompt_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
logger.debug(f"Loaded prompt: {prompt_name} from {prompt_file}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load prompt from {prompt_file}: {e}")
|
||||
return ""
|
||||
|
||||
def load_forbidden_commands(self) -> list:
|
||||
"""
|
||||
Load forbidden commands from the config directory.
|
||||
|
||||
Returns:
|
||||
List of forbidden command patterns, or empty list if not found
|
||||
"""
|
||||
config_dir = self.skills_dir / "config"
|
||||
config_file = config_dir / "forbidden_commands.txt"
|
||||
|
||||
if not config_file.exists():
|
||||
logger.warning(f"Forbidden commands file not found: {config_file}")
|
||||
return []
|
||||
|
||||
try:
|
||||
commands = []
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
commands.append(line.lower())
|
||||
logger.debug(f"Loaded {len(commands)} forbidden commands from {config_file}")
|
||||
return commands
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load forbidden commands from {config_file}: {e}")
|
||||
return []
|
||||
|
||||
def load_packet_analysis_protocols(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load packet analysis protocol definitions from YAML files.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping protocol keys to protocol definitions.
|
||||
Each protocol contains tshark_field, display_filter, and filter_examples.
|
||||
"""
|
||||
if yaml is None:
|
||||
logger.error("PyYAML is not installed. Cannot load packet analysis protocols.")
|
||||
return {}
|
||||
|
||||
protocols = {}
|
||||
packet_analysis_dir = self.skills_dir / "packet_analysis"
|
||||
|
||||
if not packet_analysis_dir.exists():
|
||||
logger.debug(f"Packet analysis directory not found: {packet_analysis_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in packet_analysis_dir.glob("*.yaml"):
|
||||
try:
|
||||
protocol_data = self._load_yaml(yaml_file)
|
||||
if not protocol_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
|
||||
# Use protocol_key field from YAML as the key
|
||||
protocol_key = protocol_data.get("protocol_key")
|
||||
if not protocol_key:
|
||||
logger.warning(f"No 'protocol_key' field in {yaml_file}, skipping")
|
||||
continue
|
||||
|
||||
protocols[protocol_key] = protocol_data
|
||||
logger.debug(f"Loaded packet analysis protocol: {protocol_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load protocol from {yaml_file}: {e}")
|
||||
|
||||
logger.debug(f"Loaded {len(protocols)} packet analysis protocols from {packet_analysis_dir}")
|
||||
return protocols
|
||||
|
||||
def _load_yaml(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a YAML file and return its content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
Parsed YAML content as dictionary, or empty dict if file is empty
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(f"YAML file {file_path} is empty or has invalid format")
|
||||
return {}
|
||||
return data
|
||||
|
||||
def validate_skill_format(self, skill_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Validate that a skill dictionary has the required fields.
|
||||
|
||||
Args:
|
||||
skill_data: Skill dictionary to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
required_fields = ["name", "description", "issues"]
|
||||
for field in required_fields:
|
||||
if field not in skill_data:
|
||||
logger.error(f"Skill missing required field: {field}")
|
||||
return False
|
||||
|
||||
if not isinstance(skill_data["issues"], dict):
|
||||
logger.error("Skill 'issues' field must be a dictionary")
|
||||
return False
|
||||
|
||||
for issue_key, issue_data in skill_data["issues"].items():
|
||||
if not isinstance(issue_data, dict):
|
||||
logger.error(f"Issue '{issue_key}' must be a dictionary")
|
||||
return False
|
||||
|
||||
issue_required = ["name", "description"]
|
||||
for field in issue_required:
|
||||
if field not in issue_data:
|
||||
logger.error(f"Issue '{issue_key}' missing required field: {field}")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Skills Manager
|
||||
|
||||
This module provides functionality to manage the skills repository,
|
||||
including Git operations and hot reload of skills.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
try:
|
||||
import git
|
||||
except ImportError:
|
||||
git = None
|
||||
|
||||
from gns3server.config import Config
|
||||
from .loader import SkillsLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Git timeout settings for network operations.
|
||||
# Applied per-command via the `env` parameter to avoid polluting
|
||||
# the global process environment.
|
||||
_GIT_TIMEOUT_ENV = {
|
||||
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
|
||||
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
|
||||
'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort
|
||||
}
|
||||
|
||||
|
||||
class SkillsManager:
|
||||
"""
|
||||
Manage skills repository and hot reload functionality.
|
||||
|
||||
This class handles:
|
||||
- Cloning the skills repository
|
||||
- Pulling latest updates
|
||||
- Hot reloading skills into memory
|
||||
- Version tracking
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo_url: str = None,
|
||||
branch: str = "main",
|
||||
auto_update: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize the skills manager.
|
||||
|
||||
Args:
|
||||
repo_url: Git repository URL (default: https://github.com/yueguobin/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"
|
||||
|
||||
# Get local path from GNS3 config directory
|
||||
config_dir = Config.instance().config_dir
|
||||
local_path = os.path.join(config_dir, "skills")
|
||||
|
||||
self.repo_url = repo_url
|
||||
self.local_path = Path(local_path)
|
||||
self.branch = branch
|
||||
self.auto_update = auto_update
|
||||
self.loader = SkillsLoader(str(local_path))
|
||||
self._repo: Optional["git.Repo"] = None
|
||||
self._prompt_count = 0
|
||||
|
||||
if git is None:
|
||||
logger.warning("GitPython is not installed. Skills management features will be limited.")
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
Initialize the skills repository.
|
||||
|
||||
Behavior:
|
||||
- If no local repo → clone from remote
|
||||
- If local repo exists → open and run _update_if_needed()
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not self.local_path.exists():
|
||||
logger.info(f"Creating skills directory: {self.local_path}")
|
||||
self.local_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not (self.local_path / ".git").exists():
|
||||
return self._clone()
|
||||
|
||||
# Repo exists, open it
|
||||
try:
|
||||
self._repo = git.Repo(self.local_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open existing repository: {e}")
|
||||
return False
|
||||
|
||||
self._update_if_needed()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize skills repository: {e}")
|
||||
return False
|
||||
|
||||
def _update_if_needed(self) -> None:
|
||||
"""
|
||||
Check local repo status and pull updates if safe.
|
||||
|
||||
- Uncommitted changes → warn and skip
|
||||
- Behind remote → pull
|
||||
- Up to date → nothing
|
||||
- Network error → use existing files, log warning
|
||||
"""
|
||||
if git is None or self._repo is None:
|
||||
return
|
||||
|
||||
# Check for uncommitted changes
|
||||
if self._repo.is_dirty(untracked_files=True):
|
||||
logger.warning(
|
||||
"Skills repository has uncommitted changes, skipping pull. "
|
||||
"Commit or stash changes in %s to enable automatic updates.",
|
||||
self.local_path
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch remote
|
||||
try:
|
||||
origin = self._repo.remotes.origin
|
||||
origin.fetch(env=_GIT_TIMEOUT_ENV)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch remote: {e}. Using local files.")
|
||||
return
|
||||
|
||||
# Check if behind and pull
|
||||
try:
|
||||
behind_commits = list(self._repo.iter_commits(
|
||||
f'{self.branch}..origin/{self.branch}'
|
||||
))
|
||||
if behind_commits:
|
||||
logger.info(
|
||||
"Skills repository is behind by %d commit(s), pulling...",
|
||||
len(behind_commits)
|
||||
)
|
||||
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
|
||||
logger.info(f"Updated to commit {self.get_current_version()}")
|
||||
else:
|
||||
logger.info("Skills repository is up to date")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pull updates: {e}. Using local files.")
|
||||
|
||||
def _clone(self) -> bool:
|
||||
"""
|
||||
Clone the skills repository.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if git is None:
|
||||
logger.error("GitPython is not installed. Cannot clone repository.")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._repo = git.Repo.clone_from(
|
||||
self.repo_url,
|
||||
self.local_path,
|
||||
branch=self.branch,
|
||||
env=_GIT_TIMEOUT_ENV
|
||||
)
|
||||
logger.info(f"Successfully cloned skills repository to {self.local_path}")
|
||||
return True
|
||||
except git.GitCommandError as e:
|
||||
logger.error(f"Git clone failed: {e}")
|
||||
return False
|
||||
|
||||
def reload_skills(self) -> bool:
|
||||
"""
|
||||
Hot reload skills from YAML files into the registry.
|
||||
|
||||
Loads the latest skill definitions from YAML files and updates
|
||||
the INJECTION_SKILLS_REGISTRY.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Import here to avoid circular dependency
|
||||
from .registry import INJECTION_SKILLS_REGISTRY, SKILLS_REGISTRY
|
||||
|
||||
# Load new injection skills from YAML files
|
||||
new_injection_skills = self.loader.load_injection_skills()
|
||||
|
||||
if not new_injection_skills:
|
||||
logger.warning("No injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
|
||||
# Validate injection skills
|
||||
for skill_key, skill_data in new_injection_skills.items():
|
||||
if not self.loader.validate_skill_format(skill_data):
|
||||
logger.error(f"Invalid skill format for {skill_key}, skipping")
|
||||
continue
|
||||
|
||||
# Load new device/feature skills from YAML files
|
||||
new_device_skills = self.loader.load_device_skills()
|
||||
|
||||
# Update registries (safe replace - never leaves dict empty)
|
||||
for k in list(INJECTION_SKILLS_REGISTRY):
|
||||
if k not in new_injection_skills:
|
||||
del INJECTION_SKILLS_REGISTRY[k]
|
||||
INJECTION_SKILLS_REGISTRY.update(new_injection_skills)
|
||||
|
||||
for k in list(SKILLS_REGISTRY):
|
||||
if k not in new_device_skills:
|
||||
del SKILLS_REGISTRY[k]
|
||||
SKILLS_REGISTRY.update(new_device_skills)
|
||||
|
||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills and {len(new_device_skills)} device skills")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload skills: {e}")
|
||||
return False
|
||||
|
||||
def reload_packet_analysis_protocols(self) -> bool:
|
||||
"""
|
||||
Hot reload packet analysis protocol definitions from YAML files.
|
||||
|
||||
Loads the latest protocol definitions from YAML files and updates
|
||||
the PACKET_ANALYSIS_REGISTRY.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from .registry import PACKET_ANALYSIS_REGISTRY
|
||||
|
||||
# Load new packet analysis protocols from YAML files
|
||||
new_protocols = self.loader.load_packet_analysis_protocols()
|
||||
|
||||
# Update registry (safe replace - never leaves dict empty)
|
||||
for k in list(PACKET_ANALYSIS_REGISTRY):
|
||||
if k not in new_protocols:
|
||||
del PACKET_ANALYSIS_REGISTRY[k]
|
||||
PACKET_ANALYSIS_REGISTRY.update(new_protocols)
|
||||
|
||||
logger.info(f"Successfully reloaded {len(new_protocols)} packet analysis protocols")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload packet analysis protocols: {e}")
|
||||
return False
|
||||
|
||||
def reload_prompts(self) -> bool:
|
||||
"""
|
||||
Hot reload prompts from Markdown files.
|
||||
|
||||
Loads the latest prompt definitions from Markdown files in
|
||||
the skills repository.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Available prompt names
|
||||
prompt_names = [
|
||||
"lab_automation_assistant",
|
||||
"teaching_assistant",
|
||||
"troubleshooting_injection",
|
||||
"title"
|
||||
]
|
||||
|
||||
# Load all prompts
|
||||
loaded_count = 0
|
||||
for prompt_name in prompt_names:
|
||||
prompt_content = self.loader.load_prompt(prompt_name)
|
||||
if prompt_content:
|
||||
loaded_count += 1
|
||||
else:
|
||||
logger.warning(f"Failed to load prompt: {prompt_name}")
|
||||
|
||||
if loaded_count == 0:
|
||||
logger.warning("No prompts loaded, keeping existing prompts")
|
||||
return False
|
||||
|
||||
self._prompt_count = loaded_count
|
||||
logger.info(f"Loaded {loaded_count} prompts")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload prompts: {e}")
|
||||
return False
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
"""
|
||||
Load a specific prompt from the skills repository.
|
||||
|
||||
Args:
|
||||
prompt_name: Name of the prompt (without .md extension)
|
||||
|
||||
Returns:
|
||||
Prompt content as string, or empty string if not found
|
||||
"""
|
||||
try:
|
||||
return self.loader.load_prompt(prompt_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load prompt '{prompt_name}': {e}")
|
||||
return ""
|
||||
|
||||
def load_forbidden_commands(self) -> list:
|
||||
"""
|
||||
Load forbidden command patterns from the skills repository.
|
||||
|
||||
Returns:
|
||||
List of forbidden command patterns, or empty list if not found
|
||||
"""
|
||||
try:
|
||||
return self.loader.load_forbidden_commands()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load forbidden commands: {e}")
|
||||
return []
|
||||
|
||||
def get_current_version(self) -> str:
|
||||
"""
|
||||
Get the current git commit hash of the skills repository.
|
||||
|
||||
Returns:
|
||||
Commit hash as string, or empty string if not available
|
||||
"""
|
||||
if git is None or self._repo is None:
|
||||
try:
|
||||
self._repo = git.Repo(self.local_path)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
try:
|
||||
return self._repo.head.commit.hexsha
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_skill_count(self) -> int:
|
||||
"""
|
||||
Get the number of currently loaded injection skills.
|
||||
|
||||
Returns:
|
||||
Number of skills in the registry
|
||||
"""
|
||||
try:
|
||||
from .registry import INJECTION_SKILLS_REGISTRY
|
||||
return len(INJECTION_SKILLS_REGISTRY)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_prompt_count(self) -> int:
|
||||
"""
|
||||
Get the number of currently loaded prompts.
|
||||
|
||||
Returns:
|
||||
Number of prompts loaded
|
||||
"""
|
||||
return self._prompt_count
|
||||
|
||||
def get_repository_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the skills repository.
|
||||
|
||||
Returns:
|
||||
Dictionary containing repository information
|
||||
"""
|
||||
return {
|
||||
"repo_url": self.repo_url,
|
||||
"local_path": str(self.local_path),
|
||||
"branch": self.branch,
|
||||
"current_version": self.get_current_version(),
|
||||
"skill_count": self.get_skill_count(),
|
||||
"prompt_count": self.get_prompt_count(),
|
||||
"auto_update": self.auto_update,
|
||||
"is_initialized": (self.local_path / ".git").exists()
|
||||
}
|
||||
|
||||
def rollback(self, commit_hash: str) -> bool:
|
||||
"""
|
||||
Rollback the skills repository to a specific commit.
|
||||
|
||||
Args:
|
||||
commit_hash: Git commit hash to rollback to
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if git is None:
|
||||
logger.error("GitPython is not installed. Cannot rollback.")
|
||||
return False
|
||||
|
||||
try:
|
||||
if self._repo is None:
|
||||
self._repo = git.Repo(self.local_path)
|
||||
|
||||
self._repo.git.reset("--hard", commit_hash)
|
||||
logger.info(f"Successfully rolled back to commit {commit_hash}")
|
||||
|
||||
# Reload skills after rollback
|
||||
return self.reload_skills()
|
||||
except git.GitCommandError as e:
|
||||
logger.error(f"Git rollback failed: {e}")
|
||||
return False
|
||||
|
||||
def get_available_versions(self, limit: int = 10) -> list:
|
||||
"""
|
||||
Get a list of recent commit hashes.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of commits to return
|
||||
|
||||
Returns:
|
||||
List of commit information dictionaries
|
||||
"""
|
||||
if git is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
if self._repo is None:
|
||||
self._repo = git.Repo(self.local_path)
|
||||
|
||||
commits = []
|
||||
for commit in self._repo.iter_commits(max_count=limit):
|
||||
commits.append({
|
||||
"hash": commit.hexsha,
|
||||
"message": commit.message.strip(),
|
||||
"author": str(commit.author),
|
||||
"date": commit.committed_datetime.isoformat()
|
||||
})
|
||||
return commits
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get commit history: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,857 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Skill Registry and Tools
|
||||
|
||||
This module provides:
|
||||
- SKILLS_REGISTRY: Device/feature skills (VPCS, topology, etc.)
|
||||
- INJECTION_SKILLS_REGISTRY: Fault injection skills only
|
||||
- get_skill(): Function to retrieve device/feature skills
|
||||
- get_injection_skill(): Function to retrieve injection skills
|
||||
- DeviceSkillsTool: LangChain tool for device/feature skills
|
||||
- InjectionSkillsTool: LangChain tool for injection skills
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
# command_filter imports are done locally in functions
|
||||
# to avoid circular import (command_filter also imports from registry)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Device/Feature skills registry - loaded from external repository
|
||||
SKILLS_REGISTRY: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Injection skills registry - fault injection skills only
|
||||
# This registry can be hot-reloaded via SkillsManager
|
||||
INJECTION_SKILLS_REGISTRY: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Packet analysis protocols registry - loaded from external repository
|
||||
# Contains protocol definitions for tshark-based packet analysis
|
||||
PACKET_ANALYSIS_REGISTRY: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Global skills manager instance for hot reload
|
||||
_skills_manager = None
|
||||
_init_in_progress = False
|
||||
|
||||
|
||||
def set_skills_manager(manager):
|
||||
"""
|
||||
Set the global skills manager instance.
|
||||
|
||||
The skills manager handles Git operations and hot reload of skills.
|
||||
|
||||
Args:
|
||||
manager: SkillsManager instance
|
||||
"""
|
||||
global _skills_manager
|
||||
_skills_manager = manager
|
||||
|
||||
|
||||
def _ensure_skills_manager():
|
||||
"""
|
||||
Initialize the SkillsManager (idempotent, retryable on failure).
|
||||
|
||||
Reads config, creates SkillsManager, clones/pulls repo,
|
||||
and loads skills/prompts into memory. Safe to call from
|
||||
background threads - uses _init_in_progress to prevent
|
||||
concurrent initialization.
|
||||
|
||||
On failure, resets _init_in_progress so future calls
|
||||
(e.g., /reload/skills API) can retry. On success, the
|
||||
manager is stored in _skills_manager and subsequent
|
||||
calls return immediately.
|
||||
"""
|
||||
global _skills_manager, _init_in_progress
|
||||
|
||||
if _skills_manager is not None:
|
||||
return
|
||||
|
||||
if _init_in_progress:
|
||||
return
|
||||
|
||||
_init_in_progress = True
|
||||
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.configs.skills_config import get_skills_config
|
||||
from gns3server.agent.gns3_copilot.skills.manager import SkillsManager
|
||||
|
||||
config = get_skills_config()
|
||||
|
||||
if not config.get("enabled", False):
|
||||
logger.info("External skills repository is disabled")
|
||||
return
|
||||
|
||||
logger.info("Initializing SkillsManager")
|
||||
|
||||
manager = SkillsManager(
|
||||
repo_url=config.get("repo_url"),
|
||||
branch=config.get("branch", "main"),
|
||||
auto_update=config.get("auto_update", False)
|
||||
)
|
||||
|
||||
if not manager.initialize():
|
||||
logger.error("Failed to initialize skills repository")
|
||||
return
|
||||
|
||||
if manager.reload_skills():
|
||||
logger.debug(f"Loaded {manager.get_skill_count()} injection skills")
|
||||
else:
|
||||
logger.warning("Failed to reload injection skills")
|
||||
|
||||
if manager.reload_prompts():
|
||||
logger.debug(f"Loaded {manager.get_prompt_count()} prompts")
|
||||
else:
|
||||
logger.warning("Failed to reload prompts")
|
||||
|
||||
if manager.reload_packet_analysis_protocols():
|
||||
logger.debug(f"Loaded {len(PACKET_ANALYSIS_REGISTRY)} packet analysis protocols")
|
||||
else:
|
||||
logger.warning("Failed to reload packet analysis protocols")
|
||||
|
||||
_skills_manager = manager
|
||||
logger.debug("SkillsManager initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing skills manager: {e}", exc_info=True)
|
||||
finally:
|
||||
_init_in_progress = False
|
||||
|
||||
|
||||
def get_skills_manager():
|
||||
"""
|
||||
Get the global skills manager instance, initializing on first access.
|
||||
|
||||
Returns:
|
||||
SkillsManager instance or None
|
||||
"""
|
||||
_ensure_skills_manager()
|
||||
return _skills_manager
|
||||
|
||||
|
||||
def reload_skills_repository() -> dict[str, Any]:
|
||||
"""
|
||||
Reload the entire skills repository.
|
||||
|
||||
Performs one git update check, then reloads all skills, prompts,
|
||||
and forbidden commands from local files.
|
||||
|
||||
Returns:
|
||||
Dictionary with combined reload results.
|
||||
"""
|
||||
manager = get_skills_manager()
|
||||
if manager is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Skills manager not initialized",
|
||||
}
|
||||
|
||||
# One git update for the entire repository
|
||||
manager._update_if_needed()
|
||||
|
||||
# Reload everything from local files
|
||||
skills_ok = manager.reload_skills()
|
||||
prompts_ok = manager.reload_prompts()
|
||||
protocols_ok = manager.reload_packet_analysis_protocols()
|
||||
|
||||
# Reload forbidden commands (local import to avoid circular dependency)
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload_fc
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands
|
||||
|
||||
_reload_fc()
|
||||
forbidden_commands = get_forbidden_commands()
|
||||
|
||||
return {
|
||||
"success": skills_ok or prompts_ok or protocols_ok,
|
||||
"skills": skills_ok,
|
||||
"skill_count": manager.get_skill_count(),
|
||||
"prompts": prompts_ok,
|
||||
"prompt_count": manager.get_prompt_count(),
|
||||
"protocols": protocols_ok,
|
||||
"protocol_count": len(PACKET_ANALYSIS_REGISTRY),
|
||||
"forbidden_commands": len(forbidden_commands),
|
||||
"version": manager.get_current_version(),
|
||||
}
|
||||
|
||||
|
||||
def reload_injection_skills() -> dict[str, Any]:
|
||||
"""
|
||||
Trigger hot reload of injection skills.
|
||||
|
||||
This function uses the global skills manager to pull latest changes
|
||||
from the skills repository and reload the INJECTION_SKILLS_REGISTRY.
|
||||
|
||||
Returns:
|
||||
Dictionary with status information:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"skill_count": int,
|
||||
"version": str
|
||||
}
|
||||
"""
|
||||
manager = get_skills_manager()
|
||||
if manager is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Skills manager not initialized",
|
||||
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
||||
"version": ""
|
||||
}
|
||||
|
||||
try:
|
||||
success = manager.reload_skills()
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Skills reloaded successfully" if success else "Failed to reload skills",
|
||||
"skill_count": manager.get_skill_count(),
|
||||
"version": manager.get_current_version()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error during skills reload: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error: {str(e)}",
|
||||
"skill_count": len(INJECTION_SKILLS_REGISTRY),
|
||||
"version": ""
|
||||
}
|
||||
|
||||
|
||||
def reload_prompts() -> dict[str, Any]:
|
||||
"""
|
||||
Trigger hot reload of system prompts.
|
||||
|
||||
This function uses the global skills manager to pull latest changes
|
||||
from the skills repository and reload prompts from disk.
|
||||
|
||||
Returns:
|
||||
Dictionary with status information:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"prompt_count": int,
|
||||
"version": str
|
||||
}
|
||||
"""
|
||||
manager = get_skills_manager()
|
||||
if manager is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Skills manager not initialized",
|
||||
"prompt_count": 0,
|
||||
"version": ""
|
||||
}
|
||||
|
||||
try:
|
||||
success = manager.reload_prompts()
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Prompts reloaded successfully" if success else "Failed to reload prompts",
|
||||
"prompt_count": manager.get_prompt_count(),
|
||||
"version": manager.get_current_version()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error during prompts reload: {e}")
|
||||
manager = get_skills_manager()
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error: {str(e)}",
|
||||
"prompt_count": manager.get_prompt_count() if manager else 0,
|
||||
"version": ""
|
||||
}
|
||||
|
||||
|
||||
def reload_forbidden_commands() -> dict[str, Any]:
|
||||
"""
|
||||
Hot reload forbidden commands from the skills repository.
|
||||
|
||||
Directly loads and caches commands from the skills repository.
|
||||
|
||||
Returns:
|
||||
Dictionary with status information:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"command_count": int,
|
||||
"version": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands
|
||||
|
||||
_reload()
|
||||
commands = get_forbidden_commands()
|
||||
manager = get_skills_manager()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Forbidden commands reloaded",
|
||||
"command_count": len(commands),
|
||||
"version": manager.get_current_version() if manager else ""
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error during forbidden commands reload: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Error: {str(e)}",
|
||||
"command_count": 0,
|
||||
"version": ""
|
||||
}
|
||||
|
||||
|
||||
def get_prompt(prompt_name: str) -> str:
|
||||
"""
|
||||
Get a system prompt by name, always loading from disk.
|
||||
|
||||
Args:
|
||||
prompt_name: Name of the prompt (e.g., "teaching_assistant")
|
||||
|
||||
Returns:
|
||||
Prompt content as string, or empty string if not found
|
||||
"""
|
||||
# Always load from skills manager (no cache), triggers lazy init
|
||||
manager = get_skills_manager()
|
||||
if manager:
|
||||
try:
|
||||
prompt = manager.load_prompt(prompt_name)
|
||||
if prompt:
|
||||
return prompt
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading prompt '{prompt_name}': {e}")
|
||||
|
||||
logger.warning(f"Prompt not found: {prompt_name}")
|
||||
return ""
|
||||
|
||||
|
||||
def get_skills_repository_info() -> dict[str, Any]:
|
||||
"""
|
||||
Get information about the skills repository.
|
||||
|
||||
Returns:
|
||||
Dictionary with repository information
|
||||
"""
|
||||
manager = get_skills_manager()
|
||||
if manager is None:
|
||||
return {
|
||||
"initialized": False,
|
||||
"message": "Skills manager not initialized"
|
||||
}
|
||||
|
||||
return manager.get_repository_info()
|
||||
|
||||
|
||||
def get_skill(
|
||||
device_type: str,
|
||||
category: str | None = None,
|
||||
detail: str = "full",
|
||||
issue: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get skill by device_type, with configurable detail level.
|
||||
|
||||
Args:
|
||||
device_type: The device type identifier
|
||||
category: Optional category filter
|
||||
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
|
||||
issue: Optional specific issue key to retrieve
|
||||
|
||||
Returns:
|
||||
Skill dictionary (detail varies by level), or error dict
|
||||
"""
|
||||
skill = SKILLS_REGISTRY.get(device_type, {})
|
||||
|
||||
if not skill:
|
||||
for did, s in SKILLS_REGISTRY.items():
|
||||
if s.get("name", "").lower() == device_type.lower():
|
||||
skill = s
|
||||
break
|
||||
|
||||
if not skill:
|
||||
return {
|
||||
"error": f"Unknown device_type: {device_type}",
|
||||
"available_device_types": list(SKILLS_REGISTRY.keys()),
|
||||
}
|
||||
|
||||
if category:
|
||||
skill_category = skill.get("category", "")
|
||||
if category.lower() != skill_category.lower():
|
||||
return {
|
||||
"error": f"device_type '{device_type}' is not in category '{category}'",
|
||||
"device_category": skill.get("category"),
|
||||
"available_in_category": [
|
||||
did for did, s in SKILLS_REGISTRY.items()
|
||||
if s.get("category", "").lower() == category.lower()
|
||||
],
|
||||
}
|
||||
|
||||
issues = skill.get("issues", {})
|
||||
|
||||
# Single issue lookup (most token-efficient)
|
||||
if issue:
|
||||
issue_def = issues.get(issue)
|
||||
if not issue_def:
|
||||
return {
|
||||
"error": f"Unknown issue '{issue}' in {device_type}",
|
||||
"available_issues": list(issues.keys()),
|
||||
}
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"skill_name": skill.get("name"),
|
||||
"issue": {issue: issue_def},
|
||||
}
|
||||
|
||||
if detail == "index":
|
||||
# Minimal: only issue keys and names (90%+ token savings)
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {k: v["name"] for k, v in issues.items()},
|
||||
}
|
||||
|
||||
if detail == "summary":
|
||||
# Moderate: names + description + severity + difficulty
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {
|
||||
k: {
|
||||
"name": v["name"],
|
||||
"description": v.get("description", ""),
|
||||
"severity": v.get("severity", ""),
|
||||
"difficulty": v.get("difficulty", ""),
|
||||
}
|
||||
for k, v in issues.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Full detail (original behavior)
|
||||
result = dict(skill)
|
||||
result["device_type"] = device_type
|
||||
return result
|
||||
|
||||
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
"""List all available device/feature skills, optionally filtered by category."""
|
||||
skills = []
|
||||
for did, skill in SKILLS_REGISTRY.items():
|
||||
if category:
|
||||
if skill.get("category", "").lower() == category.lower():
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
})
|
||||
else:
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
})
|
||||
return skills
|
||||
|
||||
|
||||
def get_injection_skill(
|
||||
device_type: str,
|
||||
detail: str = "full",
|
||||
issue: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get injection fault skill by device_type, with configurable detail level.
|
||||
|
||||
Args:
|
||||
device_type: The injection fault type (e.g., "injection_ospf")
|
||||
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
|
||||
issue: Optional specific issue key to retrieve
|
||||
|
||||
Returns:
|
||||
Skill dictionary (detail varies by level), or error dict
|
||||
"""
|
||||
skill = INJECTION_SKILLS_REGISTRY.get(device_type, {})
|
||||
|
||||
if not skill:
|
||||
return {
|
||||
"error": f"Unknown injection fault type: {device_type}",
|
||||
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
||||
"hint": "Use {'action': 'list'} to see all available fault types"
|
||||
}
|
||||
|
||||
issues = skill.get("issues", {})
|
||||
|
||||
# Single issue lookup (most token-efficient)
|
||||
if issue:
|
||||
issue_def = issues.get(issue)
|
||||
if not issue_def:
|
||||
return {
|
||||
"error": f"Unknown issue '{issue}' in {device_type}",
|
||||
"available_issues": list(issues.keys()),
|
||||
}
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"skill_name": skill.get("name"),
|
||||
"issue": {issue: issue_def},
|
||||
}
|
||||
|
||||
if detail == "index":
|
||||
# Minimal: only issue keys and names (90%+ token savings)
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {k: v["name"] for k, v in issues.items()},
|
||||
}
|
||||
|
||||
if detail == "summary":
|
||||
# Moderate: names + description + severity + difficulty
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {
|
||||
k: {
|
||||
"name": v["name"],
|
||||
"description": v.get("description", ""),
|
||||
"severity": v.get("severity", ""),
|
||||
"difficulty": v.get("difficulty", ""),
|
||||
}
|
||||
for k, v in issues.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Full detail (original behavior)
|
||||
result = dict(skill)
|
||||
result["device_type"] = device_type
|
||||
return result
|
||||
|
||||
|
||||
def list_available_injection_skills(context: list[str] | None = None) -> list[dict[str, str]]:
|
||||
"""
|
||||
List available injection fault skills, optionally filtered by context.
|
||||
|
||||
Args:
|
||||
context: List of protocol/service keywords (e.g., ["ospf", "bgp", "vlan"]).
|
||||
Only returns skills whose category matches any keyword.
|
||||
|
||||
Returns:
|
||||
List of skill info dicts with device_type, name, and category.
|
||||
"""
|
||||
skills = []
|
||||
for did, skill in INJECTION_SKILLS_REGISTRY.items():
|
||||
category = skill.get("category", "")
|
||||
if context:
|
||||
# Match if skill category contains any context keyword
|
||||
category_lower = category.lower()
|
||||
if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context):
|
||||
continue
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": category,
|
||||
})
|
||||
return skills
|
||||
|
||||
|
||||
def get_packet_analysis_protocol(protocol: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a packet analysis protocol definition.
|
||||
|
||||
Args:
|
||||
protocol: The protocol key (e.g., "ospf", "bgp", "icmp")
|
||||
|
||||
Returns:
|
||||
Protocol definition dictionary with available_fields, base_filter, etc.
|
||||
Returns error dict if protocol not found.
|
||||
"""
|
||||
protocol_data = PACKET_ANALYSIS_REGISTRY.get(protocol)
|
||||
|
||||
if not protocol_data:
|
||||
# Try case-insensitive match
|
||||
for key, data in PACKET_ANALYSIS_REGISTRY.items():
|
||||
if key.lower() == protocol.lower():
|
||||
protocol_data = data
|
||||
protocol = key
|
||||
break
|
||||
|
||||
if not protocol_data:
|
||||
return {
|
||||
"error": f"Unknown protocol: {protocol}",
|
||||
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
|
||||
}
|
||||
|
||||
return protocol_data
|
||||
|
||||
|
||||
def list_available_packet_analysis_protocols() -> list[dict[str, str]]:
|
||||
"""
|
||||
List all available packet analysis protocols.
|
||||
|
||||
Returns:
|
||||
List of protocol info dicts with protocol, name, and description.
|
||||
"""
|
||||
protocols = []
|
||||
for key, data in PACKET_ANALYSIS_REGISTRY.items():
|
||||
protocols.append({
|
||||
"protocol": key,
|
||||
"name": data.get("name", key),
|
||||
"description": data.get("description", ""),
|
||||
})
|
||||
return protocols
|
||||
|
||||
|
||||
class DeviceSkillsTool(BaseTool):
|
||||
"""
|
||||
LangChain tool for querying device/feature skills.
|
||||
|
||||
Use this tool to get device command knowledge, topology planning skills, etc.
|
||||
For fault injection skills, use InjectionSkillsTool instead.
|
||||
"""
|
||||
|
||||
name: str = "device_skills"
|
||||
description: str = """
|
||||
Get or list device and feature specific skills.
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
For action="list":
|
||||
{"action": "list"} # Lists all available device/feature skills
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute the device skills lookup."""
|
||||
logger.debug("DeviceSkillsTool invoked with input: %s", tool_input)
|
||||
|
||||
if isinstance(tool_input, str):
|
||||
try:
|
||||
params = json.loads(tool_input)
|
||||
except json.JSONDecodeError as e:
|
||||
return json.dumps({
|
||||
"error": f"Invalid JSON input: {e}",
|
||||
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
|
||||
}, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
params = tool_input
|
||||
|
||||
action = params.get("action", "get")
|
||||
|
||||
if action == "list":
|
||||
skills = list_available_skills()
|
||||
return json.dumps({
|
||||
"count": len(skills),
|
||||
"skills": skills
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
device_type = params.get("device_type")
|
||||
if not device_type:
|
||||
return json.dumps({
|
||||
"error": "Missing required field: device_type",
|
||||
"available_device_types": list(SKILLS_REGISTRY.keys()),
|
||||
"hint": 'Use {"action": "list"} to see all available device types'
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
category = params.get("category")
|
||||
detail = params.get("detail", "full")
|
||||
issue = params.get("issue")
|
||||
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue)
|
||||
|
||||
return json.dumps(skill, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class InjectionSkillsTool(BaseTool):
|
||||
"""
|
||||
LangChain tool for querying fault injection skills.
|
||||
|
||||
Use this tool to list available injection fault types and get fault details.
|
||||
"""
|
||||
|
||||
name: str = "injection_skills"
|
||||
description: str = """
|
||||
Get or list network fault injection skills for troubleshooting practice.
|
||||
|
||||
REQUIRED: When action="list", you MUST always pass "context" with the
|
||||
protocols/services found in your topology analysis.
|
||||
Example: {"action": "list", "context": ["ospf", "bgp", "mpls", "vlan", "stp"]}
|
||||
|
||||
TOKEN-EFFICIENT USAGE:
|
||||
1. List faults for YOUR topology protocols (REQUIRED):
|
||||
{"action": "list", "context": ["ospf", "bgp"]}
|
||||
2. Get specific fault details:
|
||||
{"device_type": "injection_ospf", "issue": "ospf_hello_dead_mismatch"}
|
||||
{"device_type": "injection_ospf", "detail": "index"}
|
||||
|
||||
PARAMETERS:
|
||||
- action: "list" or "get"
|
||||
- context: [str] - REQUIRED for action="list". Protocols from your topology.
|
||||
- device_type: Required for action="get" (e.g., "injection_ospf")
|
||||
- detail: "index" | "summary" | "full"
|
||||
- issue: Get single fault detail by key
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute the injection skills lookup."""
|
||||
logger.debug("InjectionSkillsTool invoked with input: %s", tool_input)
|
||||
|
||||
if isinstance(tool_input, str):
|
||||
try:
|
||||
params = json.loads(tool_input)
|
||||
except json.JSONDecodeError as e:
|
||||
return json.dumps({
|
||||
"error": f"Invalid JSON input: {e}",
|
||||
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
|
||||
}, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
params = tool_input
|
||||
|
||||
action = params.get("action", "get")
|
||||
|
||||
if action == "list":
|
||||
context = params.get("context")
|
||||
if not context or not isinstance(context, list) or len(context) == 0:
|
||||
return json.dumps({
|
||||
"error": "context parameter is required when action='list'",
|
||||
"hint": "Analyze the topology and device configurations first, "
|
||||
"then pass the protocols/services you found as context. "
|
||||
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
|
||||
"available_categories": sorted(set(
|
||||
skill.get("category", "")
|
||||
for skill in INJECTION_SKILLS_REGISTRY.values()
|
||||
))
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
skills = list_available_injection_skills(context=context)
|
||||
logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching")
|
||||
return json.dumps({
|
||||
"count": len(skills),
|
||||
"total_available": len(INJECTION_SKILLS_REGISTRY),
|
||||
"context": context,
|
||||
"fault_types": skills
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
device_type = params.get("device_type")
|
||||
if not device_type:
|
||||
return json.dumps({
|
||||
"error": "Missing required field: device_type",
|
||||
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
|
||||
"hint": 'Use {"action": "list"} to see all available fault types'
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
detail = params.get("detail", "full")
|
||||
issue = params.get("issue")
|
||||
|
||||
skill = get_injection_skill(device_type, detail=detail, issue=issue)
|
||||
|
||||
return json.dumps(skill, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class PacketAnalysisSkillsTool(BaseTool):
|
||||
"""
|
||||
LangChain tool for querying packet analysis protocol definitions.
|
||||
|
||||
Use this tool to list available protocols and get protocol-specific
|
||||
tshark fields, display filters, and check rules.
|
||||
"""
|
||||
|
||||
name: str = "packet_analysis_skills"
|
||||
description: str = """
|
||||
Get or list packet analysis protocol definitions.
|
||||
|
||||
Before calling packet_analysis tool, use this to query the protocol's
|
||||
available tshark fields, display filters, and check rules.
|
||||
|
||||
USAGE:
|
||||
- List available protocols:
|
||||
{"action": "list"}
|
||||
|
||||
- Get protocol definition with fields:
|
||||
{"action": "get", "protocol": "ospf"}
|
||||
|
||||
PARAMETERS:
|
||||
- action: "list" or "get" (required)
|
||||
- protocol: Protocol key for action="get" (e.g., "ospf", "bgp", "arp", "icmp")
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute the packet analysis skills lookup."""
|
||||
logger.debug("PacketAnalysisSkillsTool invoked with input: %s", tool_input)
|
||||
|
||||
if isinstance(tool_input, str):
|
||||
try:
|
||||
params = json.loads(tool_input)
|
||||
except json.JSONDecodeError as e:
|
||||
return json.dumps({
|
||||
"error": f"Invalid JSON input: {e}",
|
||||
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}'
|
||||
}, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
params = tool_input
|
||||
|
||||
action = params.get("action", "get")
|
||||
|
||||
if action == "list":
|
||||
protocols = list_available_packet_analysis_protocols()
|
||||
return json.dumps({
|
||||
"count": len(protocols),
|
||||
"protocols": protocols
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
protocol = params.get("protocol")
|
||||
if not protocol:
|
||||
return json.dumps({
|
||||
"error": "Missing required field: protocol",
|
||||
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
result = get_packet_analysis_protocol(protocol)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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-Copilot Tools Package
|
||||
|
||||
This package provides various tools for interacting with GNS3 network simulator:
|
||||
- Device configuration command execution
|
||||
- Display command execution
|
||||
- Multiple device command execution using Nornir
|
||||
- VPCS device configuration using Netmiko
|
||||
- Node and link management
|
||||
|
||||
Main modules:
|
||||
- config_tools_nornir: Multiple device configuration command execution tool using Nornir
|
||||
- display_tools_nornir: Multiple device command execution tool using Nornir
|
||||
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
|
||||
- gns3_create_node: GNS3 node creation tool
|
||||
- gns3_create_link: GNS3 link creation tool
|
||||
- gns3_start_node: GNS3 node startup tool
|
||||
- gns3_get_node_temp: GNS3 template retrieval tool
|
||||
- gns3_update_node_name: GNS3 node name update tool
|
||||
|
||||
Note: GNS3TopologyTool is now available from gns3_client package
|
||||
|
||||
Author: Yue Guobin (岳国宾)
|
||||
"""
|
||||
|
||||
# Import main tool classes
|
||||
from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||
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_start_node import GNS3StartNodeQuickTool
|
||||
from .gns3_start_node import GNS3StartNodeTool
|
||||
from .gns3_stop_node import GNS3StopNodeTool
|
||||
from .gns3_suspend_node import GNS3SuspendNodeTool
|
||||
from .gns3_update_node_name import GNS3UpdateNodeNameTool
|
||||
from .packet_analysis_tool import PacketAnalysisTool
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("gns3-copilot")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Yue Guobin (岳国宾)"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
# Export main tool classes
|
||||
__all__ = [
|
||||
"ExecuteMultipleDeviceConfigCommands",
|
||||
"ExecuteMultipleDeviceCommands",
|
||||
"GNS3CreateNodeTool",
|
||||
"GNS3LinkTool",
|
||||
"GNS3StartNodeTool",
|
||||
"GNS3StartNodeQuickTool",
|
||||
"GNS3StopNodeTool",
|
||||
"GNS3SuspendNodeTool",
|
||||
"GNS3UpdateNodeNameTool",
|
||||
"GNS3TemplateTool",
|
||||
"PacketAnalysisTool",
|
||||
]
|
||||
|
||||
# Package initialization message
|
||||
# print(f"GNS3-Copilot Tools package loaded (version {__version__})")
|
||||
@@ -0,0 +1,776 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
This module provides a tool to execute configuration commands on multiple
|
||||
devices in a GNS3 topology using Nornir.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
from netmiko.exceptions import ReadTimeout
|
||||
from nornir import InitNornir
|
||||
from nornir.core import Nornir
|
||||
from nornir.core.task import AggregatedResult
|
||||
from nornir.core.task import Result
|
||||
from nornir.core.task import Task
|
||||
from nornir_netmiko.tasks import netmiko_send_config
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
|
||||
from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import (
|
||||
filter_forbidden_commands,
|
||||
)
|
||||
|
||||
# Import custom Netmiko device types for GNS3 emulation
|
||||
# This registers gns3_huawei_telnet_ce and other custom device types
|
||||
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
|
||||
|
||||
# Explicitly register custom device types to ensure they are available
|
||||
# This is a safety measure in case the auto-registration on import doesn't work
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import (
|
||||
register_custom_device_type as register_ruijie_device_type,
|
||||
)
|
||||
|
||||
# Re-register to ensure device types are available
|
||||
huawei_ce.register_custom_device_type()
|
||||
register_ruijie_device_type()
|
||||
|
||||
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
|
||||
# The platforms variable is calculated at module import time in ssh_dispatcher
|
||||
# We need to update it after registering custom device types.
|
||||
# NOTE: netmiko.ssh_dispatcher is aliased to ConnectHandler function,
|
||||
# so we use importlib to get the actual module
|
||||
import importlib
|
||||
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Recalculate platforms lists to include custom device types
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Update platform strings used in error messages
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
except Exception:
|
||||
# Fail silently - the import-time registration should have worked
|
||||
pass
|
||||
|
||||
# config log
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress nornir INFO logs in console (reduce verbosity)
|
||||
# The logging={"enabled": False} in InitNornir only disables plugin
|
||||
# internal logs, but nornir.core still logs task execution at INFO level.
|
||||
# Set to WARNING to suppress these.
|
||||
logging.getLogger("nornir.core").setLevel(logging.WARNING)
|
||||
logging.getLogger("nornir").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# Local Nornir configuration functions for network devices
|
||||
def _get_nornir_defaults() -> dict[str, Any]:
|
||||
"""Get Nornir default configuration."""
|
||||
return {"data": {"location": "gns3"}}
|
||||
|
||||
|
||||
class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
"""
|
||||
A tool to execute configuration commands on multiple devices in a GNS3
|
||||
topology using Nornir.
|
||||
This class uses Nornir to manage connections and execute configuration
|
||||
commands on multiple devices concurrently.
|
||||
|
||||
IMPORTANT SAFETY NOTE:
|
||||
This tool is intended for configuration changes only. Use extreme caution
|
||||
when executing configuration commands.
|
||||
"""
|
||||
|
||||
name: str = "execute_multiple_device_config_commands"
|
||||
description: str = """
|
||||
Executes CONFIGURATION commands on multiple devices in the current GNS3
|
||||
topology.
|
||||
Use this tool ONLY for changing device settings (e.g., 'configure',
|
||||
'interface', 'ip address', 'router ospf').
|
||||
For viewing information, use the 'execute_multiple_device_commands' tool.
|
||||
Input should be a JSON object containing project_id and device configs.
|
||||
Example input:
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "R-1",
|
||||
"config_commands": [
|
||||
"interface Loopback0",
|
||||
"ip address 1.1.1.1 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"device_name": "R-2",
|
||||
"config_commands": [
|
||||
"interface Loopback0",
|
||||
"ip address 2.2.2.2 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Returns a list of dicts with device name and config results.
|
||||
|
||||
IMPORTANT SAFETY WARNING:
|
||||
Do NOT use this tool for dangerous operations that could reboot, erase,
|
||||
or factory-reset devices.
|
||||
Forbidden operations include but are not limited to:
|
||||
- reload / reboot commands
|
||||
- write erase / erase startup-config
|
||||
- format / erase nvram / delete flash:
|
||||
- boot system commands
|
||||
- factory-reset commands
|
||||
- any commands that require user confirmation prompts
|
||||
- NEVER use 'exit' command - this disconnects the session and breaks subsequent commands
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str, # or Union[str, List[Any], Dict[str, Any]]
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Executes configuration commands on multiple devices in GNS3 topology.
|
||||
|
||||
Args:
|
||||
tool_input (str): A JSON string containing project_id and device
|
||||
configuration commands to execute.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: A list of dicts containing device names and
|
||||
configuration results.
|
||||
"""
|
||||
# Log received input
|
||||
logger.debug("Received input: %s", tool_input)
|
||||
|
||||
# Validate input
|
||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||
if (
|
||||
isinstance(device_configs_list, list)
|
||||
and len(device_configs_list) > 0
|
||||
and "error" in device_configs_list[0]
|
||||
):
|
||||
return device_configs_list
|
||||
|
||||
# Filter forbidden commands and store blocked commands info
|
||||
device_configs_list, blocked_commands_map = (
|
||||
self._filter_forbidden_commands_from_device_configs(
|
||||
device_configs_list
|
||||
)
|
||||
)
|
||||
|
||||
# Expand multiline commands (e.g., banner commands with embedded newlines)
|
||||
# This converts commands like "banner motd #\nline1\nline2\n#" into
|
||||
# separate commands: ["banner motd #", "line1", "line2", "#"]
|
||||
device_configs_list = self._expand_multiline_commands(
|
||||
device_configs_list
|
||||
)
|
||||
|
||||
# Create a mapping of device names to their configuration commands
|
||||
device_configs_map = self._configs_map(device_configs_list)
|
||||
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
name: data
|
||||
for name, data in hosts_data.items()
|
||||
if "error" in data
|
||||
}
|
||||
if error_devices:
|
||||
logger.error(
|
||||
"Devices with configuration errors: %s",
|
||||
list(error_devices.keys())
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_name": name,
|
||||
"status": "failed",
|
||||
"error": data["error"]
|
||||
}
|
||||
for name, data in error_devices.items()
|
||||
]
|
||||
|
||||
# Initialize Nornir
|
||||
try:
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
# Execute all devices concurrently in a single run
|
||||
try:
|
||||
task_result = dynamic_nr.run(
|
||||
task=self._run_all_device_configs_with_single_retry,
|
||||
device_configs_map=device_configs_map,
|
||||
)
|
||||
|
||||
# Process results for all devices
|
||||
results = self._process_task_results(
|
||||
device_configs_list,
|
||||
hosts_data,
|
||||
task_result,
|
||||
blocked_commands_map,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error(
|
||||
"Error executing configurations on all devices: %s", e
|
||||
)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.info(
|
||||
"Multiple device configuration execution completed. Results: %s",
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _run_all_device_configs_with_single_retry(
|
||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
||||
) -> Result:
|
||||
"""Execute configuration commands with single retry mechanism."""
|
||||
device_name = task.host.name
|
||||
config_commands = device_configs_map.get(device_name, [])
|
||||
|
||||
if not config_commands:
|
||||
return Result(
|
||||
host=task.host, result="No configuration commands to execute"
|
||||
)
|
||||
|
||||
try:
|
||||
_result = task.run(
|
||||
task=netmiko_send_config, config_commands=config_commands
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
except ReadTimeout as e:
|
||||
# Log ReadTimeout exception with full details
|
||||
logger.error(
|
||||
"ReadTimeout occurred for device %s: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Configuration failed (ReadTimeout): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Handle Cisco IOSv L2 where '#' prompt char may be delayed,
|
||||
# causing Netmiko failures. Implements retry logic.
|
||||
if "netmiko_send_config (failed)" in str(e):
|
||||
_result = task.run(
|
||||
task=netmiko_send_config, config_commands=config_commands
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
# Log any other exceptions with full details
|
||||
logger.error(
|
||||
"Configuration failed for device %s: %s (Exception type: %s)",
|
||||
device_name,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Configuration failed (Unhandled Exception): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
def _validate_tool_input(
|
||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Validate device config command input, handling new and legacy formats.
|
||||
Supports new format with project_id and device_configs.
|
||||
|
||||
Args:
|
||||
tool_input: Input from the LangChain/LangGraph tool call.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_configs_list, project_id) or (error_list, None)
|
||||
"""
|
||||
|
||||
parsed_input = None
|
||||
|
||||
# Compatibility Check and Parsing ---
|
||||
# Check if the input is a string (or bytes) which needs to be parsed.
|
||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
||||
# Handle models (like DeepSeek) that return a raw JSON string.
|
||||
try:
|
||||
parsed_input = json.loads(tool_input)
|
||||
logger.debug("Successfully parsed tool input from JSON string.")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Handle standard models (like GPT/OpenAI) where the framework
|
||||
# has already parsed the JSON into a Python object (dict or list).
|
||||
parsed_input = tool_input
|
||||
logger.info(
|
||||
"Using tool input directly as type: %s",
|
||||
type(parsed_input).__name__,
|
||||
)
|
||||
|
||||
# Handle new format: {"project_id": "...", "device_configs": [...]}
|
||||
if isinstance(parsed_input, dict):
|
||||
project_id = parsed_input.get("project_id")
|
||||
device_configs = parsed_input.get("device_configs")
|
||||
|
||||
# Validate project_id
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = (
|
||||
f"Invalid project_id format: {project_id}. "
|
||||
"Expected UUID format."
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"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)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
return [], project_id
|
||||
|
||||
return device_configs, project_id
|
||||
|
||||
# Handle legacy format: [...]
|
||||
elif isinstance(parsed_input, list):
|
||||
logger.warning(
|
||||
"Using legacy format without project_id. "
|
||||
"Please use new format with project_id."
|
||||
)
|
||||
return parsed_input, None
|
||||
|
||||
else:
|
||||
error_msg = (
|
||||
"Tool input must be a JSON object with 'project_id' and "
|
||||
f"'device_configs' fields, or a legacy JSON array, but got "
|
||||
f"{type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
Validate project_id format (UUID).
|
||||
|
||||
Args:
|
||||
project_id: The project ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid UUID format, False otherwise
|
||||
"""
|
||||
uuid_pattern = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||
|
||||
def _filter_forbidden_commands_from_device_configs(
|
||||
self, device_configs_list: list[dict[str, Any]]
|
||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]:
|
||||
"""
|
||||
Filter out forbidden commands from device configurations.
|
||||
|
||||
Args:
|
||||
device_configs_list: List of device configs with config_commands.
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_configs, blocked_map):
|
||||
- filtered_configs: Device configs with forbidden removed.
|
||||
- blocked_map: Dict mapping device names to blocked info.
|
||||
"""
|
||||
filtered_list = []
|
||||
blocked_commands_map: dict[str, dict[str, str]] = {}
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
commands = device_config["config_commands"]
|
||||
|
||||
# Filter commands
|
||||
allowed_commands, blocked_info = filter_forbidden_commands(
|
||||
commands
|
||||
)
|
||||
|
||||
# Update device config with allowed commands only
|
||||
filtered_config = device_config.copy()
|
||||
filtered_config["config_commands"] = allowed_commands
|
||||
filtered_list.append(filtered_config)
|
||||
|
||||
# Store blocked commands info if any
|
||||
if blocked_info:
|
||||
blocked_commands_map[device_name] = blocked_info
|
||||
logger.info(
|
||||
"Device %s: %d command(s) blocked: %s",
|
||||
device_name,
|
||||
len(blocked_info),
|
||||
list(blocked_info.keys()),
|
||||
)
|
||||
|
||||
return filtered_list, blocked_commands_map
|
||||
|
||||
def _expand_multiline_commands(
|
||||
self, device_configs_list: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Expand commands that contain embedded newline characters.
|
||||
|
||||
Some commands (like banner) are passed as single strings with embedded
|
||||
newlines. This method splits them into separate commands for proper
|
||||
execution by netmiko_send_config.
|
||||
|
||||
Note: netmiko_send_config automatically handles entering config mode,
|
||||
so any config mode entry commands (configure terminal, system-view, etc.)
|
||||
are left as-is and will be safely ignored by the device.
|
||||
|
||||
Example:
|
||||
Input: ["banner motd #\nline1\nline2\n#", "exit"]
|
||||
Output: ["banner motd #", "line1", "line2", "#", "exit"]
|
||||
|
||||
Args:
|
||||
device_configs_list: List of device configs with config_commands.
|
||||
|
||||
Returns:
|
||||
List of device configs with multiline commands expanded.
|
||||
"""
|
||||
expanded_list = []
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
config_commands = device_config["config_commands"]
|
||||
|
||||
expanded_commands = []
|
||||
for cmd in config_commands:
|
||||
# Check if command contains embedded newlines
|
||||
if "\n" in cmd:
|
||||
# Split by newline and expand
|
||||
lines = cmd.split("\n")
|
||||
# Filter out empty lines but keep all content lines
|
||||
expanded_commands.extend([line for line in lines if line.strip()])
|
||||
logger.info(
|
||||
"Device %s: Expanded multiline command (%d lines)",
|
||||
device_name,
|
||||
len(lines),
|
||||
)
|
||||
else:
|
||||
# Keep single-line commands as-is
|
||||
expanded_commands.append(cmd)
|
||||
|
||||
# Create updated device config
|
||||
expanded_config = device_config.copy()
|
||||
expanded_config["config_commands"] = expanded_commands
|
||||
expanded_list.append(expanded_config)
|
||||
|
||||
return expanded_list
|
||||
|
||||
def _configs_map(
|
||||
self, device_config_list: list[dict[str, Any]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""Create a mapping of device names to their configuration commands."""
|
||||
device_configs_map = {}
|
||||
for device_config in device_config_list:
|
||||
device_name = device_config["device_name"]
|
||||
config_commands = device_config["config_commands"]
|
||||
device_configs_map[device_name] = config_commands
|
||||
|
||||
return device_configs_map
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
device_names = [
|
||||
device_config["device_name"]
|
||||
for device_config in device_config_list
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
"Failed to get device info from topology or no valid devices. "
|
||||
f"Project ID: {project_id}, Devices: {device_names}"
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for missing devices
|
||||
missing_devices = set(device_names) - set(hosts_data.keys())
|
||||
if missing_devices:
|
||||
logger.warning(
|
||||
"Some devices not found in topology (Project ID: %s): %s",
|
||||
project_id or "default",
|
||||
missing_devices,
|
||||
)
|
||||
|
||||
return hosts_data
|
||||
|
||||
def _initialize_nornir(
|
||||
self, hosts_data: dict[str, dict[str, Any]]
|
||||
) -> Nornir:
|
||||
"""
|
||||
Initialize Nornir with the provided hosts data.
|
||||
|
||||
Each host now has its own connection_options (device_type), so we only
|
||||
need a single generic group for shared configuration (hostname, timeout, etc.).
|
||||
This is the Nornir best practice for multi-vendor environments.
|
||||
"""
|
||||
try:
|
||||
defaults = _get_nornir_defaults()
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Create a single generic group for shared configuration
|
||||
# Individual device types are handled at host level via connection_options
|
||||
groups_data = {
|
||||
"network_devices": {
|
||||
"hostname": gns3_host,
|
||||
"timeout": 120,
|
||||
"username": "",
|
||||
"password": "",
|
||||
}
|
||||
}
|
||||
|
||||
# Log device types being configured
|
||||
device_types = [
|
||||
host["connection_options"]["netmiko"]["extras"]["device_type"]
|
||||
for host in hosts_data.values()
|
||||
]
|
||||
logger.info(
|
||||
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
||||
gns3_host,
|
||||
list(set(device_types)),
|
||||
len(hosts_data),
|
||||
)
|
||||
|
||||
return InitNornir(
|
||||
inventory={
|
||||
"plugin": "DictInventory",
|
||||
"options": {
|
||||
"hosts": hosts_data,
|
||||
"groups": groups_data,
|
||||
"defaults": defaults,
|
||||
},
|
||||
},
|
||||
runner={
|
||||
"plugin": "threaded",
|
||||
"options": {"num_workers": 10},
|
||||
},
|
||||
logging={"enabled": False},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
raise ValueError(f"Failed to initialize Nornir: {e}") from e
|
||||
|
||||
def _process_task_results(
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
hosts_data: dict[str, dict[str, Any]],
|
||||
task_result: AggregatedResult,
|
||||
blocked_commands_map: dict[str, dict[str, str]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Process the task results and format them for return."""
|
||||
results = []
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
config_commands = device_config["config_commands"]
|
||||
blocked_commands_info = blocked_commands_map.get(device_name, {})
|
||||
|
||||
# Check if device is in topology
|
||||
if device_name not in hosts_data:
|
||||
device_result = {
|
||||
"device_name": device_name,
|
||||
"status": "failed",
|
||||
"error": (
|
||||
f"Device '{device_name}' not found in topology or "
|
||||
"missing console_port"
|
||||
),
|
||||
}
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
results.append(device_result)
|
||||
continue
|
||||
|
||||
# Check if device has execution results
|
||||
if device_name not in task_result:
|
||||
device_result = {
|
||||
"device_name": device_name,
|
||||
"status": "failed",
|
||||
"error": (
|
||||
f"Device '{device_name}' not found in task results"
|
||||
),
|
||||
}
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
results.append(device_result)
|
||||
continue
|
||||
|
||||
# Process execution results
|
||||
multi_result = task_result[device_name]
|
||||
device_result = {"device_name": device_name}
|
||||
|
||||
if multi_result[0].failed:
|
||||
# Execution failed
|
||||
device_result["status"] = "failed"
|
||||
device_result["error"] = (
|
||||
f"Configuration execution failed: {multi_result[0].result}"
|
||||
)
|
||||
device_result["output"] = multi_result[0].result
|
||||
else:
|
||||
# Execution successful
|
||||
device_result["status"] = "success"
|
||||
device_result["output"] = multi_result[0].result
|
||||
device_result["config_commands"] = config_commands
|
||||
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
# Update status if some commands were blocked but succeeded
|
||||
if device_result["status"] == "success":
|
||||
device_result["status"] = "partial_success"
|
||||
|
||||
results.append(device_result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage with new format
|
||||
# example tool_input with project_id
|
||||
input_paras = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "R-1",
|
||||
"config_commands": [
|
||||
"interface Loopback1110",
|
||||
"ip address 201.201.201.201 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "R-2",
|
||||
"config_commands": [
|
||||
"interface Loopback1110",
|
||||
"ip address 202.202.202.202 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "SW-2",
|
||||
"config_commands": [
|
||||
"interface Loopback1110",
|
||||
"ip address 202.202.202.202 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "SW-1",
|
||||
"config_commands": [
|
||||
"interface Loopback1110",
|
||||
"ip address 202.202.202.202 255.255.255.255",
|
||||
"description CONFIG_BY_TOOL",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exe_config = ExecuteMultipleDeviceConfigCommands()
|
||||
|
||||
failed_count = 0
|
||||
|
||||
for _i in range(0, 5):
|
||||
exe_results = exe_config._run(tool_input=input_paras)
|
||||
for result in exe_results:
|
||||
for result in exe_results:
|
||||
if result.get("status") == "failed":
|
||||
failed_count += 1
|
||||
|
||||
print(f"Failed Count: {failed_count}")
|
||||
|
||||
# print("Execution results:")
|
||||
# print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,702 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
This module provides a tool to execute display commands on multiple devices
|
||||
in a GNS3 topology using Nornir.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
from netmiko.exceptions import ReadTimeout
|
||||
from nornir import InitNornir
|
||||
from nornir.core import Nornir
|
||||
from nornir.core.task import AggregatedResult
|
||||
from nornir.core.task import Result
|
||||
from nornir.core.task import Task
|
||||
from nornir_netmiko.tasks import netmiko_multiline
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
|
||||
from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
|
||||
from gns3server.agent.gns3_copilot.utils.command_filter import (
|
||||
filter_forbidden_commands,
|
||||
)
|
||||
|
||||
# Import custom Netmiko device types for GNS3 emulation
|
||||
# This registers gns3_huawei_telnet_ce and other custom device types
|
||||
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
|
||||
|
||||
# Explicitly register custom device types to ensure they are available
|
||||
# This is a safety measure in case the auto-registration on import doesn't work
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import (
|
||||
register_custom_device_type as register_ruijie_device_type,
|
||||
)
|
||||
|
||||
# Re-register to ensure device types are available
|
||||
huawei_ce.register_custom_device_type()
|
||||
register_ruijie_device_type()
|
||||
|
||||
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
|
||||
# The platforms variable is calculated at module import time in ssh_dispatcher
|
||||
# We need to update it after registering custom device types.
|
||||
# NOTE: netmiko.ssh_dispatcher is aliased to ConnectHandler function,
|
||||
# so we use importlib to get the actual module
|
||||
import importlib
|
||||
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Recalculate platforms lists to include custom device types
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Update platform strings used in error messages
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
except Exception:
|
||||
# Fail silently - the import-time registration should have worked
|
||||
pass
|
||||
|
||||
# config log
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress nornir INFO logs in console (reduce verbosity)
|
||||
# The logging={"enabled": False} in InitNornir only disables plugin
|
||||
# internal logs, but nornir.core still logs task execution at INFO level.
|
||||
# Set to WARNING to suppress these.
|
||||
logging.getLogger("nornir.core").setLevel(logging.WARNING)
|
||||
logging.getLogger("nornir").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# Local Nornir configuration functions for network devices
|
||||
def _get_nornir_defaults() -> dict[str, Any]:
|
||||
"""Get Nornir default configuration."""
|
||||
return {"data": {"location": "gns3"}}
|
||||
|
||||
|
||||
class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
"""
|
||||
A READ-ONLY diagnostic tool for viewing network device configurations.
|
||||
|
||||
**CRITICAL: DIAGNOSIS ONLY - NO CONFIGURATION PERMISSIONS**
|
||||
|
||||
This tool is exclusively designed for read-only operations to inspect and
|
||||
diagnose network devices. It CANNOT and MUST NOT be used for configuration.
|
||||
|
||||
**Allowed Command Types:**
|
||||
- Display commands: show, display (Cisco/Huawei)
|
||||
- Debug commands: debug ip routing, debug ospf events
|
||||
- Verification commands: ping, traceroute, telnet
|
||||
- Status commands: show version, show running-config, show ip route
|
||||
|
||||
**Strictly Prohibited:**
|
||||
- Configuration mode: configure terminal, config t
|
||||
- Interface configuration: interface, ip address
|
||||
- Protocol configuration: router ospf, router bgp
|
||||
- Any command that modifies device state
|
||||
"""
|
||||
|
||||
name: str = "execute_multiple_device_commands"
|
||||
description: str = """
|
||||
**READ-ONLY DIAGNOSTIC TOOL** - View network device configurations.
|
||||
|
||||
Use this tool to inspect device information without making any changes.
|
||||
|
||||
**PERMITTED USE CASES:**
|
||||
- View device status: show version, show running-config
|
||||
- Check routing: show ip route, show ip ospf neighbor, show bgp summary
|
||||
- Interface status: show ip interface brief, show interfaces
|
||||
- Protocol diagnostics: show ospf database, show bgp routes, debug commands
|
||||
- Connectivity testing: ping, traceroute
|
||||
|
||||
**STRICTLY FORBIDDEN:**
|
||||
- NO configuration commands (configure terminal, interface, router, etc.)
|
||||
- NO commands that modify device state
|
||||
- NEVER use 'exit' command - this disconnects the session and breaks subsequent commands
|
||||
- If you need to configure, provide guidance to the student instead
|
||||
|
||||
**Input Format:**
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "R-1",
|
||||
"commands": ["show version", "show ip interface brief"]
|
||||
},
|
||||
{
|
||||
"device_name": "R-2",
|
||||
"commands": ["show version", "show ip ospf neighbor"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Returns:** List of device outputs for diagnostic analysis.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Executes READ-ONLY diagnostic commands on multiple devices in GNS3.
|
||||
|
||||
This method only permits display/show commands and does not allow
|
||||
configuration changes to network devices.
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and diagnostic commands.
|
||||
|
||||
Returns:
|
||||
List[Dict]: A list of dicts with device names and outputs.
|
||||
"""
|
||||
# Log received input
|
||||
logger.debug("Received input: %s", tool_input)
|
||||
|
||||
# Validate input
|
||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||
if (
|
||||
isinstance(device_configs_list, list)
|
||||
and len(device_configs_list) > 0
|
||||
and "error" in device_configs_list[0]
|
||||
):
|
||||
return device_configs_list
|
||||
|
||||
# Filter forbidden commands and store blocked commands info
|
||||
device_configs_list, blocked_commands_map = (
|
||||
self._filter_forbidden_commands_from_device_configs(
|
||||
device_configs_list
|
||||
)
|
||||
)
|
||||
|
||||
# Create a mapping of device names to their display commands
|
||||
device_configs_map = self._configs_map(device_configs_list)
|
||||
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
name: data
|
||||
for name, data in hosts_data.items()
|
||||
if "error" in data
|
||||
}
|
||||
if error_devices:
|
||||
logger.error(
|
||||
"Devices with configuration errors: %s",
|
||||
list(error_devices.keys())
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_name": name,
|
||||
"status": "failed",
|
||||
"error": data["error"]
|
||||
}
|
||||
for name, data in error_devices.items()
|
||||
]
|
||||
|
||||
# Initialize Nornir
|
||||
try:
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
# Execute all devices concurrently in a single run
|
||||
try:
|
||||
task_result = dynamic_nr.run(
|
||||
task=self._run_all_device_configs_with_single_retry,
|
||||
device_configs_map=device_configs_map,
|
||||
)
|
||||
|
||||
# Process results for all devices
|
||||
results = self._process_task_results(
|
||||
device_configs_list,
|
||||
hosts_data,
|
||||
task_result,
|
||||
blocked_commands_map,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing display on all devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"Multiple device display execution completed. Results: %s",
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _run_all_device_configs_with_single_retry(
|
||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
||||
) -> Result:
|
||||
"""Execute READ-ONLY diagnostic commands with single retry."""
|
||||
device_name = task.host.name
|
||||
diagnostic_commands = device_configs_map.get(device_name, [])
|
||||
|
||||
if not diagnostic_commands:
|
||||
return Result(
|
||||
host=task.host, result="No diagnostic commands to execute"
|
||||
)
|
||||
|
||||
try:
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=diagnostic_commands,
|
||||
enable=True,
|
||||
read_timeout=60,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
except ReadTimeout as e:
|
||||
# Log ReadTimeout exception with full details
|
||||
logger.error(
|
||||
"ReadTimeout occurred for device %s: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"diagnostic command failed (ReadTimeout): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Handle Cisco IOSv L2 where '#' prompt char may be delayed,
|
||||
# causing Netmiko failures. Implements retry logic.
|
||||
if "netmiko_multiline (failed)" in str(e):
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=diagnostic_commands,
|
||||
enable=True,
|
||||
read_timeout=60,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
# Log any other exceptions with full details
|
||||
logger.error(
|
||||
"diagnostic command failed for device %s: %s (Exception: %s)",
|
||||
device_name,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"diagnostic command failed (Unhandled): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
def _validate_tool_input(
|
||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Validate diagnostic command input for read-only device inspection.
|
||||
|
||||
Handles both new and legacy input formats. Supports new format with
|
||||
project_id and device_configs, as well as legacy array format.
|
||||
|
||||
Args:
|
||||
tool_input: Input from LangChain/LangGraph tool call.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_configs_list, project_id) or (error_list, None)
|
||||
"""
|
||||
|
||||
parsed_input = None
|
||||
|
||||
# Compatibility Check and Parsing ---
|
||||
# Check if the input is a string (or bytes) which needs to be parsed.
|
||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
||||
# Handle models (like DeepSeek) that return a raw JSON string.
|
||||
try:
|
||||
parsed_input = json.loads(tool_input)
|
||||
logger.info("Successfully parsed tool input from JSON string.")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Handle standard models (like GPT/OpenAI) where the framework
|
||||
# has already parsed the JSON into a Python object (dict or list).
|
||||
parsed_input = tool_input
|
||||
logger.info(
|
||||
"Using tool input directly as type: %s",
|
||||
type(parsed_input).__name__,
|
||||
)
|
||||
|
||||
# Handle new format: {"project_id": "...", "device_configs": [...]}
|
||||
if isinstance(parsed_input, dict):
|
||||
project_id = parsed_input.get("project_id")
|
||||
device_configs = parsed_input.get("device_configs")
|
||||
|
||||
# Validate project_id
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"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)
|
||||
|
||||
# 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)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
return [], project_id
|
||||
|
||||
return device_configs, project_id
|
||||
|
||||
# Handle legacy format: [...]
|
||||
elif isinstance(parsed_input, list):
|
||||
logger.warning(
|
||||
"Legacy input format without project_id. Use new format."
|
||||
)
|
||||
return parsed_input, None
|
||||
|
||||
else:
|
||||
error_msg = (
|
||||
"Tool input must be JSON with project_id and device_configs, "
|
||||
f"or legacy JSON array, got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
Validate project_id format (UUID).
|
||||
|
||||
Args:
|
||||
project_id: The project ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid UUID format, False otherwise
|
||||
"""
|
||||
uuid_pattern = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||
|
||||
def _filter_forbidden_commands_from_device_configs(
|
||||
self, device_configs_list: list[dict[str, Any]]
|
||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]:
|
||||
"""
|
||||
Filter out forbidden commands from device configurations.
|
||||
|
||||
Args:
|
||||
device_configs_list: List of device configurations with commands.
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_configs, blocked_map):
|
||||
- filtered_configs: Device configs with forbidden removed.
|
||||
- blocked_map: Dict mapping device names to blocked commands.
|
||||
"""
|
||||
filtered_list = []
|
||||
blocked_commands_map: dict[str, dict[str, str]] = {}
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
commands = device_config["commands"]
|
||||
|
||||
# Filter commands
|
||||
allowed_commands, blocked_info = filter_forbidden_commands(
|
||||
commands
|
||||
)
|
||||
|
||||
# Update device config with allowed commands only
|
||||
filtered_config = device_config.copy()
|
||||
filtered_config["commands"] = allowed_commands
|
||||
filtered_list.append(filtered_config)
|
||||
|
||||
# Store blocked commands info if any
|
||||
if blocked_info:
|
||||
blocked_commands_map[device_name] = blocked_info
|
||||
logger.info(
|
||||
"Device %s: %d command(s) blocked: %s",
|
||||
device_name,
|
||||
len(blocked_info),
|
||||
list(blocked_info.keys()),
|
||||
)
|
||||
|
||||
return filtered_list, blocked_commands_map
|
||||
|
||||
def _configs_map(
|
||||
self, device_config_list: list[dict[str, Any]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""Create a mapping of device names to their diagnostic commands."""
|
||||
device_diagnostic_map = {}
|
||||
for device_config in device_config_list:
|
||||
device_name = device_config["device_name"]
|
||||
diagnostic_commands = device_config["commands"]
|
||||
device_diagnostic_map[device_name] = diagnostic_commands
|
||||
|
||||
return device_diagnostic_map
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
device_names = [
|
||||
device_config["device_name"]
|
||||
for device_config in device_config_list
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
f"Failed to get device info from topology. "
|
||||
f"Project: {project_id}, Devices: {device_names}"
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for missing devices
|
||||
missing_devices = set(device_names) - set(hosts_data.keys())
|
||||
if missing_devices:
|
||||
logger.warning(
|
||||
"Some devices not found in topology (Project ID: %s): %s",
|
||||
project_id or "default",
|
||||
missing_devices,
|
||||
)
|
||||
|
||||
return hosts_data
|
||||
|
||||
def _initialize_nornir(
|
||||
self, hosts_data: dict[str, dict[str, Any]]
|
||||
) -> Nornir:
|
||||
"""
|
||||
Initialize Nornir with the provided hosts data.
|
||||
|
||||
Each host now has its own connection_options (device_type), so we only
|
||||
need a single generic group for shared configuration (hostname, timeout, etc.).
|
||||
This is the Nornir best practice for multi-vendor environments.
|
||||
"""
|
||||
try:
|
||||
defaults = _get_nornir_defaults()
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Create a single generic group for shared configuration
|
||||
# Individual device types are handled at host level via connection_options
|
||||
groups_data = {
|
||||
"network_devices": {
|
||||
"hostname": gns3_host,
|
||||
"timeout": 120,
|
||||
"username": "",
|
||||
"password": "",
|
||||
}
|
||||
}
|
||||
|
||||
# Log device types being configured
|
||||
device_types = [
|
||||
host["connection_options"]["netmiko"]["extras"]["device_type"]
|
||||
for host in hosts_data.values()
|
||||
]
|
||||
logger.info(
|
||||
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
|
||||
gns3_host,
|
||||
list(set(device_types)),
|
||||
len(hosts_data),
|
||||
)
|
||||
|
||||
return InitNornir(
|
||||
inventory={
|
||||
"plugin": "DictInventory",
|
||||
"options": {
|
||||
"hosts": hosts_data,
|
||||
"groups": groups_data,
|
||||
"defaults": defaults,
|
||||
},
|
||||
},
|
||||
runner={
|
||||
"plugin": "threaded",
|
||||
"options": {"num_workers": 10},
|
||||
},
|
||||
logging={"enabled": False},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
raise ValueError(f"Failed to initialize Nornir: {e}") from e
|
||||
|
||||
def _process_task_results(
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
hosts_data: dict[str, dict[str, Any]],
|
||||
task_result: AggregatedResult,
|
||||
blocked_commands_map: dict[str, dict[str, str]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Process the task results and format them for return."""
|
||||
results = []
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
diagnostic_commands = device_config["commands"]
|
||||
blocked_commands_info = blocked_commands_map.get(device_name, {})
|
||||
|
||||
# Check if device is in topology
|
||||
if device_name not in hosts_data:
|
||||
device_result = {
|
||||
"device_name": device_name,
|
||||
"status": "failed",
|
||||
"error": (
|
||||
f"Device '{device_name}' not found in topology "
|
||||
"or missing console_port"
|
||||
),
|
||||
}
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
results.append(device_result)
|
||||
continue
|
||||
|
||||
# Check if device has execution results
|
||||
if device_name not in task_result:
|
||||
device_result = {
|
||||
"device_name": device_name,
|
||||
"status": "failed",
|
||||
"error": (
|
||||
f"Device '{device_name}' not found in task results"
|
||||
),
|
||||
}
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
results.append(device_result)
|
||||
continue
|
||||
|
||||
# Process execution results
|
||||
multi_result = task_result[device_name]
|
||||
device_result = {"device_name": device_name}
|
||||
|
||||
if multi_result[0].failed:
|
||||
# Execution failed
|
||||
device_result["status"] = "failed"
|
||||
device_result["error"] = (
|
||||
f"Diagnostic command failed: {multi_result[0].result}"
|
||||
)
|
||||
device_result["output"] = multi_result[0].result
|
||||
else:
|
||||
# Execution successful
|
||||
device_result["status"] = "success"
|
||||
device_result["output"] = multi_result[0].result
|
||||
device_result["diagnostic_commands"] = diagnostic_commands
|
||||
|
||||
# Add blocked commands info if any
|
||||
if blocked_commands_info:
|
||||
device_result["blocked_commands"] = list(
|
||||
blocked_commands_info.keys()
|
||||
)
|
||||
device_result["blocked_info"] = blocked_commands_info
|
||||
# Update status if some commands were blocked but succeeded
|
||||
if device_result["status"] == "success":
|
||||
device_result["status"] = "partial_success"
|
||||
|
||||
results.append(device_result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage with new format
|
||||
device_commands = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "R-2",
|
||||
"commands": ["show version", "show ip interface brief"],
|
||||
},
|
||||
{
|
||||
"device_name": "R-1",
|
||||
"commands": ["show version", "show ip interface brief"],
|
||||
},
|
||||
{
|
||||
"device_name": "SW-1",
|
||||
"commands": ["show version", "show ip interface brief"],
|
||||
},
|
||||
{
|
||||
"device_name": "SW-2",
|
||||
"commands": ["show version", "show ip interface brief"],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exe_cmd = ExecuteMultipleDeviceCommands()
|
||||
|
||||
failed_count = 0
|
||||
|
||||
for _i in range(0, 1):
|
||||
exe_results = exe_cmd._run(tool_input=device_commands)
|
||||
for result in exe_results:
|
||||
for result in exe_results:
|
||||
if result.get("status") == "failed":
|
||||
failed_count += 1
|
||||
|
||||
print(f"Failed Count: {failed_count}")
|
||||
|
||||
# print("Execution results:")
|
||||
# print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,319 @@
|
||||
# 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 link creation tool for connecting network nodes.
|
||||
|
||||
Provides functionality to create links between nodes in GNS3 projects
|
||||
using the GNS3 API connector.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import Link
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3LinkTool(BaseTool):
|
||||
"""
|
||||
Tool for creating network links between GNS3 nodes.
|
||||
|
||||
Creates one or more links between specified nodes in a GNS3 project
|
||||
by connecting their network ports. Supports batch link creation
|
||||
with error handling for individual link failures.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_link"
|
||||
description: str = """
|
||||
Creates one or more links between nodes in a GNS3 project.
|
||||
|
||||
Input: A JSON string with:
|
||||
- `project_id` (str): The UUID of the GNS3 project.
|
||||
- `links` (list): A non-empty array of link definitions, each containing:
|
||||
- `node_id1` (str): UUID of the first node.
|
||||
- `port1` (str): Port name of the first node (e.g., 'Ethernet0/0').
|
||||
- `node_id2` (str): UUID of the second node.
|
||||
- `port2` (str): Port name of the second node (e.g., 'Ethernet0/0').
|
||||
Note: Port names must match those from `gns3_topology_reader` tool.
|
||||
|
||||
Example Input:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"links": [
|
||||
{
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "uuid-of-node2",
|
||||
"port2": "Ethernet0/0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Output: A list of dicts, each containing:
|
||||
- For successful links: link_id, node_id1, port1, node_id2, port2.
|
||||
- For failed links: error(str) with error message.
|
||||
|
||||
Example Output:
|
||||
[
|
||||
{
|
||||
"link_id": "uuid-of-link",
|
||||
"node_id1": "uuid-of-node1",
|
||||
"port1" : "Ethernet0/0",
|
||||
"node_id2": "uuid-of-node2",
|
||||
"port2": "Ethernet0/0"
|
||||
},
|
||||
{
|
||||
"error": "Port not found in link 1"
|
||||
},
|
||||
]
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Creates one or multiple links between nodes in a GNS3 project.
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and links array.
|
||||
run_manager: LangChain run manager (unused).
|
||||
|
||||
Returns:
|
||||
list: A list with created link details or error messages.
|
||||
"""
|
||||
# Log received input
|
||||
logger.debug("Received input: %s", tool_input)
|
||||
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
links_data = input_data.get("links", [])
|
||||
|
||||
# Validate input
|
||||
if not project_id:
|
||||
logger.error("Missing required field: project_id")
|
||||
return [{"error": "Missing required field: project_id"}]
|
||||
|
||||
if not isinstance(links_data, list) or len(links_data) == 0:
|
||||
logger.error("Invalid links data: must be a non-empty array")
|
||||
return [
|
||||
{"error": "Invalid links data: must be a non-empty array"}
|
||||
]
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return [
|
||||
{
|
||||
"error": (
|
||||
"Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
created_links = []
|
||||
|
||||
# Process each link definition
|
||||
for i, link_data in enumerate(links_data):
|
||||
try:
|
||||
logger.info("Creating link %d/%d", i + 1, len(links_data))
|
||||
|
||||
# Extract link parameters
|
||||
node_id1 = link_data.get("node_id1")
|
||||
port1 = link_data.get("port1")
|
||||
node_id2 = link_data.get("node_id2")
|
||||
port2 = link_data.get("port2")
|
||||
|
||||
# Validate link parameters
|
||||
if not all([node_id1, port1, node_id2, port2]):
|
||||
error_msg = (
|
||||
f"Missing required fields in link definition {i}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
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
|
||||
)
|
||||
if not node1 or not node2:
|
||||
error_msg = f"Node not found in link {i}"
|
||||
logger.error(error_msg)
|
||||
created_links.append({"error": error_msg})
|
||||
continue
|
||||
|
||||
# Find port information - match by name or short_name
|
||||
port1_info = next(
|
||||
(
|
||||
port
|
||||
for port in node1.get("ports", [])
|
||||
if port.get("name") == port1 or port.get("short_name") == port1
|
||||
),
|
||||
None,
|
||||
)
|
||||
port2_info = next(
|
||||
(
|
||||
port
|
||||
for port in node2.get("ports", [])
|
||||
if port.get("name") == port2 or port.get("short_name") == port2
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not port1_info or not port2_info:
|
||||
error_msg = f"Port not found in link {i}"
|
||||
logger.error(error_msg)
|
||||
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},
|
||||
},
|
||||
],
|
||||
)
|
||||
link.create()
|
||||
link.get()
|
||||
|
||||
# Collect link details
|
||||
link_info = {
|
||||
"link_id": link.link_id,
|
||||
"node_id1": node_id1,
|
||||
"port1": port1,
|
||||
"node_id2": node_id2,
|
||||
"port2": port2,
|
||||
}
|
||||
created_links.append(link_info)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to create link {i}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
created_links.append({"error": error_msg})
|
||||
|
||||
# Log final results
|
||||
success_count = len(
|
||||
[link for link in created_links if "error" not in link]
|
||||
)
|
||||
logger.info(
|
||||
"Link creation completed: %d successful, %d failed",
|
||||
success_count,
|
||||
len(links_data) - success_count,
|
||||
)
|
||||
|
||||
return created_links
|
||||
|
||||
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 link creation: %s", e)
|
||||
return [{"error": f"Failed to process link creation: {str(e)}"}]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with single link
|
||||
single_link_input = json.dumps(
|
||||
{
|
||||
"project_id": "your-project-uuid",
|
||||
"links": [
|
||||
{
|
||||
"node_id1": "your-node1-uuid",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "your-node2-uuid",
|
||||
"port2": "Ethernet0/0",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Test with multiple links
|
||||
multiple_links_input = json.dumps(
|
||||
{
|
||||
"project_id": "your-project-uuid",
|
||||
"links": [
|
||||
{
|
||||
"node_id1": "your-node1-uuid",
|
||||
"port1": "Ethernet0/0",
|
||||
"node_id2": "your-node2-uuid",
|
||||
"port2": "Ethernet0/0",
|
||||
},
|
||||
{
|
||||
"node_id1": "your-node1-uuid",
|
||||
"port1": "Ethernet0/1",
|
||||
"node_id2": "your-node3-uuid",
|
||||
"port2": "Ethernet0/0",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
tool = GNS3LinkTool()
|
||||
|
||||
print("=== Testing Single Link Creation ===")
|
||||
result = tool._run(single_link_input)
|
||||
pprint(result)
|
||||
|
||||
print("\n=== Testing Multiple Links Creation ===")
|
||||
result = tool._run(multiple_links_input)
|
||||
pprint(result)
|
||||
@@ -0,0 +1,325 @@
|
||||
# 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 node creation tool for network topology building.
|
||||
|
||||
Provides functionality to create multiple nodes in GNS3 projects
|
||||
using specified templates and coordinates through the GNS3 API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3CreateNodeTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to create multiple nodes in a GNS3 project
|
||||
using specified templates and coordinates.
|
||||
|
||||
**Input:**
|
||||
A JSON object with project_id and array of nodes with template_id,
|
||||
x, y coordinates, and optional name.
|
||||
|
||||
Example input:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"nodes": [
|
||||
{
|
||||
"template_id": "uuid-of-template",
|
||||
"x": 100,
|
||||
"y": -200,
|
||||
"name": "R1"
|
||||
},
|
||||
{
|
||||
"template_id": "uuid-of-template2",
|
||||
"x": -200,
|
||||
"y": 300,
|
||||
"name": "R2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Output:**
|
||||
A dictionary containing the creation results for all nodes.
|
||||
Example output:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"created_nodes": [
|
||||
{
|
||||
"node_id": "uuid-of-node1",
|
||||
"name": "R1",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"node_id": "uuid-of-node2",
|
||||
"name": "R2",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"total_nodes": 2,
|
||||
"successful_nodes": 2,
|
||||
"failed_nodes": 0
|
||||
}
|
||||
If error occurs during validation, returns dict with error message.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_node"
|
||||
description: str = """
|
||||
Creates multiple nodes in a GNS3 project using templates and coordinates.
|
||||
Input is a JSON object with project_id and array of nodes.
|
||||
Each node requires: template_id, x, y. Optional: name (to set node name directly).
|
||||
Example input:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"nodes": [
|
||||
{
|
||||
"template_id": "uuid-of-template",
|
||||
"x": 100,
|
||||
"y": -200,
|
||||
"name": "R1"
|
||||
},
|
||||
{
|
||||
"template_id": "uuid-of-template2",
|
||||
"x": -200,
|
||||
"y": 300,
|
||||
"name": "R2"
|
||||
}
|
||||
]
|
||||
}
|
||||
IMPORTANT: Ensure distance between any two nodes is greater than 250 px.
|
||||
This spacing is necessary to display interface numbers clearly for better
|
||||
topology visualization.
|
||||
Returns a dictionary with creation results for all nodes, including
|
||||
success/failure status.
|
||||
If the operation fails during input validation, returns a dictionary with
|
||||
an error message.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Creates nodes in a GNS3 project with templates and coordinates.
|
||||
|
||||
Args:
|
||||
tool_input: A JSON string with project_id and an array of nodes.
|
||||
run_manager: LangChain run manager (unused).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with creation results for all nodes 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")
|
||||
nodes = input_data.get("nodes", [])
|
||||
|
||||
# Validate input
|
||||
if not project_id:
|
||||
logger.error("Invalid input: Missing project_id.")
|
||||
return {"error": "Missing project_id."}
|
||||
|
||||
if not isinstance(nodes, list) or len(nodes) == 0:
|
||||
logger.error("Invalid input: nodes must be a non-empty array.")
|
||||
return {"error": "nodes must be a non-empty array."}
|
||||
|
||||
# Validate each node in the array
|
||||
for i, node_data in enumerate(nodes):
|
||||
if not isinstance(node_data, dict):
|
||||
logger.error(
|
||||
"Invalid input: Node %d must be a dictionary.", i + 1
|
||||
)
|
||||
return {"error": f"Node {i + 1} must be a dictionary."}
|
||||
|
||||
template_id = node_data.get("template_id")
|
||||
x = node_data.get("x")
|
||||
y = node_data.get("y")
|
||||
name = node_data.get("name")
|
||||
|
||||
if not all(
|
||||
[
|
||||
template_id,
|
||||
isinstance(x, (int, float)),
|
||||
isinstance(y, (int, float)),
|
||||
]
|
||||
):
|
||||
logger.error(
|
||||
"Invalid input: Node %d missing or invalid "
|
||||
"template_id, x, or y.",
|
||||
i + 1,
|
||||
)
|
||||
return {
|
||||
"error": f"Node {i + 1} missing or invalid "
|
||||
f"template_id, x, or y."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Create nodes
|
||||
logger.info(
|
||||
"Creating %d nodes in project %s...", len(nodes), project_id
|
||||
)
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for i, node_data in enumerate(nodes):
|
||||
try:
|
||||
template_id = node_data.get("template_id")
|
||||
x = node_data.get("x")
|
||||
y = node_data.get("y")
|
||||
name = node_data.get("name")
|
||||
|
||||
logger.info(
|
||||
"Creating node %d/%d with template %s at (%s, %s), name=%s...",
|
||||
i + 1,
|
||||
len(nodes),
|
||||
template_id,
|
||||
x,
|
||||
y,
|
||||
name,
|
||||
)
|
||||
|
||||
# Create node
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
x=x,
|
||||
y=y,
|
||||
name=name,
|
||||
connector=gns3_server,
|
||||
)
|
||||
node.create()
|
||||
|
||||
# Retrieve node details
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name,
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
error_info = {
|
||||
"error": f"Node {i + 1} creation failed: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
results.append(error_info)
|
||||
logger.error("Failed to create node %d: %s", i + 1, e)
|
||||
# Continue with next node even if one fails
|
||||
|
||||
# Calculate summary statistics
|
||||
successful_nodes = len(
|
||||
[r for r in results if r.get("status") == "success"]
|
||||
)
|
||||
failed_nodes = len(
|
||||
[r for r in results if r.get("status") == "failed"]
|
||||
)
|
||||
|
||||
# Prepare final result
|
||||
final_result = {
|
||||
"project_id": project_id,
|
||||
"created_nodes": results,
|
||||
"total_nodes": len(nodes),
|
||||
"successful_nodes": successful_nodes,
|
||||
"failed_nodes": failed_nodes,
|
||||
}
|
||||
|
||||
# Log the final result
|
||||
logger.info(
|
||||
"Node creation completed: %d successful, %d failed, %d total.",
|
||||
successful_nodes,
|
||||
failed_nodes,
|
||||
len(nodes),
|
||||
)
|
||||
|
||||
# Return JSON-formatted result
|
||||
return final_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 node creation request: %s", e)
|
||||
return {
|
||||
"error": f"Failed to process node creation request: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool locally with multiple nodes
|
||||
test_input = json.dumps(
|
||||
{
|
||||
# TODO: Replace with actual project UUID
|
||||
"project_id": "d7fc094c-685e-4db1-ac11-5e33a1b2e066",
|
||||
"nodes": [
|
||||
{
|
||||
# TODO: Replace with actual template UUID
|
||||
"template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7",
|
||||
"x": 100,
|
||||
"y": -200,
|
||||
"name": "R1",
|
||||
},
|
||||
{
|
||||
# TODO: Replace with actual template UUID
|
||||
"template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7",
|
||||
"x": 200,
|
||||
"y": -300,
|
||||
"name": "R2",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
tool = GNS3CreateNodeTool()
|
||||
result = tool._run(test_input)
|
||||
pprint(result)
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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 template retrieval tool for device discovery.
|
||||
|
||||
Provides functionality to retrieve all available device templates
|
||||
from a GNS3 server, including template names, IDs, and types.
|
||||
Filters out built-in utility templates that are not useful for network labs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in templates to filter out (utility templates, not actual network devices)
|
||||
FILTERED_TEMPLATES = {
|
||||
"atm_switch", # ATM switch
|
||||
"cloud", # Cloud
|
||||
"ethernet_hub", # Ethernet hub
|
||||
"ethernet_switch", # Ethernet switch (built-in)
|
||||
"frame_relay_switch", # Frame Relay switch
|
||||
"nat", # NAT device
|
||||
}
|
||||
|
||||
|
||||
def should_filter_template(template: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Determine whether a template should be filtered out.
|
||||
|
||||
Filter condition:
|
||||
1. Template type is in the filter list (exact match)
|
||||
|
||||
Args:
|
||||
template: Template dictionary
|
||||
|
||||
Returns:
|
||||
True if template should be filtered, False if it should be kept
|
||||
"""
|
||||
template_type = template.get("template_type", "")
|
||||
|
||||
# Check if template_type is in filter list
|
||||
# This is the most reliable way to identify built-in utility templates
|
||||
return template_type in FILTERED_TEMPLATES
|
||||
|
||||
|
||||
class GNS3TemplateTool(BaseTool):
|
||||
"""
|
||||
LangChain tool to retrieve available device templates from GNS3 server.
|
||||
Connects to GNS3 server and extracts name, template_id, and template_type.
|
||||
Filters out built-in utility templates that are not useful for network labs.
|
||||
|
||||
**Input:**
|
||||
No input required. Connects to GNS3 server at default URL.
|
||||
|
||||
**Output:**
|
||||
Dict with list of dicts (name, template_id, template_type).
|
||||
If error, returns dict with error message.
|
||||
|
||||
**Filtered Templates:**
|
||||
The following built-in utility templates are excluded:
|
||||
- ATM switch
|
||||
- Cloud
|
||||
- Ethernet hub
|
||||
- Ethernet switch (built-in)
|
||||
- Frame Relay switch
|
||||
- NAT
|
||||
"""
|
||||
|
||||
name: str = "get_gns3_templates"
|
||||
description: str = """
|
||||
Retrieves available device templates from GNS3 server for network labs.
|
||||
Returns dict with list of dicts (name, template_id, template_type).
|
||||
Filters out built-in utility templates (ATM switch, Cloud, Ethernet hub,
|
||||
Ethernet switch, Frame Relay switch, NAT) as they are not useful for
|
||||
network device configuration.
|
||||
No input required.
|
||||
If connection fails, returns dict with error message.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str = "",
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Connects to GNS3 server and retrieves available device templates.
|
||||
|
||||
Args:
|
||||
tool_input: Optional input (not used).
|
||||
run_manager: LangChain run manager (unused).
|
||||
|
||||
Returns:
|
||||
dict: Dict with templates list or error message.
|
||||
"""
|
||||
try:
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": (
|
||||
"Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
)
|
||||
}
|
||||
|
||||
# Retrieve all available templates
|
||||
templates = gns3_server.get_templates()
|
||||
|
||||
# Filter out utility templates and extract relevant info
|
||||
template_info = []
|
||||
filtered_count = 0
|
||||
for template in templates:
|
||||
# Check if template should be filtered
|
||||
if should_filter_template(template):
|
||||
filtered_count += 1
|
||||
logger.debug("Filtered out template: %s", template.get("name"))
|
||||
continue
|
||||
|
||||
# Extract name, template_id, and template_type
|
||||
template_info.append({
|
||||
"name": template.get("name", "N/A"),
|
||||
"template_id": template.get("template_id", "N/A"),
|
||||
"template_type": template.get("template_type", "N/A"),
|
||||
})
|
||||
|
||||
# Return JSON-formatted result with full logging
|
||||
result = {"templates": template_info}
|
||||
logger.info(
|
||||
"Template retrieval completed. Total: %d, Filtered: %d, Remaining: %d",
|
||||
len(templates),
|
||||
filtered_count,
|
||||
len(template_info),
|
||||
)
|
||||
logger.debug("Result: %s", json.dumps(result, indent=2, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to connect to GNS3 server or retrieve templates: %s", e
|
||||
)
|
||||
return {"error": f"Failed to retrieve templates: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test's tool locally
|
||||
tool = GNS3TemplateTool()
|
||||
result = tool._run("")
|
||||
pprint(result)
|
||||
@@ -0,0 +1,544 @@
|
||||
# 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 node startup tool for network device activation.
|
||||
|
||||
Provides functionality to start one or multiple nodes in GNS3 projects
|
||||
with progress tracking and status monitoring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
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 import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Node startup time configuration by device type
|
||||
# Based on typical boot times for different emulators
|
||||
# Conservative timing to account for slower hardware environments
|
||||
NODE_STARTUP_TIME = {
|
||||
"vpcs": {"base": 15, "extra_per_node": 2}, # VPCS: Very fast startup
|
||||
"iou": {"base": 25, "extra_per_node": 3}, # IOU: Fast startup
|
||||
"default": {"base": 120, "extra_per_node": 10}, # Other devices: Conservative time
|
||||
}
|
||||
|
||||
|
||||
def calculate_startup_time(nodes: list) -> int:
|
||||
"""
|
||||
Calculate startup wait time based on node types.
|
||||
|
||||
Strategy:
|
||||
- If all nodes are fast devices (VPCS/IOU): use fast startup time
|
||||
- If any node is a slow device: use conservative startup time
|
||||
|
||||
Args:
|
||||
nodes: List of node objects with node_type attribute
|
||||
|
||||
Returns:
|
||||
Calculated wait time in seconds
|
||||
"""
|
||||
if not nodes:
|
||||
return 60 # Default: 60 seconds for empty list
|
||||
|
||||
# Get all node types
|
||||
node_types = [getattr(node, "node_type", "default") for node in nodes]
|
||||
|
||||
# Check if all nodes are fast startup devices (VPCS or IOU)
|
||||
fast_types = {"vpcs", "iou"}
|
||||
all_fast = all(node_type in fast_types for node_type in node_types)
|
||||
|
||||
if all_fast:
|
||||
# Use fast startup time: base + (count - 1) * extra_per_node
|
||||
# Use the largest base time among the fast devices
|
||||
max_fast_base = max(
|
||||
NODE_STARTUP_TIME[nt]["base"]
|
||||
for nt in node_types if nt in fast_types
|
||||
)
|
||||
# Use the smallest extra_per_node among the fast devices
|
||||
min_fast_extra = min(
|
||||
NODE_STARTUP_TIME[nt]["extra_per_node"]
|
||||
for nt in node_types if nt in fast_types
|
||||
)
|
||||
total_time = max_fast_base + (len(nodes) - 1) * min_fast_extra
|
||||
logger.info(
|
||||
"All fast devices detected (%s), using fast startup time: %ds",
|
||||
node_types,
|
||||
total_time
|
||||
)
|
||||
return total_time
|
||||
else:
|
||||
# Use conservative startup time for mixed or slow devices
|
||||
config = NODE_STARTUP_TIME["default"]
|
||||
total_time = config["base"] + (len(nodes) - 1) * config["extra_per_node"]
|
||||
logger.info(
|
||||
"Mixed or slow devices detected (%s), using conservative startup time: %ds",
|
||||
node_types,
|
||||
total_time
|
||||
)
|
||||
return total_time
|
||||
|
||||
|
||||
def show_progress_bar(
|
||||
duration: int = 120, interval: int = 1, node_count: int = 1
|
||||
) -> None:
|
||||
"""
|
||||
Display a simple text progress bar for node startup.
|
||||
|
||||
Args:
|
||||
duration: Total duration of the progress bar in seconds
|
||||
interval: Update interval in seconds
|
||||
node_count: Number of nodes being started
|
||||
"""
|
||||
print(f"Starting {node_count} node(s), please wait...")
|
||||
for elapsed in range(duration):
|
||||
# Calculate progress percentage
|
||||
progress = (elapsed + 1) / duration * 100
|
||||
|
||||
# Create progress bar display
|
||||
bar_length = 30
|
||||
filled_length = int(bar_length * elapsed // duration)
|
||||
progress_string = (
|
||||
"=" * filled_length + ">" + " " * (bar_length - filled_length - 1)
|
||||
)
|
||||
|
||||
# Print progress bar with node count
|
||||
print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True)
|
||||
time.sleep(interval)
|
||||
|
||||
print(f"\n{node_count} node(s) startup completed!")
|
||||
|
||||
|
||||
class GNS3StartNodeTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to start one or multiple nodes in a GNS3 project.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id and node_ids (list of node IDs).
|
||||
Example:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
|
||||
}
|
||||
|
||||
**Output**:
|
||||
A dictionary with all nodes' details:
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{"node_id": "...", "name": "...", "status": "..."},
|
||||
{"node_id": "...", "name": "...", "status": "..."}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
name: str = "start_gns3_node"
|
||||
description: str = """
|
||||
Starts one or multiple nodes in a GNS3 project.
|
||||
Input: JSON with project_id and node_ids (list of node IDs).
|
||||
Returns: A dict with all nodes' details (success/failure status).
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
node_ids = input_data.get("node_ids")
|
||||
|
||||
# Validate input
|
||||
if not project_id or not node_ids:
|
||||
logger.error(
|
||||
"Missing required fields: project_id or node_ids."
|
||||
)
|
||||
return {
|
||||
"error": "Missing required fields: "
|
||||
"project_id and node_ids."
|
||||
}
|
||||
|
||||
if not isinstance(node_ids, list):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server 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
|
||||
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:
|
||||
logger.error(
|
||||
"Failed to get node info for %s: %s",
|
||||
node_id,
|
||||
e,
|
||||
)
|
||||
|
||||
# Calculate startup time based on node types
|
||||
wait_time = calculate_startup_time(nodes)
|
||||
|
||||
# Send start commands for all nodes
|
||||
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:
|
||||
logger.error(
|
||||
"Failed to send start command for node %s: %s",
|
||||
node.node_id,
|
||||
e,
|
||||
)
|
||||
|
||||
# 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
|
||||
results = []
|
||||
logger.info("Retrieving status for %d nodes...", len(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",
|
||||
}
|
||||
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}
|
||||
for node_id in node_ids:
|
||||
if node_id not in retrieved_node_ids:
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found during info retrieval",
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results
|
||||
successful_nodes = [
|
||||
r for r in results if r.get("status") != "error"
|
||||
]
|
||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||
|
||||
# Construct final response
|
||||
response = {
|
||||
"project_id": project_id,
|
||||
"total_nodes": len(node_ids),
|
||||
"successful": len(successful_nodes),
|
||||
"failed": len(failed_nodes),
|
||||
"nodes": results,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Start operation completed: %d successful, %d failed",
|
||||
len(successful_nodes),
|
||||
len(failed_nodes),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
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 start nodes: %s", e)
|
||||
return {"error": f"Failed to start nodes: {str(e)}"}
|
||||
|
||||
|
||||
class GNS3StartNodeQuickTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to start nodes in a GNS3 project WITHOUT waiting.
|
||||
|
||||
This tool sends start commands to all nodes and immediately returns status,
|
||||
without blocking for startup completion. Suitable for automated deployment
|
||||
workflows where long waits would cause HTTP timeouts.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id and node_ids (list of node IDs).
|
||||
Example:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
|
||||
}
|
||||
|
||||
**Output**:
|
||||
A dict with nodes' details immediately after sending start commands:
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{"node_id": "...", "name": "...", "status": "started"},
|
||||
{"node_id": "...", "name": "...", "status": "started"}
|
||||
],
|
||||
"note": "Start commands sent. Nodes are booting in background."
|
||||
}
|
||||
"""
|
||||
|
||||
name: str = "start_gns3_node_quick"
|
||||
description: str = """
|
||||
Starts nodes in a GNS3 project WITHOUT waiting for startup completion.
|
||||
Use this for automated deployments to avoid HTTP timeouts.
|
||||
Input: JSON with project_id and node_ids (list of node IDs).
|
||||
Returns: Dict with nodes' details after start commands are sent.
|
||||
NOTE: Nodes will continue booting in background after this tool returns.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
node_ids = input_data.get("node_ids")
|
||||
|
||||
# Validate input
|
||||
if not project_id or not node_ids:
|
||||
logger.error(
|
||||
"Missing required fields: project_id or node_ids."
|
||||
)
|
||||
return {
|
||||
"error": "Missing required fields: "
|
||||
"project_id and node_ids."
|
||||
}
|
||||
|
||||
if not isinstance(node_ids, list):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server 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
|
||||
logger.info(
|
||||
"Sending start commands for %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
# 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),
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results (count based on successful command sending)
|
||||
successful_nodes = [
|
||||
r for r in results if r.get("status") != "error"
|
||||
]
|
||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||
|
||||
# Construct final response
|
||||
response = {
|
||||
"project_id": project_id,
|
||||
"total_nodes": len(node_ids),
|
||||
"successful": len(successful_nodes),
|
||||
"failed": len(failed_nodes),
|
||||
"nodes": results,
|
||||
"note": (
|
||||
"Start commands sent. Nodes are booting in background. "
|
||||
"Check node status later."
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Quick start commands sent: %d successful, %d failed",
|
||||
len(successful_nodes),
|
||||
len(failed_nodes),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
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 start nodes: %s", e)
|
||||
return {"error": f"Failed to start nodes: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with single node
|
||||
print("=== Testing single node startup ===")
|
||||
test_input_single = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
||||
], # Replace with actual node UUID
|
||||
}
|
||||
)
|
||||
tool = GNS3StartNodeTool()
|
||||
result_single = tool._run(test_input_single)
|
||||
pprint(result_single)
|
||||
|
||||
# Test with multiple nodes
|
||||
print("\n=== Testing multiple nodes startup ===")
|
||||
test_input_multiple = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90",
|
||||
# Replace with actual node UUIDs
|
||||
"another-node-uuid-here",
|
||||
"third-node-uuid-here",
|
||||
],
|
||||
}
|
||||
)
|
||||
result_multiple = tool._run(test_input_multiple)
|
||||
pprint(result_multiple)
|
||||
@@ -0,0 +1,238 @@
|
||||
# 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 node stop tool for network device shutdown.
|
||||
|
||||
Provides functionality to stop one or multiple nodes in GNS3 projects.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3StopNodeTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to stop one or multiple nodes in a GNS3 project.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id and node_ids (list of node IDs).
|
||||
Example:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
|
||||
}
|
||||
|
||||
**Output**:
|
||||
A dictionary with all nodes' details:
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{"node_id": "...", "name": "...", "status": "stopped"},
|
||||
{"node_id": "...", "name": "...", "status": "stopped"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
name: str = "stop_gns3_node"
|
||||
description: str = """
|
||||
Stops one or multiple nodes in a GNS3 project.
|
||||
Input: JSON with project_id and node_ids (list of node IDs).
|
||||
Returns: A dict with all nodes' details (success/failure status).
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
node_ids = input_data.get("node_ids")
|
||||
|
||||
# Validate input
|
||||
if not project_id or not node_ids:
|
||||
logger.error(
|
||||
"Missing required fields: project_id or node_ids."
|
||||
)
|
||||
return {
|
||||
"error": "Missing required fields: "
|
||||
"project_id and node_ids."
|
||||
}
|
||||
|
||||
if not isinstance(node_ids, list):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server 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
|
||||
logger.info(
|
||||
"Stopping %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
# 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),
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results
|
||||
successful_nodes = [
|
||||
r for r in results if r.get("status") != "error"
|
||||
]
|
||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||
|
||||
# Construct final response
|
||||
response = {
|
||||
"project_id": project_id,
|
||||
"total_nodes": len(node_ids),
|
||||
"successful": len(successful_nodes),
|
||||
"failed": len(failed_nodes),
|
||||
"nodes": results,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Stop operation completed: %d successful, %d failed",
|
||||
len(successful_nodes),
|
||||
len(failed_nodes),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
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 stop nodes: %s", e)
|
||||
return {"error": f"Failed to stop nodes: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with single node
|
||||
print("=== Testing single node stop ===")
|
||||
test_input_single = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
||||
], # Replace with actual node UUID
|
||||
}
|
||||
)
|
||||
tool = GNS3StopNodeTool()
|
||||
result_single = tool._run(test_input_single)
|
||||
pprint(result_single)
|
||||
|
||||
# Test with multiple nodes
|
||||
print("\n=== Testing multiple nodes stop ===")
|
||||
test_input_multiple = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90",
|
||||
# Replace with actual node UUIDs
|
||||
"another-node-uuid-here",
|
||||
"third-node-uuid-here",
|
||||
],
|
||||
}
|
||||
)
|
||||
result_multiple = tool._run(test_input_multiple)
|
||||
pprint(result_multiple)
|
||||
@@ -0,0 +1,248 @@
|
||||
# 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 node suspend tool for pausing network devices.
|
||||
|
||||
Provides functionality to suspend one or multiple nodes in GNS3 projects.
|
||||
Suspended nodes preserve their state in memory and can be quickly resumed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3SuspendNodeTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to suspend one or multiple nodes in a GNS3 project.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id and node_ids (list of node IDs).
|
||||
Example:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"node_ids": ["uuid-of-node-1", "uuid-of-node-2"]
|
||||
}
|
||||
|
||||
**Output**:
|
||||
A dictionary with all nodes' details:
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{"node_id": "...", "name": "...", "status": "suspended"},
|
||||
{"node_id": "...", "name": "...", "status": "suspended"}
|
||||
]
|
||||
}
|
||||
|
||||
**Note**: Suspended nodes preserve their state in memory and can be quickly
|
||||
resumed. Use this when you want to pause a lab and continue later.
|
||||
"""
|
||||
|
||||
name: str = "suspend_gns3_node"
|
||||
description: str = """
|
||||
Suspends one or multiple nodes in a GNS3 project.
|
||||
Suspended nodes preserve their state and can be quickly resumed.
|
||||
Use this to pause a lab temporarily and continue later.
|
||||
Input: JSON with project_id and node_ids (list of node IDs).
|
||||
Returns: A dict with all nodes' details (success/failure status).
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
node_ids = input_data.get("node_ids")
|
||||
|
||||
# Validate input
|
||||
if not project_id or not node_ids:
|
||||
logger.error(
|
||||
"Missing required fields: project_id or node_ids."
|
||||
)
|
||||
return {
|
||||
"error": "Missing required fields: "
|
||||
"project_id and node_ids."
|
||||
}
|
||||
|
||||
if not isinstance(node_ids, list):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server 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
|
||||
logger.info(
|
||||
"Suspending %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
# 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),
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results
|
||||
successful_nodes = [
|
||||
r for r in results if r.get("status") != "error"
|
||||
]
|
||||
failed_nodes = [r for r in results if r.get("status") == "error"]
|
||||
|
||||
# Construct final response
|
||||
response = {
|
||||
"project_id": project_id,
|
||||
"total_nodes": len(node_ids),
|
||||
"successful": len(successful_nodes),
|
||||
"failed": len(failed_nodes),
|
||||
"nodes": results,
|
||||
"note": (
|
||||
"Suspended nodes preserve their state in memory. "
|
||||
"Use resume to continue where you left off."
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Suspend operation completed: %d successful, %d failed",
|
||||
len(successful_nodes),
|
||||
len(failed_nodes),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
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 suspend nodes: %s", e)
|
||||
return {"error": f"Failed to suspend nodes: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with single node
|
||||
print("=== Testing single node suspend ===")
|
||||
test_input_single = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90"
|
||||
], # Replace with actual node UUID
|
||||
}
|
||||
)
|
||||
tool = GNS3SuspendNodeTool()
|
||||
result_single = tool._run(test_input_single)
|
||||
pprint(result_single)
|
||||
|
||||
# Test with multiple nodes
|
||||
print("\n=== Testing multiple nodes suspend ===")
|
||||
test_input_multiple = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
|
||||
"node_ids": [
|
||||
"fbeda109-9a74-4d8c-a749-cc3847911a90",
|
||||
# Replace with actual node UUIDs
|
||||
"another-node-uuid-here",
|
||||
"third-node-uuid-here",
|
||||
],
|
||||
}
|
||||
)
|
||||
result_multiple = tool._run(test_input_multiple)
|
||||
pprint(result_multiple)
|
||||
@@ -0,0 +1,277 @@
|
||||
# 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 node name update tool for renaming network devices.
|
||||
|
||||
Provides functionality to update the name of one or multiple nodes
|
||||
in GNS3 projects.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3UpdateNodeNameTool(BaseTool):
|
||||
"""
|
||||
A LangChain tool to update the name of one or multiple nodes
|
||||
in a GNS3 project.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id and nodes array containing
|
||||
node_id and new_name.
|
||||
Example:
|
||||
{
|
||||
"project_id": "uuid-of-project",
|
||||
"nodes": [
|
||||
{"node_id": "uuid-of-node-1", "new_name": "Router1"},
|
||||
{"node_id": "uuid-of-node-2", "new_name": "Switch1"}
|
||||
]
|
||||
}
|
||||
|
||||
**Output**:
|
||||
A dictionary with all nodes' update results:
|
||||
{
|
||||
"project_id": "...",
|
||||
"total_nodes": 2,
|
||||
"successful": 2,
|
||||
"failed": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "...",
|
||||
"old_name": "...",
|
||||
"new_name": "...",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"node_id": "...",
|
||||
"old_name": "...",
|
||||
"new_name": "...",
|
||||
"status": "success"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
name: str = "update_gns3_node_name"
|
||||
description: str = """
|
||||
Updates the name of one or multiple nodes in a GNS3 project.
|
||||
Input: JSON with project_id and nodes array.
|
||||
Each node must have node_id and new_name.
|
||||
Returns: A dictionary with all nodes' update results
|
||||
including success/failure status.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Parse input JSON
|
||||
input_data = json.loads(tool_input)
|
||||
project_id = input_data.get("project_id")
|
||||
nodes = input_data.get("nodes", [])
|
||||
|
||||
# Validate input
|
||||
if not project_id:
|
||||
logger.error("Missing required field: project_id.")
|
||||
return {"error": "Missing required field: project_id."}
|
||||
|
||||
if not isinstance(nodes, list) or len(nodes) == 0:
|
||||
logger.error("nodes must be a non-empty list.")
|
||||
return {"error": "nodes must be a non-empty list."}
|
||||
|
||||
# Validate each node entry
|
||||
for i, node_data in enumerate(nodes):
|
||||
if not isinstance(node_data, dict):
|
||||
logger.error("Node %d must be a dictionary.", i + 1)
|
||||
return {"error": f"Node {i + 1} must be a dictionary."}
|
||||
if "node_id" not in node_data or "new_name" not in node_data:
|
||||
logger.error("Node %d missing node_id or new_name.", i + 1)
|
||||
return {
|
||||
"error": f"Node {i + 1} missing node_id or new_name."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
|
||||
if gns3_server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Update node names
|
||||
logger.info(
|
||||
"Updating names for %d nodes in project %s...",
|
||||
len(nodes),
|
||||
project_id,
|
||||
)
|
||||
results = []
|
||||
|
||||
for i, node_data in enumerate(nodes):
|
||||
try:
|
||||
node_id = node_data.get("node_id")
|
||||
new_name = node_data.get("new_name")
|
||||
|
||||
logger.info(
|
||||
"Updating node %d/%d: %s -> %s",
|
||||
i + 1,
|
||||
len(nodes),
|
||||
node_id,
|
||||
new_name,
|
||||
)
|
||||
|
||||
# Get node to retrieve current name
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
node.get()
|
||||
old_name = node.name
|
||||
|
||||
# Update node name
|
||||
node.update(name=new_name)
|
||||
|
||||
# Verify update
|
||||
node.get()
|
||||
if node.name == new_name:
|
||||
node_info = {
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
"new_name": new_name,
|
||||
"status": "success",
|
||||
}
|
||||
results.append(node_info)
|
||||
logger.info(
|
||||
"Successfully updated node name: %s -> %s",
|
||||
old_name,
|
||||
new_name,
|
||||
)
|
||||
else:
|
||||
error_info = {
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
"new_name": new_name,
|
||||
"current_name": node.name,
|
||||
"status": "failed",
|
||||
"error": "Name verification failed",
|
||||
}
|
||||
results.append(error_info)
|
||||
logger.error(
|
||||
"Failed to update node name for %s", node_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_info = {
|
||||
"node_id": node_data.get("node_id"),
|
||||
"new_name": node_data.get("new_name"),
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
}
|
||||
results.append(error_info)
|
||||
logger.error("Failed to update node %d: %s", i + 1, e)
|
||||
|
||||
# Analyze results
|
||||
successful_nodes = [
|
||||
r for r in results if r.get("status") == "success"
|
||||
]
|
||||
failed_nodes = [r for r in results if r.get("status") == "failed"]
|
||||
|
||||
# Construct final response
|
||||
response = {
|
||||
"project_id": project_id,
|
||||
"total_nodes": len(nodes),
|
||||
"successful": len(successful_nodes),
|
||||
"failed": len(failed_nodes),
|
||||
"nodes": results,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Name update completed: %d successful, %d failed",
|
||||
len(successful_nodes),
|
||||
len(failed_nodes),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
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 update node names: %s", e)
|
||||
return {"error": f"Failed to update node names: {str(e)}"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test with single node
|
||||
print("=== Testing single node name update ===")
|
||||
test_input_single = json.dumps(
|
||||
{
|
||||
# Replace with actual project UUID
|
||||
"project_id": "your-project-uuid",
|
||||
"nodes": [
|
||||
{
|
||||
# Replace with actual node UUID
|
||||
"node_id": "your-node-uuid",
|
||||
"new_name": "Router1",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
tool = GNS3UpdateNodeNameTool()
|
||||
result_single = tool._run(test_input_single)
|
||||
pprint(result_single)
|
||||
|
||||
# Test with multiple nodes
|
||||
print("\n=== Testing multiple nodes name update ===")
|
||||
test_input_multiple = json.dumps(
|
||||
{
|
||||
# Replace with actual project UUID
|
||||
"project_id": "your-project-uuid",
|
||||
"nodes": [
|
||||
{"node_id": "node-uuid-1", "new_name": "Router1"},
|
||||
{"node_id": "node-uuid-2", "new_name": "Switch1"},
|
||||
{"node_id": "node-uuid-3", "new_name": "PC1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result_multiple = tool._run(test_input_multiple)
|
||||
pprint(result_multiple)
|
||||
@@ -0,0 +1,496 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Protocol-Oriented Packet Analysis Tool
|
||||
|
||||
Analyzes packets from an active GNS3 capture using tshark with user-provided arguments.
|
||||
The LLM constructs tshark commands based on protocol knowledge from packet analysis skills.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
get_current_jwt_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache of valid tshark field names, loaded lazily from `tshark -G fields`
|
||||
_tshark_valid_fields: set | None = None
|
||||
|
||||
|
||||
class PacketAnalysisTool(BaseTool):
|
||||
"""
|
||||
LangChain tool for analyzing packets using tshark with user-provided arguments.
|
||||
|
||||
The LLM constructs tshark command arguments based on protocol knowledge
|
||||
from packet analysis skills (available_fields, base_filter, etc.).
|
||||
|
||||
Input:
|
||||
project_id (str, required): UUID of the GNS3 project
|
||||
link_id (str, required): UUID of the link to analyze
|
||||
tshark_args (str, required): tshark command arguments (after '-r <pcap>')
|
||||
|
||||
Output:
|
||||
str: tshark output in text format (tab-separated fields or JSON)
|
||||
"""
|
||||
|
||||
name: str = "packet_analysis"
|
||||
description: str = """
|
||||
Analyze packets from an active GNS3 capture using tshark.
|
||||
|
||||
Use this to analyze captured packets when diagnosing network issues.
|
||||
|
||||
**Before calling this tool**, use `search_fields` action to look up
|
||||
valid tshark field names from the live tshark field registry, or query
|
||||
`get_packet_analysis_protocol` for protocol-level skill definitions.
|
||||
Construct `tshark_args` using accurate field names.
|
||||
|
||||
Input (JSON format) — capture analysis mode:
|
||||
- project_id (str, required): UUID of the GNS3 project
|
||||
- link_id (str, required): UUID of the link to analyze
|
||||
- tshark_args (str, required): tshark command arguments (after '-r <pcap>')
|
||||
|
||||
Input (JSON format) — field search mode (no capture needed):
|
||||
- action (str): "search_fields"
|
||||
- query (str): Single keyword, e.g. "ospf.lsa", "bgp.open", "bgp.type"
|
||||
Use dot-notation like "protocol.field" for precise results.
|
||||
|
||||
Common tshark argument patterns:
|
||||
-Y "<filter>" Display filter
|
||||
-T fields Tab-separated field output
|
||||
-e <field> Extract field (use multiple -e for multiple fields)
|
||||
-T json JSON output
|
||||
-c <count> Limit packets READ (not matched count). Since -c limits
|
||||
total packets scanned, it can hide results when used
|
||||
with -Y. Prefer piping to head -10 or rely on -Y
|
||||
alone instead.
|
||||
|
||||
Instead of analysis, you can search tshark's field registry directly for
|
||||
valid field names:
|
||||
{"action": "search_fields", "query": "ospf.lsa"}
|
||||
{"action": "search_fields", "query": "bgp.open"}
|
||||
Uses literal substring matching (not regex). Only one keyword allowed.
|
||||
Use dot-notation like "ospf", "ospf.lsa", "bgp.peer" for precise results.
|
||||
Returns matching field names, types, and descriptions. Use this to find
|
||||
correct -e field names before building tshark_args.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _search_fields(cls, query: str) -> str:
|
||||
"""
|
||||
Search tshark field registry via `tshark -G fields | grep -i <query>`.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
|
||||
Returns:
|
||||
JSON string with matching fields.
|
||||
"""
|
||||
try:
|
||||
if not query or not query.strip():
|
||||
return json.dumps({"error": "query is required"})
|
||||
|
||||
query = query.strip()
|
||||
if len(query.split()) > 1:
|
||||
return json.dumps({
|
||||
"error": "Only one keyword allowed",
|
||||
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
|
||||
})
|
||||
|
||||
tshark = subprocess.Popen(
|
||||
["tshark", "-G", "fields"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
||||
)
|
||||
grep = subprocess.Popen(
|
||||
["grep", "-iF", query, "-"],
|
||||
stdin=tshark.stdout, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, text=True,
|
||||
)
|
||||
tshark.stdout.close()
|
||||
stdout, _ = grep.communicate(timeout=30)
|
||||
tshark.wait(timeout=5)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"tshark query failed: {e}"})
|
||||
|
||||
results = []
|
||||
seen = set()
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3 and parts[0] == "F":
|
||||
name = parts[2]
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
results.append({
|
||||
"field": name,
|
||||
"type": parts[3] if len(parts) > 3 else "",
|
||||
"protocol": parts[4] if len(parts) > 4 else "",
|
||||
"description": parts[7] if len(parts) > 7 else "",
|
||||
})
|
||||
if len(results) >= 40:
|
||||
break
|
||||
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"count": len(results),
|
||||
"fields": results,
|
||||
"hint": "Use the 'field' values in tshark_args with -e",
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def _load_valid_tshark_fields(cls) -> set:
|
||||
"""
|
||||
Load and cache valid tshark field names from `tshark -G fields`.
|
||||
|
||||
Returns:
|
||||
Set of valid field names, or empty set on failure.
|
||||
"""
|
||||
global _tshark_valid_fields
|
||||
if _tshark_valid_fields is not None:
|
||||
return _tshark_valid_fields
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tshark", "-G", "fields"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
fields = set()
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
fields.add(parts[2])
|
||||
_tshark_valid_fields = fields
|
||||
logger.debug(f"Loaded {len(fields)} valid tshark field names")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load tshark field names: {e}")
|
||||
_tshark_valid_fields = set()
|
||||
|
||||
return _tshark_valid_fields
|
||||
|
||||
def _validate_tshark_args(self, tshark_args: str) -> str | None:
|
||||
"""
|
||||
Validate tshark arguments before execution.
|
||||
|
||||
Checks `-e` field names against the tshark field registry.
|
||||
Returns an error JSON string if invalid, or None if valid.
|
||||
|
||||
Args:
|
||||
tshark_args: tshark command arguments string
|
||||
|
||||
Returns:
|
||||
Error JSON string, or None if validation passes.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
args = shlex.split(tshark_args)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": f"Invalid tshark_args syntax: {e}"})
|
||||
|
||||
valid_fields = self._load_valid_tshark_fields()
|
||||
if not valid_fields:
|
||||
return None
|
||||
|
||||
invalid_fields = []
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "-e" and i + 1 < len(args):
|
||||
field = args[i + 1]
|
||||
if field not in valid_fields:
|
||||
invalid_fields.append(field)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if invalid_fields:
|
||||
return json.dumps({
|
||||
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
|
||||
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
|
||||
"invalid_fields": invalid_fields,
|
||||
})
|
||||
|
||||
return None
|
||||
|
||||
def _run(
|
||||
self,
|
||||
project_id: str | None = None,
|
||||
link_id: str | None = None,
|
||||
tshark_args: str | None = None,
|
||||
action: str | None = None,
|
||||
query: str | None = None,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Analyze packets using tshark with user-provided arguments.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the GNS3 project
|
||||
link_id: UUID of the link to analyze
|
||||
tshark_args: tshark command arguments (after '-r <pcap>')
|
||||
action: "search_fields" to query tshark field registry instead
|
||||
query: Search term for action="search_fields" (e.g., "ospf")
|
||||
run_manager: LangChain run manager (unused)
|
||||
|
||||
Returns:
|
||||
str: tshark output
|
||||
"""
|
||||
# Field search mode: query tshark directly without capture
|
||||
if action == "search_fields":
|
||||
return self._search_fields(query or "")
|
||||
|
||||
logger.info(
|
||||
f"PacketAnalysisTool invoked: project_id={project_id}, "
|
||||
f"link_id={link_id}, tshark_args={tshark_args}"
|
||||
)
|
||||
|
||||
# Validate inputs
|
||||
if not project_id:
|
||||
return '{"error": "project_id is required"}'
|
||||
if not link_id:
|
||||
return '{"error": "link_id is required"}'
|
||||
if not tshark_args or not tshark_args.strip():
|
||||
return '{"error": "tshark_args is required"}'
|
||||
|
||||
# Pre-validate tshark field names before downloading capture
|
||||
validation_error = self._validate_tshark_args(tshark_args)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
temp_file = None
|
||||
try:
|
||||
# Download capture file
|
||||
temp_file = self._download_capture(project_id, link_id)
|
||||
if not temp_file:
|
||||
return '{"error": "Failed to download capture file"}'
|
||||
|
||||
# Check if file exists and has content
|
||||
if not os.path.exists(temp_file):
|
||||
return '{"error": "Capture file not found"}'
|
||||
|
||||
file_size = os.path.getsize(temp_file)
|
||||
if file_size == 0:
|
||||
return '{"error": "Capture file is empty, no packets captured yet"}'
|
||||
|
||||
logger.info(f"Capture file downloaded: {temp_file}, size={file_size} bytes")
|
||||
|
||||
# Run tshark with user-provided arguments
|
||||
result = self._run_tshark(temp_file, tshark_args)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PacketAnalysisTool error: {e}", exc_info=True)
|
||||
return f'{{"error": "Analysis failed: {str(e)}"}}'
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
if temp_file and os.path.exists(temp_file):
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
logger.debug(f"Temporary file removed: {temp_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove temp file: {e}")
|
||||
|
||||
def _download_capture(self, project_id: str, link_id: str) -> str | None:
|
||||
"""
|
||||
Download capture file from GNS3 server.
|
||||
|
||||
Args:
|
||||
project_id: Project UUID
|
||||
link_id: Link UUID
|
||||
|
||||
Returns:
|
||||
str: Path to temporary capture file, or None on failure
|
||||
"""
|
||||
jwt_token = get_current_jwt_token()
|
||||
if not jwt_token:
|
||||
logger.error("JWT token not found in context")
|
||||
return None
|
||||
|
||||
# Detect GNS3 server URL
|
||||
url = self._detect_gns3_url()
|
||||
if not url:
|
||||
return None
|
||||
|
||||
capture_url = f"{url}/v3/projects/{project_id}/links/{link_id}/capture/file"
|
||||
logger.info(f"Downloading capture from: {capture_url}")
|
||||
|
||||
# Create temp file
|
||||
temp_fd, temp_file = tempfile.mkstemp(suffix=".pcap", prefix="gns3_capture_")
|
||||
os.close(temp_fd)
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||||
# Use verify=False for HTTPS to skip certificate validation (for self-signed certs)
|
||||
verify_cert = not capture_url.startswith("https://")
|
||||
response = requests.get(
|
||||
capture_url,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=30,
|
||||
verify=verify_cert,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to download capture: HTTP {response.status_code}")
|
||||
os.remove(temp_file)
|
||||
return None
|
||||
|
||||
# Write to temp file
|
||||
with open(temp_file, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"Capture file saved: {temp_file}, size={os.path.getsize(temp_file)} bytes")
|
||||
return temp_file
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download capture: {e}", exc_info=True)
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
return None
|
||||
|
||||
def _detect_gns3_url(self) -> str | None:
|
||||
"""
|
||||
Detect GNS3 server URL from Controller or Config.
|
||||
|
||||
Returns:
|
||||
str: GNS3 server URL, or None on failure
|
||||
"""
|
||||
try:
|
||||
from gns3server.controller import Controller
|
||||
|
||||
controller = Controller.instance()
|
||||
local_compute = controller.get_compute("local")
|
||||
url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
|
||||
logger.debug(f"Detected GNS3 URL from Controller: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.debug(f"Cannot get URL from Controller: {e}")
|
||||
|
||||
try:
|
||||
from gns3server.config import Config
|
||||
|
||||
server_config = Config.instance().settings.Server
|
||||
url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
|
||||
logger.debug(f"Detected GNS3 URL from Config: {url}")
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.debug(f"Cannot get URL from Config: {e}")
|
||||
|
||||
# Fallback default
|
||||
default_url = "http://127.0.0.1:3080"
|
||||
logger.warning(f"Using fallback default URL: {default_url}")
|
||||
return default_url
|
||||
|
||||
def _run_tshark(self, pcap_file: str, tshark_args: str) -> str:
|
||||
"""
|
||||
Run tshark with user-provided arguments.
|
||||
|
||||
Args:
|
||||
pcap_file: Path to the capture file
|
||||
tshark_args: tshark command arguments (after '-r <pcap>')
|
||||
|
||||
Returns:
|
||||
str: tshark output
|
||||
"""
|
||||
# Build command: tshark -r <file> <user_args>
|
||||
import shlex
|
||||
cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
|
||||
|
||||
logger.info(f"Running tshark: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
stderr = result.stderr.strip() if result.stderr else ""
|
||||
|
||||
# Surface tshark field/filter errors to the LLM
|
||||
if stderr and "tshark:" in stderr.lower():
|
||||
stderr_clean = stderr.replace("tshark: ", "")
|
||||
hints = [
|
||||
f"tshark reported: {stderr_clean}",
|
||||
]
|
||||
if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower():
|
||||
hints.append(
|
||||
'Use {"action": "search_fields", "query": "<protocol>"} '
|
||||
"to find correct field names."
|
||||
)
|
||||
logger.warning(f"tshark stderr: {stderr}")
|
||||
return json.dumps({"error": "tshark argument error", "hints": hints})
|
||||
|
||||
if stderr:
|
||||
logger.warning(f"tshark stderr: {stderr}")
|
||||
|
||||
if not output.strip():
|
||||
hints = []
|
||||
if "-c" in tshark_args:
|
||||
hints.append(
|
||||
"The -c flag limits total packets READ, not matched results. "
|
||||
'Try removing -c and pipe to "head -N" instead.'
|
||||
)
|
||||
hints.append(
|
||||
'Use {"action": "search_fields", "query": "<protocol>"} to '
|
||||
"verify your display filter and field names are correct."
|
||||
)
|
||||
return json.dumps({"result": "No matching packets found", "hints": hints})
|
||||
|
||||
logger.info(f"tshark output: {len(output)} characters")
|
||||
return output
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("tshark timeout after 30 seconds")
|
||||
return '{"error": "tshark timeout after 30 seconds"}'
|
||||
except FileNotFoundError:
|
||||
logger.error("tshark not found. Please install tshark: apt install tshark")
|
||||
return '{"error": "tshark not installed. Please install tshark: apt install tshark"}'
|
||||
except Exception as e:
|
||||
logger.error(f"tshark execution error: {e}", exc_info=True)
|
||||
return f'{{"error": "tshark failed: {str(e)}"}}'
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool
|
||||
tool = PacketAnalysisTool()
|
||||
print("Testing PacketAnalysisTool...")
|
||||
print("Note: Set project_id, link_id and tshark_args to test with actual GNS3 capture")
|
||||
print(tool.description)
|
||||
@@ -0,0 +1,612 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
This module provides a tool to execute commands on VPCS devices
|
||||
in a GNS3 topology using Nornir with Netmiko.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
from netmiko.exceptions import ReadTimeout
|
||||
from nornir import InitNornir
|
||||
from nornir.core import Nornir
|
||||
from nornir.core.task import AggregatedResult
|
||||
from nornir.core.task import Result
|
||||
from nornir.core.task import Task
|
||||
from nornir_netmiko.tasks import netmiko_multiline
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host
|
||||
from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology
|
||||
|
||||
# Import custom Netmiko device types for GNS3 emulation
|
||||
# This registers gns3_vpcs_telnet and other custom device types
|
||||
# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
|
||||
|
||||
# Explicitly register VPCS device type to ensure it is available
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import (
|
||||
register_custom_device_type as register_vpcs_device_type,
|
||||
)
|
||||
|
||||
# Register VPCS device type
|
||||
register_vpcs_device_type()
|
||||
|
||||
# CRITICAL: Update netmiko.ssh_dispatcher platforms lists
|
||||
import importlib
|
||||
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Recalculate platforms lists to include custom device types
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Update platform strings used in error messages
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
except Exception:
|
||||
# Fail silently - the import-time registration should have worked
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress nornir INFO logs in console (reduce verbosity)
|
||||
logging.getLogger("nornir.core").setLevel(logging.WARNING)
|
||||
logging.getLogger("nornir").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _get_nornir_defaults() -> dict[str, Any]:
|
||||
"""Get Nornir default configuration."""
|
||||
return {"data": {"location": "gns3"}}
|
||||
|
||||
|
||||
class VPCSCommands(BaseTool):
|
||||
"""
|
||||
A tool for VPCS (Virtual PC Simulator) devices.
|
||||
|
||||
**VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices.
|
||||
|
||||
**IMPORTANT DISTINCTION:**
|
||||
Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs.
|
||||
Commands like 'ip' are basic PC IP config, NOT network device config.
|
||||
|
||||
**Allowed VPCS Commands:**
|
||||
- IP configuration: ip <address>/<mask> <gateway>
|
||||
- View configuration: ip, show ip
|
||||
- Connectivity testing: ping <destination>
|
||||
- Display ARP: arp
|
||||
- Display version: version
|
||||
- Save/Load: save, load
|
||||
"""
|
||||
|
||||
name: str = "execute_vpcs_commands"
|
||||
description: str = """
|
||||
**VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices.
|
||||
|
||||
This tool ONLY works with VPCS devices, NOT routers/switches.
|
||||
|
||||
**IMPORTANT: VPCS vs Network Devices**
|
||||
- VPCS = Lightweight virtual PCs for lab testing (NOT network infra)
|
||||
- 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows)
|
||||
- NOT the same as configuring router interfaces or routing protocols
|
||||
|
||||
**When to Use This Tool:**
|
||||
- Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254
|
||||
- Test connectivity from PCs: ping 10.10.0.254
|
||||
- View PC IP configuration: ip, show ip
|
||||
- Display ARP table: arp
|
||||
- Check PC version: version
|
||||
|
||||
**Input Format:**
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": ["ip", "ping 10.10.0.254"]
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["show ip"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Returns:** PC command outputs for IP config and connectivity testing.
|
||||
|
||||
**Note:** For network devices, use execute_multiple_device_commands.
|
||||
"""
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Execute commands on multiple VPCS devices in GNS3 topology.
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and VPCS commands.
|
||||
|
||||
Returns:
|
||||
List of dicts with device names and command outputs.
|
||||
"""
|
||||
# Log received input
|
||||
logger.debug("Received input: %s", tool_input)
|
||||
|
||||
# Validate input
|
||||
device_configs_list, project_id = self._validate_tool_input(tool_input)
|
||||
if (
|
||||
isinstance(device_configs_list, list)
|
||||
and len(device_configs_list) > 0
|
||||
and "error" in device_configs_list[0]
|
||||
):
|
||||
return device_configs_list
|
||||
|
||||
# Create a mapping of device names to their commands
|
||||
device_configs_map = self._configs_map(device_configs_list)
|
||||
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device)
|
||||
error_devices = {
|
||||
name: data
|
||||
for name, data in hosts_data.items()
|
||||
if "error" in data
|
||||
}
|
||||
if error_devices:
|
||||
logger.error(
|
||||
"Devices with configuration errors: %s",
|
||||
list(error_devices.keys())
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_name": name,
|
||||
"status": "failed",
|
||||
"error": data["error"]
|
||||
}
|
||||
for name, data in error_devices.items()
|
||||
]
|
||||
|
||||
# Initialize Nornir
|
||||
try:
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
# Execute all devices concurrently in a single run
|
||||
try:
|
||||
task_result = dynamic_nr.run(
|
||||
task=self._run_vpcs_commands,
|
||||
device_configs_map=device_configs_map,
|
||||
)
|
||||
|
||||
# Process results for all devices
|
||||
results = self._process_task_results(
|
||||
device_configs_list,
|
||||
hosts_data,
|
||||
task_result,
|
||||
)
|
||||
|
||||
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)}"}]
|
||||
|
||||
logger.debug(
|
||||
"VPCS command execution completed. Results: %s",
|
||||
json.dumps(results, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _run_vpcs_commands(
|
||||
self, task: Task, device_configs_map: dict[str, list[str]]
|
||||
) -> Result:
|
||||
"""Execute VPCS commands with single retry."""
|
||||
device_name = task.host.name
|
||||
commands = device_configs_map.get(device_name, [])
|
||||
|
||||
if not commands:
|
||||
return Result(
|
||||
host=task.host, result="No commands to execute"
|
||||
)
|
||||
|
||||
try:
|
||||
# Use netmiko_multiline for VPCS commands
|
||||
# This works well with simple commands and ping output
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=commands,
|
||||
read_timeout=30,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
|
||||
except ReadTimeout as e:
|
||||
logger.error(
|
||||
"ReadTimeout occurred for VPCS device %s: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Command failed (ReadTimeout): {str(e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Retry once for transient failures
|
||||
logger.warning(
|
||||
"First attempt failed for VPCS device %s, retrying: %s",
|
||||
device_name,
|
||||
str(e),
|
||||
)
|
||||
try:
|
||||
_result = task.run(
|
||||
task=netmiko_multiline,
|
||||
commands=commands,
|
||||
read_timeout=30,
|
||||
)
|
||||
return Result(host=task.host, result=_result.result)
|
||||
except Exception as retry_e:
|
||||
logger.error(
|
||||
"VPCS command failed for device %s: %s (Exception: %s)",
|
||||
device_name,
|
||||
str(retry_e),
|
||||
type(retry_e).__name__,
|
||||
)
|
||||
return Result(
|
||||
host=task.host,
|
||||
result=f"Command failed: {str(retry_e)}",
|
||||
failed=True,
|
||||
)
|
||||
|
||||
def _validate_tool_input(
|
||||
self, tool_input: str | bytes | list[Any] | dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Validate VPCS command input.
|
||||
|
||||
Args:
|
||||
tool_input: Input from LangChain/LangGraph tool call.
|
||||
|
||||
Returns:
|
||||
Tuple of (device_configs_list, project_id) or (error_list, None)
|
||||
"""
|
||||
parsed_input = None
|
||||
|
||||
# Compatibility Check and Parsing
|
||||
if isinstance(tool_input, (str, bytes, bytearray)):
|
||||
try:
|
||||
parsed_input = json.loads(tool_input)
|
||||
logger.info("Successfully parsed tool input from JSON string.")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
parsed_input = tool_input
|
||||
logger.info(
|
||||
"Using tool input directly as type: %s",
|
||||
type(parsed_input).__name__,
|
||||
)
|
||||
|
||||
# Handle new format: {"project_id": "...", "device_configs": [...]}
|
||||
if isinstance(parsed_input, dict):
|
||||
project_id = parsed_input.get("project_id")
|
||||
device_configs = parsed_input.get("device_configs")
|
||||
|
||||
# Validate project_id
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"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)
|
||||
|
||||
# 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)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
return [], project_id
|
||||
|
||||
return device_configs, project_id
|
||||
|
||||
else:
|
||||
error_msg = (
|
||||
"Tool input must be JSON with project_id and device_configs, "
|
||||
f"got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
Validate project_id format (UUID).
|
||||
|
||||
Args:
|
||||
project_id: The project ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid UUID format, False otherwise
|
||||
"""
|
||||
uuid_pattern = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
)
|
||||
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
|
||||
|
||||
def _configs_map(
|
||||
self, device_config_list: list[dict[str, Any]]
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Create a mapping of device names to their command lists.
|
||||
|
||||
Args:
|
||||
device_config_list: List of device configurations
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to command lists
|
||||
"""
|
||||
return {
|
||||
config["device_name"]: config["commands"]
|
||||
for config in device_config_list
|
||||
}
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self, device_configs_list: list[dict[str, Any]], project_id: str
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Prepare Nornir inventory hosts data for VPCS devices.
|
||||
|
||||
Args:
|
||||
device_configs_list: List of device configurations
|
||||
project_id: GNS3 project ID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their host data
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found in topology or missing port
|
||||
"""
|
||||
# Get GNS3 server host
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Extract all device names from input
|
||||
device_names = [config["device_name"] for config in device_configs_list]
|
||||
|
||||
# Get device port mappings from topology
|
||||
device_ports = get_device_ports_from_topology(
|
||||
device_names, project_id=project_id
|
||||
)
|
||||
|
||||
# Build Nornir inventory hosts data
|
||||
hosts_data = {}
|
||||
for device_name in device_names:
|
||||
if device_name not in device_ports:
|
||||
logger.error("Device '%s' not found in topology", device_name)
|
||||
hosts_data[device_name] = {
|
||||
"error": f"Device '{device_name}' not found in topology"
|
||||
}
|
||||
continue
|
||||
|
||||
port = device_ports[device_name]["port"]
|
||||
|
||||
# VPCS devices use gns3_vpcs_telnet device type
|
||||
hosts_data[device_name] = {
|
||||
"port": port,
|
||||
"platform": "vpcs",
|
||||
"groups": ["vpcs_devices"], # All VPCS devices share one group
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {
|
||||
"device_type": "gns3_vpcs_telnet",
|
||||
"fast_cli": False,
|
||||
"global_delay_factor": 2.0,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return hosts_data
|
||||
|
||||
def _initialize_nornir(
|
||||
self, hosts_data: dict[str, dict[str, Any]]
|
||||
) -> "Nornir":
|
||||
"""
|
||||
Initialize Nornir with VPCS device inventory.
|
||||
|
||||
Args:
|
||||
hosts_data: Dictionary of device host data
|
||||
|
||||
Returns:
|
||||
Initialized Nornir instance
|
||||
|
||||
Raises:
|
||||
ValueError: If initialization fails
|
||||
"""
|
||||
try:
|
||||
defaults = _get_nornir_defaults()
|
||||
gns3_host = get_gns3_server_host()
|
||||
|
||||
# Create a single generic group for shared configuration
|
||||
groups_data = {
|
||||
"vpcs_devices": {
|
||||
"hostname": gns3_host,
|
||||
"timeout": 30,
|
||||
"username": "", # VPCS doesn't require authentication
|
||||
"password": "", # VPCS doesn't require authentication
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Initializing Nornir for VPCS: host=%s, devices=%d",
|
||||
gns3_host,
|
||||
len(hosts_data),
|
||||
)
|
||||
|
||||
return InitNornir(
|
||||
inventory={
|
||||
"plugin": "DictInventory",
|
||||
"options": {
|
||||
"hosts": hosts_data,
|
||||
"groups": groups_data,
|
||||
"defaults": defaults,
|
||||
},
|
||||
},
|
||||
runner={
|
||||
"plugin": "threaded",
|
||||
"options": {"num_workers": 10},
|
||||
},
|
||||
logging={"enabled": False},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
raise ValueError(f"Failed to initialize Nornir: {e}") from e
|
||||
|
||||
def _process_task_results(
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
hosts_data: dict[str, dict[str, Any]],
|
||||
task_result: AggregatedResult,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Process Nornir task results into the format expected by the tool.
|
||||
|
||||
Args:
|
||||
device_configs_list: Original device configurations list
|
||||
hosts_data: Device hosts data
|
||||
task_result: Nornir aggregated task result
|
||||
|
||||
Returns:
|
||||
List of result dictionaries
|
||||
"""
|
||||
results = []
|
||||
|
||||
for device_config in device_configs_list:
|
||||
device_name = device_config["device_name"]
|
||||
|
||||
# Check if device had an error during preparation
|
||||
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"],
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
continue
|
||||
|
||||
# Get result from Nornir task
|
||||
if device_name in task_result:
|
||||
host_result = task_result[device_name]
|
||||
|
||||
if host_result.failed:
|
||||
# Task failed
|
||||
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": error_msg,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
# Task succeeded
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "success",
|
||||
"output": host_result.result,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
# 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",
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
command_groups = json.dumps(
|
||||
{
|
||||
"project_id": "<PROJECT_UUID>",
|
||||
"device_configs": [
|
||||
{
|
||||
"device_name": "PC1",
|
||||
"commands": [
|
||||
"ip 10.10.0.12/24 10.10.0.254",
|
||||
"ping 10.10.0.254",
|
||||
],
|
||||
},
|
||||
{
|
||||
"device_name": "PC2",
|
||||
"commands": ["ip 10.10.0.13/24 10.10.0.254"],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exe_cmd = VPCSCommands()
|
||||
result = exe_cmd._run(tool_input=command_groups)
|
||||
print("Execution results:")
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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-Copilot Public Model Package
|
||||
|
||||
This package provides reusable public models and utilities for GNS3 automation tasks.
|
||||
It contains common functionality that can be shared across different tools and modules.
|
||||
|
||||
Main modules:
|
||||
- get_gns3_device_port: Device port information retrieval from GNS3 topology
|
||||
- parse_tool_content: Tool execution result parsing and formatting utilities
|
||||
|
||||
Author: Yue Guobin (岳国宾)
|
||||
"""
|
||||
|
||||
# Import main utility functions
|
||||
from .error_handler import format_error_message
|
||||
from .get_gns3_device_port import get_device_ports_from_topology
|
||||
from .parse_tool_content import format_tool_response
|
||||
from .parse_tool_content import normalize_tool_response
|
||||
from .parse_tool_content import parse_tool_content
|
||||
|
||||
# Dynamic version management
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("gns3-copilot")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
__author__ = "Yue Guobin (岳国宾)"
|
||||
__description__ = "AI-powered network automation assistant for GNS3"
|
||||
__url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
# Export main utility functions
|
||||
__all__ = [
|
||||
"format_error_message",
|
||||
"get_device_ports_from_topology",
|
||||
"parse_tool_content",
|
||||
"format_tool_response",
|
||||
"normalize_tool_response",
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Command filter module for GNS3-Copilot.
|
||||
|
||||
This module provides functionality to filter out dangerous or long-running
|
||||
commands that may cause issues with tool execution timeouts or device console
|
||||
availability.
|
||||
|
||||
Forbidden commands are loaded from the external GNS3-Skills repository
|
||||
(config/forbidden_commands.txt) via SkillsManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.agent.gns3_copilot.skills.registry import get_skills_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default forbidden commands (fallback if skills repository is not available)
|
||||
DEFAULT_FORBIDDEN_COMMANDS = [
|
||||
"traceroute",
|
||||
"tracepath",
|
||||
"tracert",
|
||||
"ping -f",
|
||||
"debug",
|
||||
"test",
|
||||
]
|
||||
|
||||
# Cache for forbidden commands
|
||||
_forbidden_commands_cache: list[str] | None = None
|
||||
|
||||
|
||||
def _load_forbidden_commands() -> list[str]:
|
||||
"""
|
||||
Load forbidden commands, preferring the skills repository.
|
||||
|
||||
Tries to load from the external GNS3-Skills repository first.
|
||||
Falls back to hardcoded defaults if the repo is unavailable.
|
||||
|
||||
Returns:
|
||||
List of forbidden command patterns.
|
||||
"""
|
||||
global _forbidden_commands_cache
|
||||
|
||||
# Return cached value if available
|
||||
if _forbidden_commands_cache is not None:
|
||||
return _forbidden_commands_cache
|
||||
|
||||
# Try to load from skills repository
|
||||
manager = get_skills_manager()
|
||||
if manager is not None:
|
||||
commands = manager.load_forbidden_commands()
|
||||
if commands:
|
||||
logger.info(
|
||||
"Loaded %d forbidden command patterns from skills repository",
|
||||
len(commands),
|
||||
)
|
||||
_forbidden_commands_cache = commands
|
||||
return _forbidden_commands_cache
|
||||
|
||||
# Fallback to defaults
|
||||
logger.warning("Using default forbidden commands list")
|
||||
_forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy()
|
||||
return _forbidden_commands_cache
|
||||
|
||||
|
||||
def reload_forbidden_commands() -> None:
|
||||
"""
|
||||
Reload the forbidden commands list from the skills repository.
|
||||
|
||||
Directly loads and caches the commands from the repository.
|
||||
Falls back to defaults if the repository is unavailable.
|
||||
"""
|
||||
global _forbidden_commands_cache
|
||||
|
||||
from gns3server.agent.gns3_copilot.skills.registry import get_skills_manager
|
||||
|
||||
manager = get_skills_manager()
|
||||
if manager is not None:
|
||||
commands = manager.load_forbidden_commands()
|
||||
if commands:
|
||||
logger.info(
|
||||
"Loaded %d forbidden command patterns from skills repository",
|
||||
len(commands),
|
||||
)
|
||||
_forbidden_commands_cache = commands
|
||||
return
|
||||
|
||||
logger.warning("Using default forbidden commands list")
|
||||
_forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy()
|
||||
|
||||
|
||||
def get_forbidden_commands() -> list[str]:
|
||||
"""
|
||||
Get the current list of forbidden command patterns.
|
||||
|
||||
Returns:
|
||||
List of forbidden command patterns (lowercase).
|
||||
"""
|
||||
return _load_forbidden_commands()
|
||||
|
||||
|
||||
def is_command_forbidden(command: str) -> bool:
|
||||
"""
|
||||
Check if a single command is forbidden.
|
||||
|
||||
Args:
|
||||
command: The command string to check.
|
||||
|
||||
Returns:
|
||||
True if the command matches a forbidden pattern, False otherwise.
|
||||
"""
|
||||
forbidden_commands = _load_forbidden_commands()
|
||||
command_lower = command.strip().lower()
|
||||
|
||||
for forbidden_pattern in forbidden_commands:
|
||||
if command_lower.startswith(forbidden_pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def filter_forbidden_commands(
|
||||
commands: list[str],
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""
|
||||
Filter out forbidden commands from a list of commands.
|
||||
|
||||
Args:
|
||||
commands: List of command strings to filter.
|
||||
|
||||
Returns:
|
||||
A tuple of (allowed_commands, blocked_commands_info):
|
||||
- allowed_commands: List of commands that are not forbidden.
|
||||
- blocked_commands_info: Dict mapping blocked commands to their reasons.
|
||||
"""
|
||||
allowed_commands: list[str] = []
|
||||
blocked_commands_info: dict[str, str] = {}
|
||||
|
||||
for command in commands:
|
||||
if is_command_forbidden(command):
|
||||
# Find which pattern matched
|
||||
forbidden_commands = _load_forbidden_commands()
|
||||
command_lower = command.strip().lower()
|
||||
matched_pattern = None
|
||||
|
||||
for pattern in forbidden_commands:
|
||||
if command_lower.startswith(pattern):
|
||||
matched_pattern = pattern
|
||||
break
|
||||
|
||||
reason = (
|
||||
f"Command '{command}' is not allowed because it matches the "
|
||||
f"forbidden pattern '{matched_pattern}'. "
|
||||
f"This command may run longer than the tool timeout or leave "
|
||||
f"the device console unavailable for subsequent commands."
|
||||
)
|
||||
blocked_commands_info[command] = reason
|
||||
else:
|
||||
allowed_commands.append(command)
|
||||
|
||||
if blocked_commands_info:
|
||||
logger.info(
|
||||
"Filtered %d command(s): %s",
|
||||
len(blocked_commands_info),
|
||||
list(blocked_commands_info.keys()),
|
||||
)
|
||||
|
||||
return allowed_commands, blocked_commands_info
|
||||
@@ -0,0 +1,186 @@
|
||||
# Custom Netmiko Drivers
|
||||
|
||||
Custom Netmiko drivers for GNS3-emulated network devices.
|
||||
|
||||
## Overview
|
||||
|
||||
This package contains Netmiko drivers optimized for GNS3 emulation environments where devices may have non-standard authentication or behavior patterns.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
custom_netmiko/
|
||||
├── __init__.py # Package initialization, auto-registers all drivers
|
||||
├── huawei_ce.py # Huawei CloudEngine custom driver (GNS3HuaweiTelnetCE)
|
||||
├── ruijie_telnet.py # Ruijie OS custom driver (RuijieTelnetEnhanced)
|
||||
├── README.md # This file
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── list_netmiko_telnet_devices.py
|
||||
└── tests/ # Unit tests
|
||||
├── __init__.py
|
||||
└── test_huawei_ce.py # Huawei CE driver tests
|
||||
```
|
||||
|
||||
## Supported Drivers
|
||||
|
||||
### Huawei (`huawei_ce.py`)
|
||||
|
||||
**Driver Name:** `GNS3HuaweiTelnetCE`
|
||||
|
||||
**Device Types:**
|
||||
- `gns3_huawei_telnet_ce` - Primary type (GNS3 custom driver)
|
||||
- `huawei_telnet` - Standard Netmiko type (for devices with authentication)
|
||||
|
||||
**Features:**
|
||||
- Skip authentication (for GNS3 devices without username/password)
|
||||
- VRP prompt recognition (`<HUAWEI>`, `[HUAWEI]`)
|
||||
- Auto-commit before exit (prevents [Y/N/C] prompts)
|
||||
- Proper output collection using `read_channel_timing()`
|
||||
|
||||
**Limitations:**
|
||||
- Does not support username/password authentication
|
||||
- If your device requires authentication, use standard `huawei_telnet` driver
|
||||
|
||||
## Usage
|
||||
|
||||
### Direct Usage
|
||||
|
||||
```python
|
||||
from netmiko import ConnectHandler
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko
|
||||
|
||||
device = {
|
||||
"device_type": "gns3_huawei_telnet_ce",
|
||||
"host": "127.0.0.1",
|
||||
"port": 5000,
|
||||
}
|
||||
|
||||
with ConnectHandler(**device) as conn:
|
||||
output = conn.send_command("display version")
|
||||
config = ["interface GE1/0/1", "description Test"]
|
||||
output = conn.send_config_set(config)
|
||||
```
|
||||
|
||||
### In GNS3 Copilot Tools
|
||||
|
||||
The drivers are auto-registered when the tools are imported:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir
|
||||
|
||||
tool = DisplayToolNornir()
|
||||
result = tool._run(json.dumps({
|
||||
"device_names": ["huawei-sw1"],
|
||||
"commands": ["display version"],
|
||||
"project_id": "project-uuid"
|
||||
}))
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
source venv/bin/activate
|
||||
python -m unittest discover -s gns3server/agent/gns3_copilot/utils/custom_netmiko/tests
|
||||
|
||||
# Run Huawei CE tests only
|
||||
python gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py
|
||||
```
|
||||
|
||||
## Adding New Drivers
|
||||
|
||||
### 1. Create Driver File
|
||||
|
||||
Create a new file in `custom_netmiko/` (e.g., `cisco.py`):
|
||||
|
||||
```python
|
||||
# cisco.py
|
||||
from netmiko.cisco.cisco_ios import CiscoIosTelnet
|
||||
|
||||
class CustomCiscoDriver(CiscoIosTelnet):
|
||||
"""Custom Cisco driver for GNS3."""
|
||||
|
||||
def telnet_login(self, ...):
|
||||
# Override login logic
|
||||
pass
|
||||
|
||||
def register_custom_device_type() -> None:
|
||||
"""Register this driver with Netmiko."""
|
||||
import importlib
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
sd.CLASS_MAPPER["cisco_custom"] = CustomCiscoDriver
|
||||
sd.CLASS_MAPPER_BASE["cisco_custom"] = CustomCiscoDriver
|
||||
|
||||
# Rebuild static lists
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
# Auto-register on import
|
||||
try:
|
||||
register_custom_device_type()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to register: {e}")
|
||||
```
|
||||
|
||||
### 2. Update `__init__.py`
|
||||
|
||||
Add import to `__init__.py`:
|
||||
|
||||
```python
|
||||
try:
|
||||
from . import cisco # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import Cisco driver: {e}", exc_info=True)
|
||||
|
||||
__all__ = ["huawei_ce", "cisco"]
|
||||
```
|
||||
|
||||
### 3. Create Tests
|
||||
|
||||
Create `tests/test_cisco.py`:
|
||||
|
||||
```python
|
||||
import unittest
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
|
||||
|
||||
class TestCustomCiscoDriver(unittest.TestCase):
|
||||
def test_device_type_registered(self):
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
self.assertIn("cisco_custom", CLASS_MAPPER)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Driver Not Found Error
|
||||
|
||||
```
|
||||
ValueError: Unsupported 'device_type'
|
||||
```
|
||||
|
||||
**Solution:** Ensure the custom_netmiko package is imported before using the device:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko
|
||||
# Now use the device
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
|
||||
If you get import errors, check:
|
||||
1. Virtual environment is activated
|
||||
2. Python path includes project root
|
||||
3. Dependencies are installed (`pip install netmiko nornir nornir-netmiko`)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Multi-Vendor Device Support](../../../docs/gns3-copilot/implemented/multi-vendor-device-support.md)
|
||||
- [Netmiko Documentation](https://ktbyers.github.io/netmiko/)
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-03-12_
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Custom Netmiko drivers for GNS3-emulated network devices.
|
||||
|
||||
This package contains custom Netmiko drivers optimized for GNS3
|
||||
emulation environments where devices may have non-standard
|
||||
authentication or behavior patterns.
|
||||
|
||||
Supported Drivers:
|
||||
- huawei_ce: GNS3HuaweiTelnetCE for CloudEngine devices (no authentication)
|
||||
- ruijie_telnet: RuijieTelnetEnhanced for Ruijie devices (interactive prompt handling)
|
||||
- vpcs_telnet: VPCSTelnet for VPCS virtual PC simulator devices (no authentication)
|
||||
|
||||
All drivers use 'gns3_' prefix to clearly distinguish them from Netmiko's
|
||||
built-in drivers.
|
||||
|
||||
Usage:
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko
|
||||
|
||||
# Auto-registers all drivers on import
|
||||
from netmiko import ConnectHandler
|
||||
|
||||
# Huawei device
|
||||
device = {
|
||||
"device_type": "gns3_huawei_telnet_ce",
|
||||
"host": "127.0.0.1",
|
||||
"port": 5000,
|
||||
}
|
||||
|
||||
with ConnectHandler(**device) as conn:
|
||||
output = conn.send_command("display version")
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import all custom drivers (auto-registers them with Netmiko)
|
||||
try:
|
||||
from . import huawei_ce # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import Huawei CE driver: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
from .ruijie_telnet import RuijieTelnetEnhanced # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import Ruijie driver: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
from . import vpcs_telnet # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import VPCS driver: {e}", exc_info=True)
|
||||
|
||||
__all__ = ["huawei_ce", "ruijie_telnet", "vpcs_telnet"]
|
||||
@@ -0,0 +1,457 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
# Mypy type checking is disabled for this module due to Netmiko library
|
||||
# limitations.
|
||||
#
|
||||
# Reason: Netmiko does not provide type stubs (py.typed marker), which
|
||||
# causes mypy to generate errors when importing and using Netmiko classes.
|
||||
# The main issues are:
|
||||
#
|
||||
# 1. Missing library stubs for 'netmiko.huawei.huawei' module
|
||||
# 2. Dynamic attribute assignments on netmiko.ssh_dispatcher module
|
||||
# (platforms, platforms_base, telnet_platforms, etc.) that mypy cannot
|
||||
# detect
|
||||
# 3. Inheritance from HuaweiBase triggers 'import-untyped' errors
|
||||
#
|
||||
# Since Netmiko is a third-party library without type annotations, and this
|
||||
# module is a runtime driver that extends Netmiko's functionality, type
|
||||
# checking would require maintaining separate type stub files which is
|
||||
# beyond the scope of this project.
|
||||
#
|
||||
# Alternative solutions considered:
|
||||
# - Adding type stubs for Netmiko (too extensive, requires ongoing
|
||||
# maintenance)
|
||||
# - Using 'type: ignore' on specific lines (too many annotations needed)
|
||||
# - Disabling only specific mypy errors (still noisy, doesn't address
|
||||
# root cause)
|
||||
#
|
||||
# Solution: Disable mypy for this entire file. Runtime testing and
|
||||
# integration tests ensure correctness. Netmiko's own test suite validates
|
||||
# base functionality.
|
||||
#
|
||||
# mypy: ignore-errors
|
||||
|
||||
"""
|
||||
Custom Netmiko device driver for Huawei devices in GNS3 emulation environment.
|
||||
|
||||
This module provides a custom device type 'gns3_huawei_telnet_ce' for Huawei
|
||||
network devices that connect via console without requiring authentication
|
||||
(username/password).
|
||||
|
||||
This is specifically designed for GNS3 emulated Huawei devices
|
||||
(e.g., CloudEngine series) where the console connection directly enters the
|
||||
system view without login prompts.
|
||||
|
||||
Key Features:
|
||||
- Inherits from HuaweiBase for proper VRP command handling
|
||||
- Skips authentication (no username/password required)
|
||||
- Supports Huawei-specific config mode (system-view)
|
||||
- Handles Huawei prompt patterns (<>, [], >)
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from netmiko.huawei.huawei import HuaweiBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GNS3HuaweiTelnetCE(HuaweiBase):
|
||||
"""
|
||||
Custom Huawei device driver for GNS3 emulation.
|
||||
|
||||
Inherits from HuaweiBase to leverage existing VRP-specific functionality:
|
||||
- system-view configuration mode
|
||||
- Huawei prompt patterns
|
||||
- Config mode detection and exit
|
||||
|
||||
This driver overrides telnet_login to handle GNS3 devices that
|
||||
don't require authentication.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Initialize GNS3HuaweiTelnetCE connection."""
|
||||
# Set default device type for proper initialization
|
||||
# The '_telnet' suffix in device_type tells Netmiko to use
|
||||
# Telnet protocol
|
||||
kwargs.setdefault("device_type", "huawei_telnet")
|
||||
|
||||
# Huawei prompt patterns (inherited from HuaweiBase)
|
||||
# User view: <HUAWEI>
|
||||
# System view: [HUAWEI]
|
||||
# Interface view: [HUAWEI-GigabitEthernet0/0/1]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def telnet_login(
|
||||
self,
|
||||
pri_prompt_terminator: str = r"<\S+>|>\s*$",
|
||||
alt_prompt_terminator: str = r"\[\S+\]",
|
||||
username_pattern: str = r"(?:user:|username|login|user name)",
|
||||
pwd_pattern: str = r"assword",
|
||||
delay_factor: float = 1.0,
|
||||
max_loops: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
Telnet login for GNS3 Huawei devices (no authentication).
|
||||
|
||||
Simplified login logic for devices that connect directly to
|
||||
command line without username/password prompts.
|
||||
|
||||
Strategy:
|
||||
1. Clear any existing buffer data
|
||||
2. Send carriage returns to trigger prompt
|
||||
3. Wait for Huawei prompt pattern
|
||||
4. Return once prompt is detected
|
||||
|
||||
Args:
|
||||
pri_prompt_terminator: Primary prompt pattern (e.g., <HUAWEI>)
|
||||
alt_prompt_terminator: Alternate prompt pattern (e.g., [HUAWEI])
|
||||
username_pattern: Not used (kept for signature compatibility)
|
||||
pwd_pattern: Not used (kept for signature compatibility)
|
||||
delay_factor: Delay factor for timing
|
||||
max_loops: Maximum wait loops
|
||||
|
||||
Returns:
|
||||
Output from the connection process
|
||||
"""
|
||||
delay_factor = self.select_delay_factor(delay_factor)
|
||||
output = ""
|
||||
return_msg = ""
|
||||
|
||||
# Step 1: Clear buffer - read any existing data to avoid interference
|
||||
try:
|
||||
initial_data = self.read_channel()
|
||||
if initial_data:
|
||||
return_msg += initial_data
|
||||
except Exception:
|
||||
# Ignore errors during initial read
|
||||
pass
|
||||
|
||||
# Step 2: Send carriage returns and wait for prompt
|
||||
for i in range(max_loops):
|
||||
try:
|
||||
# Send return to trigger prompt
|
||||
self.write_channel(self.RETURN)
|
||||
time.sleep(0.5 * delay_factor)
|
||||
|
||||
# Read response
|
||||
new_output = self.read_channel()
|
||||
output = new_output
|
||||
return_msg += new_output
|
||||
|
||||
# Check for Huawei prompt patterns
|
||||
# User view: <HUAWEI>, System view: [HUAWEI], or just >
|
||||
if re.search(pri_prompt_terminator, output, flags=re.M):
|
||||
return return_msg
|
||||
if re.search(alt_prompt_terminator, output, flags=re.M):
|
||||
return return_msg
|
||||
|
||||
except EOFError:
|
||||
self.remote_conn.close()
|
||||
msg = f"Connection failed (EOF): {self.host}"
|
||||
raise self.connection_error(msg) from None
|
||||
except Exception:
|
||||
# Continue trying on other exceptions
|
||||
pass
|
||||
|
||||
# Step 3: Final attempt - return what we have
|
||||
# Even if we couldn't detect the prompt clearly, the connection
|
||||
# might still be usable
|
||||
return return_msg
|
||||
|
||||
def session_preparation(self) -> None:
|
||||
"""
|
||||
Prepare the session after connection is established.
|
||||
|
||||
Inherited from HuaweiBase, but ensures proper initialization
|
||||
for GNS3 emulation environment.
|
||||
"""
|
||||
# Wait for prompt to stabilize
|
||||
time.sleep(0.5 * self.global_delay_factor)
|
||||
|
||||
# Disable paging using Huawei-specific command
|
||||
try:
|
||||
self.disable_paging(command="screen-length 0 temporary")
|
||||
except Exception:
|
||||
# If disable_paging fails, try the parent implementation
|
||||
super().disable_paging()
|
||||
|
||||
# Ensure we're in a clean state
|
||||
try:
|
||||
if hasattr(self, 'base_prompt') and self.base_prompt:
|
||||
self._test_channel_read(pattern=self.base_prompt)
|
||||
else:
|
||||
# If base_prompt is not set yet, just read to clear buffer
|
||||
self.read_channel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def disable_paging(
|
||||
self,
|
||||
command: str = "screen-length 0 temporary",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Disable paging for Huawei devices.
|
||||
|
||||
Uses Huawei-specific command 'screen-length 0 temporary'
|
||||
which disables paging for the current session only.
|
||||
|
||||
Args:
|
||||
command: Command to disable paging
|
||||
|
||||
Returns:
|
||||
Output from the disable paging command
|
||||
"""
|
||||
return super().disable_paging(command=command, **kwargs)
|
||||
|
||||
def send_config_set(
|
||||
self,
|
||||
config_commands: str | list[str],
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Send configuration commands to Huawei device.
|
||||
|
||||
Overrides the parent method to handle Huawei-specific behavior:
|
||||
- Uses Huawei-specific prompts (<...> for user view, [...] for
|
||||
system view)
|
||||
- Handles the 'return' confirmation prompt
|
||||
- Uses read_channel_timing for proper output collection
|
||||
|
||||
Args:
|
||||
config_commands: Configuration commands to send
|
||||
**kwargs: Additional arguments (exit_config_mode, read_timeout,
|
||||
etc.)
|
||||
|
||||
Returns:
|
||||
Output from configuration commands
|
||||
"""
|
||||
# Convert string to list
|
||||
if isinstance(config_commands, str):
|
||||
config_commands = [config_commands]
|
||||
|
||||
# Get parameters with Huawei-specific defaults
|
||||
exit_config_mode = kwargs.get("exit_config_mode", True)
|
||||
# Longer timeout for GNS3 emulation
|
||||
read_timeout = kwargs.get("read_timeout", 30)
|
||||
delay_factor = self.global_delay_factor
|
||||
strip_prompt = kwargs.get("strip_prompt", False)
|
||||
strip_command = kwargs.get("strip_command", False)
|
||||
config_mode_command = kwargs.get("config_command", "system-view")
|
||||
|
||||
output = ""
|
||||
|
||||
# Enter config mode if needed
|
||||
if kwargs.get("enter_config_mode", True):
|
||||
output += self.config_mode(config_command=config_mode_command)
|
||||
|
||||
# Send all configuration commands
|
||||
# Send all commands, then read all output at once
|
||||
for cmd in config_commands:
|
||||
self.write_channel(f"{cmd}{self.RETURN}")
|
||||
# Small delay between commands
|
||||
time.sleep(delay_factor * 0.05)
|
||||
|
||||
# Use read_channel_timing to collect all output
|
||||
# This method keeps reading until there is no new data for
|
||||
# 'last_read' seconds. This is the proper Netmiko way to handle
|
||||
# command output
|
||||
output += self.read_channel_timing(
|
||||
read_timeout=read_timeout, last_read=2.0
|
||||
)
|
||||
|
||||
# Exit config mode if requested
|
||||
if exit_config_mode:
|
||||
# For Huawei devices, commit configuration before exiting
|
||||
# This avoids the "Uncommitted configurations" [Y/N/C] prompt
|
||||
try:
|
||||
# Send commit command (in system view [HUAWEI])
|
||||
self.write_channel(f"commit{self.RETURN}")
|
||||
time.sleep(0.5 * self.global_delay_factor)
|
||||
commit_output = self.read_channel()
|
||||
output += commit_output
|
||||
except Exception:
|
||||
# If commit fails, continue with exit
|
||||
# (might not support commit)
|
||||
pass
|
||||
|
||||
# Now exit config mode
|
||||
output += self.exit_config_mode()
|
||||
|
||||
if strip_prompt:
|
||||
output = self.strip_prompt(output)
|
||||
|
||||
if strip_command:
|
||||
output = self.strip_command(config_commands, output)
|
||||
|
||||
return output
|
||||
|
||||
def exit_config_mode(
|
||||
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
|
||||
) -> str:
|
||||
r"""
|
||||
Exit configuration mode for Huawei devices.
|
||||
|
||||
Huawei devices display a confirmation prompt when using 'return':
|
||||
Return to user view? [y/n]:
|
||||
|
||||
This method automatically answers 'y' to the prompt.
|
||||
|
||||
Args:
|
||||
exit_config: Command to exit config mode (default: "return")
|
||||
pattern: Pattern to detect user view prompt
|
||||
(default: r"<\S+>|>\s*$")
|
||||
|
||||
Returns:
|
||||
Output from exiting config mode
|
||||
"""
|
||||
# Check if we're currently in config mode
|
||||
if not self.check_config_mode():
|
||||
return ""
|
||||
|
||||
output = ""
|
||||
# Send the exit command (write_channel returns None, don't concatenate)
|
||||
self.write_channel(f"{exit_config}{self.RETURN}")
|
||||
time.sleep(0.5 * self.global_delay_factor)
|
||||
|
||||
# Look for the confirmation prompt
|
||||
# Huawei prompt: "Return to user view? [y/n]:"
|
||||
prompt_pattern = r"\[y/n\]"
|
||||
max_loops = 20 # More loops for slower devices
|
||||
|
||||
for _ in range(max_loops):
|
||||
new_output = self.read_channel()
|
||||
output += new_output
|
||||
|
||||
# If we see the confirmation prompt, send 'y'
|
||||
if re.search(prompt_pattern, new_output):
|
||||
self.write_channel(f"y{self.RETURN}") # Returns None
|
||||
time.sleep(0.5 * self.global_delay_factor)
|
||||
# Clear the confirmation response
|
||||
new_output = self.read_channel()
|
||||
output += new_output
|
||||
|
||||
# Check if we've exited to user view
|
||||
if re.search(pattern, new_output):
|
||||
return output
|
||||
|
||||
# Final read to get remaining output
|
||||
new_output = self.read_channel()
|
||||
output += new_output
|
||||
|
||||
return output
|
||||
|
||||
|
||||
# Register the custom device type with Netmiko
|
||||
_registered = False # Flag to prevent duplicate registration
|
||||
|
||||
def register_custom_device_type() -> None:
|
||||
"""
|
||||
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
|
||||
|
||||
This function adds 'gns3_huawei_telnet_ce' to both Netmiko's CLASS_MAPPER
|
||||
and CLASS_MAPPER_BASE so it can be used like any other built-in device
|
||||
type.
|
||||
|
||||
Additionally, it updates the static 'platforms' and 'telnet_platforms'
|
||||
lists which are used by ConnectHandler for device type validation.
|
||||
|
||||
IMPORTANT: This function should be called BEFORE initializing Nornir
|
||||
or running any Netmiko tasks. Call it explicitly at the appropriate
|
||||
time.
|
||||
|
||||
Note: This function is idempotent - multiple calls will only register once.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
global _registered
|
||||
|
||||
# Prevent duplicate registration
|
||||
if _registered:
|
||||
logger.debug("Huawei CE device type already registered, skipping")
|
||||
return
|
||||
|
||||
# Use importlib to avoid namespace conflicts
|
||||
# Import the module using importlib to ensure we get the module,
|
||||
# not a function
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Register the device type in both mappers
|
||||
# CLASS_MAPPER_BASE is used for base class definitions
|
||||
sd.CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
|
||||
|
||||
# CLASS_MAPPER is used by ConnectHandler for device type validation
|
||||
sd.CLASS_MAPPER["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE
|
||||
|
||||
# CRITICAL: Update the static platforms lists
|
||||
# These lists are computed at module import time and won't
|
||||
# automatically update when CLASS_MAPPER is modified.
|
||||
# We need to manually rebuild them.
|
||||
|
||||
# Recalculate platforms list
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
# Recalculate platforms_base list
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
# Recalculate telnet_platforms list
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Rebuild the platform strings used in error messages
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
|
||||
# Mark as registered
|
||||
_registered = True
|
||||
|
||||
logger.info("Successfully registered Huawei CE device type with Netmiko")
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
# This ensures the device type is available when the module is imported
|
||||
# NOTE: For Nornir scenarios, you may need to call this explicitly
|
||||
# before InitNornir to ensure proper timing
|
||||
try:
|
||||
register_custom_device_type()
|
||||
except Exception as e:
|
||||
# Log but don't fail on import
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Failed to register custom device type: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
|
||||
"""
|
||||
Custom Netmiko device driver for Ruijie (锐捷) devices in GNS3 emulation.
|
||||
|
||||
This module provides enhanced handling for Ruijie network devices,
|
||||
specifically addressing interactive prompts during configuration.
|
||||
|
||||
Key Features:
|
||||
- Inherits from RuijieOSBase (Netmiko's native Ruijie support)
|
||||
- Handles interactive prompts (e.g., OSPF router-id [yes/no] confirmation)
|
||||
- Maintains compatibility with Netmiko's Ruijie implementation
|
||||
|
||||
Device Type: gns3_ruijie_telnet
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from netmiko.ruijie.ruijie_os import RuijieOSBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuijieTelnetEnhanced(RuijieOSBase):
|
||||
"""
|
||||
Enhanced Ruijie device driver with interactive prompt handling.
|
||||
|
||||
Inherits from RuijieOSBase to maintain native Netmiko compatibility,
|
||||
and adds automatic handling for interactive configuration prompts.
|
||||
"""
|
||||
|
||||
# Interactive command patterns that trigger [yes/no] prompts
|
||||
# These are commands that commonly require confirmation
|
||||
INTERACTIVE_PATTERNS = [
|
||||
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id
|
||||
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
|
||||
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
|
||||
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
|
||||
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
|
||||
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Initialize RuijieTelnetEnhanced connection."""
|
||||
# Set default_enter for telnet (like Netmiko's RuijieOSTelnet)
|
||||
default_enter = kwargs.get("default_enter")
|
||||
if default_enter is None:
|
||||
kwargs["default_enter"] = "\r\n"
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _preprocess_interactive_commands(
|
||||
self, config_commands: list[str]
|
||||
) -> list[str]:
|
||||
"""
|
||||
Preprocess commands to insert 'yes' after interactive commands.
|
||||
|
||||
Detects commands that trigger [yes/no] prompts and automatically
|
||||
inserts 'yes' response after them.
|
||||
|
||||
Args:
|
||||
config_commands: List of configuration commands
|
||||
|
||||
Returns:
|
||||
List of commands with 'yes' inserted after interactive commands
|
||||
"""
|
||||
processed = []
|
||||
|
||||
for cmd in config_commands:
|
||||
# Add the original command
|
||||
processed.append(cmd)
|
||||
|
||||
# Check if this command matches any interactive pattern
|
||||
for pattern in self.INTERACTIVE_PATTERNS:
|
||||
if pattern.match(cmd.strip()):
|
||||
logging.info(
|
||||
"Ruijie device: Detected interactive command '%s', "
|
||||
"inserting 'yes'",
|
||||
cmd.strip(),
|
||||
)
|
||||
# Insert 'yes' after this command
|
||||
processed.append("yes")
|
||||
break
|
||||
|
||||
return processed
|
||||
|
||||
def send_config_set(
|
||||
self,
|
||||
config_commands: str | list[str],
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Send configuration commands with interactive prompt handling.
|
||||
|
||||
Hybrid Strategy:
|
||||
1. Preprocess commands to insert 'yes' after known interactive commands
|
||||
2. Try batch send (fast)
|
||||
3. If batch fails, fallback to one-by-one send with real-time detection
|
||||
|
||||
Args:
|
||||
config_commands: Configuration commands to send
|
||||
**kwargs: Additional arguments (exit_config_mode, read_timeout,
|
||||
etc.)
|
||||
|
||||
Returns:
|
||||
Output from configuration commands
|
||||
"""
|
||||
# Convert string to list
|
||||
if isinstance(config_commands, str):
|
||||
config_commands = [config_commands]
|
||||
|
||||
# Get parameters with defaults
|
||||
exit_config_mode = kwargs.get("exit_config_mode", True)
|
||||
enter_config_mode = kwargs.get("enter_config_mode", True)
|
||||
read_timeout = kwargs.get("read_timeout", 60)
|
||||
delay_factor = self.global_delay_factor
|
||||
|
||||
output = ""
|
||||
|
||||
# Enter config mode if needed
|
||||
if enter_config_mode:
|
||||
output += self.config_mode()
|
||||
|
||||
# Preprocess: Insert 'yes' after known interactive commands
|
||||
processed_commands = self._preprocess_interactive_commands(
|
||||
config_commands
|
||||
)
|
||||
|
||||
# Try batch send first (fast path)
|
||||
try:
|
||||
output += self._send_config_batch(
|
||||
processed_commands, read_timeout, delay_factor
|
||||
)
|
||||
except Exception as batch_error:
|
||||
logging.warning(
|
||||
"Ruijie device: Batch send failed, "
|
||||
"falling back to one-by-one: %s",
|
||||
batch_error,
|
||||
)
|
||||
# Fallback to one-by-one send with real-time detection
|
||||
output += self._send_config_one_by_one(
|
||||
config_commands, read_timeout, delay_factor
|
||||
)
|
||||
|
||||
# Exit config mode if requested
|
||||
if exit_config_mode:
|
||||
output += self.exit_config_mode()
|
||||
|
||||
return output
|
||||
|
||||
def _send_config_batch(
|
||||
self, commands: list[str], read_timeout: int, delay_factor: float
|
||||
) -> str:
|
||||
"""
|
||||
Send configuration commands in batch (fast).
|
||||
|
||||
This works when all interactive prompts have been pre-processed
|
||||
with 'yes' responses inserted.
|
||||
"""
|
||||
output = ""
|
||||
|
||||
# Batch send: Write all commands rapidly
|
||||
for cmd in commands:
|
||||
self.write_channel(f"{cmd}{self.RETURN}")
|
||||
time.sleep(0.01)
|
||||
|
||||
# Read all output at once
|
||||
# Use same default as Netmiko (2.0 seconds) to ensure complete output
|
||||
output += self.read_channel_timing(
|
||||
read_timeout=read_timeout, last_read=2.0
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
def _send_config_one_by_one(
|
||||
self, commands: list[str], read_timeout: int, delay_factor: float
|
||||
) -> str:
|
||||
"""
|
||||
Send commands one-by-one with real-time prompt detection.
|
||||
|
||||
This is the fallback method when batch send fails.
|
||||
"""
|
||||
output = ""
|
||||
interactive_patterns = [
|
||||
r"\[yes/no\]",
|
||||
r"\[y/n\]",
|
||||
r"\[Y/N\]",
|
||||
]
|
||||
|
||||
# Send commands ONE BY ONE to detect prompts after each command
|
||||
for cmd in commands:
|
||||
# Write the command
|
||||
self.write_channel(f"{cmd}{self.RETURN}")
|
||||
time.sleep(0.05)
|
||||
|
||||
# Read output after this command
|
||||
new_output = self.read_channel_timing(
|
||||
read_timeout=10, last_read=0.5
|
||||
)
|
||||
output += new_output
|
||||
|
||||
# Check if interactive prompt appeared after this command
|
||||
for pattern in interactive_patterns:
|
||||
if re.search(pattern, new_output, re.IGNORECASE):
|
||||
logging.info(
|
||||
"Ruijie device: Detected interactive prompt "
|
||||
"after '%s', sending 'yes'",
|
||||
cmd,
|
||||
)
|
||||
# Send 'yes' to confirm
|
||||
self.write_channel(f"yes{self.RETURN}")
|
||||
time.sleep(0.3)
|
||||
# Read the confirmation response
|
||||
output += self.read_channel_timing(
|
||||
read_timeout=30, last_read=0.5
|
||||
)
|
||||
break
|
||||
|
||||
return output
|
||||
|
||||
|
||||
# Register the custom device type with Netmiko
|
||||
_registered = False # Flag to prevent duplicate registration
|
||||
|
||||
def register_custom_device_type() -> None:
|
||||
"""
|
||||
Register the custom RuijieTelnetEnhanced device type with Netmiko.
|
||||
|
||||
This function adds 'gns3_ruijie_telnet' to Netmiko's CLASS_MAPPER
|
||||
and updates the platforms lists.
|
||||
|
||||
Note: This function is idempotent - multiple calls will only register once.
|
||||
"""
|
||||
global _registered
|
||||
|
||||
# Prevent duplicate registration
|
||||
if _registered:
|
||||
logger.debug("Ruijie Telnet device type already registered, skipping")
|
||||
return
|
||||
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Register in both mappers
|
||||
sd.CLASS_MAPPER_BASE["gns3_ruijie_telnet"] = RuijieTelnetEnhanced
|
||||
sd.CLASS_MAPPER["gns3_ruijie_telnet"] = RuijieTelnetEnhanced
|
||||
|
||||
# Update platforms lists
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
|
||||
# Mark as registered
|
||||
_registered = True
|
||||
|
||||
logger.info("Successfully registered Ruijie Telnet device type with Netmiko")
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
try:
|
||||
register_custom_device_type()
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"Failed to register Ruijie device type: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Script to list all Netmiko supported devices (SSH and Telnet)
|
||||
#
|
||||
# This script queries the Netmiko library to extract all device types
|
||||
# that support SSH and Telnet connections, grouped by vendor/brand.
|
||||
# Includes custom device drivers from the GNS3-Copilot project.
|
||||
# Outputs a Markdown document with the complete device list.
|
||||
#
|
||||
# Usage: python list_netmiko_devices.py
|
||||
# Output: netmiko_devices.md (in current directory)
|
||||
#
|
||||
|
||||
"""
|
||||
Netmiko Device List Generator
|
||||
|
||||
This script extracts and displays all SSH and Telnet-supported device
|
||||
types from the Netmiko library, organized by vendor/brand.
|
||||
|
||||
Features:
|
||||
- Automatically detects Netmiko version
|
||||
- Loads custom GNS3-Copilot device drivers
|
||||
- Extracts all SSH and Telnet device types from CLASS_MAPPER
|
||||
- Groups devices by vendor/brand
|
||||
- Generates Markdown documentation
|
||||
- Provides statistics and summary
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_netmiko_version() -> str:
|
||||
"""Get Netmiko library version."""
|
||||
try:
|
||||
import netmiko
|
||||
return netmiko.__version__
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def register_custom_drivers() -> set:
|
||||
"""
|
||||
Register custom GNS3-Copilot device drivers.
|
||||
|
||||
This function imports and executes the registration functions for
|
||||
custom device drivers, adding them to Netmiko's CLASS_MAPPER.
|
||||
|
||||
Returns:
|
||||
Set of custom device type names that were registered
|
||||
"""
|
||||
# Get the directory where this script is located
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Find the custom drivers directory
|
||||
custom_drivers_dir = os.path.dirname(script_dir)
|
||||
|
||||
# Add to sys.path if not already there
|
||||
if custom_drivers_dir not in sys.path:
|
||||
sys.path.insert(0, custom_drivers_dir)
|
||||
|
||||
custom_devices = set()
|
||||
|
||||
try:
|
||||
# Import and register Huawei CE driver
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
|
||||
import huawei_ce
|
||||
|
||||
# Store custom device types before registration
|
||||
custom_devices.add("gns3_huawei_telnet_ce")
|
||||
|
||||
# The driver auto-registers on import, but we can call it
|
||||
# explicitly
|
||||
if hasattr(huawei_ce, 'register_custom_device_type'):
|
||||
huawei_ce.register_custom_device_type()
|
||||
except ImportError:
|
||||
# Silently skip if custom drivers are not available
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log but don't fail
|
||||
print(
|
||||
f"Warning: Failed to register Huawei CE driver: {e}",
|
||||
file=sys.stderr
|
||||
)
|
||||
|
||||
try:
|
||||
# Import and register Ruijie Telnet driver
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
|
||||
import ruijie_telnet
|
||||
|
||||
# Store custom device types before registration
|
||||
custom_devices.add("gns3_ruijie_telnet")
|
||||
|
||||
# The driver auto-registers on import, but we can call it
|
||||
# explicitly
|
||||
if hasattr(ruijie_telnet, 'register_custom_device_type'):
|
||||
ruijie_telnet.register_custom_device_type()
|
||||
except ImportError:
|
||||
# Silently skip if custom drivers are not available
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log but don't fail
|
||||
print(
|
||||
f"Warning: Failed to register Ruijie driver: {e}",
|
||||
file=sys.stderr
|
||||
)
|
||||
|
||||
return custom_devices
|
||||
|
||||
|
||||
def extract_brand_name(device_type: str) -> str:
|
||||
"""
|
||||
Extract brand name from device type.
|
||||
|
||||
Args:
|
||||
device_type: Device type string (e.g., 'cisco_ios_telnet')
|
||||
|
||||
Returns:
|
||||
Brand name in a standardized format
|
||||
"""
|
||||
# Remove common suffixes
|
||||
name = device_type.replace('_telnet', '').replace('_ssh', '')
|
||||
name = name.replace('_serial', '')
|
||||
|
||||
# Map to standard brand names
|
||||
brand_mapping = {
|
||||
'cisco': 'Cisco',
|
||||
'huawei': 'Huawei',
|
||||
'juniper': 'Juniper',
|
||||
'arista': 'Arista',
|
||||
'hp': 'HP',
|
||||
'aruba': 'Aruba',
|
||||
'dell': 'Dell',
|
||||
'brocade': 'Brocade',
|
||||
'extreme': 'Extreme',
|
||||
'ruckus': 'Ruckus',
|
||||
'ruijie': 'Ruijie (锐捷)',
|
||||
'zte': 'ZTE (中兴)',
|
||||
'maipu': 'Maipu (迈普)',
|
||||
'h3c': 'H3C (华三)',
|
||||
'nokia': 'Nokia',
|
||||
'paloalto': 'Palo Alto',
|
||||
'f5': 'F5',
|
||||
'checkpoint': 'Check Point',
|
||||
'generic': 'Generic',
|
||||
'huaweiyt': 'HuaweiYT',
|
||||
'vsz': 'Ruckus',
|
||||
}
|
||||
|
||||
# Check for exact matches first
|
||||
for key, value in brand_mapping.items():
|
||||
if name == key:
|
||||
return value
|
||||
|
||||
# Check for partial matches
|
||||
for key, value in brand_mapping.items():
|
||||
if key in name:
|
||||
return value
|
||||
|
||||
# Return capitalized version of the first part
|
||||
return name.split('_')[0].capitalize()
|
||||
|
||||
|
||||
def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Group device types by brand.
|
||||
|
||||
Args:
|
||||
device_types: List of device type strings
|
||||
|
||||
Returns:
|
||||
Dictionary mapping brand names to device type lists
|
||||
"""
|
||||
brands = {}
|
||||
|
||||
for device_type in device_types:
|
||||
brand = extract_brand_name(device_type)
|
||||
|
||||
if brand not in brands:
|
||||
brands[brand] = []
|
||||
|
||||
brands[brand].append(device_type)
|
||||
|
||||
return brands
|
||||
|
||||
|
||||
def get_devices_by_protocol() -> Tuple[List[str], List[str]]:
|
||||
"""
|
||||
Get all SSH and Telnet device types from Netmiko.
|
||||
|
||||
Returns:
|
||||
Tuple of (ssh_devices, telnet_devices) as sorted lists
|
||||
"""
|
||||
try:
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
|
||||
ssh_devices = []
|
||||
telnet_devices = []
|
||||
both_protocols = []
|
||||
|
||||
for device_type in CLASS_MAPPER.keys():
|
||||
device_lower = device_type.lower()
|
||||
has_telnet = 'telnet' in device_lower
|
||||
has_ssh = 'ssh' in device_lower
|
||||
|
||||
if has_telnet and has_ssh:
|
||||
both_protocols.append(device_type)
|
||||
elif has_telnet:
|
||||
telnet_devices.append(device_type)
|
||||
elif has_ssh:
|
||||
ssh_devices.append(device_type)
|
||||
|
||||
# Devices with both protocols go to both lists
|
||||
ssh_devices.extend(both_protocols)
|
||||
telnet_devices.extend(both_protocols)
|
||||
|
||||
return sorted(ssh_devices), sorted(telnet_devices)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error: Failed to import Netmiko: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_total_device_count() -> int:
|
||||
"""Get total number of device types in Netmiko."""
|
||||
try:
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
return len(CLASS_MAPPER)
|
||||
except ImportError:
|
||||
return 0
|
||||
|
||||
|
||||
def generate_markdown(
|
||||
version: str,
|
||||
ssh_devices: List[str],
|
||||
telnet_devices: List[str],
|
||||
total_count: int,
|
||||
custom_devices: set
|
||||
) -> str:
|
||||
"""
|
||||
Generate Markdown documentation with table format.
|
||||
|
||||
Args:
|
||||
version: Netmiko version string
|
||||
ssh_devices: List of SSH device types
|
||||
telnet_devices: List of Telnet device types
|
||||
total_count: Total number of device types
|
||||
custom_devices: Set of custom device type names
|
||||
|
||||
Returns:
|
||||
Markdown formatted string
|
||||
"""
|
||||
md_lines = []
|
||||
|
||||
# Header
|
||||
md_lines.append("# Netmiko Supported Devices")
|
||||
md_lines.append("")
|
||||
md_lines.append(f"**Netmiko Version:** {version}")
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
md_lines.append(f"**Generated:** {timestamp}")
|
||||
md_lines.append("")
|
||||
md_lines.append("---")
|
||||
md_lines.append("")
|
||||
|
||||
# Summary
|
||||
md_lines.append("## Summary")
|
||||
md_lines.append("")
|
||||
md_lines.append(f"- **Total Device Types:** {total_count}")
|
||||
md_lines.append(f"- **SSH Devices:** {len(ssh_devices)}")
|
||||
md_lines.append(f"- **Telnet Devices:** {len(telnet_devices)}")
|
||||
md_lines.append(f"- **Custom Devices:** {len(custom_devices)}")
|
||||
md_lines.append("")
|
||||
|
||||
# SSH Devices Section
|
||||
md_lines.append("## SSH Supported Devices")
|
||||
md_lines.append("")
|
||||
md_lines.append("| Platform | Device Type | Source |")
|
||||
md_lines.append("|----------|-------------|--------|")
|
||||
|
||||
ssh_brands = group_devices_by_brand(ssh_devices)
|
||||
for brand in sorted(ssh_brands.keys()):
|
||||
devices = sorted(ssh_brands[brand])
|
||||
for device in devices:
|
||||
if device in custom_devices:
|
||||
source = "Custom ✨"
|
||||
else:
|
||||
source = "Netmiko"
|
||||
md_lines.append(f"| {brand} | `{device}` | {source} |")
|
||||
md_lines.append("")
|
||||
|
||||
# Telnet Devices Section
|
||||
md_lines.append("## Telnet Supported Devices")
|
||||
md_lines.append("")
|
||||
md_lines.append("| Platform | Device Type | Source |")
|
||||
md_lines.append("|----------|-------------|--------|")
|
||||
|
||||
telnet_brands = group_devices_by_brand(telnet_devices)
|
||||
for brand in sorted(telnet_brands.keys()):
|
||||
devices = sorted(telnet_brands[brand])
|
||||
for device in devices:
|
||||
if device in custom_devices:
|
||||
source = "Custom ✨"
|
||||
else:
|
||||
source = "Netmiko"
|
||||
md_lines.append(f"| {brand} | `{device}` | {source} |")
|
||||
md_lines.append("")
|
||||
|
||||
# Footer
|
||||
md_lines.append("---")
|
||||
md_lines.append("")
|
||||
md_lines.append("*This document was generated automatically by the "
|
||||
"Netmiko device list script.*")
|
||||
|
||||
return "\n".join(md_lines)
|
||||
|
||||
|
||||
def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
|
||||
"""
|
||||
Save Markdown content to file in docs/gns3-copilot directory.
|
||||
|
||||
Args:
|
||||
content: Markdown content to save
|
||||
filename: Output filename (default: netmiko_devices.md)
|
||||
"""
|
||||
# Get the project root directory
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Go up from: .../gns3server/agent/gns3_copilot/utils/
|
||||
# custom_netmiko/scripts to project root
|
||||
# That's 6 levels up
|
||||
project_root = os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(script_dir))))
|
||||
)
|
||||
)
|
||||
|
||||
# Target directory: docs/gns3-copilot
|
||||
target_dir = os.path.join(project_root, "docs", "gns3-copilot")
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# Full file path
|
||||
output_path = os.path.join(target_dir, filename)
|
||||
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"Markdown document saved to: {output_path}")
|
||||
except IOError as e:
|
||||
print(f"Error: Failed to save Markdown file: {e}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def print_summary(version: str, ssh_count: int, telnet_count: int,
|
||||
total_count: int, output_file: str) -> None:
|
||||
"""Print summary to console."""
|
||||
print("=" * 80)
|
||||
print("Netmiko Device List Generator")
|
||||
print(f"Netmiko Version: {version}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
print(f"Total SSH devices: {ssh_count}")
|
||||
print(f"Total Telnet devices: {telnet_count}")
|
||||
print(f"Total device types: {total_count}")
|
||||
print()
|
||||
print(f"Output file: {output_file}")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Main function to execute the script.
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, 1 for error)
|
||||
"""
|
||||
# Register custom GNS3-Copilot drivers
|
||||
custom_devices = register_custom_drivers()
|
||||
|
||||
# Get Netmiko version
|
||||
version = get_netmiko_version()
|
||||
|
||||
# Get SSH and Telnet devices
|
||||
ssh_devices, telnet_devices = get_devices_by_protocol()
|
||||
|
||||
if not ssh_devices and not telnet_devices:
|
||||
print("Error: No devices found!", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get total device count
|
||||
total_count = get_total_device_count()
|
||||
|
||||
# Generate Markdown
|
||||
markdown_content = generate_markdown(
|
||||
version=version,
|
||||
ssh_devices=ssh_devices,
|
||||
telnet_devices=telnet_devices,
|
||||
total_count=total_count,
|
||||
custom_devices=custom_devices
|
||||
)
|
||||
|
||||
# Save to file
|
||||
output_file = "netmiko_devices.md"
|
||||
save_markdown(markdown_content, output_file)
|
||||
|
||||
# Print summary
|
||||
print_summary(
|
||||
version=version,
|
||||
ssh_count=len(ssh_devices),
|
||||
telnet_count=len(telnet_devices),
|
||||
total_count=total_count,
|
||||
output_file=output_file
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Unit tests for custom Netmiko drivers.
|
||||
"""
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Unit test script for custom Netmiko GNS3HuaweiTelnetCE driver
|
||||
#
|
||||
|
||||
"""
|
||||
Unit test script for GNS3HuaweiTelnetCE custom device driver.
|
||||
|
||||
This script tests:
|
||||
1. Device type registration
|
||||
2. Inheritance from HuaweiBase
|
||||
3. VRP-specific command handling
|
||||
4. Telnet login logic (mocked)
|
||||
|
||||
Run with: python test_huawei_ce.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import os
|
||||
|
||||
# Add project root to path using relative path
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(test_dir)))))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
|
||||
class TestHuaweiTelnetCEDriver(unittest.TestCase):
|
||||
"""Test suite for GNS3HuaweiTelnetCE custom driver."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures - import and register custom driver."""
|
||||
# Import the custom driver module (triggers registration)
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
||||
huawei_ce,
|
||||
)
|
||||
|
||||
cls.huawei_ce = huawei_ce
|
||||
cls.HuaweiTelnetCE = huawei_ce.GNS3HuaweiTelnetCE
|
||||
|
||||
def test_device_type_registered(self):
|
||||
"""Test that gns3_huawei_telnet_ce is registered in Netmiko."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE
|
||||
|
||||
# Check CLASS_MAPPER
|
||||
self.assertIn("gns3_huawei_telnet_ce", CLASS_MAPPER)
|
||||
registered_class = CLASS_MAPPER["gns3_huawei_telnet_ce"]
|
||||
# Compare class name since class may be imported via different paths
|
||||
self.assertEqual(registered_class.__name__, self.HuaweiTelnetCE.__name__)
|
||||
|
||||
# Check CLASS_MAPPER_BASE
|
||||
self.assertIn("gns3_huawei_telnet_ce", CLASS_MAPPER_BASE)
|
||||
registered_class_base = CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"]
|
||||
self.assertEqual(registered_class_base.__name__, self.HuaweiTelnetCE.__name__)
|
||||
|
||||
def test_inheritance_from_huawei_base(self):
|
||||
"""Test that GNS3HuaweiTelnetCE inherits from HuaweiBase."""
|
||||
from netmiko.huawei.huawei import HuaweiBase
|
||||
|
||||
# Verify inheritance
|
||||
self.assertIsInstance(self.HuaweiTelnetCE, type)
|
||||
# Check if GNS3HuaweiTelnetCE is a subclass of HuaweiBase
|
||||
self.assertTrue(issubclass(self.HuaweiTelnetCE, HuaweiBase))
|
||||
|
||||
def test_huawei_base_methods_available(self):
|
||||
"""Test that VRP-specific methods are available."""
|
||||
# These methods should be inherited from HuaweiBase
|
||||
vrp_methods = [
|
||||
"config_mode",
|
||||
"check_config_mode",
|
||||
"exit_config_mode",
|
||||
"send_config_set",
|
||||
"send_command",
|
||||
"disable_paging",
|
||||
]
|
||||
|
||||
for method_name in vrp_methods:
|
||||
self.assertTrue(
|
||||
hasattr(self.HuaweiTelnetCE, method_name),
|
||||
f"Method {method_name} not found in GNS3HuaweiTelnetCE",
|
||||
)
|
||||
|
||||
def test_telnet_login_method_exists(self):
|
||||
"""Test that telnet_login method is overridden."""
|
||||
# Should have overridden telnet_login
|
||||
self.assertTrue(hasattr(self.HuaweiTelnetCE, "telnet_login"))
|
||||
|
||||
# Check if method is in GNS3HuaweiTelnetCE's __dict__
|
||||
# (means it's defined there, not inherited)
|
||||
self.assertIn(
|
||||
"telnet_login",
|
||||
self.HuaweiTelnetCE.__dict__,
|
||||
"telnet_login should be defined in GNS3HuaweiTelnetCE",
|
||||
)
|
||||
|
||||
def test_initialization_parameters(self):
|
||||
"""Test that initialization sets correct parameters."""
|
||||
# Create a mock instance (without actual connection)
|
||||
with patch.object(
|
||||
self.HuaweiTelnetCE, "__init__",
|
||||
lambda self, *args, **kwargs: None
|
||||
):
|
||||
instance = self.HuaweiTelnetCE.__new__(
|
||||
self.HuaweiTelnetCE
|
||||
)
|
||||
|
||||
# Mock the necessary attributes
|
||||
instance.protocol = "telnet"
|
||||
instance.device_type = "huawei_telnet"
|
||||
|
||||
# Verify protocol is set to telnet
|
||||
self.assertEqual(instance.protocol, "telnet")
|
||||
self.assertEqual(instance.device_type, "huawei_telnet")
|
||||
|
||||
def test_connect_handler_accepts_device_type(self):
|
||||
"""Test that ConnectHandler accepts gns3_huawei_telnet_ce."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
|
||||
# Get platforms list
|
||||
platforms = list(CLASS_MAPPER.keys())
|
||||
|
||||
# Verify gns3_huawei_telnet_ce is in platforms
|
||||
self.assertIn("gns3_huawei_telnet_ce", platforms)
|
||||
|
||||
# Verify it's in telnet platforms
|
||||
telnet_platforms = [x for x in platforms if "telnet" in x]
|
||||
self.assertIn("gns3_huawei_telnet_ce", telnet_platforms)
|
||||
|
||||
def test_prompt_pattern_constants(self):
|
||||
"""Test that Huawei prompt patterns are correctly defined."""
|
||||
import inspect
|
||||
|
||||
# Get the telnet_login method signature
|
||||
sig = inspect.signature(self.HuaweiTelnetCE.telnet_login)
|
||||
|
||||
# Check default prompt patterns
|
||||
pri_prompt = sig.parameters["pri_prompt_terminator"].default
|
||||
alt_prompt = sig.parameters["alt_prompt_terminator"].default
|
||||
|
||||
# Should match Huawei prompt patterns
|
||||
self.assertIn("<", pri_prompt)
|
||||
self.assertIn("[", alt_prompt)
|
||||
|
||||
def test_disable_paging_command(self):
|
||||
"""Test that disable_paging uses correct Huawei command."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(self.HuaweiTelnetCE.disable_paging)
|
||||
command_default = sig.parameters["command"].default
|
||||
|
||||
# Should use Huawei-specific command
|
||||
self.assertEqual(command_default, "screen-length 0 temporary")
|
||||
|
||||
|
||||
class TestHuaweiTelnetCEIntegration(unittest.TestCase):
|
||||
"""Integration tests for GNS3HuaweiTelnetCE driver."""
|
||||
|
||||
def test_mock_telnet_connection(self):
|
||||
"""Test telnet_login logic with mocked connection."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.huawei_ce import ( # noqa: E501
|
||||
GNS3HuaweiTelnetCE,
|
||||
)
|
||||
|
||||
# Create a mock instance
|
||||
instance = GNS3HuaweiTelnetCE.__new__(
|
||||
GNS3HuaweiTelnetCE
|
||||
)
|
||||
|
||||
# Mock the necessary attributes and methods
|
||||
instance.host = "127.0.0.1"
|
||||
instance.RETURN = "\r\n"
|
||||
instance.global_delay_factor = 1.0
|
||||
instance.remote_conn = Mock()
|
||||
|
||||
# Mock select_delay_factor
|
||||
instance.select_delay_factor = Mock(return_value=1.0)
|
||||
|
||||
# Mock read_channel and write_channel
|
||||
test_outputs = ["", "<HUAWEI>"]
|
||||
instance.read_channel = Mock(side_effect=test_outputs)
|
||||
instance.write_channel = Mock()
|
||||
|
||||
# Call telnet_login
|
||||
result = instance.telnet_login()
|
||||
|
||||
# Verify behavior
|
||||
self.assertIn("<HUAWEI>", result)
|
||||
instance.write_channel.assert_called()
|
||||
self.assertEqual(instance.read_channel.call_count, 2)
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests and print results."""
|
||||
# Create test suite
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# Add test cases
|
||||
suite.addTests(
|
||||
loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver)
|
||||
)
|
||||
suite.addTests(
|
||||
loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration)
|
||||
)
|
||||
|
||||
# Run tests
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 100)
|
||||
print("Test Summary:")
|
||||
print(f" Run: {result.testsRun}")
|
||||
success_count = (
|
||||
result.testsRun - len(result.failures) - len(result.errors)
|
||||
)
|
||||
print(f" Success: {success_count}")
|
||||
print(f" Failed: {len(result.failures)}")
|
||||
print(f" Errors: {len(result.errors)}")
|
||||
print("=" * 100)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Unit test script for custom Netmiko RuijieTelnetEnhanced driver
|
||||
#
|
||||
|
||||
"""
|
||||
Unit test script for RuijieTelnetEnhanced custom device driver.
|
||||
|
||||
This script tests:
|
||||
1. Device type registration
|
||||
2. Inheritance from RuijieOSBase
|
||||
3. Interactive command preprocessing
|
||||
4. Batch send and fallback logic (mocked)
|
||||
|
||||
Run with: python test_ruijie_telnet.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import os
|
||||
|
||||
# Add project root to path using relative path
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(test_dir)))))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
|
||||
class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
|
||||
"""Test suite for RuijieTelnetEnhanced custom driver."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures - import and register custom driver."""
|
||||
# Import the custom driver module (triggers registration)
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
||||
ruijie_telnet,
|
||||
)
|
||||
|
||||
cls.ruijie_telnet = ruijie_telnet
|
||||
cls.RuijieTelnetEnhanced = ruijie_telnet.RuijieTelnetEnhanced
|
||||
|
||||
def test_device_type_registered(self):
|
||||
"""Test that gns3_ruijie_telnet is registered in Netmiko."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE
|
||||
|
||||
# Check CLASS_MAPPER
|
||||
self.assertIn("gns3_ruijie_telnet", CLASS_MAPPER)
|
||||
registered_class = CLASS_MAPPER["gns3_ruijie_telnet"]
|
||||
# Compare class name since class may be imported via different paths
|
||||
self.assertEqual(registered_class.__name__, self.RuijieTelnetEnhanced.__name__)
|
||||
|
||||
# Check CLASS_MAPPER_BASE
|
||||
self.assertIn("gns3_ruijie_telnet", CLASS_MAPPER_BASE)
|
||||
registered_class_base = CLASS_MAPPER_BASE["gns3_ruijie_telnet"]
|
||||
self.assertEqual(registered_class_base.__name__, self.RuijieTelnetEnhanced.__name__)
|
||||
|
||||
def test_inheritance_from_ruijie_os_base(self):
|
||||
"""Test that RuijieTelnetEnhanced inherits from RuijieOSBase."""
|
||||
from netmiko.ruijie.ruijie_os import RuijieOSBase
|
||||
|
||||
# Verify inheritance
|
||||
self.assertIsInstance(self.RuijieTelnetEnhanced, type)
|
||||
# Check if RuijieTelnetEnhanced is a subclass of RuijieOSBase
|
||||
self.assertTrue(issubclass(self.RuijieTelnetEnhanced, RuijieOSBase))
|
||||
|
||||
def test_ruijie_base_methods_available(self):
|
||||
"""Test that Ruijie-specific methods are available."""
|
||||
# These methods should be inherited from RuijieOSBase
|
||||
ruijie_methods = [
|
||||
"config_mode",
|
||||
"check_config_mode",
|
||||
"exit_config_mode",
|
||||
"send_config_set",
|
||||
"send_command",
|
||||
"disable_paging",
|
||||
]
|
||||
|
||||
for method_name in ruijie_methods:
|
||||
self.assertTrue(
|
||||
hasattr(self.RuijieTelnetEnhanced, method_name),
|
||||
f"Method {method_name} not found in RuijieTelnetEnhanced",
|
||||
)
|
||||
|
||||
def test_interactive_patterns_defined(self):
|
||||
"""Test that INTERACTIVE_PATTERNS is correctly defined."""
|
||||
import re
|
||||
|
||||
# Should have INTERACTIVE_PATTERNS as a class attribute
|
||||
self.assertTrue(
|
||||
hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS")
|
||||
)
|
||||
|
||||
# Check it's a list
|
||||
patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||
self.assertIsInstance(patterns, list)
|
||||
|
||||
# Check it contains expected patterns (compiled regex)
|
||||
self.assertGreater(len(patterns), 0)
|
||||
for pattern in patterns:
|
||||
self.assertIsInstance(pattern, re.Pattern)
|
||||
|
||||
def test_preprocess_interactive_commands(self):
|
||||
"""Test that interactive commands are preprocessed correctly."""
|
||||
# Create a mock instance (without actual connection)
|
||||
with patch.object(
|
||||
self.RuijieTelnetEnhanced, "__init__",
|
||||
lambda self, *args, **kwargs: None
|
||||
):
|
||||
instance = self.RuijieTelnetEnhanced.__new__(
|
||||
self.RuijieTelnetEnhanced
|
||||
)
|
||||
|
||||
# Set INTERACTIVE_PATTERNS from class
|
||||
instance.INTERACTIVE_PATTERNS = (
|
||||
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||
)
|
||||
|
||||
# Test commands with router-id (should trigger 'yes')
|
||||
commands = [
|
||||
"router ospf 1",
|
||||
"router-id 1.1.1.1",
|
||||
"network 10.0.0.0 0.0.0.255 area 0",
|
||||
]
|
||||
|
||||
processed = instance._preprocess_interactive_commands(
|
||||
commands
|
||||
)
|
||||
|
||||
# Should have inserted 'yes' after router-id command
|
||||
self.assertEqual(len(processed), 4)
|
||||
self.assertEqual(processed[0], "router ospf 1")
|
||||
self.assertEqual(processed[1], "router-id 1.1.1.1")
|
||||
self.assertEqual(processed[2], "yes")
|
||||
self.assertEqual(
|
||||
processed[3], "network 10.0.0.0 0.0.0.255 area 0"
|
||||
)
|
||||
|
||||
def test_preprocess_non_interactive_commands(self):
|
||||
"""Test that non-interactive commands are not modified."""
|
||||
# Create a mock instance
|
||||
with patch.object(
|
||||
self.RuijieTelnetEnhanced, "__init__",
|
||||
lambda self, *args, **kwargs: None
|
||||
):
|
||||
instance = self.RuijieTelnetEnhanced.__new__(
|
||||
self.RuijieTelnetEnhanced
|
||||
)
|
||||
|
||||
# Set INTERACTIVE_PATTERNS from class
|
||||
instance.INTERACTIVE_PATTERNS = (
|
||||
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||
)
|
||||
|
||||
# Test commands without interactive prompts
|
||||
commands = [
|
||||
"interface GigabitEthernet 0/1",
|
||||
"description Test Interface",
|
||||
"ip address 192.168.1.1 255.255.255.0",
|
||||
]
|
||||
|
||||
processed = instance._preprocess_interactive_commands(commands)
|
||||
|
||||
# Should not have inserted any 'yes' commands
|
||||
self.assertEqual(len(processed), 3)
|
||||
self.assertEqual(processed, commands)
|
||||
|
||||
def test_connect_handler_accepts_device_type(self):
|
||||
"""Test that ConnectHandler accepts gns3_ruijie_telnet."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
|
||||
# Get platforms list
|
||||
platforms = list(CLASS_MAPPER.keys())
|
||||
|
||||
# Verify gns3_ruijie_telnet is in platforms
|
||||
self.assertIn("gns3_ruijie_telnet", platforms)
|
||||
|
||||
# Verify it's in telnet platforms
|
||||
telnet_platforms = [x for x in platforms if "telnet" in x]
|
||||
self.assertIn("gns3_ruijie_telnet", telnet_platforms)
|
||||
|
||||
def test_default_enter_parameter(self):
|
||||
"""Test that default_enter is set to \\r\\n for telnet."""
|
||||
import inspect
|
||||
|
||||
# Check that default_enter is handled in __init__
|
||||
# (The actual logic is in the __init__ method body)
|
||||
init_source = inspect.getsource(
|
||||
self.RuijieTelnetEnhanced.__init__
|
||||
)
|
||||
self.assertIn("default_enter", init_source)
|
||||
self.assertIn("\\r\\n", init_source)
|
||||
|
||||
|
||||
class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
|
||||
"""Integration tests for RuijieTelnetEnhanced driver."""
|
||||
|
||||
def test_send_config_set_calls_preprocess(self):
|
||||
"""Test that send_config_set calls preprocessing."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501
|
||||
RuijieTelnetEnhanced,
|
||||
)
|
||||
|
||||
# Create a mock instance
|
||||
instance = RuijieTelnetEnhanced.__new__(
|
||||
RuijieTelnetEnhanced
|
||||
)
|
||||
|
||||
# Mock the necessary attributes and methods
|
||||
instance.RETURN = "\r\n"
|
||||
instance.global_delay_factor = 1.0
|
||||
|
||||
# Mock config_mode and exit_config_mode
|
||||
instance.config_mode = Mock(return_value="")
|
||||
instance.exit_config_mode = Mock(return_value="")
|
||||
instance._send_config_batch = Mock(return_value="Output")
|
||||
|
||||
# Test commands
|
||||
commands = ["interface GigabitEthernet 0/1", "description Test"]
|
||||
|
||||
# Call send_config_set
|
||||
instance.send_config_set(
|
||||
commands, enter_config_mode=False, exit_config_mode=False
|
||||
)
|
||||
|
||||
# Verify _send_config_batch was called
|
||||
instance._send_config_batch.assert_called_once()
|
||||
|
||||
def test_multiple_interactive_patterns(self):
|
||||
"""Test that multiple interactive commands are detected."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501
|
||||
RuijieTelnetEnhanced,
|
||||
)
|
||||
|
||||
# Create a mock instance
|
||||
instance = RuijieTelnetEnhanced.__new__(
|
||||
RuijieTelnetEnhanced
|
||||
)
|
||||
instance.INTERACTIVE_PATTERNS = (
|
||||
RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
|
||||
)
|
||||
|
||||
# Test commands with multiple interactive prompts
|
||||
commands = [
|
||||
"router-id 1.1.1.1", # Should trigger 'yes'
|
||||
"erase startup-config", # Should trigger 'yes'
|
||||
"interface GigabitEthernet 0/1", # Should NOT trigger
|
||||
]
|
||||
|
||||
processed = instance._preprocess_interactive_commands(commands)
|
||||
|
||||
# Should have inserted 'yes' after first two commands
|
||||
# processed[0] = "router-id 1.1.1.1", processed[1] = "yes"
|
||||
# processed[2] = "erase startup-config", processed[3] = "yes"
|
||||
# processed[4] = "interface GigabitEthernet 0/1"
|
||||
self.assertEqual(len(processed), 5)
|
||||
self.assertEqual(processed[0], "router-id 1.1.1.1")
|
||||
self.assertEqual(processed[1], "yes")
|
||||
self.assertEqual(processed[2], "erase startup-config")
|
||||
self.assertEqual(processed[3], "yes")
|
||||
self.assertEqual(processed[4], "interface GigabitEthernet 0/1")
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests and print results."""
|
||||
# Create test suite
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# Add test cases
|
||||
suite.addTests(
|
||||
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver)
|
||||
)
|
||||
suite.addTests(
|
||||
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration)
|
||||
)
|
||||
|
||||
# Run tests
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 100)
|
||||
print("Test Summary:")
|
||||
print(f" Run: {result.testsRun}")
|
||||
success_count = (
|
||||
result.testsRun - len(result.failures) - len(result.errors)
|
||||
)
|
||||
print(f" Success: {success_count}")
|
||||
print(f" Failed: {len(result.failures)}")
|
||||
print(f" Errors: {len(result.errors)}")
|
||||
print("=" * 100)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Unit test script for custom Netmiko VPCSTelnet driver
|
||||
#
|
||||
|
||||
"""
|
||||
Unit test script for VPCSTelnet custom device driver.
|
||||
|
||||
This script tests:
|
||||
1. Device type registration
|
||||
2. Inheritance from BaseConnection
|
||||
3. VPCS-specific methods (telnet_login, session_preparation, send_command)
|
||||
4. Config mode methods (check_config_mode, config_mode, exit_config_mode)
|
||||
5. disable_paging method
|
||||
6. Default parameters (default_enter, global_delay_factor, fast_cli)
|
||||
|
||||
Run with: python test_vpcs_telnet.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import os
|
||||
|
||||
# Add project root to path using relative path
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(test_dir)))))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
|
||||
class TestVPCSTelnetDriver(unittest.TestCase):
|
||||
"""Test suite for VPCSTelnet custom driver."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures - import and register custom driver."""
|
||||
# Import the custom driver module (triggers registration)
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
||||
vpcs_telnet,
|
||||
)
|
||||
|
||||
cls.vpcs_telnet = vpcs_telnet
|
||||
cls.VPCSTelnet = vpcs_telnet.VPCSTelnet
|
||||
|
||||
def test_device_type_registered(self):
|
||||
"""Test that gns3_vpcs_telnet is registered in Netmiko."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE
|
||||
|
||||
# Check CLASS_MAPPER
|
||||
self.assertIn("gns3_vpcs_telnet", CLASS_MAPPER)
|
||||
registered_class = CLASS_MAPPER["gns3_vpcs_telnet"]
|
||||
# Compare class name since class may be imported via different paths
|
||||
self.assertEqual(registered_class.__name__, self.VPCSTelnet.__name__)
|
||||
|
||||
# Check CLASS_MAPPER_BASE
|
||||
self.assertIn("gns3_vpcs_telnet", CLASS_MAPPER_BASE)
|
||||
registered_class_base = CLASS_MAPPER_BASE["gns3_vpcs_telnet"]
|
||||
self.assertEqual(registered_class_base.__name__, self.VPCSTelnet.__name__)
|
||||
|
||||
def test_inheritance_from_base_connection(self):
|
||||
"""Test that VPCSTelnet inherits from BaseConnection."""
|
||||
from netmiko.base_connection import BaseConnection
|
||||
|
||||
# Verify inheritance
|
||||
self.assertIsInstance(self.VPCSTelnet, type)
|
||||
# Check if VPCSTelnet is a subclass of BaseConnection
|
||||
self.assertTrue(issubclass(self.VPCSTelnet, BaseConnection))
|
||||
|
||||
def test_vpcs_methods_available(self):
|
||||
"""Test that VPCS-specific methods are available."""
|
||||
# These methods should be available in VPCSTelnet
|
||||
vpcs_methods = [
|
||||
"telnet_login",
|
||||
"session_preparation",
|
||||
"send_command",
|
||||
"send_command_timing",
|
||||
"check_config_mode",
|
||||
"config_mode",
|
||||
"exit_config_mode",
|
||||
"disable_paging",
|
||||
]
|
||||
|
||||
for method_name in vpcs_methods:
|
||||
self.assertTrue(
|
||||
hasattr(self.VPCSTelnet, method_name),
|
||||
f"Method {method_name} not found in VPCSTelnet",
|
||||
)
|
||||
|
||||
def test_connect_handler_accepts_device_type(self):
|
||||
"""Test that ConnectHandler accepts gns3_vpcs_telnet."""
|
||||
from netmiko.ssh_dispatcher import CLASS_MAPPER
|
||||
|
||||
# Get platforms list
|
||||
platforms = list(CLASS_MAPPER.keys())
|
||||
|
||||
# Verify gns3_vpcs_telnet is in platforms
|
||||
self.assertIn("gns3_vpcs_telnet", platforms)
|
||||
|
||||
# Verify it's in telnet platforms
|
||||
telnet_platforms = [x for x in platforms if "telnet" in x]
|
||||
self.assertIn("gns3_vpcs_telnet", telnet_platforms)
|
||||
|
||||
def test_telnet_platforms_list(self):
|
||||
"""Test that gns3_vpcs_telnet is in telnet_platforms list."""
|
||||
from netmiko.ssh_dispatcher import telnet_platforms
|
||||
|
||||
self.assertIn("gns3_vpcs_telnet", telnet_platforms)
|
||||
|
||||
|
||||
class TestVPCSTelnetInit(unittest.TestCase):
|
||||
"""Test VPCSTelnet initialization and default parameters."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||
VPCSTelnet,
|
||||
)
|
||||
cls.VPCSTelnet = VPCSTelnet
|
||||
|
||||
def test_default_enter_parameter(self):
|
||||
"""Test that default_enter is set to \\r\\n for VPCS."""
|
||||
import inspect
|
||||
|
||||
# Check that default_enter is handled in __init__
|
||||
init_source = inspect.getsource(self.VPCSTelnet.__init__)
|
||||
self.assertIn("default_enter", init_source)
|
||||
self.assertIn("\\r\\n", init_source)
|
||||
|
||||
def test_default_parameters_when_none(self):
|
||||
"""Test default parameters when not provided."""
|
||||
# Mock the BaseConnection.__init__ to avoid actual connection
|
||||
# But also set device_type since VPCSTelnet.__init__ sets it
|
||||
def mock_init(self, *args, **kwargs):
|
||||
# Simulate what VPCSTelnet.__init__ does
|
||||
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
||||
self.device_type = kwargs.get("device_type")
|
||||
|
||||
with patch.object(
|
||||
self.VPCSTelnet, "__init__", mock_init
|
||||
):
|
||||
instance = self.VPCSTelnet(host="127.0.0.1")
|
||||
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
||||
|
||||
def test_device_type_set(self):
|
||||
"""Test that device_type is set to gns3_vpcs_telnet."""
|
||||
# Mock the __init__ to set device_type
|
||||
def mock_init(self, *args, **kwargs):
|
||||
# Simulate what VPCSTelnet.__init__ does
|
||||
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
||||
self.device_type = kwargs.get("device_type")
|
||||
|
||||
with patch.object(
|
||||
self.VPCSTelnet, "__init__", mock_init
|
||||
):
|
||||
instance = self.VPCSTelnet(host="127.0.0.1")
|
||||
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
|
||||
|
||||
|
||||
class TestVPCSTelnetMethods(unittest.TestCase):
|
||||
"""Test VPCSTelnet method implementations."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||
VPCSTelnet,
|
||||
)
|
||||
cls.VPCSTelnet = VPCSTelnet
|
||||
|
||||
def test_check_config_mode_always_false(self):
|
||||
"""Test that check_config_mode always returns False."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
result = instance.check_config_mode()
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_config_mode_returns_empty(self):
|
||||
"""Test that config_mode returns empty string."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
result = instance.config_mode()
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_exit_config_mode_returns_empty(self):
|
||||
"""Test that exit_config_mode returns empty string."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
result = instance.exit_config_mode()
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_disable_paging_returns_empty(self):
|
||||
"""Test that disable_paging returns empty string."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
result = instance.disable_paging()
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_session_preparation_logs_debug(self):
|
||||
"""Test that session_preparation completes without errors."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
# Should not raise any exception
|
||||
instance.session_preparation()
|
||||
|
||||
|
||||
class TestVPCSTelnetSendCommand(unittest.TestCase):
|
||||
"""Test VPCSTelnet send_command method."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||
VPCSTelnet,
|
||||
)
|
||||
cls.VPCSTelnet = VPCSTelnet
|
||||
|
||||
def test_send_command_writes_bytes(self):
|
||||
"""Test that send_command writes command as bytes."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
|
||||
# Mock the necessary attributes
|
||||
instance.remote_conn = MagicMock()
|
||||
instance.remote_conn.write = MagicMock()
|
||||
|
||||
# Test command encoding
|
||||
command_string = "ip 192.168.1.1 255.255.255.0"
|
||||
expected_bytes = command_string.encode("ascii") + b"\n"
|
||||
|
||||
# Call send_command (will fail at read_until_pattern, but we can
|
||||
# check the write call)
|
||||
try:
|
||||
instance.send_command(command_string)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify write was called with correct bytes
|
||||
instance.remote_conn.write.assert_called_once_with(expected_bytes)
|
||||
|
||||
def test_send_command_default_expect_string(self):
|
||||
"""Test that send_command uses VPCS prompt as default expect_string."""
|
||||
import inspect
|
||||
|
||||
# Check that expect_string defaults to r"PC\d+>"
|
||||
source = inspect.getsource(self.VPCSTelnet.send_command)
|
||||
self.assertIn("expect_string = r\"PC\\d+>\"", source)
|
||||
|
||||
def test_send_command_timing_calls_send_command(self):
|
||||
"""Test that send_command_timing delegates to send_command."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
|
||||
# Mock send_command
|
||||
instance.send_command = Mock(return_value="test output")
|
||||
|
||||
# Call send_command_timing
|
||||
result = instance.send_command_timing(
|
||||
"ip 192.168.1.1 255.255.255.0",
|
||||
strip_prompt=True,
|
||||
strip_command=True,
|
||||
)
|
||||
|
||||
# Verify send_command was called
|
||||
instance.send_command.assert_called_once_with(
|
||||
command_string="ip 192.168.1.1 255.255.255.0",
|
||||
strip_prompt=True,
|
||||
strip_command=True,
|
||||
)
|
||||
self.assertEqual(result, "test output")
|
||||
|
||||
|
||||
class TestVPCSTelnetTelnetLogin(unittest.TestCase):
|
||||
"""Test VPCSTelnet telnet_login method."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||
VPCSTelnet,
|
||||
)
|
||||
cls.VPCSTelnet = VPCSTelnet
|
||||
|
||||
def test_telnet_login_default_prompt_pattern(self):
|
||||
"""Test that telnet_login has correct default prompt pattern."""
|
||||
import inspect
|
||||
|
||||
# Check default prompt terminator
|
||||
source = inspect.getsource(self.VPCSTelnet.telnet_login)
|
||||
self.assertIn('pri_prompt_terminator: str = r"PC\\d+>"', source)
|
||||
|
||||
def test_telnet_login_sends_newlines(self):
|
||||
"""Test that telnet_login sends 4 newlines."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
|
||||
# Mock necessary attributes
|
||||
instance.remote_conn = MagicMock()
|
||||
instance.remote_conn.write = MagicMock()
|
||||
instance.read_channel = MagicMock(return_value="PC1>")
|
||||
instance.select_delay_factor = Mock(return_value=1.0)
|
||||
instance.read_until_pattern = MagicMock(return_value="PC1>")
|
||||
|
||||
# Call telnet_login
|
||||
instance.telnet_login()
|
||||
|
||||
# Verify write was called 4 times (4 newlines)
|
||||
self.assertEqual(instance.remote_conn.write.call_count, 4)
|
||||
|
||||
# Verify each call was with b"\n"
|
||||
for call in instance.remote_conn.write.call_args_list:
|
||||
self.assertEqual(call[0][0], b"\n")
|
||||
|
||||
def test_telnet_login_waits_for_prompt(self):
|
||||
"""Test that telnet_login waits for VPCS prompt."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
|
||||
# Mock necessary attributes
|
||||
instance.remote_conn = MagicMock()
|
||||
instance.remote_conn.write = MagicMock()
|
||||
instance.read_channel = MagicMock(return_value="PC1>")
|
||||
instance.select_delay_factor = Mock(return_value=1.0)
|
||||
instance.read_until_pattern = MagicMock(return_value="PC1>")
|
||||
|
||||
# Call telnet_login
|
||||
result = instance.telnet_login()
|
||||
|
||||
# Verify read_until_pattern was called
|
||||
instance.read_until_pattern.assert_called_once()
|
||||
|
||||
|
||||
class TestVPCSTelnetRegistration(unittest.TestCase):
|
||||
"""Test VPCSTelnet registration function."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
|
||||
vpcs_telnet,
|
||||
)
|
||||
cls.vpcs_telnet = vpcs_telnet
|
||||
|
||||
def test_register_function_exists(self):
|
||||
"""Test that register_custom_device_type function exists."""
|
||||
self.assertTrue(
|
||||
hasattr(self.vpcs_telnet, "register_custom_device_type")
|
||||
)
|
||||
|
||||
def test_register_function_is_callable(self):
|
||||
"""Test that register_custom_device_type is callable."""
|
||||
self.assertTrue(
|
||||
callable(self.vpcs_telnet.register_custom_device_type)
|
||||
)
|
||||
|
||||
|
||||
class TestVPCSTelnetAnsiStripping(unittest.TestCase):
|
||||
"""Test VPCSTelnet ANSI escape code stripping."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test fixtures."""
|
||||
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
|
||||
VPCSTelnet,
|
||||
)
|
||||
cls.VPCSTelnet = VPCSTelnet
|
||||
|
||||
def test_strip_ansi_codes_method_exists(self):
|
||||
"""Test that _strip_ansi_codes method exists."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
self.assertTrue(hasattr(instance, "_strip_ansi_codes"))
|
||||
self.assertTrue(callable(instance._strip_ansi_codes))
|
||||
|
||||
def test_strip_bold_codes(self):
|
||||
"""Test stripping bold ANSI codes."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
text = "\x1b[1mip\x1b[0m \x1b[4mARG\x1b[0m"
|
||||
result = instance._strip_ansi_codes(text)
|
||||
self.assertEqual(result, "ip ARG")
|
||||
|
||||
def test_strip_underline_codes(self):
|
||||
"""Test stripping underline ANSI codes."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
text = "\x1b[4maddress\x1b[0m [\x1b[4mmask\x1b[0m]"
|
||||
result = instance._strip_ansi_codes(text)
|
||||
self.assertEqual(result, "address [mask]")
|
||||
|
||||
def test_strip_reset_codes(self):
|
||||
"""Test stripping reset ANSI codes."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
text = "\x1b[1mNAME\x1b[0m : PC2[1]"
|
||||
result = instance._strip_ansi_codes(text)
|
||||
# Note: [1] at the end is not a valid ANSI code, so it should remain
|
||||
self.assertEqual(result, "NAME : PC2[1]")
|
||||
|
||||
def test_strip_color_codes(self):
|
||||
"""Test stripping color ANSI codes."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
text = "\x1b[31mRed\x1b[32mGreen\x1b[0mNormal"
|
||||
result = instance._strip_ansi_codes(text)
|
||||
self.assertEqual(result, "RedGreenNormal")
|
||||
|
||||
def test_strip_complex_vpcs_output(self):
|
||||
"""Test stripping ANSI codes from real VPCS output."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
# Sample VPCS output with ANSI codes (with proper ESC characters)
|
||||
text = """\x1b[1mip\x1b[0m \x1b[4mARG\x1b[0m ... [[\x1b[4mOPTION\x1b[0m]
|
||||
Configure the current VPC's IP settings
|
||||
\x1b[1mNAME\x1b[0m : PC2[1]
|
||||
IP/MASK : 192.168.1.20/24"""
|
||||
result = instance._strip_ansi_codes(text)
|
||||
# Verify ANSI codes are removed
|
||||
self.assertNotIn("\x1b[1m", result)
|
||||
self.assertNotIn("\x1b[0m", result)
|
||||
self.assertNotIn("\x1b[4m", result)
|
||||
self.assertIn("ip ARG", result)
|
||||
self.assertIn("NAME", result)
|
||||
self.assertIn("PC2[1]", result) # [1] is not a valid ANSI escape
|
||||
|
||||
def test_strip_empty_string(self):
|
||||
"""Test stripping ANSI codes from empty string."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
result = instance._strip_ansi_codes("")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_strip_no_ansi_codes(self):
|
||||
"""Test that text without ANSI codes is unchanged."""
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
text = "NAME : PC2\nIP/MASK : 192.168.1.20/24"
|
||||
result = instance._strip_ansi_codes(text)
|
||||
self.assertEqual(result, text)
|
||||
|
||||
def test_send_command_calls_strip_ansi(self):
|
||||
"""Test that send_command calls _strip_ansi_codes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
instance = self.VPCSTelnet.__new__(self.VPCSTelnet)
|
||||
|
||||
# Mock necessary attributes
|
||||
instance.remote_conn = MagicMock()
|
||||
instance.remote_conn.write = MagicMock()
|
||||
instance.read_until_pattern = MagicMock(
|
||||
return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>"
|
||||
)
|
||||
|
||||
# Mock _strip_ansi_codes to verify it's called
|
||||
instance._strip_ansi_codes = Mock(
|
||||
return_value="NAME : PC2\nPC2>"
|
||||
)
|
||||
|
||||
# Call send_command
|
||||
instance.send_command("show ip")
|
||||
|
||||
# Verify _strip_ansi_codes was called
|
||||
instance._strip_ansi_codes.assert_called_once()
|
||||
call_arg = instance._strip_ansi_codes.call_args[0][0]
|
||||
self.assertIn("\x1b[1m", call_arg) # Verify it received raw output
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests and print results."""
|
||||
# Create test suite
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# Add test cases
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetDriver))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetInit))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetMethods))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetSendCommand))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetTelnetLogin))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetRegistration))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetAnsiStripping))
|
||||
|
||||
# Run tests
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 100)
|
||||
print("Test Summary:")
|
||||
print(f" Run: {result.testsRun}")
|
||||
success_count = (
|
||||
result.testsRun - len(result.failures) - len(result.errors)
|
||||
)
|
||||
print(f" Success: {success_count}")
|
||||
print(f" Failed: {len(result.failures)}")
|
||||
print(f" Errors: {len(result.errors)}")
|
||||
print("=" * 100)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,438 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
|
||||
"""
|
||||
Custom Netmiko device driver for VPCS (Virtual PC Simulator) in GNS3.
|
||||
|
||||
VPCS is a lightweight virtual PC simulator used in GNS3 lab environments
|
||||
for testing network connectivity and basic IP configuration.
|
||||
|
||||
Key Features:
|
||||
- No authentication required (direct console access)
|
||||
- Simple command-line interface (PC1>, PC2>, etc.)
|
||||
- Supports basic PC commands (ip, ping, arp, version, etc.)
|
||||
- Telnet-based connection
|
||||
|
||||
Device Type: gns3_vpcs_telnet
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from netmiko.base_connection import BaseConnection
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ANSI escape code pattern for stripping terminal formatting codes
|
||||
# Matches sequences like: [1m (bold), [0m (reset), [4m (underline), etc.
|
||||
ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
class VPCSTelnet(BaseConnection):
|
||||
"""
|
||||
Custom VPCS device driver for GNS3 emulation.
|
||||
|
||||
VPCS devices are lightweight virtual PCs that simulate basic network
|
||||
functionality for lab testing. This driver handles the unique
|
||||
characteristics of VPCS:
|
||||
- No authentication (no username/password)
|
||||
- Simple prompt pattern (PC1>, PC2>, etc.)
|
||||
- No configuration modes
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Initialize VPCS Telnet connection."""
|
||||
# Set device type to identify this as a VPCS device
|
||||
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
|
||||
|
||||
# VPCS uses carriage return + line feed for command termination
|
||||
# This is critical for VPCS devices to respond to commands
|
||||
default_enter = kwargs.get("default_enter")
|
||||
if default_enter is None:
|
||||
kwargs["default_enter"] = "\r\n"
|
||||
|
||||
kwargs.setdefault("global_delay_factor", 1.0)
|
||||
kwargs.setdefault("fast_cli", False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def telnet_login(
|
||||
self,
|
||||
pri_prompt_terminator: str = r"PC\d+>",
|
||||
alt_prompt_terminator: str = r"",
|
||||
username_pattern: str = r"(?:user:|username|login|user name)",
|
||||
pwd_pattern: str = r"assword",
|
||||
delay_factor: float = 1.0,
|
||||
max_loops: int = 20,
|
||||
) -> str:
|
||||
"""
|
||||
Telnet login for VPCS devices (no authentication).
|
||||
|
||||
VPCS devices connect directly to command line without username/password.
|
||||
This method initializes the connection and waits for the VPCS prompt.
|
||||
|
||||
Strategy (matching telnetlib3 implementation):
|
||||
1. Send 4 newlines with 0.5s delay between each
|
||||
2. Wait for VPCS prompt pattern (PC1>, PC2>, etc.)
|
||||
3. Return once prompt is detected
|
||||
|
||||
Args:
|
||||
pri_prompt_terminator: Primary prompt pattern (default: r"PC\\d+>")
|
||||
alt_prompt_terminator: Not used for VPCS
|
||||
username_pattern: Not used (kept for signature compatibility)
|
||||
pwd_pattern: Not used (kept for signature compatibility)
|
||||
delay_factor: Delay factor for timing
|
||||
max_loops: Maximum wait loops
|
||||
|
||||
Returns:
|
||||
Output from the connection process
|
||||
"""
|
||||
delay_factor = self.select_delay_factor(delay_factor)
|
||||
return_msg = ""
|
||||
|
||||
# Step 1: Clear buffer - read any existing data
|
||||
try:
|
||||
initial_data = self.read_channel()
|
||||
if initial_data:
|
||||
return_msg += initial_data
|
||||
except Exception:
|
||||
# Ignore errors during initial read
|
||||
pass
|
||||
|
||||
# Step 2: Send 4 newlines with delays (matching telnetlib3 exactly)
|
||||
# tn.write(b"\n"); sleep(0.5) - repeated 4 times
|
||||
for i in range(4):
|
||||
try:
|
||||
# Send newline directly as bytes
|
||||
self.remote_conn.write(b"\n")
|
||||
time.sleep(0.5 * delay_factor)
|
||||
|
||||
# Read response
|
||||
new_output = self.read_channel()
|
||||
if new_output:
|
||||
return_msg += new_output
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error during VPCS connection initialization: %s", e)
|
||||
|
||||
# Step 3: Wait for VPCS prompt pattern
|
||||
try:
|
||||
# Read until we see the prompt
|
||||
output = self.read_until_pattern(
|
||||
pattern=pri_prompt_terminator,
|
||||
read_timeout=10
|
||||
)
|
||||
return_msg += output
|
||||
|
||||
if re.search(pri_prompt_terminator, return_msg, flags=re.M):
|
||||
logger.info("VPCS prompt detected")
|
||||
return return_msg
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error waiting for VPCS prompt: %s", e)
|
||||
|
||||
# Step 4: Return what we have (connection might still work)
|
||||
logger.debug("VPCS prompt not clearly detected, returning current output")
|
||||
return return_msg
|
||||
|
||||
def session_preparation(self) -> None:
|
||||
"""
|
||||
Prepare the session after connection is established.
|
||||
|
||||
VPCS doesn't require special session preparation.
|
||||
The telnet_login method already handles initialization with 4 newlines.
|
||||
"""
|
||||
# No additional preparation needed
|
||||
logger.debug("VPCS session preparation completed")
|
||||
|
||||
def send_command(
|
||||
self,
|
||||
command_string: str,
|
||||
expect_string: str | None = None,
|
||||
read_timeout: float | None = None,
|
||||
delay_factor: float | None = None,
|
||||
max_loops: int | None = None,
|
||||
strip_prompt: bool = False,
|
||||
strip_command: bool = False,
|
||||
normalize: bool = False,
|
||||
use_textfsm: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Send a command to VPCS and return the output.
|
||||
|
||||
This method exactly matches the telnetlib3 implementation:
|
||||
1. Encode command as ASCII and append newline
|
||||
2. Write command+newline as single byte sequence
|
||||
3. Sleep for 5 seconds
|
||||
4. Wait for prompt pattern and capture output
|
||||
5. Return captured output
|
||||
|
||||
Args:
|
||||
command_string: The command to send
|
||||
expect_string: Pattern to expect (default: VPCS prompt r"PC\\d+>")
|
||||
read_timeout: Timeout for reading output
|
||||
delay_factor: Not used (kept for signature compatibility)
|
||||
max_loops: Not used (kept for signature compatibility)
|
||||
strip_prompt: Remove prompt from output (default: False for debugging)
|
||||
strip_command: Remove command from output (default: False for debugging)
|
||||
normalize: Not used for VPCS
|
||||
use_textfsm: Not used for VPCS
|
||||
|
||||
Returns:
|
||||
Command output from VPCS
|
||||
"""
|
||||
# Use VPCS prompt as default expect_string
|
||||
if expect_string is None:
|
||||
expect_string = r"PC\d+>"
|
||||
|
||||
# CRITICAL: Match telnetlib3 behavior exactly
|
||||
# tn.write(command.encode(encoding="ascii") + b"\n")
|
||||
cmd_bytes = command_string.encode(encoding="ascii") + b"\n"
|
||||
self.remote_conn.write(cmd_bytes)
|
||||
|
||||
logger.debug("VPCS: Sent command '%s' (%d bytes)", command_string, len(cmd_bytes))
|
||||
|
||||
# Sleep for 5 seconds (matching telnetlib3: sleep(5))
|
||||
time.sleep(5)
|
||||
|
||||
# Wait for prompt pattern and capture output
|
||||
# This matches telnetlib3: tn.expect([rb"PC\d+>"]) + tn.read_very_eager()
|
||||
# read_until_pattern returns the output including the matched pattern
|
||||
try:
|
||||
output = self.read_until_pattern(pattern=expect_string, read_timeout=10)
|
||||
logger.debug("VPCS: read_until_pattern returned %d chars", len(output))
|
||||
except Exception as e:
|
||||
# If pattern not found, try to read whatever is available
|
||||
logger.debug("VPCS: Pattern not found, reading available output: %s", e)
|
||||
output = self.read_channel()
|
||||
logger.debug("VPCS: read_channel returned %d chars", len(output) if output else 0)
|
||||
|
||||
if isinstance(output, bytes):
|
||||
output = output.decode("utf-8", errors="replace")
|
||||
|
||||
logger.debug("VPCS: Raw output before strip: '%s'", output)
|
||||
|
||||
# Strip ANSI escape codes from VPCS output
|
||||
# VPCS uses terminal formatting codes (bold, underline, etc.)
|
||||
# that need to be removed for clean output parsing
|
||||
output = self._strip_ansi_codes(output)
|
||||
|
||||
# Strip command and prompt if requested (currently disabled for debugging)
|
||||
if strip_command:
|
||||
output = self.strip_command_packets(command_string, output)
|
||||
if strip_prompt:
|
||||
output = self.strip_prompt(output)
|
||||
|
||||
logger.debug("VPCS: Final output after strip: '%s'", output)
|
||||
|
||||
return output
|
||||
|
||||
def send_command_timing(
|
||||
self,
|
||||
command_string: str,
|
||||
delay_factor: float | None = None,
|
||||
max_loops: int | None = None,
|
||||
strip_prompt: bool = True,
|
||||
strip_command: bool = True,
|
||||
normalize: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Send command using timing-based delay.
|
||||
|
||||
For VPCS, timing-based and pattern-based methods are identical,
|
||||
both use the telnetlib3 implementation pattern with fixed 5s delay.
|
||||
|
||||
Args:
|
||||
command_string: The command to send
|
||||
delay_factor: Not used (fixed 5s delay for VPCS)
|
||||
max_loops: Not used (kept for signature compatibility)
|
||||
strip_prompt: Remove prompt from output
|
||||
strip_command: Remove command from output
|
||||
normalize: Not used for VPCS
|
||||
|
||||
Returns:
|
||||
Command output from VPCS
|
||||
"""
|
||||
# For VPCS, timing and pattern-based methods are identical
|
||||
return self.send_command(
|
||||
command_string=command_string,
|
||||
strip_prompt=strip_prompt,
|
||||
strip_command=strip_command,
|
||||
)
|
||||
|
||||
def check_config_mode(
|
||||
self, check_string: str = "", pattern: str = "", force_regex: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
VPCS has no configuration mode.
|
||||
|
||||
This method always returns False for VPCS devices.
|
||||
|
||||
Returns:
|
||||
False (VPCS has no config mode)
|
||||
"""
|
||||
return False
|
||||
|
||||
def _strip_ansi_codes(self, text: str) -> str:
|
||||
"""
|
||||
Strip ANSI escape codes from VPCS output.
|
||||
|
||||
VPCS uses terminal formatting codes (bold, underline, colors, etc.)
|
||||
that need to be removed for clean output parsing. This method removes
|
||||
all ANSI escape sequences from the given text.
|
||||
|
||||
Examples of codes removed:
|
||||
- [1m - Bold text
|
||||
- [0m - Reset formatting
|
||||
- [4m - Underline text
|
||||
- [30-47m - Color codes
|
||||
|
||||
Args:
|
||||
text: Text potentially containing ANSI escape codes
|
||||
|
||||
Returns:
|
||||
Text with all ANSI escape codes removed
|
||||
"""
|
||||
return ANSI_ESCAPE_PATTERN.sub("", text)
|
||||
|
||||
def config_mode(
|
||||
self, config_command: str = "", pattern: str = "", re_flags: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
VPCS has no configuration mode.
|
||||
|
||||
This method returns an empty string for VPCS devices.
|
||||
|
||||
Returns:
|
||||
Empty string (no config mode to enter)
|
||||
"""
|
||||
return ""
|
||||
|
||||
def exit_config_mode(self, exit_config: str = "", pattern: str = "") -> str:
|
||||
"""
|
||||
VPCS has no configuration mode.
|
||||
|
||||
This method returns an empty string for VPCS devices.
|
||||
|
||||
Returns:
|
||||
Empty string (no config mode to exit)
|
||||
"""
|
||||
return ""
|
||||
|
||||
def disable_paging(
|
||||
self,
|
||||
command: str = "",
|
||||
delay_factor: float | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
VPCS doesn't use paging.
|
||||
|
||||
This method returns an empty string for VPCS devices.
|
||||
|
||||
Returns:
|
||||
Empty string (no paging to disable)
|
||||
"""
|
||||
return ""
|
||||
|
||||
|
||||
# Register the custom device type with Netmiko
|
||||
_registered = False # Flag to prevent duplicate registration
|
||||
|
||||
def register_custom_device_type() -> None:
|
||||
"""
|
||||
Register the custom VPCS Telnet device type with Netmiko.
|
||||
|
||||
This function adds 'gns3_vpcs_telnet' to Netmiko's CLASS_MAPPER
|
||||
and updates the platforms lists.
|
||||
|
||||
IMPORTANT: This function should be called BEFORE using the VPCS device
|
||||
type with Netmiko.
|
||||
|
||||
Note: This function is idempotent - multiple calls will only register once.
|
||||
"""
|
||||
global _registered
|
||||
|
||||
# Prevent duplicate registration
|
||||
if _registered:
|
||||
logger.debug("VPCS Telnet device type already registered, skipping")
|
||||
return
|
||||
|
||||
# Use importlib to avoid namespace conflicts
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Register the device type in both mappers
|
||||
# CLASS_MAPPER_BASE is used for base class definitions
|
||||
sd.CLASS_MAPPER_BASE["gns3_vpcs_telnet"] = VPCSTelnet
|
||||
|
||||
# CLASS_MAPPER is used by ConnectHandler for device type validation
|
||||
sd.CLASS_MAPPER["gns3_vpcs_telnet"] = VPCSTelnet
|
||||
|
||||
# CRITICAL: Update the static platforms lists
|
||||
# These lists are computed at module import time and won't
|
||||
# automatically update when CLASS_MAPPER is modified.
|
||||
# We need to manually rebuild them.
|
||||
|
||||
# Recalculate platforms list
|
||||
sd.platforms = list(sd.CLASS_MAPPER.keys())
|
||||
sd.platforms.sort()
|
||||
|
||||
# Recalculate platforms_base list
|
||||
sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys())
|
||||
sd.platforms_base.sort()
|
||||
|
||||
# Recalculate telnet_platforms list
|
||||
sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x]
|
||||
|
||||
# Rebuild the platform strings used in error messages
|
||||
sd.platforms_str = "\n" + "\n".join(sd.platforms_base)
|
||||
sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms)
|
||||
|
||||
# Mark as registered
|
||||
_registered = True
|
||||
|
||||
logger.info("Successfully registered VPCS Telnet device type with Netmiko")
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
# This ensures the device type is available when the module is imported
|
||||
try:
|
||||
register_custom_device_type()
|
||||
except Exception as e:
|
||||
# Log but don't fail on import
|
||||
logger.warning(
|
||||
"Failed to register VPCS device type: %s",
|
||||
e,
|
||||
exc_info=True
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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 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
|
||||
#
|
||||
|
||||
"""
|
||||
Error Handler for GNS3-Copilot Agent
|
||||
|
||||
This module provides utilities for formatting and handling errors from
|
||||
the LLM API client layer (LangChain), particularly HTTP errors that
|
||||
may return HTML or other unhelpful content.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Patterns to detect HTML in error messages
|
||||
HTML_PATTERNS = [
|
||||
r"<!DOCTYPE html",
|
||||
r"<html",
|
||||
r"<head>",
|
||||
r"<body",
|
||||
r"<div",
|
||||
]
|
||||
|
||||
|
||||
def format_error_message(error: Exception) -> str:
|
||||
"""
|
||||
Format an error message for user display, cleaning up HTML and
|
||||
extracting meaningful error information.
|
||||
|
||||
Args:
|
||||
error: The exception to format
|
||||
|
||||
Returns:
|
||||
A formatted, user-friendly error message
|
||||
"""
|
||||
error_str = str(error)
|
||||
|
||||
# Check if error message contains HTML
|
||||
if _contains_html(error_str):
|
||||
logger.warning(
|
||||
"Detected HTML in error message, likely due to incorrect API "
|
||||
"configuration or base URL"
|
||||
)
|
||||
return (
|
||||
"API request failed. The error response indicates an issue with "
|
||||
"your API configuration. Please check:\n"
|
||||
"1. Base URL is correct (e.g., https://api.openai.com/v1 for OpenAI)\n"
|
||||
"2. API key is valid\n"
|
||||
"3. Provider is correctly configured"
|
||||
)
|
||||
|
||||
# For other errors, return the original message
|
||||
return error_str
|
||||
|
||||
|
||||
def _contains_html(text: str) -> bool:
|
||||
"""Check if text contains HTML tags."""
|
||||
text_lower = text.lower()
|
||||
for pattern in HTML_PATTERNS:
|
||||
if re.search(pattern, text_lower, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
Public module for getting device port information from GNS3 topology
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_device_ports_from_topology(
|
||||
device_names: list[str],
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get device connection information from GNS3 topology
|
||||
|
||||
Args:
|
||||
device_names: List of device names to look up
|
||||
project_id: UUID of the specific GNS3 project to retrieve topology from
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their connection data:
|
||||
{
|
||||
"device_name": {
|
||||
"port": console_port,
|
||||
"platform": "huawei", # Extracted from tags
|
||||
"groups": ["network_devices"], # For inheriting shared settings
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {"device_type": "huawei_telnet"} # Extracted from tags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Devices that don't exist or missing console_port will not be included
|
||||
"""
|
||||
# Log received parameters
|
||||
logger.info(
|
||||
"Called with device_names=%s, project_id=%s", device_names, project_id
|
||||
)
|
||||
|
||||
try:
|
||||
# Lazy import to avoid circular dependency
|
||||
from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool
|
||||
|
||||
# Get topology information
|
||||
topo = GNS3TopologyTool()
|
||||
topology = topo._run(project_id=project_id)
|
||||
|
||||
# Dynamically build hosts_data from topology
|
||||
hosts_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if not topology:
|
||||
logger.warning("Unable to get topology information")
|
||||
return hosts_data
|
||||
|
||||
for device_name in device_names:
|
||||
# Check if device exists in topology
|
||||
if device_name not in topology.get("nodes", {}):
|
||||
logger.warning(
|
||||
"Device '%s' not found in topology", device_name
|
||||
)
|
||||
continue
|
||||
|
||||
node_info = topology["nodes"][device_name]
|
||||
if "console_port" not in node_info:
|
||||
logger.warning("Device '%s' missing console_port", device_name)
|
||||
continue
|
||||
|
||||
# Extract device_type and platform from tags
|
||||
device_type = None
|
||||
platform = None
|
||||
tags = node_info.get("tags", [])
|
||||
|
||||
for tag in tags:
|
||||
if tag.startswith("device_type:"):
|
||||
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
|
||||
# Using a default would cause command execution errors
|
||||
if device_type is None:
|
||||
tested_device_types = (
|
||||
"cisco_ios_telnet (Netmiko built-in), gns3_huawei_telnet_ce (custom Huawei), "
|
||||
"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"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}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
hosts_data[device_name] = {
|
||||
"error": error_msg
|
||||
}
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Device '%s': extracted device_type=%s from tags",
|
||||
device_name,
|
||||
device_type,
|
||||
)
|
||||
|
||||
if platform is None:
|
||||
platform = "cisco_ios"
|
||||
logger.debug(
|
||||
"Device '%s': platform not found in tags, using default: cisco_ios",
|
||||
device_name,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Device '%s': extracted platform=%s from tags",
|
||||
device_name,
|
||||
platform,
|
||||
)
|
||||
|
||||
# Add device to hosts_data with connection_options at host level
|
||||
# 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] = {
|
||||
"port": node_info["console_port"],
|
||||
"platform": platform,
|
||||
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {"device_type": device_type}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("Returning %d device port mappings", len(hosts_data))
|
||||
|
||||
return hosts_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting device port information: %s", e)
|
||||
return {}
|
||||
@@ -0,0 +1,475 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
Drawing utility functions for GNS3 area annotations.
|
||||
|
||||
Calculates drawing parameters and generates SVG content for network area annotations.
|
||||
Supports ellipse and rectangle shapes for two-node annotations.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_DEVICE_WIDTH = 50
|
||||
DEFAULT_DEVICE_HEIGHT = 50
|
||||
DEFAULT_FONT_SIZE = 14
|
||||
DEFAULT_SHAPE_Z = 0
|
||||
DEFAULT_TEXT_Z = 1
|
||||
|
||||
# Z-order thresholds (pixels)
|
||||
Z_ORDER_LARGE_AREA_THRESHOLD = 500 # z=1 for large areas
|
||||
Z_ORDER_MEDIUM_AREA_THRESHOLD = 300 # z=2 for medium areas
|
||||
|
||||
# Color schemes (business professional style)
|
||||
# Text uses 'stroke', shape uses 'fill' with 'fill-opacity'
|
||||
COLOR_SCHEMES = {
|
||||
# Core/Backbone - biz_blue
|
||||
"CORE_BACKBONE": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#2980B9", # Blue background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Normal Areas - biz_blue_light
|
||||
"NORMAL_AREA": {
|
||||
"stroke": "#000000", # Black (for text on light background)
|
||||
"fill": "#5AA9DD", # Light blue background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Logical Isolation - biz_purple
|
||||
"ISOLATION": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#9B59B6", # Purple background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Management/Infrastructure - biz_orange
|
||||
"MANAGEMENT_INFRA": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#E67E22", # Orange background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Redundancy/High Availability - biz_orange_bright
|
||||
"HIGH_AVAILABILITY": {
|
||||
"stroke": "#000000", # Black (for text on bright background)
|
||||
"fill": "#F39C12", # Bright orange background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# External/Boundary - biz_red_bright
|
||||
"EXTERNAL": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#E74C3C", # Bright red background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Security/Trusted - biz_green_bright
|
||||
"SECURITY_TRUSTED": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#2ECC71", # Bright green background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Cloud/Tunnel - biz_cyan_dark
|
||||
"CLOUD_TUNNEL": {
|
||||
"stroke": "#FFFFFF", # White (for text on dark background)
|
||||
"fill": "#00CED1", # Dark cyan background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
# Default - biz_gray
|
||||
"DEFAULT": {
|
||||
"stroke": "#FFFFFF", # White (for text on medium-dark background)
|
||||
"fill": "#A5CC78", # Gray background
|
||||
"fill_opacity": 0.8, # Transparency level
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def calculate_two_node_shape(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
shape_type: Literal["ellipse", "rectangle"] = "ellipse",
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate shape annotation parameters for two nodes.
|
||||
|
||||
Args:
|
||||
node1: First node with 'x', 'y', 'height', 'width' (top-left)
|
||||
node2: Second node with 'x', 'y', 'height', 'width' (top-left)
|
||||
area_name: Name of area (e.g., "Area 0", "AS 100")
|
||||
shape_type: "ellipse" or "rectangle" (default: "ellipse")
|
||||
text_offset_ratio: Ratio to offset text (0=center, positive=offset)
|
||||
|
||||
Returns:
|
||||
Dict with shape, text SVG params, and metadata
|
||||
"""
|
||||
node1_width = node1.get("width", DEFAULT_DEVICE_WIDTH)
|
||||
node1_height = node1.get("height", DEFAULT_DEVICE_HEIGHT)
|
||||
node2_width = node2.get("width", DEFAULT_DEVICE_WIDTH)
|
||||
node2_height = node2.get("height", DEFAULT_DEVICE_HEIGHT)
|
||||
|
||||
node1_center_x = node1["x"] + (node1_width / 2)
|
||||
node1_center_y = node1["y"] + (node1_height / 2)
|
||||
node2_center_x = node2["x"] + (node2_width / 2)
|
||||
node2_center_y = node2["y"] + (node2_height / 2)
|
||||
|
||||
distance = math.sqrt(
|
||||
(node2_center_x - node1_center_x) ** 2
|
||||
+ (node2_center_y - node1_center_y) ** 2
|
||||
)
|
||||
|
||||
angle_rad = math.atan2(
|
||||
node2_center_y - node1_center_y, node2_center_x - node1_center_x
|
||||
)
|
||||
angle_deg = round(math.degrees(angle_rad))
|
||||
angle_rad = math.radians(angle_deg)
|
||||
|
||||
center_x = (node1_center_x + node2_center_x) / 2
|
||||
center_y = (node1_center_y + node2_center_y) / 2
|
||||
|
||||
color_scheme = _get_color_scheme(area_name)
|
||||
text_svg = generate_text_svg(area_name, color_scheme)
|
||||
|
||||
text_svg_width = len(area_name) * 8 + 20
|
||||
text_svg_height = DEFAULT_FONT_SIZE + 16
|
||||
|
||||
if shape_type == "ellipse":
|
||||
rx = distance / 2
|
||||
ry = math.sqrt((node1_width / 2) ** 2 + (node1_height / 2) ** 2)
|
||||
|
||||
shape_width = rx * 2
|
||||
shape_height = ry * 2
|
||||
|
||||
svg_x = center_x - (
|
||||
rx * math.cos(angle_rad) - ry * math.sin(angle_rad)
|
||||
)
|
||||
svg_y = center_y - (
|
||||
rx * math.sin(angle_rad) + ry * math.cos(angle_rad)
|
||||
)
|
||||
|
||||
shape_svg = generate_ellipse_svg(
|
||||
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
|
||||
)
|
||||
|
||||
offset_distance = ry * text_offset_ratio
|
||||
|
||||
metadata = {
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"distance": distance,
|
||||
"shape_width": shape_width,
|
||||
"shape_height": shape_height,
|
||||
"angle_deg": angle_deg,
|
||||
"rx": rx,
|
||||
"ry": ry,
|
||||
}
|
||||
|
||||
else: # rectangle
|
||||
shape_width = distance
|
||||
shape_height = max(
|
||||
node1_width, node1_height, node2_width, node2_height
|
||||
)
|
||||
|
||||
svg_x = center_x - (
|
||||
(shape_width / 2) * math.cos(angle_rad)
|
||||
- (shape_height / 2) * math.sin(angle_rad)
|
||||
)
|
||||
svg_y = center_y - (
|
||||
(shape_width / 2) * math.sin(angle_rad)
|
||||
+ (shape_height / 2) * math.cos(angle_rad)
|
||||
)
|
||||
|
||||
shape_svg = generate_rectangle_svg(
|
||||
int(shape_width), int(shape_height), color_scheme
|
||||
)
|
||||
|
||||
offset_distance = (shape_height / 2) * text_offset_ratio
|
||||
|
||||
metadata = {
|
||||
"center_x": center_x,
|
||||
"center_y": center_y,
|
||||
"distance": distance,
|
||||
"shape_width": shape_width,
|
||||
"shape_height": shape_height,
|
||||
"angle_deg": angle_deg,
|
||||
"rx": None,
|
||||
"ry": None,
|
||||
}
|
||||
|
||||
if text_offset_ratio != 0:
|
||||
perpendicular_x = -math.sin(angle_rad)
|
||||
perpendicular_y = math.cos(angle_rad)
|
||||
|
||||
text_offset_x = perpendicular_x * offset_distance
|
||||
text_offset_y = perpendicular_y * offset_distance
|
||||
|
||||
text_x = int(center_x + text_offset_x - text_svg_width / 2)
|
||||
text_y = int(center_y + text_offset_y - text_svg_height / 2)
|
||||
else:
|
||||
text_x = int(center_x - text_svg_width / 2)
|
||||
text_y = int(center_y - text_svg_height / 2)
|
||||
|
||||
return {
|
||||
"shape": {
|
||||
"svg": shape_svg,
|
||||
"x": int(svg_x),
|
||||
"y": int(svg_y),
|
||||
"z": DEFAULT_SHAPE_Z,
|
||||
"rotation": int(angle_deg),
|
||||
"type": shape_type,
|
||||
},
|
||||
"text": {
|
||||
"svg": text_svg,
|
||||
"x": text_x,
|
||||
"y": text_y,
|
||||
"z": DEFAULT_TEXT_Z,
|
||||
"rotation": 0,
|
||||
},
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def generate_ellipse_svg(
|
||||
rx: int,
|
||||
ry: int,
|
||||
color_scheme: dict[str, Any],
|
||||
svg_width: int,
|
||||
svg_height: int,
|
||||
) -> str:
|
||||
"""Generate SVG for ellipse."""
|
||||
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" # noqa: E501
|
||||
|
||||
|
||||
def generate_rectangle_svg(
|
||||
width: int,
|
||||
height: int,
|
||||
color_scheme: dict[str, Any],
|
||||
) -> str:
|
||||
"""Generate SVG for rectangle."""
|
||||
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>""" # noqa: E501
|
||||
|
||||
|
||||
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
|
||||
"""Generate SVG for text label."""
|
||||
text_width = len(text) * 8 + 20
|
||||
text_height = DEFAULT_FONT_SIZE + 16
|
||||
|
||||
return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>""" # noqa: E501
|
||||
|
||||
|
||||
def _hsv_to_hex(h: int, s: int, v: int) -> str:
|
||||
"""Convert HSV to HEX color."""
|
||||
h_norm = (h % 360) / 360
|
||||
s_norm = s / 100
|
||||
v_norm = v / 100
|
||||
|
||||
c: float = v_norm * s_norm
|
||||
x: float = c * (1 - abs((h_norm / 60) % 2 - 1))
|
||||
m: float = v_norm - c
|
||||
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
|
||||
if 0 <= h_norm < 60:
|
||||
r, g, b = c, x, 0.0
|
||||
elif 60 <= h_norm < 120:
|
||||
r, g, b = x, c, 0.0
|
||||
elif 120 <= h_norm < 180:
|
||||
r, g, b = 0.0, c, x
|
||||
elif 180 <= h_norm < 240:
|
||||
r, g, b = 0.0, x, c
|
||||
elif 240 <= h_norm < 300:
|
||||
r, g, b = x, 0.0, c
|
||||
else:
|
||||
r, g, b = c, 0.0, x
|
||||
|
||||
r = int((r + m) * 255)
|
||||
g = int((g + m) * 255)
|
||||
b = int((b + m) * 255)
|
||||
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def calculate_z_order(area_size: float) -> int:
|
||||
"""
|
||||
Calculate z-order based on area size for proper layering.
|
||||
|
||||
Args:
|
||||
area_size: Size of area (width for rectangles, 2*rx for ellipses)
|
||||
|
||||
Returns:
|
||||
Z-order: 1=back (large), 2=middle, 3=front (small)
|
||||
"""
|
||||
if area_size >= Z_ORDER_LARGE_AREA_THRESHOLD:
|
||||
return 1
|
||||
elif area_size >= Z_ORDER_MEDIUM_AREA_THRESHOLD:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
|
||||
|
||||
def _get_color_scheme(area_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get color scheme based on area name using keyword inference.
|
||||
|
||||
Maps labels to semantic groups for professional business styling.
|
||||
"""
|
||||
if area_name in COLOR_SCHEMES:
|
||||
return COLOR_SCHEMES[area_name]
|
||||
|
||||
label = area_name.upper()
|
||||
|
||||
# 1. Routing Domain Core/Backbone
|
||||
if "AREA 0" in label or "BACKBONE" in label or "CORE" in label:
|
||||
return COLOR_SCHEMES["CORE_BACKBONE"]
|
||||
|
||||
if "BGP" in label or "AS " in label:
|
||||
return COLOR_SCHEMES["CORE_BACKBONE"]
|
||||
|
||||
# 2. Routing Domain Normal Areas
|
||||
if "AREA " in label or "LEVEL" in label:
|
||||
return COLOR_SCHEMES["NORMAL_AREA"]
|
||||
|
||||
# 3. Logical Isolation
|
||||
if (
|
||||
"VRF" in label
|
||||
or "VLAN" in label
|
||||
or "MSTP" in label
|
||||
or "VXLAN" in label
|
||||
or "MPLS" in label
|
||||
):
|
||||
return COLOR_SCHEMES["ISOLATION"]
|
||||
|
||||
# 4. High Availability
|
||||
if (
|
||||
"VRRP" in label
|
||||
or "HSRP" in label
|
||||
or "HA" in label
|
||||
or "STACK" in label
|
||||
or "M-LAG" in label
|
||||
):
|
||||
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
|
||||
|
||||
# 5. External/Internet
|
||||
if (
|
||||
"INET" in label
|
||||
or "OUT" in label
|
||||
or "EXTERNAL" in label
|
||||
or "INTERNET" in label
|
||||
or "DMZ" in label
|
||||
):
|
||||
return COLOR_SCHEMES["EXTERNAL"]
|
||||
|
||||
# 6. Management
|
||||
if "MGMT" in label or "OOB" in label or "MANAGEMENT" in label:
|
||||
return COLOR_SCHEMES["MANAGEMENT_INFRA"]
|
||||
|
||||
# 7. Security/Trusted
|
||||
if "SECURITY" in label or "TRUSTED" in label or "VPN" in label:
|
||||
return COLOR_SCHEMES["SECURITY_TRUSTED"]
|
||||
|
||||
# 8. Cloud/Tunnel
|
||||
if (
|
||||
"TUNNEL" in label
|
||||
or "CLOUD" in label
|
||||
or "GRE" in label
|
||||
or "IPSEC" in label
|
||||
):
|
||||
return COLOR_SCHEMES["CLOUD_TUNNEL"]
|
||||
|
||||
# Legacy keyword matching for backward compatibility
|
||||
label_lower = area_name.lower()
|
||||
protocol_keywords = [
|
||||
("ospf", "NORMAL_AREA"),
|
||||
("is-is", "NORMAL_AREA"),
|
||||
("rip", "NORMAL_AREA"),
|
||||
("eigrp", "NORMAL_AREA"),
|
||||
("bgp", "CORE_BACKBONE"),
|
||||
("vxlan", "ISOLATION"),
|
||||
("mpls", "ISOLATION"),
|
||||
("vrf", "ISOLATION"),
|
||||
("vlan", "ISOLATION"),
|
||||
("vrrp", "HIGH_AVAILABILITY"),
|
||||
("hsrp", "HIGH_AVAILABILITY"),
|
||||
("gre", "CLOUD_TUNNEL"),
|
||||
("ipsec", "SECURITY_TRUSTED"),
|
||||
]
|
||||
|
||||
for keyword, scheme_key in protocol_keywords:
|
||||
if keyword in label_lower:
|
||||
return COLOR_SCHEMES[scheme_key]
|
||||
|
||||
return COLOR_SCHEMES["DEFAULT"]
|
||||
|
||||
|
||||
def calculate_two_node_ellipse(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate ellipse annotation parameters for two nodes.
|
||||
|
||||
Wrapper around calculate_two_node_shape with shape_type="ellipse".
|
||||
"""
|
||||
result = calculate_two_node_shape(
|
||||
node1, node2, area_name, "ellipse", text_offset_ratio
|
||||
)
|
||||
return {
|
||||
"ellipse": result["shape"],
|
||||
"text": result["text"],
|
||||
"metadata": result["metadata"],
|
||||
}
|
||||
|
||||
|
||||
def calculate_two_node_rectangle(
|
||||
node1: dict,
|
||||
node2: dict,
|
||||
area_name: str,
|
||||
text_offset_ratio: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate rectangle annotation parameters for two nodes.
|
||||
|
||||
Wrapper around calculate_two_node_shape with shape_type="rectangle".
|
||||
"""
|
||||
result = calculate_two_node_shape(
|
||||
node1, node2, area_name, "rectangle", text_offset_ratio
|
||||
)
|
||||
metadata = result["metadata"]
|
||||
return {
|
||||
"rectangle": result["shape"],
|
||||
"text": result["text"],
|
||||
"metadata": {
|
||||
"center_x": metadata["center_x"],
|
||||
"center_y": metadata["center_y"],
|
||||
"distance": metadata["distance"],
|
||||
"rect_width": metadata["shape_width"],
|
||||
"rect_height": metadata["shape_height"],
|
||||
"angle_deg": metadata["angle_deg"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
|
||||
LLM Model Configuration Helper for GNS3 Copilot
|
||||
|
||||
This module provides utility functions to retrieve LLM model configurations
|
||||
with decrypted API keys by directly accessing the database.
|
||||
|
||||
Usage:
|
||||
from gns3server.agent.gns3_copilot.utils.llm_config_helper import (
|
||||
get_user_llm_config_with_app,
|
||||
)
|
||||
|
||||
# Get user's default LLM config (with API key)
|
||||
config = await get_user_llm_config_with_app(user_id, app)
|
||||
if config:
|
||||
provider = config['provider']
|
||||
api_key = config['api_key']
|
||||
model = config['model']
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_user_llm_config_with_app(
|
||||
user_id: UUID, app: FastAPI
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get user's default LLM model configuration with decrypted API key.
|
||||
|
||||
This function directly accesses the database through the app reference,
|
||||
bypassing API security restrictions to get the complete configuration including
|
||||
decrypted API keys, even for inherited group configurations.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
app: FastAPI application instance
|
||||
|
||||
Returns:
|
||||
Configuration dict with provider, api_key, model, etc., or None if not found
|
||||
|
||||
Example:
|
||||
config = await get_user_llm_config_with_app(user_id, app)
|
||||
if config:
|
||||
print(f"Provider: {config['provider']}")
|
||||
print(f"Model: {config['model']}")
|
||||
print(f"API Key: {config['api_key']}")
|
||||
print(f"Source: {config['source']}")
|
||||
"""
|
||||
from gns3server.db.tasks import get_user_llm_config_full
|
||||
|
||||
try:
|
||||
user_id_str = str(user_id)
|
||||
config = await get_user_llm_config_full(user_id_str, app)
|
||||
|
||||
if config:
|
||||
logger.info(
|
||||
f"Successfully retrieved LLM config for user {user_id}: "
|
||||
f"provider={config.get('provider')}, model={config.get('model')}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"No LLM configuration found for user {user_id}")
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to retrieve LLM config for user {user_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,234 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
|
||||
Message format converters for OpenAI-compatible message format.
|
||||
Converts between LangChain messages and OpenAI-compatible format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
|
||||
def _ensure_string(content: Any) -> str:
|
||||
"""Ensure content is a string, converting dicts/lists to JSON if needed."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
elif isinstance(content, (dict, list)):
|
||||
return json.dumps(content, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
return str(content)
|
||||
|
||||
|
||||
def convert_langchain_to_openai(lc_message) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert LangChain message to OpenAI-compatible format.
|
||||
|
||||
Args:
|
||||
lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage,
|
||||
ToolMessage)
|
||||
|
||||
Returns:
|
||||
Dictionary in OpenAI-compatible format
|
||||
"""
|
||||
# Generate message ID
|
||||
msg_id = getattr(lc_message, "id", None)
|
||||
if msg_id is None:
|
||||
msg_id = str(uuid.uuid4())
|
||||
|
||||
# Get metadata from message (including created_at)
|
||||
metadata = getattr(lc_message, "metadata", None) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
# Base message structure (no top-level created_at, only metadata)
|
||||
base_msg = {"id": msg_id, "metadata": metadata}
|
||||
|
||||
# Convert based on message type
|
||||
if isinstance(lc_message, HumanMessage):
|
||||
return {**base_msg, "role": "user", "content": lc_message.content}
|
||||
|
||||
elif isinstance(lc_message, AIMessage):
|
||||
msg = {**base_msg, "role": "assistant", "content": lc_message.content}
|
||||
|
||||
# Handle tool calls - convert to OpenAI format
|
||||
if hasattr(lc_message, "tool_calls") and lc_message.tool_calls:
|
||||
tool_calls = []
|
||||
for tc in lc_message.tool_calls:
|
||||
# Convert to dict if it's an object
|
||||
tc_dict = tc if isinstance(tc, dict) else tc.model_dump()
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tc_dict.get("id", str(uuid.uuid4())),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc_dict.get("name", ""),
|
||||
"arguments": tc_dict.get("args", {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
msg["tool_calls"] = tool_calls
|
||||
|
||||
return msg
|
||||
|
||||
elif isinstance(lc_message, ToolMessage):
|
||||
return {
|
||||
**base_msg,
|
||||
"role": "tool",
|
||||
"content": _ensure_string(lc_message.content),
|
||||
"name": getattr(lc_message, "name", ""),
|
||||
"tool_call_id": getattr(lc_message, "tool_call_id", ""),
|
||||
}
|
||||
|
||||
elif isinstance(lc_message, SystemMessage):
|
||||
return {**base_msg, "role": "system", "content": lc_message.content}
|
||||
|
||||
else:
|
||||
# Fallback for unknown message types
|
||||
return {**base_msg, "role": "unknown", "content": str(lc_message)}
|
||||
|
||||
|
||||
def convert_openai_to_langchain(msg: Dict[str, Any]):
|
||||
"""
|
||||
Convert OpenAI-compatible format to LangChain message.
|
||||
|
||||
Args:
|
||||
msg: Dictionary in OpenAI-compatible format
|
||||
|
||||
Returns:
|
||||
LangChain message
|
||||
"""
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "user":
|
||||
return HumanMessage(content=content, id=msg.get("id"))
|
||||
|
||||
elif role == "assistant":
|
||||
ai_msg = AIMessage(content=content, id=msg.get("id"))
|
||||
|
||||
# Restore tool calls if present
|
||||
if "tool_calls" in msg and msg["tool_calls"]:
|
||||
tool_calls = []
|
||||
for tc in msg["tool_calls"]:
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tc.get("id", str(uuid.uuid4())),
|
||||
"name": tc.get("function", {}).get("name", ""),
|
||||
"args": tc.get("function", {}).get("arguments", {}),
|
||||
}
|
||||
)
|
||||
ai_msg.tool_calls = tool_calls
|
||||
|
||||
return ai_msg
|
||||
|
||||
elif role == "tool":
|
||||
return ToolMessage(
|
||||
content=content,
|
||||
name=msg.get("name", ""),
|
||||
tool_call_id=msg.get("tool_call_id", ""),
|
||||
)
|
||||
|
||||
elif role == "system":
|
||||
return SystemMessage(content=content)
|
||||
|
||||
else:
|
||||
# Fallback to HumanMessage for unknown roles
|
||||
return HumanMessage(content=content)
|
||||
|
||||
|
||||
def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert LangGraph streaming event to OpenAI-compatible format.
|
||||
|
||||
Args:
|
||||
event: LangGraph streaming event
|
||||
|
||||
Returns:
|
||||
Dictionary in OpenAI-compatible streaming response format
|
||||
"""
|
||||
event_type = event.get("event", "")
|
||||
|
||||
if event_type == "on_chat_model_stream":
|
||||
chunk = event.get("data", {}).get("chunk", {})
|
||||
content = getattr(chunk, "content", "")
|
||||
|
||||
if content:
|
||||
return {
|
||||
"type": "content",
|
||||
"content": content,
|
||||
"message_id": event.get("metadata", {}).get("msg_id"),
|
||||
}
|
||||
|
||||
# Check for tool call chunks
|
||||
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
||||
for tc_chunk in chunk.tool_call_chunks:
|
||||
tc_id = getattr(tc_chunk, "id", None)
|
||||
tc_name = getattr(tc_chunk, "name", None)
|
||||
tc_args = getattr(tc_chunk, "args", None)
|
||||
|
||||
if tc_id:
|
||||
return {
|
||||
"type": "tool_call",
|
||||
"tool_call": {
|
||||
"id": tc_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc_name or "",
|
||||
"arguments": tc_args or "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
elif event_type == "on_tool_start":
|
||||
return {
|
||||
"type": "tool_start",
|
||||
"tool_name": event.get("name", ""),
|
||||
"metadata": event.get("metadata", {}),
|
||||
}
|
||||
|
||||
elif event_type == "on_tool_end":
|
||||
tool_output = event.get("data", {}).get("output", "")
|
||||
# Convert dict or list output to JSON string for serialization
|
||||
if isinstance(tool_output, (dict, list)):
|
||||
tool_output = json.dumps(tool_output, ensure_ascii=False, indent=2)
|
||||
|
||||
return {
|
||||
"type": "tool_end",
|
||||
"tool_output": tool_output,
|
||||
"tool_name": event.get("name", ""),
|
||||
"metadata": event.get("metadata", {}),
|
||||
}
|
||||
|
||||
# Default empty response
|
||||
return {"type": "unknown"}
|
||||
@@ -0,0 +1,573 @@
|
||||
# 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
|
||||
#
|
||||
"""
|
||||
Public module for parsing tool execution results
|
||||
|
||||
This module is specifically designed to parse and format results returned by tools_v2
|
||||
after tool execution, supporting multiple formats and providing unified error handling.
|
||||
Mainly used for result display in UI interfaces.
|
||||
|
||||
Supported formats:
|
||||
- JSON strings
|
||||
- Python literal strings
|
||||
- Dictionary objects
|
||||
- List objects (JSON arrays)
|
||||
- Primitive types (int, float, bool, str)
|
||||
- Error message strings
|
||||
- Plain text output
|
||||
|
||||
Standard Tool Response Format:
|
||||
All tools should follow this standardized format for consistency:
|
||||
|
||||
{
|
||||
"success": bool, # Whether the overall operation succeeded
|
||||
"total": int, # Total number of items processed
|
||||
"successful": int, # Number of successful operations
|
||||
"failed": int, # Number of failed operations
|
||||
"data": list[dict], # Detailed results (one entry per item)
|
||||
"error": str, # Global error message (if operation failed entirely)
|
||||
"metadata": dict # Optional metadata (timestamp, execution_time, etc.)
|
||||
}
|
||||
|
||||
Single item format (for data array items):
|
||||
{
|
||||
"id": str, # Device/node/link ID
|
||||
"name": str, # Human-readable name
|
||||
"status": "success" | "failed", # Item status
|
||||
"result": str, # Success result or output
|
||||
"error": str # Error message (if failed)
|
||||
}
|
||||
|
||||
Author: Yue Guobin (岳国宾)
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_tool_content(
|
||||
content: str | dict | list | int | float | bool | None,
|
||||
fallback_to_raw: bool = True,
|
||||
strict_mode: bool = False,
|
||||
) -> dict[str, Any] | list[Any] | Any:
|
||||
"""
|
||||
Parse tool execution results into structured data, specifically for UI display.
|
||||
|
||||
This function can handle various input types including strings, dictionaries, lists,
|
||||
and primitive types. It ensures the returned data can be properly serialized
|
||||
by json.dumps.
|
||||
|
||||
Args:
|
||||
content: Content returned by tools (can be str, dict, list, int, float, bool,
|
||||
or None)
|
||||
fallback_to_raw: Whether to return raw content when parsing fails, default True
|
||||
strict_mode: Strict mode, raises exceptions when parsing fails, default False
|
||||
|
||||
Returns:
|
||||
Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by
|
||||
json.dumps:
|
||||
- Successfully parsed JSON/Python literal data
|
||||
- Original dict/list objects (passed through)
|
||||
- Primitive types (int, float, bool, str)
|
||||
- {"raw": content} when unable to parse but fallback_to_raw=True
|
||||
- {"error": "error_message"} when parsing fails and fallback_to_raw=False
|
||||
- {} for None input
|
||||
|
||||
Raises:
|
||||
ValueError: When strict_mode=True and parsing fails
|
||||
TypeError: When content type is unsupported and strict_mode=True
|
||||
|
||||
Examples:
|
||||
>>> parse_tool_content('{"status": "success", "data": [1, 2, 3]}')
|
||||
{'status': 'success', 'data': [1, 2, 3]}
|
||||
|
||||
>>> parse_tool_content({"status": "success"})
|
||||
{'status': 'success'}
|
||||
|
||||
>>> parse_tool_content([1, 2, 3])
|
||||
[1, 2, 3]
|
||||
|
||||
>>> parse_tool_content(42)
|
||||
42
|
||||
|
||||
>>> parse_tool_content("{'name': 'PC1', 'status': 'ok'}")
|
||||
{'name': 'PC1', 'status': 'ok'}
|
||||
|
||||
>>> parse_tool_content("Invalid JSON input: ...")
|
||||
{'raw': 'Invalid JSON input: ...'}
|
||||
|
||||
>>> parse_tool_content("{}")
|
||||
{}
|
||||
|
||||
>>> parse_tool_content(None)
|
||||
{}
|
||||
"""
|
||||
# Log received input parameters
|
||||
logger.info(
|
||||
"Received parameters: fallback_to_raw=%s, strict_mode=%s, content=%s",
|
||||
fallback_to_raw,
|
||||
strict_mode,
|
||||
content,
|
||||
)
|
||||
|
||||
# Handle None input
|
||||
if content is None:
|
||||
result: dict[str, Any] = {}
|
||||
logger.info("Content is None, returning: %s", result)
|
||||
return result
|
||||
|
||||
# Handle dictionary objects (already parsed)
|
||||
if isinstance(content, dict):
|
||||
logger.info("Content is already a dictionary, returning: %s", content)
|
||||
return content
|
||||
|
||||
# Handle list objects (JSON arrays)
|
||||
if isinstance(content, list):
|
||||
logger.info("Content is already a list, returning: %s", content)
|
||||
return content
|
||||
|
||||
# Handle primitive types that are JSON serializable
|
||||
if isinstance(content, (str, int, float, bool)):
|
||||
# For strings, we need to try parsing them
|
||||
if isinstance(content, str):
|
||||
# Empty string handling
|
||||
if not content.strip():
|
||||
result = {}
|
||||
logger.info(
|
||||
"Content is empty or whitespace, returning: %s", result
|
||||
)
|
||||
return result
|
||||
|
||||
s = content.strip()
|
||||
|
||||
# Handle empty dictionary case
|
||||
if s == "{}":
|
||||
result = {}
|
||||
logger.info(
|
||||
"Content is empty dictionary, returning: %s", result
|
||||
)
|
||||
return result
|
||||
|
||||
# Try to parse as Python literal
|
||||
# (higher priority as many tools return Python format strings)
|
||||
try:
|
||||
result = ast.literal_eval(s)
|
||||
logger.info(
|
||||
"Successfully parsed as Python literal, returning: %s",
|
||||
result,
|
||||
)
|
||||
return result
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
result = json.loads(s)
|
||||
logger.info(
|
||||
"Successfully parsed as JSON, returning: %s", result
|
||||
)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Handle parsing failure for strings
|
||||
error_msg = "Unable to parse content as JSON or Python literal"
|
||||
logger.warning("%s: %s", error_msg, s)
|
||||
|
||||
if strict_mode:
|
||||
raise ValueError("%s. Content: %s", error_msg, s)
|
||||
|
||||
if fallback_to_raw:
|
||||
result = {"raw": s}
|
||||
logger.info("Returning raw content as fallback: %s", result)
|
||||
return result
|
||||
result = {"error": error_msg}
|
||||
logger.info("Returning error: %s", result)
|
||||
return result
|
||||
# For non-string primitives (int, float, bool), return as-is
|
||||
logger.info(
|
||||
"Content is a primitive type %s, returning: %s",
|
||||
type(content).__name__,
|
||||
content,
|
||||
)
|
||||
return content
|
||||
|
||||
# Handle unsupported types
|
||||
error_msg = ( # type: ignore[unreachable]
|
||||
"Content must be str, dict, list, int, float, bool, or None, got "
|
||||
f"{type(content).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
|
||||
if strict_mode:
|
||||
raise TypeError(error_msg)
|
||||
|
||||
if fallback_to_raw:
|
||||
result = {"raw": str(content)}
|
||||
logger.info("Returning raw content as fallback: %s", result)
|
||||
return result
|
||||
result = {"error": error_msg}
|
||||
logger.info("Returning error: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
def format_tool_response(
|
||||
content: str | dict | list | int | float | bool | None, indent: int = 2
|
||||
) -> str:
|
||||
"""
|
||||
Format tool response as a beautiful JSON string for UI display.
|
||||
|
||||
This function ensures that the output is always a valid JSON string that can be
|
||||
properly displayed in UI interfaces.
|
||||
|
||||
Args:
|
||||
content: Content returned by tools (can be str, dict, list, int, float, bool,
|
||||
or None)
|
||||
indent: JSON indentation spaces, default 2
|
||||
|
||||
Returns:
|
||||
str: Formatted JSON string, always valid JSON
|
||||
"""
|
||||
logger.info("format_tool_response received input content: %s", content)
|
||||
logger.info("format_tool_response parameter indent: %s", indent)
|
||||
|
||||
try:
|
||||
parsed = parse_tool_content(
|
||||
content, fallback_to_raw=True, strict_mode=False
|
||||
)
|
||||
# Ensure the result can be serialized to JSON
|
||||
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
|
||||
logger.info("format_tool_response returning: %s", result)
|
||||
return result
|
||||
except (TypeError, ValueError) as e:
|
||||
# If the parsed result cannot be serialized, convert to string and wrap
|
||||
logger.error("Cannot serialize parsed result to JSON: %s", e)
|
||||
try:
|
||||
result = json.dumps(
|
||||
{"raw": str(content)}, ensure_ascii=False, indent=indent
|
||||
)
|
||||
logger.info("format_tool_response returning fallback: %s", result)
|
||||
return result
|
||||
except Exception:
|
||||
# Last resort: return a simple error message
|
||||
result = json.dumps(
|
||||
{"error": "Unable to format response"},
|
||||
ensure_ascii=False,
|
||||
indent=indent,
|
||||
)
|
||||
logger.info("format_tool_response returning error: %s", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error formatting tool response: %s", e)
|
||||
result = json.dumps(
|
||||
{"error": str(e)}, ensure_ascii=False, indent=indent
|
||||
)
|
||||
logger.info("format_tool_response returning error: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_tool_response(
|
||||
response: dict | list | str, tool_name: str = "unknown"
|
||||
) -> dict:
|
||||
"""
|
||||
Normalize tool response to standard format for consistent frontend display.
|
||||
|
||||
This function converts various tool response formats into a standardized structure
|
||||
that frontend code can rely on. It handles both legacy formats and new formats,
|
||||
ensuring backward compatibility.
|
||||
|
||||
Args:
|
||||
response: Raw tool response (dict, list, or string)
|
||||
tool_name: Name of the tool (for error messages)
|
||||
|
||||
Returns:
|
||||
dict: Normalized response in standard format:
|
||||
{
|
||||
"success": bool,
|
||||
"total": int,
|
||||
"successful": int,
|
||||
"failed": int,
|
||||
"data": list[dict],
|
||||
"error": str (optional),
|
||||
"metadata": dict
|
||||
}
|
||||
|
||||
Examples:
|
||||
>>> normalize_tool_response({"status": "success", "output": "OK"})
|
||||
{'success': True, 'total': 1, 'successful': 1, 'failed': 0,
|
||||
'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}}
|
||||
|
||||
>>> normalize_tool_response([{"device_name": "R1", "status": "success"}])
|
||||
{'success': True, 'total': 1, 'successful': 1, 'failed': 0,
|
||||
'data': [...], 'metadata': {}}
|
||||
"""
|
||||
metadata = {
|
||||
"tool_name": tool_name,
|
||||
"normalized_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Handle error responses
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "error" in response
|
||||
and len(response) == 1
|
||||
):
|
||||
return {
|
||||
"success": False,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"error": str(response["error"]),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
# Handle empty responses
|
||||
if not response:
|
||||
return {
|
||||
"success": True,
|
||||
"total": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"data": [],
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
# Handle list responses (most tools return list of device results)
|
||||
if isinstance(response, list):
|
||||
successful = sum(
|
||||
1
|
||||
for item in response
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
)
|
||||
failed = len(response) - successful
|
||||
|
||||
normalized_data = []
|
||||
for item in response:
|
||||
if isinstance(item, dict):
|
||||
normalized_item = {
|
||||
"id": item.get("device_id")
|
||||
or item.get("node_id")
|
||||
or item.get("id")
|
||||
or "",
|
||||
"name": item.get("device_name") or item.get("name") or "",
|
||||
"status": item.get("status", "unknown"),
|
||||
}
|
||||
if normalized_item["status"] == "success":
|
||||
normalized_item["result"] = (
|
||||
item.get("output") or item.get("result") or ""
|
||||
)
|
||||
else:
|
||||
normalized_item["error"] = (
|
||||
item.get("error")
|
||||
or item.get("output")
|
||||
or "Unknown error"
|
||||
)
|
||||
normalized_data.append(normalized_item)
|
||||
else:
|
||||
# Non-dict items in list
|
||||
normalized_data.append(
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"status": "unknown",
|
||||
"result": str(item),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": failed == 0,
|
||||
"total": len(response),
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"data": normalized_data,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
# Handle dict responses (some tools return summary + results)
|
||||
if isinstance(response, dict):
|
||||
# Check if already in standard format
|
||||
if "success" in response and "data" in response:
|
||||
return {
|
||||
"success": response.get("success", True),
|
||||
"total": response.get("total", len(response.get("data", []))),
|
||||
"successful": response.get("successful", 0),
|
||||
"failed": response.get("failed", 0),
|
||||
"data": response.get("data", []),
|
||||
"error": response.get("error"),
|
||||
"metadata": {**metadata, **response.get("metadata", {})},
|
||||
}
|
||||
|
||||
# Legacy format: extract common fields
|
||||
total = (
|
||||
response.get("total_nodes")
|
||||
or response.get("total")
|
||||
or response.get("count", 1)
|
||||
)
|
||||
successful = (
|
||||
response.get("successful_nodes") or response.get("successful") or 0
|
||||
)
|
||||
failed = response.get("failed_nodes") or response.get("failed") or 0
|
||||
|
||||
# Extract data from various possible locations
|
||||
data = []
|
||||
if "nodes" in response:
|
||||
data = response["nodes"]
|
||||
elif "results" in response:
|
||||
data = response["results"]
|
||||
elif "data" in response:
|
||||
data = response["data"]
|
||||
elif "output" in response:
|
||||
# Single device response
|
||||
data = [
|
||||
{
|
||||
"name": response.get("device_name", ""),
|
||||
"status": response.get("status", "success"),
|
||||
"result": response["output"],
|
||||
}
|
||||
]
|
||||
|
||||
# If no data found but have status, create single item
|
||||
if not data and "status" in response:
|
||||
data = [
|
||||
{
|
||||
"name": response.get("device_name")
|
||||
or response.get("name")
|
||||
or "",
|
||||
"status": response["status"],
|
||||
"result": response.get("output")
|
||||
or response.get("result")
|
||||
or "",
|
||||
"error": response.get("error") or "",
|
||||
}
|
||||
]
|
||||
|
||||
# Recursively normalize data items
|
||||
if data and isinstance(data, list):
|
||||
return normalize_tool_response(data, tool_name)
|
||||
else:
|
||||
# No data array, return empty but preserve counts
|
||||
return {
|
||||
"success": failed == 0,
|
||||
"total": total,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"data": [],
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
# Handle string responses (parse first)
|
||||
if isinstance(response, str):
|
||||
parsed = parse_tool_content(response, fallback_to_raw=True)
|
||||
return normalize_tool_response(parsed, tool_name)
|
||||
|
||||
# Fallback for unknown types
|
||||
return {
|
||||
"success": True,
|
||||
"total": 1,
|
||||
"successful": 1,
|
||||
"failed": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": "",
|
||||
"name": "",
|
||||
"status": "unknown",
|
||||
"result": str(response),
|
||||
}
|
||||
],
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
# Test function to verify the implementation
|
||||
def _test_parse_tool_content() -> None:
|
||||
"""Test function to verify parse_tool_content works correctly with all input
|
||||
types
|
||||
"""
|
||||
test_cases: list[tuple[Any, Any]] = [
|
||||
# String inputs
|
||||
(
|
||||
'{"status": "success", "data": [1, 2, 3]}',
|
||||
{"status": "success", "data": [1, 2, 3]},
|
||||
),
|
||||
("{'name': 'PC1', 'status': 'ok'}", {"name": "PC1", "status": "ok"}),
|
||||
("[1, 2, 3]", [1, 2, 3]),
|
||||
('"hello"', "hello"),
|
||||
("42", 42),
|
||||
("true", True),
|
||||
("3.14", 3.14),
|
||||
("{}", {}),
|
||||
(" {} ", {}),
|
||||
("", {}),
|
||||
(" ", {}),
|
||||
("Invalid JSON input", {"raw": "Invalid JSON input"}),
|
||||
# Direct object inputs
|
||||
({"status": "success"}, {"status": "success"}),
|
||||
([1, 2, 3], [1, 2, 3]),
|
||||
("hello", "hello"),
|
||||
(42, 42),
|
||||
(True, True),
|
||||
(3.14, 3.14),
|
||||
(None, {}),
|
||||
]
|
||||
|
||||
print("Testing parse_tool_content function:")
|
||||
for i, (input_data, expected) in enumerate(test_cases):
|
||||
result = parse_tool_content(input_data)
|
||||
status = "✓" if result == expected else "✗"
|
||||
print(f"Test {i + 1}: {status} Input: {repr(input_data)} -> {result}")
|
||||
|
||||
print("\nTesting format_tool_response function:")
|
||||
format_tests = [
|
||||
'{"status": "success"}',
|
||||
"{}",
|
||||
None,
|
||||
"Invalid input",
|
||||
{"direct": "dict"},
|
||||
[1, 2, 3],
|
||||
42,
|
||||
True,
|
||||
]
|
||||
|
||||
for i, input_data in enumerate(format_tests):
|
||||
result = format_tool_response(input_data)
|
||||
# Verify it's valid JSON
|
||||
try:
|
||||
json.loads(result)
|
||||
valid = "✓"
|
||||
except Exception:
|
||||
valid = "✗"
|
||||
print(
|
||||
f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_test_parse_tool_content()
|
||||
@@ -0,0 +1,196 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Tool call streaming accumulator for handling progressive tool call arguments
|
||||
Maintains state for streaming tool call chunks
|
||||
Based on FlowNet-Lab implementation
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ToolCallStreamAccumulator:
|
||||
"""
|
||||
Accumulates tool call information from streaming events
|
||||
Handles the progressive build-up of tool call arguments
|
||||
|
||||
This class processes LangGraph streaming events and accumulates
|
||||
tool call arguments that come in chunks, emitting progressive
|
||||
tool_call events to the frontend.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Current active tool call being accumulated
|
||||
# Format: {"id": str, "name": str, "args_string": str}
|
||||
self._current_tool_call: Optional[Dict[str, str]] = None
|
||||
|
||||
def process_event(self, event: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process a streaming event and return one or more response chunks
|
||||
|
||||
Args:
|
||||
event: LangGraph streaming event (on_chat_model_stream)
|
||||
|
||||
Returns:
|
||||
List of response chunks to send to frontend
|
||||
|
||||
Processing phases:
|
||||
1. Initialize: Extract tool ID and name from tool_calls
|
||||
2. Accumulate: Concatenate argument strings from tool_call_chunks
|
||||
3. Complete: Mark as complete when finish_reason is "tool_calls" or "stop"
|
||||
"""
|
||||
event_type = event.get("event", "")
|
||||
chunks = []
|
||||
|
||||
if event_type == "on_chat_model_stream":
|
||||
chunk = event.get("data", {}).get("chunk", {})
|
||||
|
||||
# ========== Phase 1: Initialize tool call from tool_calls ==========
|
||||
# Get metadata (ID and name) from tool_calls
|
||||
if hasattr(chunk, "tool_calls") and chunk.tool_calls:
|
||||
for tool_call in chunk.tool_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
tc_id = tool_call.get("id")
|
||||
tc_name = tool_call.get("name", "")
|
||||
else:
|
||||
tc_id = getattr(tool_call, "id", None)
|
||||
tc_name = getattr(tool_call, "name", "")
|
||||
|
||||
# Only when ID is not empty, consider it as the start of a new
|
||||
# tool call
|
||||
if tc_id:
|
||||
# Initialize current tool state (this is the only time to
|
||||
# get ID). Note: only one tool can be called at a time
|
||||
self._current_tool_call = {
|
||||
"id": tc_id,
|
||||
"name": tc_name if tc_name else "UNKNOWN_TOOL",
|
||||
"args_string": "",
|
||||
}
|
||||
|
||||
# Send initial tool_call event with empty args
|
||||
chunks.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_call": {
|
||||
"id": tc_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self._current_tool_call[
|
||||
"name"
|
||||
],
|
||||
"arguments": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# ========== Phase 2: Concatenate parameter strings from
|
||||
# tool_call_chunks ==========
|
||||
# Concatenate parameter strings from tool_call_chunk
|
||||
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
||||
if self._current_tool_call:
|
||||
tool_data = self._current_tool_call
|
||||
for tc_chunk in chunk.tool_call_chunks:
|
||||
# Default to "" instead of None
|
||||
if isinstance(tc_chunk, dict):
|
||||
args_chunk = tc_chunk.get("args", "")
|
||||
else:
|
||||
args_chunk = getattr(tc_chunk, "args", "")
|
||||
|
||||
# Core: string concatenation
|
||||
if isinstance(args_chunk, str):
|
||||
tool_data[
|
||||
"args_string"
|
||||
] += args_chunk
|
||||
|
||||
# Send updated tool_call event with accumulated args
|
||||
chunks.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_call": {
|
||||
"id": tool_data["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_data["name"],
|
||||
"arguments": tool_data[
|
||||
"args_string"
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# ========== Phase 3: Determine if tool_calls_chunks output is
|
||||
# complete ==========
|
||||
# Check finish_reason == "tool_calls" or "STOP"
|
||||
response_metadata = getattr(chunk, "response_metadata", {})
|
||||
finish_reason = (
|
||||
response_metadata.get("finish_reason")
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
if (finish_reason == "tool_calls") or (
|
||||
finish_reason == "stop" and self._current_tool_call is not None
|
||||
):
|
||||
if self._current_tool_call:
|
||||
tool_data = self._current_tool_call
|
||||
|
||||
# Send final complete tool_call event
|
||||
chunks.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_call": {
|
||||
"id": tool_data["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_data["name"],
|
||||
"arguments": tool_data["args_string"],
|
||||
"complete": True, # Mark as complete
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Clear the current tool call state
|
||||
self._current_tool_call = None
|
||||
|
||||
# Also handle regular content (when not in tool call mode)
|
||||
content = getattr(chunk, "content", "")
|
||||
if content and not self._current_tool_call:
|
||||
chunks.append(
|
||||
{
|
||||
"type": "content",
|
||||
"content": content,
|
||||
"message_id": event.get("metadata", {}).get("msg_id"),
|
||||
}
|
||||
)
|
||||
|
||||
return chunks
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the accumulator state"""
|
||||
self._current_tool_call = None
|
||||
@@ -0,0 +1,466 @@
|
||||
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
|
||||
|
||||
|
||||
# GNS3 Web Wireshark Container Management Guide
|
||||
|
||||
This document describes how to manage GNS3 Web Wireshark containers using the management script or Docker commands.
|
||||
|
||||
## Installation
|
||||
|
||||
Before using Web Wireshark, you need to set up the Docker image:
|
||||
|
||||
```bash
|
||||
pip install . && gns3server-web-wireshark-setup
|
||||
```
|
||||
|
||||
This will:
|
||||
1. First try to pull the `gns3/web-wireshark:latest` image from Docker Hub
|
||||
2. If pull fails, build the image locally using the Dockerfile
|
||||
|
||||
The setup command shows the output from `docker pull` or `docker build` directly, so you can see the progress.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Host Machine │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Docker Network: gns3-wireshark │ │
|
||||
│ │ (Bridge network for container-host │ │
|
||||
│ │ communication) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────────────┐ │ │
|
||||
│ │ │ Container: gns3-PROJECT-ID │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Link 1: Display :10001, Port 10001 │ │ │
|
||||
│ │ │ Link 2: Display :10002, Port 10002 │ │ │
|
||||
│ │ │ Link 3: Display :10003, Port 10003 │ │ │
|
||||
│ │ │ ... │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start (Using Management Script)
|
||||
|
||||
### Prerequisites
|
||||
- Docker is running
|
||||
- Virtual environment is activated: `source venv/bin/activate`
|
||||
|
||||
### Start Session
|
||||
|
||||
```bash
|
||||
# Start session (using all defaults)
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \
|
||||
--link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \
|
||||
--jwt-token "YOUR_JWT_TOKEN"
|
||||
|
||||
# Use custom image
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \
|
||||
--link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \
|
||||
--jwt-token "YOUR_JWT_TOKEN" \
|
||||
--image "gns3/web-wireshark:test"
|
||||
```
|
||||
|
||||
### Access Web Interface
|
||||
|
||||
After starting, access Wireshark at:
|
||||
```
|
||||
ws://<container-ip>:<port>
|
||||
```
|
||||
|
||||
### Stop Session
|
||||
|
||||
```bash
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
stop \
|
||||
--project-id "test-project" \
|
||||
--link-id "link-1"
|
||||
```
|
||||
|
||||
### Delete Container
|
||||
|
||||
```bash
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
delete-container \
|
||||
--project-id "test-project"
|
||||
```
|
||||
|
||||
## Resource Parameter Configuration
|
||||
|
||||
### Memory Configuration
|
||||
|
||||
```bash
|
||||
# Default 2GB memory
|
||||
--memory "2g"
|
||||
|
||||
# Custom memory
|
||||
--memory "4g" # 4GB
|
||||
--memory "512m" # 512MB
|
||||
--memory "1g" \
|
||||
--memory-swap "2g" # 1GB memory + 2GB swap
|
||||
```
|
||||
|
||||
### CPU Configuration
|
||||
|
||||
```bash
|
||||
# Default 1 CPU core
|
||||
--cpus 1.0
|
||||
|
||||
# Custom CPU
|
||||
--cpus 0.5 # 50% CPU
|
||||
--cpus 2.0 # 2 CPU cores
|
||||
--cpus 4.0 # 4 CPU cores
|
||||
```
|
||||
|
||||
### Process Limit Configuration
|
||||
|
||||
```bash
|
||||
# Default max 1000 processes
|
||||
--pids-limit 1000
|
||||
|
||||
# Custom limit
|
||||
--pids-limit 500 # Max 500 processes
|
||||
--pids-limit 2000 # Max 2000 processes
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```bash
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "test-project" \
|
||||
--link-id "link-1" \
|
||||
--jwt-token "test-token" \
|
||||
--image "gns3/web-wireshark:latest" \
|
||||
--memory "4g" \
|
||||
--memory-swap "6g" \
|
||||
--cpus 2.0 \
|
||||
--pids-limit 2000
|
||||
```
|
||||
|
||||
## Parameter Reference Table
|
||||
|
||||
| Parameter | Default Value | Description | Example |
|
||||
|-----------|---------------|-------------|---------|
|
||||
| --image | gns3/web-wireshark:latest | Docker image | ubuntu:latest |
|
||||
| --memory | 2g | Memory limit | 4g, 512m |
|
||||
| --memory-swap | Same as memory | Memory swap limit | 4g, 8g |
|
||||
| --cpus | 1.0 | CPU cores | 0.5, 2.0 |
|
||||
| --pids-limit | 1000 | Process limit | 500, 2000 |
|
||||
|
||||
## Docker Network Management
|
||||
|
||||
### Create Network
|
||||
|
||||
```bash
|
||||
docker network create \
|
||||
--driver bridge \
|
||||
--subnet=100.64.0.0/22 \
|
||||
gns3-wireshark
|
||||
```
|
||||
|
||||
### Delete Network
|
||||
|
||||
```bash
|
||||
docker network rm gns3-wireshark
|
||||
```
|
||||
|
||||
**Warning:** Stop and disconnect all containers before deleting the network.
|
||||
|
||||
### View Network Information
|
||||
|
||||
```bash
|
||||
# List all Docker networks
|
||||
docker network ls
|
||||
|
||||
# View network details
|
||||
docker network inspect gns3-wireshark
|
||||
|
||||
# List containers connected to the network
|
||||
docker network inspect gns3-wireshark -f '{{range .Containers}}{{.Name}} {{end}}'
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Per Wireshark Instance Resource Usage
|
||||
|
||||
| Resource Type | Usage | Description |
|
||||
|---------------|-------|-------------|
|
||||
| **Memory** | 150-250 MB | Depends on capture traffic and number of parsed protocols |
|
||||
| **CPU** | 0.5-2% | Lower at idle, increases with high traffic |
|
||||
| **Threads** | ~30 threads | Wireshark multi-threaded architecture |
|
||||
| **Disk I/O** | Minimal | Mostly log writing |
|
||||
|
||||
### Container Resource Configuration Recommendations
|
||||
|
||||
Based on `--pids-limit 1000` and `--memory="2g"` configuration:
|
||||
|
||||
| Wireshark Instances | Estimated Threads | Estimated Memory | Recommended Use Case |
|
||||
|---------------------|-------------------|------------------|---------------------|
|
||||
| 1-3 | 120-200 threads | 450-750 MB | Lightweight projects, small topologies |
|
||||
| 4-6 | 230-290 threads | 600-1.5 GB | Medium projects, multiple network links |
|
||||
| 7-10 | 320-410 threads | 1-2.5 GB | Large projects, dense capture |
|
||||
| 10+ | >400 threads | >2.5 GB | Warning: Increase memory limit |
|
||||
|
||||
### Actual Test Data
|
||||
|
||||
**Test Environment:** 3 Wireshark instances running simultaneously
|
||||
|
||||
```
|
||||
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
|
||||
19363d29bd9d gns3-PROJECT-ID 4.59% 735.6MiB / 2GiB 35.92% 386kB / 38.6MB 0B / 15.5MB 201
|
||||
```
|
||||
|
||||
**Detailed Process Statistics:**
|
||||
- Total threads: ~204
|
||||
- Total processes: ~61
|
||||
- Per Wireshark instance: ~30 threads + 1 parent process
|
||||
|
||||
### Performance Optimization Recommendations
|
||||
|
||||
1. **Memory is the main bottleneck**, not PID limit
|
||||
- Default 2GB memory can support 6-8 Wireshark instances
|
||||
- When needing more instances, increase memory first rather than PID limit
|
||||
|
||||
2. **Start Wireshark on demand**
|
||||
- Only start Wireshark for links that need packet capture
|
||||
- Stop sessions promptly when done to release resources
|
||||
|
||||
3. **Multi-container strategy**
|
||||
- For super large projects (10+ links), consider using multiple containers
|
||||
- Each container handles 5-8 links for better resource isolation and stability
|
||||
|
||||
4. **Monitor resource usage**
|
||||
```bash
|
||||
# Real-time container resource monitoring
|
||||
docker stats gns3-PROJECT-ID
|
||||
|
||||
# Check process count inside container
|
||||
docker exec gns3-PROJECT_ID bash -c "ps -eLf | wc -l"
|
||||
```
|
||||
|
||||
## Management Commands (Docker)
|
||||
|
||||
### View All Active xpra Sessions
|
||||
|
||||
```bash
|
||||
docker exec "${CONTAINER_NAME}" xpra list
|
||||
```
|
||||
|
||||
### View Container Logs
|
||||
|
||||
```bash
|
||||
docker logs "${CONTAINER_NAME}"
|
||||
```
|
||||
|
||||
### View Processes Inside Container
|
||||
|
||||
```bash
|
||||
docker exec "${CONTAINER_NAME}" ps aux | grep -E "Xvfb|xpra"
|
||||
```
|
||||
|
||||
### Enter Container Shell
|
||||
|
||||
```bash
|
||||
docker exec -it "${CONTAINER_NAME}" /bin/bash
|
||||
```
|
||||
|
||||
### Stop Container
|
||||
|
||||
```bash
|
||||
docker stop "${CONTAINER_NAME}"
|
||||
```
|
||||
|
||||
### Delete Container
|
||||
|
||||
```bash
|
||||
docker rm "${CONTAINER_NAME}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check if Container is Running
|
||||
|
||||
```bash
|
||||
docker ps | grep "${CONTAINER_NAME}"
|
||||
```
|
||||
|
||||
### Check Network Connection
|
||||
|
||||
```bash
|
||||
# Ping container from host
|
||||
docker exec "${CONTAINER_NAME}" ping -c 3 100.64.0.1
|
||||
|
||||
# Check port listening
|
||||
docker exec "${CONTAINER_NAME}" netstat -tlnp | grep xpra
|
||||
```
|
||||
|
||||
### View xpra Logs
|
||||
|
||||
```bash
|
||||
docker exec "${CONTAINER_NAME}" ls -la /tmp/sessions/
|
||||
```
|
||||
|
||||
### Restart Specific Session
|
||||
|
||||
```bash
|
||||
LINK_ID=1
|
||||
DISPLAY_ID=$LINK_ID
|
||||
|
||||
# Stop session
|
||||
docker exec "${CONTAINER_NAME}" xpra stop ":${DISPLAY_ID}"
|
||||
|
||||
# Clean up session files
|
||||
docker exec "${CONTAINER_NAME}" rm -rf "/tmp/sessions/link-${LINK_ID}"
|
||||
|
||||
# Restart (see previous start commands)
|
||||
```
|
||||
|
||||
### Thread Creation Error (QThread::start: Thread creation error)
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
QThread::start: Thread creation error (Resource temporarily unavailable)
|
||||
```
|
||||
|
||||
**Root Cause Analysis:**
|
||||
- Docker container PID limit (`--pids-limit`) actually limits thread count
|
||||
- Each Wireshark instance requires approximately 30 threads
|
||||
- Default limit of 200 may not be enough for multiple Wireshark instances
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check current thread usage
|
||||
docker exec "${CONTAINER_NAME}" bash -c "ps -eLf | wc -l"
|
||||
|
||||
# Increase PID limit (recommended to set to 1000)
|
||||
docker update --pids-limit 1000 "${CONTAINER_NAME}"
|
||||
|
||||
# Verify new limit
|
||||
docker inspect "${CONTAINER_NAME}" --format '{{.HostConfig.PidsLimit}}'
|
||||
```
|
||||
|
||||
**Prevention:**
|
||||
- Set a reasonable PID limit when starting the container: `--pids-limit 1000`
|
||||
- Refer to "Performance Metrics" section for appropriate configuration
|
||||
|
||||
### XDG_RUNTIME_DIR Warning
|
||||
|
||||
**Warning Message:**
|
||||
```
|
||||
Warning: XDG_RUNTIME_DIR is not defined
|
||||
and '/run/user/1000' does not exist
|
||||
using '/tmp'
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- This is a warning, not an error; Xpra falls back to using `/tmp`
|
||||
- May affect some features relying on XDG specification
|
||||
|
||||
**Solution:**
|
||||
Ensure you are using the latest Docker image, which includes the following fix:
|
||||
- Create `/run/user/1000` directory
|
||||
- Set `XDG_RUNTIME_DIR` environment variable
|
||||
|
||||
For manual fix:
|
||||
```bash
|
||||
docker exec "${CONTAINER_NAME}" mkdir -p /run/user/1000
|
||||
docker exec "${CONTAINER_NAME}" bash -c "export XDG_RUNTIME_DIR=/run/user/1000"
|
||||
```
|
||||
|
||||
## Manual Testing (Step-by-Step)
|
||||
|
||||
This section preserves the original manual testing steps used during development.
|
||||
|
||||
### Prerequisites
|
||||
- Docker is running
|
||||
- Virtual environment is activated: `source venv/bin/activate`
|
||||
|
||||
### Basic Test Commands
|
||||
|
||||
```bash
|
||||
# 1. Start session (using all defaults)
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \
|
||||
--link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \
|
||||
--jwt-token "YOUR_JWT_TOKEN"
|
||||
|
||||
# 2. Use custom image
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
--verbose start \
|
||||
--project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \
|
||||
--link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \
|
||||
--jwt-token "YOUR_JWT_TOKEN" \
|
||||
--image "gns3/web-wireshark:test"
|
||||
|
||||
# 3. View containers
|
||||
docker ps | grep gns3-wireshark
|
||||
docker logs gns3-wireshark-test-project
|
||||
|
||||
# 4. Stop session
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
stop \
|
||||
--project-id "test-project" \
|
||||
--link-id "link-1"
|
||||
|
||||
# 5. Delete container
|
||||
python3 gns3server/agent/web_wireshark/manage_wireshark.py \
|
||||
delete-container \
|
||||
--project-id "test-project"
|
||||
```
|
||||
|
||||
### WebSocket Access URL
|
||||
|
||||
After starting a session, access Wireshark via WebSocket:
|
||||
```
|
||||
ws://192.168.1.140:3080/v3/projects/5af0fe00-f39d-4985-8669-7e8c512d729c/links/f233f27f-7432-49c3-9aa2-50e326a10eec/capture/web-wireshark?token=YOUR_JWT_TOKEN
|
||||
```
|
||||
|
||||
## Cleanup (If Tests Fail)
|
||||
|
||||
```bash
|
||||
# Delete test containers
|
||||
docker ps -a | grep 'gns3-wireshark-test' | awk '{print $1}' | xargs -r docker rm -f
|
||||
|
||||
# Delete test networks
|
||||
docker network ls | grep 'gns3-wireshark' | awk '{print $2}' | xargs -r docker network rm
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
gns3server/agent/web_wireshark/
|
||||
├── setup_wireshark_image.py # Docker image setup tool (gns3-wireshark-setup)
|
||||
├── manage_wireshark.py # CLI management tool
|
||||
├── manager.py # Session management logic
|
||||
├── docker_client.py # Docker API client
|
||||
├── docker/
|
||||
│ └── Dockerfile # Container image definition
|
||||
└── WEB_WIRESHARK.md # This documentation
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- JWT token is passed via command-line arguments (visible in `/proc/<pid>/cmdline`). Consider using a temporary file inside the container for improved security.
|
||||
- `cmd_delete_container` and `cmd_delete` in manage_wireshark.py are duplicate code.
|
||||
- `stop-container` and `delete-container` subcommands are defined but not registered in the commands dictionary.
|
||||
- `link_id_to_display` and `link_id_to_port` return the same value (10000-19999), which may cause confusion since xpra typically uses different display numbers and ports.
|
||||
- Container health check timeout (5 seconds) may be insufficient on slow systems.
|
||||
- Docker Unix socket connection error handling could be improved (FileNotFoundError not properly caught).
|
||||
|
||||
## Notes
|
||||
|
||||
- Default uses `gns3/web-wireshark:latest` image
|
||||
- Use `--verbose` to see detailed logs
|
||||
- Containers use 100.64.0.0/22 network
|
||||
- xpra port range: 10000-19999 (deterministic based on link_id)
|
||||
- Health check: `xpra list`
|
||||
- Log configuration: json-file, max-size=10m, max-file=3
|
||||
@@ -0,0 +1,61 @@
|
||||
FROM debian:trixie
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Set timezone to UTC
|
||||
ENV TZ=UTC
|
||||
|
||||
# Set UTF-8 locale to avoid Qt warnings
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
# Use Alibaba Cloud Debian mirror for faster downloads in China (Debian Trixie uses deb822 format)
|
||||
RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& 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
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
wget \
|
||||
gnupg \
|
||||
&& wget -O "/usr/share/keyrings/xpra.asc" https://xpra.org/xpra.asc \
|
||||
&& echo "deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/xpra.asc] https://xpra.org trixie main" > /etc/apt/sources.list.d/xpra.list \
|
||||
&& apt-get update
|
||||
|
||||
# Install xpra and dependencies
|
||||
# Lock xpra version for reproducible builds
|
||||
RUN apt-get install -y \
|
||||
wireshark-common \
|
||||
wireshark \
|
||||
xpra=6.4.3* \
|
||||
xpra-x11 \
|
||||
xvfb \
|
||||
curl \
|
||||
x11-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Metadata
|
||||
LABEL maintainer="YueGuobin <yueguobin@outlook.com>"
|
||||
LABEL description="Web Wireshark container with xpra for GNS3"
|
||||
|
||||
# Create gns3 user
|
||||
RUN groupadd -g 1000 gns3 && \
|
||||
useradd -u 1000 -g gns3 -m -s /bin/bash gns3
|
||||
|
||||
# Create sessions directory with proper permissions
|
||||
RUN mkdir -p /tmp/sessions && chmod 1777 /tmp/sessions
|
||||
|
||||
# Create XDG runtime directory for gns3 user
|
||||
RUN mkdir -p /run/user/1000 && chown -R gns3:gns3 /run/user/1000 && chmod 700 /run/user/1000
|
||||
|
||||
# Set XDG environment variables
|
||||
ENV XDG_RUNTIME_DIR=/run/user/1000
|
||||
|
||||
|
||||
# Set default user
|
||||
USER gns3
|
||||
|
||||
# No EXPOSE - port will be dynamically allocated
|
||||
# Keep container running
|
||||
CMD ["tail", "-f", "/dev/null"]
|
||||
@@ -0,0 +1,291 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Copyright (C) 2026 YueGuobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
Web Wireshark - Docker HTTP API client
|
||||
|
||||
This module handles Docker API operations via aiohttp Unix socket.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
from gns3server.utils import parse_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Docker API configuration
|
||||
DOCKER_SOCKET = "/var/run/docker.sock"
|
||||
DOCKER_MINIMUM_API_VERSION = "1.40"
|
||||
DOCKER_PREFERRED_API_VERSION = "1.44"
|
||||
|
||||
|
||||
class DockerHTTPClient:
|
||||
"""Docker HTTP API client using aiohttp with Unix socket connection."""
|
||||
|
||||
# API request timeout (seconds)
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
def __init__(self):
|
||||
self._connector = None
|
||||
self._session = None
|
||||
self._connected = False
|
||||
# Start with preferred API version (1.44) to support newer Docker
|
||||
# Will fallback to minimum version (1.40) if server doesn't support it
|
||||
self._api_version = DOCKER_PREFERRED_API_VERSION
|
||||
|
||||
async def _get_connector(self):
|
||||
"""Get or create Unix socket connector."""
|
||||
if self._connector is None or self._connector.closed:
|
||||
try:
|
||||
self._connector = aiohttp.connector.UnixConnector(DOCKER_SOCKET, limit=None)
|
||||
except (aiohttp.ClientError, FileNotFoundError):
|
||||
raise RuntimeError(f"Can't connect to Docker daemon at {DOCKER_SOCKET}")
|
||||
return self._connector
|
||||
|
||||
async def _get_session(self):
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
connector = await self._get_connector()
|
||||
self._session = aiohttp.ClientSession(connector=connector)
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
"""Close connections."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
if self._connector and not self._connector.closed:
|
||||
await self._connector.close()
|
||||
self._connected = False
|
||||
|
||||
async def _check_connection(self):
|
||||
"""Check Docker connection and detect API version."""
|
||||
if not self._connected:
|
||||
try:
|
||||
# Get Docker version info
|
||||
docker_info = await self._request("GET", "version", check_connection=False)
|
||||
self._connected = True
|
||||
|
||||
# Parse API version
|
||||
api_version = parse_version(docker_info['ApiVersion'])
|
||||
docker_version = docker_info["Version"]
|
||||
|
||||
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
|
||||
|
||||
# Check minimum version requirement
|
||||
if api_version < parse_version(DOCKER_MINIMUM_API_VERSION):
|
||||
raise RuntimeError(
|
||||
f"Docker version is {docker_version}. "
|
||||
f"GNS3 requires a minimum API version of {DOCKER_MINIMUM_API_VERSION}"
|
||||
)
|
||||
|
||||
# Use preferred API version if supported
|
||||
preferred_api_version = parse_version(DOCKER_PREFERRED_API_VERSION)
|
||||
if api_version >= preferred_api_version:
|
||||
self._api_version = DOCKER_PREFERRED_API_VERSION
|
||||
logger.info(f"Using Docker API version {self._api_version}")
|
||||
else:
|
||||
# Use Docker daemon's actual API version
|
||||
self._api_version = docker_info['ApiVersion']
|
||||
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
|
||||
|
||||
except (aiohttp.ClientError, FileNotFoundError) as e:
|
||||
self._connected = False
|
||||
raise RuntimeError(f"Can't connect to Docker daemon: {e}") from e
|
||||
except KeyError as e:
|
||||
raise RuntimeError(f"Unexpected Docker API response: missing {e}") from e
|
||||
|
||||
async def _request(self, method: str, endpoint: str, **kwargs):
|
||||
"""
|
||||
Send request to Docker API.
|
||||
|
||||
Args:
|
||||
method: HTTP method
|
||||
endpoint: API endpoint (without version prefix)
|
||||
**kwargs: Other parameters for aiohttp.request
|
||||
|
||||
Returns:
|
||||
Response JSON data
|
||||
"""
|
||||
# Check connection and version on first request
|
||||
check_connection = kwargs.pop('check_connection', True)
|
||||
if check_connection and not self._connected:
|
||||
await self._check_connection()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"http://docker/v{self._api_version}/{endpoint}"
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(self.REQUEST_TIMEOUT):
|
||||
async with session.request(method, url, **kwargs) as response:
|
||||
if response.status >= 300:
|
||||
error_text = await response.text()
|
||||
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
|
||||
if response.status == 204: # No Content
|
||||
return None
|
||||
return await response.json()
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for {endpoint}")
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Docker connection error: {e}") from e
|
||||
except RuntimeError as e:
|
||||
# Retry with lower API version if Docker daemon doesn't support current version
|
||||
error_msg = str(e)
|
||||
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION:
|
||||
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}")
|
||||
self._api_version = DOCKER_MINIMUM_API_VERSION
|
||||
return await self._request(method, endpoint, check_connection=False, **kwargs)
|
||||
raise
|
||||
|
||||
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
|
||||
"""Create Docker network."""
|
||||
data = {
|
||||
"Name": name,
|
||||
"Driver": driver
|
||||
}
|
||||
if subnet:
|
||||
data["IPAM"] = {
|
||||
"Config": [{"Subnet": subnet}]
|
||||
}
|
||||
await self._request("POST", "networks/create", json=data)
|
||||
|
||||
async def get_network(self, name: str):
|
||||
"""Get network info."""
|
||||
try:
|
||||
return await self._request("GET", f"networks/{name}")
|
||||
except RuntimeError as e:
|
||||
if "404" in str(e):
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_container(self, name: str, image: str, **kwargs):
|
||||
"""Create container."""
|
||||
data = {
|
||||
"Image": image,
|
||||
"name": name,
|
||||
"HostConfig": {},
|
||||
"NetworkingConfig": {}
|
||||
}
|
||||
|
||||
# Handle network config
|
||||
if "network" in kwargs:
|
||||
network_name = kwargs.pop("network")
|
||||
data["NetworkingConfig"]["EndpointsConfig"] = {
|
||||
network_name: {}
|
||||
}
|
||||
|
||||
# Handle environment variables
|
||||
if "environment" in kwargs:
|
||||
data["Env"] = [f"{k}={v}" for k, v in kwargs.pop("environment").items()]
|
||||
|
||||
# Handle resource limits
|
||||
if "host_config" in kwargs:
|
||||
data["HostConfig"].update(kwargs.pop("host_config"))
|
||||
else:
|
||||
# Legacy parameter format
|
||||
host_config = {}
|
||||
if "mem_limit" in kwargs:
|
||||
host_config["Memory"] = kwargs.pop("mem_limit")
|
||||
if "cpu_quota" in kwargs:
|
||||
host_config["NanoCpus"] = kwargs.pop("cpu_quota")
|
||||
if "pids_limit" in kwargs:
|
||||
host_config["PidsLimit"] = kwargs.pop("pids_limit")
|
||||
if "restart_policy" in kwargs:
|
||||
host_config["RestartPolicy"] = kwargs.pop("restart_policy")
|
||||
data["HostConfig"].update(host_config)
|
||||
|
||||
# Handle health check
|
||||
if "health_config" in kwargs:
|
||||
data["HealthCheck"] = kwargs.pop("health_config")
|
||||
|
||||
# Create container
|
||||
result = await self._request("POST", "containers/create", params={"name": name}, json=data)
|
||||
return result["Id"]
|
||||
|
||||
async def start_container(self, container_id: str):
|
||||
"""Start container."""
|
||||
await self._request("POST", f"containers/{container_id}/start")
|
||||
|
||||
async def get_container(self, name: str):
|
||||
"""Get container info."""
|
||||
try:
|
||||
return await self._request("GET", f"containers/{name}/json")
|
||||
except RuntimeError as e:
|
||||
if "404" in str(e):
|
||||
return None
|
||||
raise
|
||||
|
||||
async def stop_container(self, container_id: str, timeout: int = 0):
|
||||
"""Stop container immediately (force kill, no graceful shutdown)."""
|
||||
await self._request("POST", f"containers/{container_id}/stop", params={"t": timeout})
|
||||
|
||||
async def remove_container(self, container_id: str, force: bool = False):
|
||||
"""Remove container."""
|
||||
params = {"force": "true"} if force else {}
|
||||
await self._request("DELETE", f"containers/{container_id}", params=params)
|
||||
|
||||
async def list_processes(self, container_name: str) -> list:
|
||||
"""Get process list from container.
|
||||
|
||||
Args:
|
||||
container_name: Container name (e.g., "gns3-wireshark-xxx")
|
||||
|
||||
Returns:
|
||||
List of process dicts with keys: PID, USER, COMMAND, etc.
|
||||
"""
|
||||
if not self._connected:
|
||||
await self._check_connection()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"http://docker/v{self._api_version}/containers/{container_name}/top?ps_args=aux"
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(self.REQUEST_TIMEOUT):
|
||||
async with session.get(url) as response:
|
||||
if response.status >= 300:
|
||||
error_text = await response.text()
|
||||
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
|
||||
result = await response.json()
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for containers/{container_name}/top")
|
||||
except aiohttp.ClientError as e:
|
||||
raise RuntimeError(f"Docker connection error: {e}") from e
|
||||
|
||||
# Docker API returns JSON format:
|
||||
# {"Processes": [["yueguob+", "259847", ...], [...]], "Titles": ["USER", "PID", "%CPU", ...]}
|
||||
# Headers are in "Titles" key, data rows are in "Processes"
|
||||
|
||||
headers = result.get("Titles", [])
|
||||
processes_data = result.get("Processes", [])
|
||||
if not headers or not processes_data:
|
||||
return []
|
||||
|
||||
processes = []
|
||||
for values in processes_data:
|
||||
if not values or len(values) < len(headers):
|
||||
continue
|
||||
|
||||
proc = {}
|
||||
for i, header in enumerate(headers):
|
||||
if i < len(values):
|
||||
proc[header] = values[i]
|
||||
|
||||
processes.append(proc)
|
||||
|
||||
return processes
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Copyright (C) 2026 YueGuobin
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
Web Wireshark Management Script (CLI Tool)
|
||||
|
||||
This script provides a command-line interface for managing Web Wireshark containers.
|
||||
It is intended for manual management, debugging, and testing purposes.
|
||||
|
||||
NOTE: For programmatic access within GNS3 server, use WebWiresharkManager class directly
|
||||
from gns3server.agent.web_wireshark.manager instead of calling this script via subprocess.
|
||||
|
||||
Usage Examples:
|
||||
# Start a Web Wireshark session
|
||||
python manage_wireshark.py start \\
|
||||
--project-id <uuid> \\
|
||||
--link-id <uuid> \\
|
||||
--jwt-token <token>
|
||||
|
||||
# Stop a Web Wireshark session
|
||||
python manage_wireshark.py stop \\
|
||||
--project-id <uuid> \\
|
||||
--link-id <uuid>
|
||||
|
||||
# Restart a Web Wireshark session
|
||||
python manage_wireshark.py restart \\
|
||||
--project-id <uuid> \\
|
||||
--link-id <uuid> \\
|
||||
--jwt-token <token>
|
||||
|
||||
# Stop all sessions for a project
|
||||
python manage_wireshark.py stop-all --project-id <uuid>
|
||||
|
||||
# Delete project container
|
||||
python manage_wireshark.py delete --project-id <uuid>
|
||||
|
||||
Available Commands:
|
||||
start Start a new Web Wireshark session
|
||||
stop Stop a Web Wireshark session
|
||||
restart Restart a Web Wireshark session
|
||||
stop-all Stop all Web Wireshark sessions for a project
|
||||
delete Delete the Web Wireshark container
|
||||
stop-container Stop container without deleting
|
||||
delete-container Delete the container
|
||||
|
||||
Output Format:
|
||||
All commands output JSON to stdout for easy parsing:
|
||||
- Success: {"status": "...", "ws_url": "...", ...}
|
||||
- Error: {"error": "message"} (to stderr)
|
||||
|
||||
For more information on each command, use:
|
||||
python manage_wireshark.py <command> --help
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from gns3server.utils.uuid_validator import validate_uuid
|
||||
|
||||
# Add parent directory to path for imports when run directly
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
|
||||
try:
|
||||
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
||||
except ImportError:
|
||||
from manager import WebWiresharkManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False):
|
||||
"""Setup logging configuration."""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
|
||||
async def cmd_start(args) -> int:
|
||||
"""Start Web Wireshark session.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.ensure_network()
|
||||
|
||||
result = await manager.start_wireshark_session(
|
||||
project_id=args.project_id,
|
||||
link_id=args.link_id,
|
||||
jwt_token=args.jwt_token,
|
||||
capture_stream_url=args.capture_url,
|
||||
image=args.image,
|
||||
memory=args.memory,
|
||||
cpus=args.cpus,
|
||||
pids_limit=args.pids_limit
|
||||
)
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_stop(args) -> int:
|
||||
"""Stop Web Wireshark session.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.stop_wireshark_session(
|
||||
project_id=args.project_id,
|
||||
link_id=args.link_id
|
||||
)
|
||||
print(json.dumps({"status": "stopped"}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_restart(args) -> int:
|
||||
"""Restart Web Wireshark session.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
result = await manager.restart_wireshark_session(
|
||||
project_id=args.project_id,
|
||||
link_id=args.link_id,
|
||||
jwt_token=args.jwt_token,
|
||||
capture_stream_url=args.capture_url
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_stop_all(args) -> int:
|
||||
"""Stop all Web Wireshark sessions for a project.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.stop_all_sessions(project_id=args.project_id)
|
||||
print(json.dumps({"status": "all sessions stopped"}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_stop_container(args) -> int:
|
||||
"""Stop Web Wireshark container (without deleting).
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.stop_container(project_id=args.project_id)
|
||||
print(json.dumps({"status": "stopped"}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_delete_container(args) -> int:
|
||||
"""Delete Web Wireshark container.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.delete_container(project_id=args.project_id)
|
||||
print(json.dumps({"status": "deleted"}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
async def cmd_delete(args) -> int:
|
||||
"""Delete Web Wireshark container.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
manager = WebWiresharkManager()
|
||||
try:
|
||||
await manager.delete_container(project_id=args.project_id)
|
||||
print(json.dumps({"status": "deleted"}))
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
"""Create command line argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Web Wireshark container management"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable verbose logging"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# Start command
|
||||
start_parser = subparsers.add_parser("start", help="Start Web Wireshark session")
|
||||
start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
||||
start_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
||||
start_parser.add_argument(
|
||||
"--capture-url",
|
||||
help="Capture stream URL (auto-detected if not provided)"
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--image",
|
||||
default="gns3/web-wireshark:latest",
|
||||
help="Docker image (default: gns3/web-wireshark:latest)"
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--memory",
|
||||
default="2g",
|
||||
help="Memory limit (default: 2g)"
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--cpus",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="CPU cores (default: 1.0)"
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--pids-limit",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Process limit (default: 1000)"
|
||||
)
|
||||
|
||||
# Stop command
|
||||
stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session")
|
||||
stop_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
stop_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
||||
|
||||
# Restart command
|
||||
restart_parser = subparsers.add_parser("restart", help="Restart Web Wireshark session")
|
||||
restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
|
||||
restart_parser.add_argument("--jwt-token", required=True, help="JWT token")
|
||||
restart_parser.add_argument(
|
||||
"--capture-url",
|
||||
help="Capture stream URL (auto-detected if not provided)"
|
||||
)
|
||||
|
||||
# Stop all command
|
||||
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")
|
||||
stop_all_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
|
||||
# Delete command
|
||||
delete_parser = subparsers.add_parser("delete", help="Delete container")
|
||||
delete_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
|
||||
# Stop container command
|
||||
stop_container_parser = subparsers.add_parser("stop-container", help="Stop container (without deleting)")
|
||||
stop_container_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
|
||||
# Delete container command
|
||||
delete_container_parser = subparsers.add_parser("delete-container", help="Delete container")
|
||||
delete_container_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
setup_logging(args.verbose)
|
||||
|
||||
commands = {
|
||||
"start": cmd_start,
|
||||
"stop": cmd_stop,
|
||||
"restart": cmd_restart,
|
||||
"stop-all": cmd_stop_all,
|
||||
"delete": cmd_delete,
|
||||
"stop-container": cmd_stop_container,
|
||||
"delete-container": cmd_delete_container,
|
||||
}
|
||||
|
||||
return await commands[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 471 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user