feat(web-wireshark): add implementation plan for script-driven (#2666)

* feat(web-wireshark): add implementation plan for script-driven integration

Add comprehensive implementation plan for Web Wireshark integration using script-driven approach. The plan outlines:

- Background and motivation for script-based Web Wireshark integration
- Core workflow from API request to WebSocket proxy connection
- Management script architecture with WebWiresharkManager class
- Docker container configuration and resource management
- Performance analysis and optimization recommendations
- Network setup and management procedures

Key features include:
- JWT token extraction from Authorization header
- Automatic GNS3 server URL detection
- Container lifecycle management per project
- Xpra session isolation per link
- WebSocket proxy integration through gns3server
- Resource monitoring and scaling guidelines

The implementation enables users to start Web Wireshark sessions via POST requests with `wireshark: true` parameter, providing a unified web-based packet capture interface.

* feat(web-wireshark): implement script-driven Web Wireshark integration

Add complete Web Wireshark container integration with script-driven architecture:

- Create manage_wireshark.py script for Docker container management
- Add LinkCapture schema with wireshark boolean field
- Update Link class with wireshark and jwt_token parameters
- Implement WebSocket proxy endpoint for xpra HTML5 client
- Add cleanup logic in Project class for container lifecycle

Key features:
- Script-driven container and xpra session management
- JWT token extraction from Authorization header
- GNS3 URL auto-detection with Controller/Config fallback
- WebSocket proxy for unified access through gns3server
- Proper cleanup on project close/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): use aiohttp instead of docker SDK

Replace docker-py SDK with direct Docker HTTP API calls using aiohttp,
following GNS3's existing architecture pattern in gns3server/compute/docker/.

Changes:
- Create DockerHTTPClient class using aiohttp + Unix socket
- Implement all Docker operations as async methods
- Remove dependency on docker Python SDK
- Use Docker API v1.44 via /var/run/docker.sock

Benefits:
- No additional dependencies required
- Consistent with GNS3's async architecture
- Better integration with existing codebase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): add dynamic Docker API version detection

Implement dynamic Docker API version detection following GNS3's pattern
in gns3server/compute/docker/__init__.py.

Changes:
- Add DOCKER_MINIMUM_API_VERSION and DOCKER_PREFERRED_API_VERSION constants
- Remove hardcoded "v1.44" prefix (now using "1.44" format)
- Add _check_connection() method to detect Docker daemon version
- Dynamically select API version based on daemon capabilities
- Add proper error handling for version mismatches

Behavior:
- Initialize with minimum API version (1.40)
- On first connection, detect Docker daemon API version
- Use preferred API version (1.44) if supported
- Fall back to daemon's minimum API version if needed
- Raise error if daemon version is too old

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): add optional --verbose logging parameter

Remove hardcoded logging.basicConfig() and add --verbose parameter
to control log output, following GNS3's logging pattern.

Changes:
- Remove logging.basicConfig(level=logging.INFO) from module level
- Add --verbose/-v command line parameter
- Configure logging only when --verbose is specified
- Use structured log format with timestamp when verbose

Behavior:
- When called by GNS3: uses GNS3's logging configuration (default)
- When run standalone: no output unless --verbose is specified
- With --verbose: shows detailed logs with timestamps

This prevents the script from interfering with GNS3's logging
configuration while still allowing debug output when needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): fix code quality issues from static analysis

Fix issues identified by flake8 and pylint static analysis:

Import and formatting fixes:
- Remove unused 'time' import
- Fix import order (stdlib before third-party)
- Remove unnecessary f-strings without placeholders
- Split long line to comply with flake8 (max line length: 127)

Code structure improvements:
- Remove unnecessary 'else' after 'return'
- Add 'from e' to exception re-raising for better tracebacks

Code quality metrics:
- pylint score: 8.11/10 → 9.97/10 (+1.86)
- flake8: 0 errors (max line length: 127, per CI/CD standard)
- All critical issues resolved

Note: R0914 (too-many-locals) warning is informational only,
code remains clear and maintainable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): export LinkCapture schema in schemas module

Add LinkCapture to the schemas module exports in __init__.py to fix
the AttributeError when starting the GNS3 server.

Error was:
  AttributeError: module 'gns3server.schemas' has no attribute 'LinkCapture'

This was caused by adding the LinkCapture class to controller/links.py
but forgetting to export it in the schemas __init__.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add --image parameter for custom Docker images

Add optional --image parameter to allow using custom Docker images
for testing, instead of requiring gns3/web-wireshark:latest.

Changes:
- Add --image parameter to start command (default: gns3/web-wireshark:latest)
- Update get_or_create_container() to accept image parameter
- Update start_wireshark_session() to accept and pass image parameter
- Update TEST.md with examples of using custom images

Usage:
  # Using default image
  python3 manage_wireshark.py start --project-id x --link-id y --jwt-token z

  # Using custom image (for testing)
  python3 manage_wireshark.py start --project-id x --link-id y --jwt-token z --image ubuntu:latest

This makes it easier to test the script without having the official
gns3/web-wireshark image available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add configurable resource limits and increase session capacity

Add configurable resource parameters and increase session support from 10 to 100.

Resource parameters:
- --memory: Memory limit (default: 2g)
- --memory-swap: Memory swap limit (default: same as memory)
- --cpus: CPU cores (default: 1.0)
- --pids-limit: Process limit (default: 1000)

Other improvements:
- Fix CPU quota calculation: 1000000 microseconds = 1.0 CPU core
  (was incorrectly 100000 = 0.1 CPU core)
- Add health check: xpra list command
- Add log configuration: json-file, max-size=10m, max-file=3
- Increase session capacity: 10 → 100 concurrent sessions
- Port range: 12300-12399 (was 12300-12309)
- Display range: :100-:199 (was :100-:109)

This matches the docker run command parameters and provides
better resource management and monitoring capabilities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): update UDPLink.start_capture() to accept new parameters

Update UDPLink.start_capture() to accept the new wireshark and jwt_token
parameters and pass them to the parent Link class.

This fixes the TypeError when starting capture on UDP links:
  TypeError: UDPLink.start_capture() got an unexpected keyword argument 'wireshark'

The UDPLink class overrides start_capture() but didn't include the new
parameters added for Web Wireshark support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add project memory infrastructure and JWT token flow documentation

- Add memory skill for recording project knowledge
- Document JWT token flow in Web Wireshark integration
- Update .gitignore to track skills and memory directories

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): fix Docker API URL format and use docker exec CLI

- Add "v" prefix to Docker API URL (http://docker/v{version}/...)
- Remove unused exec_create/exec_start API methods
- Fix Healthcheck.Test format to ["CMD-SHELL", "command"]
- Use docker exec CLI instead of Docker API for command execution
- This aligns with GNS3 docker_vm pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): improve xpra session management and error handling

- Check and clean up existing sessions before starting new xpra session
- Add verification that xpra session started successfully
- Use docker exec CLI consistently instead of Docker API
- Improve error logging and diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add timeout handling and container health checks

- Add REQUEST_TIMEOUT constant for Docker HTTP API requests
- Add asyncio.timeout wrapper for Docker API calls to prevent hanging
- Add _is_container_healthy() to check container responsiveness
- Add _exec_in_container() helper with timeout support
- Improve unhealthy container handling with force remove
- Add detailed logging for container health state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): split monolithic file into modules

Split manage_wireshark.py into:
- docker_client.py: Docker HTTP API client
- manager.py: WebWiresharkManager with session management
- manage_wireshark.py: CLI entry point

This improves code organization and maintainability.
Keep all timeout handling and health check logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): correct NanoCpus calculation to use nanoseconds

NanoCpus in Docker API requires nanoseconds (1 CPU = 1000000000),
not the previous incorrect multiplier of 100000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): fix xpra command quoting and session verification

- Add quotes around --xvfb parameter value to preserve spaces
- Fix session verification to check display number instead of session name
  (xpra list shows "LIVE session at :185" not session name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): run wireshark in background with &

Wireshark with curl pipeline runs continuously, so it must run
in background to avoid blocking. Also reduced timeout since we
don't wait for completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add deterministic hash for display/port allocation

Use MD5-based hash instead of Python's hash() which is randomized
across process restarts. This ensures display and port numbers are
stable when the GNS3 server restarts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract link_id_to_port to shared utils module

Move deterministic hash functions to gns3server/utils/port_allocator.py
to avoid code duplication and ensure consistent algorithm across
manager.py and links.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(links): move import to top of file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web-wireshark): use correct Config access pattern like gns3-copilot

The Server config is an object with .protocol.value, .host, .port
attributes, not a dict. Fixes URL detection failing and falling back
to 127.0.0.1:3080 which doesn't work from inside containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add UUID validation utility and use in manage_wireshark

Create gns3server/utils/uuid_validator.py with validate_uuid function
to validate UUID format and catch typos early with clear error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use argparse.ArgumentTypeError for proper error message display

Previously ValueError was used which only showed 'invalid validate_uuid value'.
Now shows the full helpful message about expected format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add --dpi=96 to xpra start parameters

Set standard DPI for better display scaling in web Wireshark.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update TEST.md with correct project-id format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web-wireshark): hide xpra shutdown menu via XPRA_CLIENT_CAN_SHUTDOWN

Prevents users from accidentally shutting down the xpra server
through the HTML5 client menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(memory): add xpra-html5-client configuration reference

Document xpra HTML5 client configuration including:
- URL parameters for toolbar menu control
- default-settings.txt parameters (Features, Connection, Advanced)
- Server-controlled submenu items
- XPRA_CLIENT_CAN_SHUTDOWN environment variable
- Background image customization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web-wireshark): remove xpra stop cleanup to avoid zombie processes

xpra stop can leave zombie processes and timeout. Instead, let xpra start
reuse the display directly (it will overwrite existing session).

Also added --file-transfer=no, --printing=no, --sound=no to disable
unneeded features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web-wireshark): add fullscreen_button=false to default-settings.txt

Also remove --file-transfer/--printing/--sound from command line since
xpra doesn't support these options. Configure in default-settings.txt instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web-wireshark): update xpra background with GNS3 branding

- Replace default xpra background with GNS3 icon and modern gradient
- Update CSS to use SVG background image with cover sizing
- Change background color to gradient from #021d3a to linear gradient
- Improves visual integration with GNS3 web interface

* style: add GPLv3 headers with copyright to web_wireshark modules

Co-Authored-By: YueGuobin <yueguobin@gmail.com>

* feat(docker): use Alibaba Cloud Debian mirror for faster builds in China

Use mirrors.aliyun.com for Debian packages with correct paths for
both main repo (/debian) and security repo (/debian-security).

Co-Authored-By: YueGuobin <yueguobin@gmail.com>

* feat(web-wireshark): update xpra background styling and echo command

- Replace `echo -e` with `printf` for better POSIX compatibility in Dockerfile
- Update xpra HTML5 client background to use GNS3 icon with light gradient
- Change background color from dark blue to light gray for improved visibility

* fix(web-wireshark): use pkill to stop xpra sessions

Replace 'xpra stop :{display}' with 'pkill -f "xpra.*:{display}"' for more reliable session termination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): start Wireshark in fullscreen mode

Add --fullscreen flag to Wireshark startup command to prevent window decorations and improve user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): use carrier-grade NAT subnet to avoid conflicts

Change Docker network subnet from 172.28.0.0/16 to 100.64.1.0/24 to avoid conflicts with common private networks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(web-wireshark): update subnet to /22 for 1000+ projects

Change Docker network subnet from 100.64.1.0/24 to 100.64.0.0/22 to support 1000+ projects (1022 available IPs). Update all documentation to reflect the new subnet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): replace CGNAT subnet with configurable private subnet

Replace the CGNAT address block (100.64.0.0/22) with a standard private
subnet (172.31.0.0/22) to avoid network access issues. Many networks and
ISPs block or cannot route CGNAT addresses.

Changes:
- Add WebWiresharkSettings to config schema with configurable subnet
- Read network_subnet from config, default to 172.31.0.0/22
- Add Web Wireshark configuration section to sample config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): comment out dpi setting

Allow xpra to use default DPI settings for better display scaling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): set DPI in Xvfb to prevent scaling warnings

Add -dpi 96 to Xvfb startup command to match xpra's expected DPI
and avoid "scaling problems" warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): remove custom Xvfb to use default Xorg-dummy

Remove --xvfb parameter to let xpra use the default Xorg-dummy driver,
which properly handles DPI changes when resize-display is enabled and
prevents DPI mismatch warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(web-wireshark): restore Xvfb configuration

Restore the Xvfb display server configuration for xpra sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): enhance xpra HTML5 default settings

Update xpra HTML5 configuration to disable additional unused features:
- audio, keyboard, mediasource, aurora, http-stream
- Improve readability using heredoc format instead of printf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): add RBAC authentication to web wireshark WebSocket endpoint

- Add `has_privilege_on_websocket` dependency to enforce Link.Capture privilege
- Include current_user parameter in endpoint for user identification and logging
- Update endpoint documentation to reflect token requirement and privilege
- Add test example WebSocket URL in TEST.md for reference

* refactor(web-wireshark): add generic WebSocket proxy and disable xpra HTML

- Add websocket_to_websocket.py utility for binary WebSocket proxy
- Disable xpra HTML server with --html=no flag
- Update manager return value: url -> ws_url
- Simplify WebSocket proxy implementation in links.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web-wireshark): remove unnecessary sleep delays

Remove 2-second sleep delays after container start and xpra initialization.
Health checks and verification happen immediately, improving startup time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agent): prevent blocking during project close

- Add 5s timeout for AgentService.checkpointer_conn.close()
- Optimize stop_all_sessions: single command instead of 100 docker exec calls
- Prevents indefinite hang when closing projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-wireshark): return errors to client on startup failure

- Throw ControllerError when Web Wireshark startup fails
- Include detailed error messages from script stderr
- Support both ws_url and url response formats
- Client now receives proper error response instead of success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web-wireshark): parallelize startup and remove unnecessary waits

- Parallel execution: container info query + xpra start
- Remove xpra list verification (unnecessary)
- Fire-and-forget Wireshark startup (no timeout wait)
- Startup time reduced from ~7s to ~1s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web-wireshark): add stop-container and delete-container commands

- Add stop-container command to stop container on project close
- Add delete-container command to delete container on project delete
- Enables proper container lifecycle management
- Containers are now stopped (not deleted) when project closes
- Containers are deleted when project is deleted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(project): simplify close flow by removing redundant xpra session cleanup

Since docker stop with timeout=0 already force-kills all processes in the
container, explicitly stopping xpra sessions before stopping the container
is unnecessary. Remove the _cleanup_web_wireshark_xpra_sessions() call
to reduce subprocess overhead and simplify the close flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf(docker): force kill containers on stop by default

Change docker stop timeout from 10 seconds to 0 (immediate SIGKILL).
Web Wireshark containers don't need graceful shutdown since they have
no persistent state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web-wireshark): enable WebSocket protocol for xpra connection

- Change xpra bind from --bind-tcp to --bind-ws for WebSocket support
- Enable HTML client with --html=on for xpra WebSocket server
- Pass binary subprotocol when connecting to xpra container

The xpra WebSocket server requires the 'binary' subprotocol during
handshake. Without it, the server returns 403 with message:
"client does not support 'binary' protocol".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web-wireshark): add WebSocket subprotocol negotiation support

Fix WebSocket proxy for xpra container by implementing proper subprotocol
negotiation. The xpra client requires the server to respond with the
negotiated subprotocol (binary) in the WebSocket handshake response.

Changes:
- Modified get_current_active_user_from_websocket to extract client's
  requested subprotocols from Sec-WebSocket-Protocol header
- Updated websocket.accept() call to include negotiated subprotocol
- Enhanced websocket_proxy to support subprotocol parameter
- Improved logging for WebSocket connection debugging

This fixes the issue where xpra clients would immediately disconnect
after connection due to missing subprotocol in server response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(web-wireshark): remove verbose debug logging

Remove excessive debug logs from WebSocket proxy implementation while
keeping essential logs for troubleshooting:
- Keep: subprotocol negotiation, connection establishment, errors
- Remove: verbose step-by-step debugging information

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web-wireshark): translate Chinese comments and docs to English

- Translate all Chinese comments in project.py, link.py, and links.py to English
- Convert TEST.md and maunal-test.md documentation to English
- Remove obsolete IMPLEMENTATION_PLAN.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(docker): lock xpra version to 6.4.3 for reproducible builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(docker): improve Dockerfile with TZ, no-install-recommends, and labels

- Add TZ=UTC timezone setting
- Use --no-install-recommends to reduce image size
- Add LABEL metadata for maintainer and description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(docs): rename and consolidate documentation

- Rename TEST.md to WEB_WIRESHARK.md for clearer naming
- Merge maunal-test.md content into main document under "Manual Testing" section
- Remove obsolete gns3_icon_black.svg

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docker): add xpra-x11 package and remove --no-install-recommends

- Add xpra-x11 package required for seamless mode
- Remove --no-install-recommends to ensure all recommended packages are installed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(api): add capture file download endpoint

Add GET /{link_id}/capture/file endpoint to download PCAP capture files.
Supports downloading while capture is active (streaming).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web_wireshark): improve session cleanup and add known issues

- Add cleanup of existing processes before starting xpra session to prevent
  "another window manager seems to be running" errors
- Stop all associated processes (xpra, wireshark, Xvfb) when stopping sessions
- Document known issues in WEB_WIRESHARK.md including JWT token security,
  duplicate code, and other implementation concerns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web_wireshark): add restart wireshark API endpoint

- Add POST /links/{link_id}/capture/wireshark/restart endpoint
- Add restart command to manage_wireshark.py CLI
- Add _restart_web_wireshark method in link.py controller
- Restart simply calls start_wireshark_session which handles cleanup

This allows users to recover after accidentally closing the Wireshark
window without having to stop and restart the entire capture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Web Wireshark business process documentation

Document the Web Wireshark feature including architecture diagrams,
business processes for capture start/stop, container lifecycle,
WebSocket connection flow, and session management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web_wireshark): fix container network access and improve session cleanup

- Fix GNS3 server URL detection to use container gateway IP (172.31.0.1)
  instead of localhost/127.0.0.1/0.0.0.0 which don't work from containers
- Add import socket module for gateway IP conversion
- Move urllib.parse import to file header (code style improvement)
- Add logging for Web Wireshark startup (capture stream URL, display, etc.)
- Fix X lock file cleanup to prevent "Server is already active" errors
- Use exec pkill to reduce zombie processes from docker exec bash
- Add _cleanup_x_lock() method to remove X lock files after stopping sessions
- Update link.py to always log subprocess stderr for debugging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): avoid zombie pkill processes by using Docker API

Replace pkill commands with Docker API-based process killing to prevent
zombie process accumulation in Web Wireshark containers.

Changes:
- Add DockerHTTPClient.list_processes() method to query container processes
- Rewrite _kill_process_tree() to use Docker API instead of pkill
- Unify all process cleanup to use _kill_process_tree() method
- Add logging for killed processes (count and PIDs)
- Include fallback to pkill if Docker API fails

This prevents the accumulation of zombie pkill processes that occurred
when using bash -c "pkill -9 -f pattern" commands.

Testing: Started and stopped multiple capture sessions, verified no new
pkill zombie processes are created during cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): use docker-init and cleanup socket files

Enable Docker init system (tini) as PID 1 and clean up xpra socket
files when stopping sessions to prevent zombie processes and leftover
sockets.

Changes:
- Add "Init": True to container host_config to use docker-init (tini)
- Extend _cleanup_x_lock() to remove xpra socket files
  * /run/user/1000/xpra/{display}/socket
  * /run/user/1000/xpra/*-{display}
  * /home/gns3/.xpra/*-{display}

Benefits:
- docker-init (tini) automatically reaps orphan processes, eliminating
  zombie process accumulation (tested: 53 zombies -> 0 zombies)
- Socket cleanup prevents xpra list from showing UNKNOWN sessions
- Cleaner container state after stopping sessions

Testing:
- Started and stopped 3 capture sessions
- Verified 0 zombie processes after stopping
- Verified xpra list shows "No xpra sessions found" (no UNKNOWN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(web_wireshark): add comprehensive CLI script documentation

Add detailed module docstring to manage_wireshark.py explaining:
- Script purpose: CLI tool for manual management, debugging, and testing
- Important note: Use WebWiresharkManager directly for programmatic access
- Usage examples for all commands (start, stop, restart, stop-all, delete)
- Available commands list with descriptions
- Output format specification (JSON to stdout/stderr)
- Help information for getting command-specific usage

This clarifies that the script is intended as a CLI utility, not for
subprocess calls from within GNS3 server code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web_wireshark): use direct API calls instead of subprocess

Replace subprocess calls to manage_wireshark.py with direct API calls
to WebWiresharkManager, eliminating subprocess overhead and JSON parsing.

Changes:
- Add import for WebWiresharkManager
- Refactor _start_web_wireshark() to use direct API call
- Refactor _stop_web_wireshark() to use direct API call
- Refactor _restart_web_wireshark() to use direct API call

Benefits:
- Code reduction: 78 lines (-68%)
- Better performance: No subprocess creation overhead
- Better logging: Manager logs directly to GNS3 logging system
- Simpler error handling: Direct exceptions instead of return codes
- No JSON parsing: Direct Python objects
- Resource cleanup: Added finally blocks to ensure manager.close()

Testing:
- All Web Wireshark operations work correctly
- Logs now appear in GNS3 server logs instead of stderr
- Error handling improved with proper exception propagation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): resolve circular import with delayed imports

Fix circular import error by moving WebWiresharkManager import from
module level to function level (delayed import).

Circular dependency was:
  link.py → WebWiresharkManager → Controller → link.py

Solution:
  - Remove top-level import of WebWiresharkManager
  - Add delayed imports in each method that uses it:
    * _start_web_wireshark()
    * _stop_web_wireshark()
    * _restart_web_wireshark()

This allows the controller module to fully initialize before importing
WebWiresharkManager, breaking the circular dependency.

Error before fix:
  ImportError: cannot import name 'Controller' from partially initialized
  module 'gns3server.controller' (most likely due to a circular import)

Testing:
  - Successfully imports Link module
  - GNS3 server starts without import errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: move imports to module level and remove Controller dependency

Remove circular dependency between link.py and manager.py by:

1. Remove Controller import from manager.py
   - Manager no longer imports Controller
   - URL detection now relies on Config or default only
   - link.py passes capture_stream_url to manager

2. Move WebWiresharkManager import to module level in link.py
   - No longer need delayed imports since no circular dependency
   - Clean, standard Python import pattern

3. Remove redundant Config import in ensure_network()
   - Config was already imported at module level

Changes:
- manager.py: -24 lines (removed _get_gns3_url_from_controller and redundant import)
- link.py: -30 lines net reduction after moving import to top

Testing:
- All imports work without circular dependency errors
- GNS3 server starts successfully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(docker_client): use text response for list_processes API

Docker API /containers/{id}/top returns plain text, not JSON.
The generic _request method auto-parses JSON which caused:
  "'dict' object has no attribute 'strip'"

Fix by using session.get() directly with response.text()
instead of the generic _request method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): use correct capture stream URL endpoint

Remove passing capture_stream_url from link.py to manager.py.
Manager.py auto-detects URL using correct endpoint:
  /v3/projects/{project_id}/links/{link_id}/capture/stream

This endpoint supports JWT authentication, unlike the compute URL
(/v3/compute/...) which requires compute credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): use container-local PIDs for process killing

- Docker API returns host PIDs, not container PIDs - use pgrep inside
  container to get correct PIDs for kill
- Fix list_processes to parse Docker API JSON response correctly
- Remove unnecessary exec prefix in shell commands
- Add logging for debugging process killing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf(web_wireshark): optimize process killing with single pgrep call

Replace sequential _kill_process_tree calls with a new _kill_process_tree_batch
method that combines all patterns into a single regex. This reduces docker exec
calls from 8 to 1, improving performance from ~8 seconds to ~0.9 seconds.

Changes:
- Add _kill_process_tree_batch() method with combined regex pattern matching
- Update start_wireshark_session() to use batch cleanup
- Update stop_wireshark_session() to use batch cleanup
- Update stop_all_sessions() to use batch cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): disable HTML5 client to improve xpra startup speed

Change --html=on to --html=off to disable the xpra HTML5 client interface.
This reduces xpra startup time from ~6.4s to ~2.9s (55% improvement) while
keeping the WebSocket server functional for browser connections.

The HTML5 client is not needed as we only require the WebSocket endpoint
for remote display forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): optimize container health check using Docker built-in status

Replace manual docker exec ping with Docker's built-in health check status
to reduce container startup latency by ~1 second.

Changes:
- Use container["Health"]["Status"] instead of manual ping check
- Skip health check for containers with "healthy" or "starting" status
- Only verify containers with "unhealthy" status
- Keep manual health check for newly started containers

This reduces the container verification time from ~1s to near-zero
for healthy running containers while maintaining safety for unhealthy ones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): get gateway IP from Docker API instead of container exec

Replace slow docker exec method with fast Docker Network API call to get
gateway IP. This reduces gateway detection from ~850ms to ~1ms (1000x faster).

Changes:
- Use docker.get_network() API to get gateway from IPAM config
- Keep container exec methods as fallback if Docker API fails
- Remove container_id requirement (not needed for Docker API method)

The Docker network gateway is shared by all containers in the network,
so querying the network is more efficient than querying each container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web_wireshark): remove unreliable nameserver fallback for gateway detection

Remove the fallback method that reads /etc/resolv.conf nameserver as gateway.
This method is unreliable because:
- nameserver is not necessarily the gateway (could be upstream DNS)
- Many systems use 127.0.0.53 (systemd-resolved) or 127.0.0.1
- Even when not local, it could be public DNS (8.8.8.8, 1.1.1.1)

The current fallback strategy is sufficient:
1. Docker Network API (fast, reliable)
2. /proc/net/route (standard gateway detection)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): use host perspective for process killing (37x faster)

Replace docker exec with host perspective process management for killing
container processes. This reduces process cleanup from ~850ms to ~23ms.

Key optimization:
- Get container init PID using docker inspect (fast)
- Use pgrep -P <init_pid> to find child processes from host perspective
- Kill processes directly using host PID (no docker exec needed)

Performance improvements:
- Process finding: 850ms → 23ms (37x faster)
- Process killing: 850ms → <1ms (850x faster)
- File checking: 850ms → 0.003ms (280,000x faster)

Fallback to docker exec method if host perspective fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): use host perspective for file cleanup (891x faster)

Replace docker exec with direct filesystem access from host perspective
for cleaning up X lock files and xpra sockets. This reduces cleanup time
from ~890ms to ~1ms (891x faster).

Key optimization:
- Access /proc/<container_pid>/root/ directly from host
- Use os.remove() and glob.glob() instead of docker exec rm -f
- Fallback to docker exec if host perspective fails

This complements the earlier optimization for process killing, further
reducing startup time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "perf(web_wireshark): use host perspective for file cleanup (891x faster)"

* fix(web_wireshark): recursively kill all descendant processes to prevent orphans

Previous implementation only killed direct children of container init using
`pgrep -P <init_pid>`, but xpra spawns child processes (Xvfb, pulseaudio,
ibus-daemon) that become grandchildren and were not being terminated.

This fix walks the entire process tree recursively to find and kill all
descendant processes, ensuring complete cleanup without orphans.

Performance: ~20-40ms (only 10-20ms slower than previous 23ms, but ensures
thorough cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(web_wireshark): add comprehensive startup/shutdown performance metrics

Add detailed performance characteristics section documenting:
- Startup performance breakdown (before/after optimization)
- Shutdown performance breakdown (before/after optimization)
- First startup vs subsequent startup comparison
- Measured production data
- Key optimization techniques

Performance improvements: 67% faster startup, 78% faster shutdown,
zero orphan processes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): ensure container stops on project close

Remove dependency on _web_wireshark_container_created flag which gets
reset when project is reloaded, causing container to not stop on project close.

Now directly attempts to stop container on every project close. If container
doesn't exist, the script handles it gracefully. This is simpler and more
reliable than maintaining a flag.

Also removed unnecessary _cleanup_web_wireshark_xpra_sessions() call since
stopping the container automatically terminates all xpra sessions inside.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): ensure container deletion on project delete

Remove dependency on _web_wireshark_container_created flag from
_cleanup_web_wireshark_container() to ensure container is deleted
when project is deleted, even if project was reloaded and flag was reset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(web_wireshark): remove unused _web_wireshark_container_created flag

This flag was set but never read for any conditionals since we changed
the stop/delete methods to directly attempt the operation. It's purely
redundant code that adds confusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): skip unnecessary cleanup using fast host perspective check

Add _check_residuals_exist() function that uses host perspective (~20ms)
to check if residual processes or socket files exist before cleanup.

Returns (has_process_residuals, has_socket_residuals) tuple to allow
selective cleanup - if only sockets need cleaning, skip the slower
process tree killing.

For new containers with no residuals, this saves ~3 seconds of
unnecessary docker exec calls.

Socket cleanup always uses docker exec for safety (avoiding accidental
host filesystem deletion from /proc/<pid>/root/ path errors).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web_wireshark): increase container exec timeout from 5s to 10s

xpra start with Xvfb initialization can take longer than 5 seconds in Docker containers, causing timeout failures. Increasing timeout to 10 seconds to allow sufficient startup time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(web_wireshark): optimize cleanup and document docker exec limitations

- Combine X lock and xpra socket cleanup into single docker exec call
  Reduces exec calls from 2 to 1 per stop operation, improving performance
  when stopping multiple capture sessions.

- Document Docker exec performance limitations with test data showing
  parallel exec is 42% slower than serial due to Docker daemon's
  internal queuing mechanism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(links): prevent AssertionError in stream_pcap during rapid stop_capture

Fix race condition where stream_pcap checks link.capturing (True) but
link.capture_node becomes None before accessing link.compute.

This happens when stop_capture() is called and rapidly cleans up
_capture_node while WebSocket requests are still processing.

Now check both capturing and capture_node to ensure capture is still active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(links): prevent AssertionError in stream_pcap during rapid stop_capture

Fix race condition where stream_pcap checks link.capturing (True) but
link.capture_node becomes None before accessing link.compute.

This happens when stop_capture() is called and rapidly cleans up
_capture_node while WebSocket requests are still processing.

Now check both capturing and capture_node to ensure capture is still active,
and log at DEBUG level since this is expected behavior during rapid stop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web_wireshark): load container config from settings

- Load memory, cpus, and pids_limit from WebWireshark config section
- Apply config only on container creation (_start_web_wireshark)
- Skip config on restart (_restart_web_wireshark) as container exists

Users can now configure container resources in gns3_server.conf:

[WebWireshark]
memory = 4g
cpus = 2.0
pids_limit = 2000

If not configured, defaults (2g, 1.0, 1000) are used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web_wireshark): add container statistics to /v3/statistics endpoint

Add Web Wireshark container monitoring to the statistics API for
better visibility into packet capture sessions.

Changes:
- Create new stats.py module with collect_webwireshark_stats()
  * Collects container info (status, project, active sessions)
  * Gets resource limits (memory, CPU, PIDs) from container config
  * Gets resource usage via docker stats (memory, CPU, PIDs)
  * Properly closes aiohttp connections to avoid leaks

- Update /v3/statistics endpoint to include webwireshark data
  * total_containers: Total number of Web Wireshark containers
  * running_containers: Number of containers currently running
  * active_sessions: Total active capture sessions
  * containers: Array with per-container details

- Update statistics-api.md documentation
  * Change URL from /v1/statistics to /v3/statistics
  * Add webwireshark field descriptions and examples
  * Add dashboard integration for Web Wireshark monitoring

Example response:
{
  "webwireshark": {
    "total_containers": 1,
    "running_containers": 1,
    "active_sessions": 2,
    "containers": [{
      "project_id": "...",
      "project_name": "test",
      "container_id": "6edc9029bac0",
      "status": "running",
      "memory_limit": "4.0 GB",
      "cpu_limit": "4.0",
      "pids_limit": 4000,
      "memory": "535.7MiB / 4GiB",
      "cpu": "0.29%",
      "pids": 124
    }]
  }
}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(project): use WebWiresharkManager directly instead of subprocess

Replace subprocess calls to manage_wireshark.py with direct
WebWiresharkManager method calls in project.py. This eliminates
unnecessary process overhead and maintains consistent usage pattern
with link.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(logging): use %-formatting instead of f-strings in WebWireshark methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(web_wireshark): use info level log when container not found

Change log level from warning to info when container doesn't exist
during stop/delete operations, and fix f-string without placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(logging): lower log level for client disconnection

Change log level from warning to debug when client possibly
disconnects, as this is normal behavior when users close
browser tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(linting): resolve ruff warnings and errors

- Remove unused variable proc (fire-and-forget subprocess)
- Remove duplicate nodes property definition
- Remove extraneous f-prefix from f-strings without placeholders
- Change bare except to except Exception
- Remove unused imports (sys, asyncio, json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web_wireshark): improve error message when Docker image not found

When creating a container fails due to missing image, provide helpful
error message with docker pull command and local build instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(schema): add wireshark field to Link schema

Add wireshark boolean field to Link Pydantic schema so it is
included in API responses when Web Wireshark session is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(link): add wireshark state tracking

Track whether Web Wireshark is running on a link by adding
_wireshark boolean property, updated on start/stop operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(chat): add session abort functionality

Add ability to abort streaming chat sessions via REST API.

Changes:
- Add abort flag to MessagesState for tracking abort requests
- Add session_id to state for abort event correlation
- Add _abort_flags dict and check/set/clear functions in gns3_copilot.py
- Add abort_handler_node to generate aborted tool messages
- Modify should_continue to route to abort_handler_node when aborting
- Add abort_session() method in AgentService
- Add POST /sessions/{session_id}/abort API endpoint
- Clear abort flag at stream start

When abort is triggered during streaming:
- If LLM has tool_calls pending, abort_handler_node generates
  aborted tool messages to maintain checkpoint consistency
- Prevents "insufficient tool messages" error on resume

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(chat): send tool_end event when stream is aborted

When a stream is aborted during tool execution, yield proper tool_end
events for aborted tools instead of a generic abort event. This maintains
compatibility with the frontend's expected tool_start/tool_end flow.

Changes:
- Add stream_aborted tracking flag
- After stream loop, check abort flag and yield tool_end events
- Add "abort" to ChatResponse type enum (unused but available)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web_wireshark): add gns3-wireshark-setup command for Docker image setup

Add a new entry point script that allows users to setup the Web
Wireshark Docker image with a single command:

    pip install gns3-server && gns3-wireshark-setup

The script will:
1. Try to pull gns3/web-wireshark:latest from Docker Hub
2. If pull fails, build the image locally using the included Dockerfile
3. Show raw docker pull/build output for full visibility

Files changed:
- Add setup_wireshark_image.py (new entry point script)
- Update pyproject.toml (add gns3-wireshark-setup entry point)
- Update documentation (README.md, WEB_WIRESHARK.md, web-wireshark-business-process.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(appliance): add tags field support for appliances and templates

- Add tags field to ApplianceV1_6 and ApplianceV8 schemas
- Add tags propagation from appliance config to template
- Simplify VPCS builtin template tags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix test in test_link.py

* Fix link.py after failed tests

* feat(copilot): add device skills system for LLM context injection

- Add skills module with registry and DeviceSkillsTool
- Skills organized by vendor/series for easy extensibility
- Support device_type, category (device/protocol/feature), operation (config/diagnosis)
- First implementation: VPCS skill (gns3_vpcs_telnet)
- Skills tool integrated into teaching_assistant and lab_automation modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(vpcs): change prompt detection log from warning to debug

The "VPCS prompt not clearly detected" message is not an error,
connection works fine. Change to debug level to reduce noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(memory): add uBridge permission issue documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(copilot): include link_id in links_summary output

Return link_id in the links_summary method so that the AI can
identify which link to analyze when using the packet capture tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): add packet capture analysis tool

Add PacketCaptureTool that allows AI to analyze packets from an active
GNS3 capture. The tool downloads capture files from the GNS3 server
and runs tshark analysis to help users understand network traffic.

Features:
- Download capture file from /capture/file endpoint
- Run tshark with custom arguments for flexible analysis
- Support analyzing specific packets (e.g., "explain packet #42")
- Support protocol statistics, traffic analysis, and expert info

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(copilot): use shlex.split to properly parse quoted tshark arguments

tshark_args may contain quoted filter expressions like "-Y \"frame.number == 24\"".
Using plain str.split() breaks these quoted arguments, causing tshark warnings
about conflicting display filters. Use shlex.split() to correctly parse quoted
arguments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): simplify PacketCaptureTool to accept only packet_number

Instead of exposing complex tshark arguments to the LLM, the tool now
accepts a simple packet_number parameter and internally constructs the
tshark command with verbose output. This prevents the LLM from generating
incorrect tshark parameters while still providing detailed packet analysis.

Changes:
- Remove tshark_args and max_lines parameters
- Accept only packet_number as input
- Internal command: tshark -r <file> -Y "frame.number == N" -V
- Returns complete packet structure without line limits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add CC BY-SA 4.0 license headers to documentation files

Add SPDX license headers referencing docs/LICENSE file to all markdown
and HTML documentation files in the docs directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): add topology planner skill

Add new skill for automatic network lab topology planning:
- IOU as default image, 10.0.0.0/8 IP range, max 10 nodes
- Node naming convention: R/S/PC + number
- 7-step workflow for create/rename/link/start/verify/config
- IP allocation rules and troubleshooting guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): add node positioning rules to topology planner skill

Add grid-based positioning for GNS3 nodes:
- Minimum 250px distance between nodes
- Grid layout: 4 columns, 300x250px spacing
- Formula: x = -400 + col * 300, y = -200 + row * 250
- Update workflow params_required to include x, y coordinates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): remove unused helper functions from topology planner skill

Delete Python helper functions that cannot be serialized to JSON:
- calculate_node_positions()
- get_position_for_node()
- allocate_subnet()
- allocate_ip()
- TOPOLOGY_CREATION_STEPS (duplicate of workflow in skill dict)

LLM should use formula descriptions in skill to calculate positions/IPs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): add topology_planner workflow to lab automation prompt

Add device_skills tool to AVAILABLE TOOLS table.
Add TOPOLOGY PLANNING WORKFLOW section explaining:
- How to query topology_planner skill
- Default conventions (IOU, 10.0.0.0/8, naming rules, position formula)
- 7-step workflow for topology creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): support name parameter in gns3_create_node tool

- Add optional 'name' field to node creation, allowing direct naming
- Update Node constructor to pass name parameter
- Remove separate rename step (step_3) from topology planner workflow
- Renumber workflow steps: 1-2-3-4-5-6 (skipping old step_3 rename)

This eliminates the need for a separate gns3_update_node_name_tool call
when creating nodes with predefined names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(copilot): update topology workflow to 6 steps

Remove rename step since name can be set directly in create_gns3_node.
Workflow changed from 7 steps to 6 steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): replace grid positioning with topology-based layout

Redesign node positioning rules to follow classic network topologies:
- Star: hub at center, spokes radiating outward
- Ring: nodes in circular arrangement
- Bus: linear chain of nodes
- Mesh: grid pattern for interconnected nodes
- Hierarchical: three-tier (Core → Distribution → Access)
- Linear P2P: point-to-point WAN links in a line

Remove old grid formula. Update prompt and workflow to reflect new approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(copilot): add note about combining topology types

LLM can mix topology types as needed, e.g., star + linear_p2p for WAN segments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): remove language matching rules from prompts

Remove language matching rules that cause inconsistent responses:
- "User writes in Chinese → Respond in Chinese"
- "User writes in English → Respond in English"

These created ambiguity and led to inconsistent language behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): remove Chinese from response template

Replace mixed Chinese/English headers with English only:
- "操作总结 / Operation Summary" → "Operation Summary"
- "详细信息 / Details" → "Details"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(copilot): remove Chinese mentions from prompts

Remove references to "Chinese" language in title_prompt.py:
- "Generates concise Chinese or English titles" → "Generates concise titles"
- "If the content is predominantly in Chinese, generate a Chinese title" → removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(copilot): use short_name for link labels and support short port names

- Match ports by both name and short_name for flexibility
- Use short_name (e.g. "e0/0") instead of full name (e.g. "Ethernet0/0")
  for link labels, improving visual clarity in dense topologies

* feat(topology): replace hardcoded interface names with dynamic placeholders

- Update topology planner skill template to use {short_name} placeholder
- Allows dynamic interface name generation based on device type
- Improves template flexibility for different network device configurations

* refactor(topology): use node-pair IP format and hyphenated naming

- Use 10.0.{node_pair}.x format for P2P links (e.g., 10.0.12.x for R-1-R-2)
- Change node naming to hyphenated format: R-1, R-2, SW-1, PC-1
- Use {short_name} as placeholder for port names in output template
- Remove unused IP_SUBNET_POOL constant
- Simplify ip_planning rules to intuitive format description

* fix(web_wireshark): raise minimum Docker API version to 1.44

Docker daemon requires API version 1.44+, so the minimum
supported version should match rather than defaulting to 1.40.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web_wireshark): call ensure_network in start_wireshark_session

Ensure Docker network exists before creating container, so that
Web Wireshark works when started via API without going through CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(agent): improve Docker image setup with network error detection

- Add `is_network_error()` function to detect network-related failures in Docker pull output
- Modify `pull_image()` to capture output and return success status along with output
- Enhance main logic to detect network errors and skip local build when Docker Hub is inaccessible
- Provide helpful suggestions for network issues (Docker mirror, VPN, manual image transfer)
- Improve error messages to differentiate between network failures and other errors

* feat(docs): add Ubuntu 24.04 development setup guide

Add a comprehensive development environment setup guide for Ubuntu 24.04. The guide includes steps to install dependencies via PPA, configure Docker with mirror accelerators for users in mainland China, set up user permissions, and run the server from source with a Python virtual environment. This provides a clear, step-by-step reference for new contributors and developers.

* feat(docs): add pip mirror note for China mainland users

Add a comment in the development setup documentation suggesting the use of the Aliyun PyPI mirror for users in China mainland to improve installation speed and reliability. This helps overcome network restrictions and slow downloads from the default PyPI repository.

* feat(docs): add LVM root partition expansion guide

Add optional section to development setup documentation with instructions for expanding the root partition when using LVM. This helps developers resolve low disk space issues by utilizing unallocated space in the volume group. The guide includes commands to check LVM status, extend the logical volume, and verify the changes.

* feat(docs): add tshark to development setup dependencies

Add tshark to the apt install command in the development setup documentation. Tshark is required for packet capture functionality in GNS3, ensuring the development environment has all necessary tools for network analysis and debugging.

* feat: add GNS3 documentation skill with standardized structure

Add new documentation skill file defining standards for GNS3 technical documentation. The skill establishes a structured approach focusing on architecture diagrams, flow diagrams, and text descriptions while prohibiting code examples and user scenarios. This ensures consistent, high-quality documentation across the GNS3 ecosystem.

* feat(docs): update documentation skill to focus on server technical docs

Update the GNS3 documentation skill to specifically target server technical documentation under the docs/ directory. The revised standard emphasizes high-level understanding over implementation details, using ASCII diagrams for architecture and business processes, API endpoint tables, and measured performance data. Code specifics are intentionally omitted, directing readers to the codebase for implementation details.

* feat(docs): rewrite documentation skill and statistics API doc

- Rewrite gns3-documentation skill to match actual server-side doc style:
  focus on architecture/flow diagrams, API data, skip code details
- Switch diagram standard from ASCII to Mermaid (GitHub native rendering)
- Rewrite statistics-api.md with Mermaid architecture and sequence diagrams,
  add missing webwireshark container fields (memory_limit, cpu_limit, pids_limit),
  remove speculative content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(docs): update VNC WebSocket console documentation with Mermaid diagrams

- Replace ASCII diagrams with Mermaid flowcharts for better visualization
- Clarify connection flow between browser, controller, compute, and VNC server
- Update authentication details and endpoint descriptions
- Add missing documentation for packet capture workflow
- Improve overall readability and maintainability

* docs: add AI disclaimer to documentation files

Add a standardized disclaimer to multiple documentation files indicating that the content has been organized by AI with reference to actual code. The disclaimer warns users that AI can make mistakes and advises verification against the source code when in doubt. This improves transparency about the documentation's origin and encourages careful usage.

* feat(docs): update chat API documentation with new features and clarifications

- Update title from "Design Document" to reflect current implementation status
- Add new features: session abort, copilot modes (teaching_assistant, lab_automation_assistant), session pinning
- Enhance architecture diagram with LangGraph StateGraph details including abort_handler_node, title_generator_node, and conditional edges
- Clarify statistics tracking: add ai_response_counted flag, filter title_generator_node from LLM counts, explain incremental token counting
- Improve documentation accuracy for real-time statistics collection and token calculation methods

* feat(docs): restructure command security documentation with visual workflows

- Replace verbose implementation details with concise architecture overview
- Add Mermaid diagrams to visualize command filtering and multi-line expansion flows
- Simplify configuration instructions and remove redundant examples
- Consolidate file structure and function reference tables for clarity
- Maintain all security principles while improving readability and maintainability

* feat(context): refactor context window management documentation for clarity

- Reorganize documentation with improved structure and visual diagrams
- Add Mermaid diagrams to illustrate architecture and trimming process
- Simplify content while maintaining technical accuracy
- Update token counting and trimming strategy explanations
- Enhance readability with better formatting and component tables

* feat(docs): update LLM model configs documentation

- Change config column type from JSONB to JSON (JSONB on PostgreSQL)
- Update provider table to mark base_url as required with planned optional status
- Add note about base_url being currently required in API schema
- Clarify GET own configs endpoint returns plain array
- Add copilot_mode field to update request schema
- Document update limitations for context_strategy and copilot_mode fields

* feat(docs): add GNS3StartNodeQuickTool and update node creation examples

- Add documentation for new `GNS3StartNodeQuickTool` (`start_gns3_node_quick`) that starts nodes without waiting for boot completion
- Update node creation example to include optional `name` field in template placement
- Clarify dynamic wait time strategy for `GNS3StartNodeTool` and contrast with quick tool
- Update tool file structure to reflect new configuration, display, and packet capture tools
- Fix truncated line in suspend tool documentation

* feat(docs): add comprehensive documentation structure with CC BY-SA 4.0 license

Add initial documentation directory with detailed README.md that outlines:
- Dual license structure (CC BY-SA 4.0 for docs, GPLv3 for code)
- Complete directory structure for features, AI Copilot, and bugs
- Feature documentation covering Controller+Compute setup, Statistics API, VNC WebSocket console, and Web Wireshark
- AI Copilot implemented features including Chat API, LLM model configs, command security, and multi-vendor device support

This provides organized technical documentation for the GNS3 server project with proper licensing and feature coverage.

* docs: update multi-vendor device support documentation

- Simplify vendor support table by removing redundant platform column
- Add VPCS as a simulator in status column
- Improve VPCS driver diagram with Mermaid syntax and detailed components
- Document VPCS-specific Netmiko parameters (fast_cli, global_delay_factor)
- Update VPCS tool usage example with connection options structure
- Remove outdated Nornir configuration approaches, reference current implementation
- Add file reference for VPCS tools location
- Clean up documentation structure for better readability

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: YueGuobin <yueguobin@gmail.com>
Co-authored-by: Jeremy Grossmann <grossmj@gns3.net>
This commit is contained in:
authored and GitHub committed 2026-04-20 22:56:55 +08:00
1 parent 80cc85e5d5
commit f07d21c511
77 files changed
+7544 -1767

No files matched your search

@@ -94,6 +94,8 @@ 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 PacketCaptureTool
from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool
# Set up logger for GNS3-Copilot
logger = logging.getLogger(__name__)
@@ -112,6 +114,8 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
GNS3UpdateNodeNameTool(), # Update node name
ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands
# (READ-ONLY)
PacketCaptureTool(), # Analyze packets from active capture
DeviceSkillsTool(), # Get device-specific skills and command knowledge
]
# Lab automation assistant mode: Full diagnostic AND configuration tools
@@ -127,6 +131,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
# (READ-ONLY)
ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands
VPCSCommands(), # Execute VPCS commands using Netmiko
PacketCaptureTool(), # Analyze packets from active capture
DeviceSkillsTool(), # Get device-specific skills and command knowledge
]
# Default tools (legacy support - will be overridden by mode-specific tools)
@@ -146,6 +152,45 @@ 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):
@@ -165,6 +210,8 @@ class MessagesState(TypedDict):
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]
@@ -179,6 +226,12 @@ class MessagesState(TypedDict):
# 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):
@@ -334,6 +387,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
"messages": [response],
"llm_calls": state.get("llm_calls", 0) + 1,
"topology_info": topology_info,
"session_id": state.get("session_id"),
}
@@ -352,7 +406,7 @@ def generate_title(
if not llm_config:
logger.error("LLM config not found in context, cannot generate title")
return {"conversation_title": UNTITLED_SESSION_FALLBACK}
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")
@@ -403,7 +457,7 @@ def generate_title(
)
logger.info("Generated new title: %s", new_title)
return {"conversation_title": 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")
@@ -428,14 +482,14 @@ def generate_title(
"Using fallback title from user message: '%s'",
fallback_title,
)
return {"conversation_title": 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}
return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")}
# Title already exists → no update needed
return {}
@@ -481,21 +535,63 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
)
result.append(tool_msg)
return {"messages": result}
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", END]:
) -> 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")
@@ -526,9 +622,15 @@ def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]:
"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:
@@ -546,6 +648,7 @@ agent_builder = StateGraph(MessagesState)
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")
@@ -560,6 +663,7 @@ agent_builder.add_conditional_edges(
# 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
},
)
@@ -576,3 +680,4 @@ agent_builder.add_conditional_edges(
)
agent_builder.add_edge("title_generator_node", END)
agent_builder.add_edge("abort_handler_node", END)
+57 -2
View File
@@ -91,6 +91,20 @@ class AgentService:
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")
@@ -313,9 +327,18 @@ class AgentService:
],
"llm_calls": 0,
"remaining_steps": 20,
"mode": mode,
"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")
@@ -335,6 +358,9 @@ class AgentService:
# 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(
@@ -459,6 +485,26 @@ class AgentService:
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,
@@ -688,11 +734,20 @@ class AgentService:
async with self._init_lock:
if self._checkpointer_conn:
try:
await self._checkpointer_conn.close()
# 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:
@@ -55,7 +55,6 @@ tracert
# Ping flood variants
ping -f
ping -f
# Debug commands that may destabilize devices
debug
@@ -2192,12 +2192,12 @@ class Project:
def links_summary(
self, is_print: bool = True
) -> list[tuple[str, str, str, str]] | None:
) -> list[dict[str, str]] | None:
"""
Returns a summary of the links insode the project. If `is_print` is False, it
will return a list of tuples like:
Returns a summary of the links inside the project. If `is_print` is False,
it will return a list of dicts like:
`[(node_a, port_a, node_b, port_b) ...]`
`[{"link_id": "xxx", "node_a": "R1", "port_a": "Eth0/0", "node_b": "R2", "port_b": "Eth0/0"}, ...]`
**Required Attributes:**
@@ -2213,7 +2213,7 @@ class Project:
assert self.links is not None, "Links must be loaded"
assert self.nodes is not None, "Nodes must be loaded"
_links_summary: list[tuple[str, str, str, str]] = []
_links_summary: list[dict[str, str]] = []
for _l in self.links:
if not _l.nodes:
@@ -2258,7 +2258,13 @@ class Project:
if is_print:
print(f"{endpoint_a} ---- {endpoint_b}")
_links_summary.append((name_a, _port_a, name_b, _port_b))
_links_summary.append({
"link_id": _l.link_id,
"node_a": name_a,
"port_a": _port_a,
"node_b": name_b,
"port_b": _port_b
})
except (StopIteration, KeyError, AttributeError):
# Prevent errors when list comprehension can't match data
@@ -2426,13 +2432,13 @@ class Project:
"node_id": _node_a.node_id,
"adapter_number": _port_a["adapter_number"],
"port_number": _port_a["port_number"],
"label": {"text": _port_a["name"]},
"label": {"text": _port_a.get("short_name") or _port_a["name"]},
},
{
"node_id": _node_b.node_id,
"adapter_number": _port_b["adapter_number"],
"port_number": _port_b["port_number"],
"label": {"text": _port_b["name"]},
"label": {"text": _port_b.get("short_name") or _port_b["name"]},
},
],
)
@@ -52,6 +52,7 @@ You have access to the following tools to help users:
| Tool | Purpose | Usage |
|------|---------|-------|
| `device_skills` | Query device/protocol/feature skills | Get command syntax, troubleshooting |
| `gns3_template_reader` | Get available node templates | List templates |
| `gns3_create_node` | Create new nodes in topology | Add routers, switches, VPCS |
| `gns3_link_tool` | Create links between nodes | Connect topology |
@@ -96,6 +97,28 @@ Clearly communicate:
---
# TOPOLOGY PLANNING WORKFLOW
When user asks to create a network lab/experiment/topology:
1. **Query topology_planner skill**:
```
device_skills({"action": "get", "device_type": "topology_planner"})
```
2. **Follow the skill's guidance**:
- Use IOU image by default
- Plan IP addressing with 10.0.0.0/8 range, /24 for LANs, /30 for P2P links
- Use naming convention: R1, R2 for routers; S1, S2 for switches; PC1, PC2 for PCs
- Position nodes based on topology type (star/ring/bus/mesh/hierarchical)
- Place hub/spine nodes at center, leaf nodes radiating outward
- Use "name" field in create_gns3_node to set names directly (no separate rename step)
- Follow 6-step workflow: read templates → create nodes → link → start → verify → config
3. **Output topology plan** using the skill's output_template format
---
# COMMAND EXAMPLES
## Diagnostic Commands (READ-ONLY)
@@ -140,19 +163,14 @@ vlan 10
# RESPONSE GUIDELINES
1. **Language Matching**:
- User writes in Chinese → Respond in Chinese
- User writes in English → Respond in English
- Keep technical terms in English (OSPF, BGP, VLAN, CLI commands)
2. **Clear Structure**:
```markdown
## 操作总结 / Operation Summary
## Operation Summary
**执行的任务**: [What was done]
**结果**: [Success/Failure]
**Task**: [What was done]
**Result**: [Success/Failure]
## 详细信息 / Details
## Details
[Device outputs, configurations, etc.]
```
@@ -119,14 +119,6 @@ ip route, ip addr, tcpdump, ping, traceroute
---
# RESPONSE LANGUAGE
- User writes in Chinese → Respond in Chinese
- User writes in English → Respond in English
- Keep technical terms in English (OSPF, BGP, VLAN, CLI commands)
---
# CURRENT TOPOLOGY
{{topology_info}}
@@ -27,14 +27,13 @@
Title Generation Prompt for GNS3-Copilot
Prompt template for generating conversation titles.
Generates concise Chinese or English titles based on conversation language.
Generates concise titles matching the conversation language.
"""
TITLE_PROMPT = """
Based on the following conversation records,
analyze the language composition and generate a concise, summary title.
If the content is predominantly in Chinese, generate a Chinese title.
If the content is predominantly in English, generate an English title.
Match the language of the conversation.
Only return the title, do not include any additional explanations or punctuation:
"""
@@ -0,0 +1,49 @@
# 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
#
"""
Device Skills Package
This package provides device-specific skills for GNS3 Copilot.
Skills are organized by vendor and product series for easy extensibility.
Directory Structure:
- skills/
- registry.py # SKILLS_REGISTRY and get_skill()
- cisco/ # Cisco devices
- huawei/ # Huawei devices
- h3c/ # H3C devices
- ruijie/ # Ruijie devices
- vpcs/ # GNS3 VPCS
- generic/ # Base templates
"""
from .registry import SKILLS_REGISTRY, get_skill, DeviceSkillsTool
__all__ = [
"SKILLS_REGISTRY",
"get_skill",
"DeviceSkillsTool",
]
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Cisco Skill Package
#
# Placeholder for future Cisco device skill implementations.
#
# Available device types:
# - cisco_ios_telnet: Cisco IOS Router (via Telnet)
# - cisco_iou_telnet: Cisco IOU L2/L3 Switch
#
# TODO: Implement Cisco IOS skill
# TODO: Implement Cisco IOU skill
@@ -0,0 +1,18 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Generic Skill Package
#
# Base templates and generic skill utilities.
#
# Base skill template for reference:
# SKILL_TEMPLATE = {
# "device_type": "",
# "name": "",
# "description": "",
# "config_commands": {},
# "display_commands": {},
# "notes": [],
# "troubleshooting": {},
# "command_aliases": {},
# }
@@ -0,0 +1,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# H3C Skill Package
#
# Placeholder for future H3C (Hewlett Packard Enterprise) device skill implementations.
#
# Available device types:
# - h3c_telnet: H3C Comware Series (via Telnet)
#
# TODO: Implement H3C Comware skill
@@ -0,0 +1,12 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Huawei Skill Package
#
# Placeholder for future Huawei device skill implementations.
#
# Available device types:
# - huawei_telnet: Huawei NE/AR/CE Series (via Telnet)
#
# TODO: Implement Huawei CloudEngine (CE) skill
# TODO: Implement Huawei AR skill
@@ -0,0 +1,276 @@
# 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 DeviceSkillsTool
This module provides:
- SKILLS_REGISTRY: A dictionary mapping device_type to skill definitions
- get_skill(): Function to retrieve skill for a device_type
- DeviceSkillsTool: LangChain tool for LLM to query skills
"""
import json
import logging
from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
logger = logging.getLogger(__name__)
# Import all skill modules to register them
from gns3server.agent.gns3_copilot.skills.vpcs import VPCS_SKILL
from gns3server.agent.gns3_copilot.skills.topology import TOPOLOGY_PLANNER_SKILL
# Global skill registry - maps device_type to skill definition
SKILLS_REGISTRY: dict[str, dict[str, Any]] = {
"gns3_vpcs_telnet": VPCS_SKILL,
"topology_planner": TOPOLOGY_PLANNER_SKILL,
# Add more skills here as they are implemented
# "huawei_telnet": HUAWEI_SKILL,
# "ruijie_telnet": RUIJIE_SKILL,
# Protocol/Feature skills:
# "ospf": OSPF_SKILL,
# "bgp": BGP_SKILL,
# "mpls": MPLS_SKILL,
}
def get_skill(
device_type: str,
category: str | None = None,
operation: str = "all"
) -> dict[str, Any]:
"""
Get skill by device_type, optionally filtered by category.
Args:
device_type: The device type identifier (e.g., "gns3_vpcs_telnet", "huawei_telnet")
category: Optional category filter - "device", "protocol", "feature"
operation: Filter by operation type - "config", "diagnosis", or "all" (default)
Returns:
Skill dictionary containing commands, notes, and troubleshooting info,
or error dict if device_type not found
"""
skill = SKILLS_REGISTRY.get(device_type, {})
if not skill:
# Try to find by name if not found by device_type
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()),
}
# Filter by category if specified
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()
],
}
if operation == "config":
return {
"device_type": device_type,
"name": skill.get("name"),
"category": skill.get("category"),
"description": skill.get("description"),
"config_commands": skill.get("config_commands", {}),
}
elif operation == "diagnosis":
return {
"device_type": device_type,
"name": skill.get("name"),
"category": skill.get("category"),
"display_commands": skill.get("display_commands", {}),
"troubleshooting": skill.get("troubleshooting", {}),
}
else:
# Return full skill
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 skills, optionally filtered by category.
Args:
category: Optional category filter - "device", "protocol", "feature"
Returns:
List of dicts with device_type, name, and 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
class DeviceSkillsTool(BaseTool):
"""
LangChain tool for querying device-specific skills.
Use this tool to get device-specific command syntax, examples,
and troubleshooting guidance before executing commands.
Example:
# Get VPCS skill by device_type
tool.run('{"device_type": "gns3_vpcs_telnet"}')
# Get OSPF protocol skill
tool.run('{"device_type": "ospf", "category": "protocol"}')
# Get only config commands
tool.run('{"device_type": "gns3_vpcs_telnet", "operation": "config"}')
# Get only diagnosis commands
tool.run('{"device_type": "gns3_vpcs_telnet", "operation": "diagnosis"}')
# List all available skills
tool.run('{"action": "list"}')
# List skills by category
tool.run('{"action": "list", "category": "device"}')
"""
name: str = "device_skills"
description: str = """
Get or list device/ protocol/ feature specific skills and command knowledge.
Use this tool BEFORE executing device commands to understand:
- Command syntax for the specific device type
- Configuration command examples
- Display/diagnostic command syntax
- Troubleshooting guidance
INPUT FORMAT (JSON string):
{
"action": "get", # Optional: "get" (default) or "list"
"device_type": "gns3_vpcs_telnet", # Required for action="get": device type identifier
"category": "device", # Optional: "device", "protocol", "feature"
"operation": "all" # Optional: "config", "diagnosis", or "all" (default)
}
For action="list":
{
"action": "list",
"category": "device" # Optional: filter by category
}
OUTPUT:
- Skill name and description
- Command syntax with parameters
- Usage examples
- Troubleshooting tips
- Important notes
Available categories:
- "device": Device-specific skills (VPCS, routers, switches)
- "protocol": Network protocol skills (OSPF, BGP, MPLS)
- "feature": Feature skills (ACL, QoS, NAT)
"""
def _run(
self,
tool_input: str | dict[str, Any],
run_manager: CallbackManagerForToolRun | None = None,
**kwargs: Any,
) -> str:
"""
Execute the device skills lookup.
Args:
tool_input: JSON string or dict with device_type and optional operation/category
Returns:
JSON string with skill information or skill list
"""
logger.info("DeviceSkillsTool invoked with input: %s", tool_input)
# Parse 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":
category = params.get("category")
skills = list_available_skills(category)
return json.dumps({
"category": category or "all",
"skills": skills
}, ensure_ascii=False, indent=2)
# Default action: "get"
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")
operation = params.get("operation", "all")
# Get skill
skill = get_skill(device_type, category, operation)
return json.dumps(skill, ensure_ascii=False, indent=2)
@@ -0,0 +1,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Ruijie Skill Package
#
# Placeholder for future Ruijie Networks device skill implementations.
#
# Available device types:
# - ruijie_telnet: Ruijie RGOS (via Telnet)
#
# TODO: Implement Ruijie RGOS skill
@@ -0,0 +1,34 @@
# 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
#
"""
Topology Planner Skill Package
This package provides the skill for automatic network lab topology planning.
"""
from .topology_planner_skill import TOPOLOGY_PLANNER_SKILL
__all__ = ["TOPOLOGY_PLANNER_SKILL"]
@@ -0,0 +1,235 @@
# 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
#
"""
Topology Planner Skill for GNS3 Lab Automation
This skill helps users plan and create network lab topologies automatically.
Key Constraints:
- Default image: IOU (L3 device)
- Default IP range: 10.0.0.0/8 (use 10.x.x.x subnets)
- Max 10 nodes recommended
- Node naming: Router=R, Switch=S, PC=PC
"""
# Default node naming convention
NODE_NAMING = {
"router": "R", # e.g., R-1, R-2, R-3
"switch": "SW", # e.g., SW-1, SW-2
"pc": "PC", # e.g., PC-1, PC-2
}
# Default IOU template name
DEFAULT_IOU_TEMPLATE = "IOU"
# Topology Planner Skill Definition
TOPOLOGY_PLANNER_SKILL = {
"device_type": "topology_planner",
"category": "feature",
"name": "GNS3 Topology Planner",
"description": "Automatically plan and create GNS3 network lab topologies",
# Default settings
"defaults": {
"image": "IOU",
"ip_range": "10.0.0.0/8",
"max_nodes": 10,
"naming": NODE_NAMING,
},
# IP planning rules
"ip_planning": {
"subnet_format": "10.0.{node_pair}.0/30 for P2P links, 10.0.{node_pair}.0/24 for LANs",
"subnet_example": "10.0.12.x = R1-R2 (.1=R1, .2=R2)",
"gateway_convention": ".1/.2 for P2P, .254 for LAN gateway",
},
# Node naming conventions
"naming_rules": {
"router": "R{number}, e.g., R-1, R-2, R-3",
"switch": "SW{number}, e.g., SW-1, SW-2",
"pc": "PC{number}, e.g., PC-1, PC-2",
"firewall": "FW{number}, e.g., FW-1",
"loopback": "Lo{number}, e.g., Lo-0, Lo-1",
},
# Node positioning rules based on topology type
"positioning_rules": {
"min_distance_px": 250,
"topology_types": {
"star": {
"description": "Central node with peripherals around it",
"center_node": {"x": 0, "y": 0},
"peripheral_nodes": "Arrange in circle around center, angle = index * (360 / count)",
"radius": 300,
"example_3nodes": {"R-1": (0, 0), "R-2": (-300, 0), "R-3": (300, 0)},
"example_5nodes": {"R-1": (0, 0), "R-2": (-250, -250), "R-3": (250, -250), "R-4": (-250, 250), "R-5": (250, 250)},
},
"ring": {
"description": "Nodes connected in a closed loop",
"arrangement": "Circle arrangement, equal spacing",
"radius": 250,
"example_3nodes": {"R-1": (0, -250), "R-2": (216, 125), "R-3": (-216, 125)},
"example_4nodes": {"R-1": (0, -250), "R-2": (250, 0), "R-3": (0, 250), "R-4": (-250, 0)},
},
"bus": {
"description": "Linear chain of nodes",
"arrangement": "Horizontal line, equal spacing",
"spacing_x": 300,
"spacing_y": 0,
"example_3nodes": {"R-1": (-300, 0), "R-2": (0, 0), "R-3": (300, 0)},
},
"mesh": {
"description": "Fully or partially interconnected nodes",
"arrangement": "Grid pattern, rows and columns",
"cols": 2,
"spacing_x": 300,
"spacing_y": 250,
"example_4nodes": {"R-1": (-150, -125), "R-2": (150, -125), "R-3": (-150, 125), "R-4": (150, 125)},
},
"hierarchical": {
"description": "Three-tier: Core -> Distribution -> Access",
"layers": {
"core": {"y": -250, "x": 0},
"distribution": {"y": 0, "x_offset": 200},
"access": {"y": 250, "x_offset": 300},
},
"example_5nodes": {"Core": (0, -250), "Dist1": (-200, 0), "Dist2": (200, 0), "Acc1": (-300, 250), "Acc2": (300, 250)},
},
"linear_p2p": {
"description": "Point-to-point links in a line (WAN links)",
"arrangement": "Horizontal or vertical line",
"spacing_x": 250,
"spacing_y": 0,
"example_3routers": {"R-1": (-250, 0), "R-2": (0, 0), "R-3": (250, 0)},
},
},
"general_guidelines": [
"Place hub/spine nodes at center (0,0) or top center",
"Leaf/edge nodes radiate outward from center",
"WAN routers typically on left and right sides",
"PCs/terminals placed at outer edges",
"Maintain minimum 250px between any two nodes",
"Adjust positions to reflect actual network topology logic",
"Combine topology types as needed (e.g., star + linear_p2p for WAN segments)",
],
},
# Tool call workflow
"workflow": {
"step_1_read_templates": {
"tool": "gns3_template_reader",
"purpose": "Find available IOU template name",
"example": "List templates to identify IOU template"
},
"step_2_create_nodes": {
"tool": "gns3_create_node",
"purpose": "Create all router/switch/PC nodes",
"params_required": ["project_id", "nodes: [{template_id, x, y, name?}]"],
"positioning": "Choose topology type (star/ring/bus/mesh/hierarchical) based on network design. Place hub/spine at center, leaves at edges. Maintain 250px min distance.",
"note": "Use 'name' field to set node names directly (e.g., R1, R2). No separate rename step needed."
},
"step_3_create_links": {
"tool": "gns3_link_tool",
"purpose": "Connect nodes according to topology design",
"params_required": ["node1_id", "node1_port", "node2_id", "node2_port"]
},
"step_4_start_nodes": {
"tool": "gns3_start_node_tool",
"purpose": "Power on all nodes",
"params_required": ["node_ids"],
"note": "Start nodes before configuration"
},
"step_5_verify": {
"tool": "execute_multiple_device_commands",
"purpose": "Verify connectivity before config",
"example_commands": ["ping <neighbor_ip>"]
},
"step_6_config": {
"tool": "execute_multiple_device_config_commands",
"purpose": "Apply network configuration",
"note": "Only after verifying physical connectivity"
},
},
# Troubleshooting
"troubleshooting": {
"node_creation_failed": [
"1. Check if template name is correct (use gns3_template_reader)",
"2. Verify GNS3 server is running",
"3. Check compute resource availability"
],
"link_creation_failed": [
"1. Verify both nodes exist and have available ports",
"2. Check if link already exists between nodes",
"3. Confirm nodes are stopped before linking (some setups)"
],
"node_start_failed": [
"1. Check if node is already running",
"2. Verify compute resource has enough memory",
"3. Check console port availability"
],
"connectivity_failed": [
"1. Use show ip interface brief to verify IPs are configured",
"2. Check if interfaces are administratively up (no shutdown)",
"3. Verify cable/port mapping in GNS3 topology"
],
},
# Planning output format
"output_template": """
## Topology Plan
### Devices
| Node | Type | Image | Description |
|------|------|-------|-------------|
| R-1 | Router | IOU | Core router |
| ... | ... | ... | ... |
### Connections
| Node1 | Port | Node2 | Port |
|-------|------|-------|------|
| R-1 | {short_name} | R-2 | {short_name} |
| ... | ... | ... | ... |
### IP Addressing
| Device | Interface | IP Address | Subnet |
|--------|-----------|------------|--------|
| R-1 | {short_name} | 10.0.12.1 | /30 |
| R-2 | {short_name} | 10.0.12.2 | /30 |
| ... | ... | ... | ... |
### Configuration Steps
1. Create nodes: gns3_create_node(...)
2. Create links: gns3_link_tool(...)
3. Start nodes: gns3_start_node_tool(...)
4. Verify connectivity: ping ...
5. Apply config: execute_multiple_device_config_commands(...)
Note: Use "name" field in gns3_create_node to set node names directly. No separate rename step needed.
""",
}
@@ -0,0 +1,34 @@
# 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
#
"""
VPCS Skill Package
This package provides the skill definition for GNS3 VPCS Virtual PC Simulator.
"""
from .vpcs_skill import VPCS_SKILL
__all__ = ["VPCS_SKILL"]
@@ -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
#
"""
VPCS Skill Definition
GNS3 VPCS (Virtual PC Simulator) is a lightweight virtual PC simulator
used in GNS3 labs for testing network connectivity and basic IP configuration.
Key Characteristics:
- NOT a network device (router/switch), it's a simple PC simulator
- No authentication required (direct console access)
- No configuration mode (commands entered directly)
- Simple command set focused on IP configuration and connectivity testing
Device Type Tag: device_type:gns3_vpcs_telnet
"""
# VPCS Skill Definition
VPCS_SKILL = {
"device_type": "gns3_vpcs_telnet",
"category": "device", # device, protocol, or feature
"name": "VPCS Virtual PC Simulator",
"description": "GNS3 VPCS lightweight virtual PC simulator for testing network connectivity and basic IP configuration",
# Configuration Commands (no config mode needed)
"config_commands": {
"ip_config": {
"syntax": "ip <address>/<mask> <gateway>",
"example": "ip 10.10.0.12/24 10.10.0.254",
"description": "Configure PC IP address and default gateway",
"parameters": {
"address": "IP address, e.g., 10.10.0.12",
"mask": "Subnet mask in CIDR notation, e.g., 24 means 255.255.255.0",
"gateway": "Default gateway, e.g., 10.10.0.254"
}
},
"ip_dhcp": {
"syntax": "ip dhcp",
"description": "Obtain IP configuration from DHCP server"
},
"save": {
"syntax": "save",
"description": "Save current configuration to NVRAM (persists after reboot)"
},
"reset": {
"syntax": "reset",
"description": "Reset VPCS configuration (clears all settings)"
},
},
# Display/Diagnostic Commands
"display_commands": {
"show_ip": {
"syntax": "show ip",
"description": "Show current IP configuration (IP address, subnet mask, gateway)"
},
"ping": {
"syntax": "ping <destination>",
"example": "ping 10.10.0.254",
"description": "Test connectivity to destination (sends 4 ICMP echo requests)"
},
"ping_count": {
"syntax": "ping <destination> <count>",
"example": "ping 10.10.0.254 10",
"description": "Send specified number of ICMP packets"
},
"arp": {
"syntax": "arp",
"description": "Display ARP cache table"
},
"version": {
"syntax": "version",
"description": "Show VPCS version information"
},
"show": {
"syntax": "show",
"description": "Display current running configuration"
},
"pc_info": {
"syntax": "pcinfo",
"description": "Display PC hardware information"
},
"route": {
"syntax": "route",
"description": "Display routing table (static routes)"
},
},
# Important Notes
"notes": [
"WARNING: VPCS is NOT a network device (router/switch), it is a lightweight PC simulator!",
"WARNING: Do NOT use router/switch config commands on VPCS (e.g., configure terminal, interface)",
"VPCS has no config mode, commands are entered directly",
"VPCS does not require username/password authentication, direct console access",
"Prompt format: PC1>, PC2>, VPCS>",
"Default sends 4 ICMP packets, use ping <ip> <count> to specify number",
"Must execute save to persist configuration, otherwise lost after reboot",
],
# Troubleshooting Guide
"troubleshooting": {
"ping failed": [
"1. Use show ip to verify IP configuration is correct",
"2. Verify target gateway is reachable (ping gateway IP)",
"3. Ensure source and target are in same subnet or gateway is correct",
"4. Check if link is UP (verify GNS3 topology connections)"
],
"configuration lost": [
"1. VPCS configuration is lost after reboot",
"2. Must execute save command after any configuration change",
"3. Use show command to verify current configuration"
],
"cannot connect to console": [
"1. Check if node is started in GNS3",
"2. Verify console port mapping is correct",
"3. Confirm telnet connection parameters are correct (IP:port)"
],
},
# Command aliases (for LLM understanding)
"command_aliases": {
"show ip": "show ip",
"display ip": "show ip",
"config ip": "ip <address>/<mask> <gateway>",
"set ip": "ip <address>/<mask> <gateway>",
"test connectivity": "ping <destination>",
"ping test": "ping <destination>",
"save config": "save",
"save": "save",
"reset": "reset",
"show route": "route",
"show arp": "arp",
"show version": "version",
},
}
@@ -59,6 +59,7 @@ 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_capture_tools import PacketCaptureTool
# Dynamic version management
try:
@@ -84,6 +85,7 @@ __all__ = [
"GNS3SuspendNodeTool",
"GNS3UpdateNodeNameTool",
"GNS3TemplateTool",
"PacketCaptureTool",
]
# Package initialization message
@@ -183,12 +183,12 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg})
continue
# Find port information
# Find port information - match by name or short_name
port1_info = next(
(
port
for port in node1.get("ports", [])
if port.get("name") == port1
if port.get("name") == port1 or port.get("short_name") == port1
),
None,
)
@@ -196,7 +196,7 @@ class GNS3LinkTool(BaseTool):
(
port
for port in node2.get("ports", [])
if port.get("name") == port2
if port.get("name") == port2 or port.get("short_name") == port2
),
None,
)
@@ -219,7 +219,7 @@ class GNS3LinkTool(BaseTool):
"port_number": port1_info.get(
"port_number", 0
),
"label": {"text": port1},
"label": {"text": port1_info.get("short_name") or port1},
},
{
"node_id": node_id2,
@@ -229,7 +229,7 @@ class GNS3LinkTool(BaseTool):
"port_number": port2_info.get(
"port_number", 0
),
"label": {"text": port2},
"label": {"text": port2_info.get("short_name") or port2},
},
],
)
@@ -52,7 +52,7 @@ class GNS3CreateNodeTool(BaseTool):
**Input:**
A JSON object with project_id and array of nodes with template_id,
x and y coordinates.
x, y coordinates, and optional name.
Example input:
{
@@ -61,12 +61,14 @@ class GNS3CreateNodeTool(BaseTool):
{
"template_id": "uuid-of-template",
"x": 100,
"y": -200
"y": -200,
"name": "R1"
},
{
"template_id": "uuid-of-template2",
"x": -200,
"y": 300
"y": 300,
"name": "R2"
}
]
}
@@ -79,12 +81,12 @@ class GNS3CreateNodeTool(BaseTool):
"created_nodes": [
{
"node_id": "uuid-of-node1",
"name": "NodeName1",
"name": "R1",
"status": "success"
},
{
"node_id": "uuid-of-node2",
"name": "NodeName2",
"name": "R2",
"status": "success"
}
],
@@ -99,6 +101,7 @@ class GNS3CreateNodeTool(BaseTool):
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",
@@ -106,12 +109,14 @@ class GNS3CreateNodeTool(BaseTool):
{
"template_id": "uuid-of-template",
"x": 100,
"y": -200
"y": -200,
"name": "R1"
},
{
"template_id": "uuid-of-template2",
"x": -200,
"y": 300
"y": 300,
"name": "R2"
}
]
}
@@ -170,6 +175,7 @@ class GNS3CreateNodeTool(BaseTool):
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(
[
@@ -210,14 +216,16 @@ class GNS3CreateNodeTool(BaseTool):
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)...",
"Creating node %d/%d with template %s at (%s, %s), name=%s...",
i + 1,
len(nodes),
template_id,
x,
y,
name,
)
# Create node
@@ -226,6 +234,7 @@ class GNS3CreateNodeTool(BaseTool):
template_id=template_id,
x=x,
y=y,
name=name,
connector=gns3_server,
)
node.create()
@@ -299,12 +308,14 @@ if __name__ == "__main__":
"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",
},
],
}
@@ -0,0 +1,303 @@
# 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
#
"""
Packet Capture Analysis Tool
Analyzes specific packets from an active GNS3 capture using tshark.
Downloads the capture file from GNS3 server and returns detailed packet information.
"""
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__)
class PacketCaptureTool(BaseTool):
"""
LangChain tool for analyzing a specific packet from an active GNS3 capture.
This tool:
1. Downloads the capture file from GNS3 server via /capture/file endpoint
2. Saves it to a temporary file
3. Runs tshark with verbose output (-V) for the specified packet
4. Returns the complete packet structure
Input:
project_id (str, required): UUID of the GNS3 project
link_id (str, required): UUID of the link to analyze
packet_number (int, required): The packet number to analyze
Output:
str: Detailed packet information in text format
"""
name: str = "analyze_packets"
description: str = """
Analyze a specific packet from an active GNS3 capture using tshark.
Downloads the capture file from GNS3 server and returns detailed packet information.
Input (JSON format):
- project_id (str, required): UUID of the GNS3 project
- link_id (str, required): UUID of the link to analyze
- packet_number (int, required): The packet number to analyze
Output:
Complete packet structure in verbose text format
Example:
# Analyze packet #42
tool._run(
project_id="xxx",
link_id="yyy",
packet_number=42
)
"""
def _run(
self,
project_id: str,
link_id: str,
packet_number: int,
run_manager: CallbackManagerForToolRun | None = None,
) -> str:
"""
Analyze a specific packet from an active GNS3 capture.
Args:
project_id: UUID of the GNS3 project
link_id: UUID of the link to analyze
packet_number: The packet number to analyze
run_manager: LangChain run manager (unused)
Returns:
str: Detailed packet information
"""
logger.info(
f"PacketCaptureTool invoked: project_id={project_id}, "
f"link_id={link_id}, packet_number={packet_number}"
)
# Validate inputs
if not project_id:
return '{"error": "project_id is required"}'
if not link_id:
return '{"error": "link_id is required"}'
if packet_number is None or packet_number <= 0:
return '{"error": "packet_number must be a positive integer"}'
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 verbose analysis for the specific packet
result = self._run_tshark_verbose(temp_file, packet_number)
return result
except Exception as e:
logger.error(f"PacketCaptureTool 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}"}
response = requests.get(
capture_url,
headers=headers,
stream=True,
timeout=30,
)
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_verbose(self, pcap_file: str, packet_number: int) -> str:
"""
Run tshark verbose output for a specific packet.
Args:
pcap_file: Path to the capture file
packet_number: The packet number to analyze
Returns:
str: tshark verbose output
"""
# Build command: tshark -r <file> -Y "frame.number == N" -V
cmd = [
"tshark",
"-r", pcap_file,
"-Y", f"frame.number == {packet_number}",
"-V"
]
logger.info(f"Running tshark: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
output = result.stdout
if result.stderr:
if "tshark:" in result.stderr.lower():
logger.warning(f"tshark stderr: {result.stderr}")
if not output.strip():
return f'No packet found with frame.number == {packet_number}'
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 = PacketCaptureTool()
print("Testing PacketCaptureTool...")
print("Note: Set project_id, link_id and packet_number to test with actual GNS3 capture")
print(tool.description)
@@ -163,7 +163,7 @@ class VPCSTelnet(BaseConnection):
logger.debug("Error waiting for VPCS prompt: %s", e)
# Step 4: Return what we have (connection might still work)
logger.warning("VPCS prompt not clearly detected, returning current output")
logger.debug("VPCS prompt not clearly detected, returning current output")
return return_msg
def session_preparation(self) -> 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 . && gns3-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,281 @@
# 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.44"
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
self._api_version = DOCKER_MINIMUM_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
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
View File
@@ -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
@@ -0,0 +1,269 @@
#!/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/>.
"""
Setup Web Wireshark Docker image.
This script pulls or builds the gns3/web-wireshark Docker image.
Run with: pip install gns3server[wireshark] && gns3-wireshark-setup
"""
import os
import sys
import shutil
import subprocess
import argparse
DOCKER_IMAGE = "gns3/web-wireshark:latest"
DOCKERFILE_NAME = "Dockerfile"
def find_dockerfile():
"""Find the Dockerfile.
The Dockerfile is expected to be in the 'docker' subdirectory
relative to this script's location.
"""
# This script is in: gns3server/agent/web_wireshark/setup_wireshark_image.py
# The Dockerfile is in: gns3server/agent/web_wireshark/docker/Dockerfile
script_dir = os.path.dirname(os.path.abspath(__file__))
dockerfile_path = os.path.join(script_dir, "docker", DOCKERFILE_NAME)
if os.path.exists(dockerfile_path):
return dockerfile_path
return None
def check_docker():
"""Check if Docker is available."""
if not shutil.which("docker"):
print("Error: docker command not found", file=sys.stderr)
print("Please install Docker first: https://docs.docker.com/get-docker/", file=sys.stderr)
return False
# Check if Docker daemon is running
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True
)
if result.returncode != 0:
print("Error: Docker daemon is not running", file=sys.stderr)
print("Please start Docker and try again", file=sys.stderr)
return False
except Exception as e:
print(f"Error: Cannot connect to Docker: {e}", file=sys.stderr)
return False
return True
def image_exists():
"""Check if the Docker image already exists."""
try:
result = subprocess.run(
["docker", "image", "inspect", DOCKER_IMAGE],
capture_output=True,
text=True
)
return result.returncode == 0
except Exception:
return False
def is_network_error(output):
"""Check if the error is network-related."""
network_error_patterns = [
"connection reset",
"connection refused",
"timeout",
"no route to host",
"network unreachable",
"failed to fetch",
"failed to authorize",
"i/o timeout",
]
output_lower = output.lower()
return any(pattern in output_lower for pattern in network_error_patterns)
def pull_image():
"""Pull the Docker image from registry."""
print(f"Pulling Docker image: {DOCKER_IMAGE}")
print("-" * 60)
result = subprocess.run(
["docker", "pull", DOCKER_IMAGE],
capture_output=True,
text=True
)
# Store output for network error detection
pull_output = result.stdout + result.stderr
pull_success = result.returncode == 0
# Print output
print(pull_output)
return pull_success, pull_output
def build_image(dockerfile_path):
"""Build the Docker image locally."""
dockerfile_dir = os.path.dirname(dockerfile_path)
print(f"Building Docker image: {DOCKER_IMAGE}")
print(f"Using Dockerfile: {dockerfile_path}")
print("-" * 60)
result = subprocess.run(
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."],
cwd=dockerfile_dir,
pass_fds=(1, 2)
)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(
description="Setup Web Wireshark Docker image for GNS3"
)
parser.add_argument(
"--force",
action="store_true",
help="Force rebuild even if image exists"
)
parser.add_argument(
"--build-only",
action="store_true",
help="Only build locally, skip pull"
)
parser.add_argument(
"--pull-only",
action="store_true",
help="Only pull from registry, skip build"
)
args = parser.parse_args()
print("=" * 60)
print(" GNS3 Web Wireshark Image Setup")
print("=" * 60)
print()
# Check Docker
if not check_docker():
sys.exit(1)
# Check if image already exists
if image_exists() and not args.force:
print(f"Image {DOCKER_IMAGE} already exists.")
response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
if response != 'y':
print("Setup cancelled.")
sys.exit(0)
print()
# Try strategies in order
success = False
pull_failed_due_to_network = False
if not args.build_only:
# Strategy 1: Pull from registry
print("[Strategy 1/2] Attempting to pull image from Docker Hub...")
print()
pull_ok, pull_output = pull_image()
if pull_ok:
success = True
print()
print(f"Successfully pulled {DOCKER_IMAGE}")
else:
# Check if it's a network error
if is_network_error(pull_output):
pull_failed_due_to_network = True
print()
print("Pull failed due to network issues.")
print("The local build will also fail since it requires pulling the base image from Docker Hub.")
print()
print("Suggestions:")
print(" 1. Configure Docker mirror accelerator (see /etc/docker/daemon.json)")
print(" 2. Use a VPN/proxy")
print(" 3. Manually import the image on a machine with Docker Hub access:")
print(f" docker save -o web-wireshark.tar {DOCKER_IMAGE}")
print(" scp web-wireshark.tar your-server:/tmp/")
print(" docker load -i /tmp/web-wireshark.tar")
print()
print("Skipping local build...")
else:
print()
print("Pull failed, trying local build...")
if not success and not args.pull_only and not pull_failed_due_to_network:
# Strategy 2: Build locally
# Skip if pull failed due to network - local build will also fail
print()
print("[Strategy 2/2] Attempting local build...")
print()
dockerfile_path = find_dockerfile()
if not dockerfile_path:
print("Error: Cannot find Dockerfile", file=sys.stderr)
print("Please ensure the GNS3 server package is correctly installed.", file=sys.stderr)
sys.exit(1)
if build_image(dockerfile_path):
success = True
print()
print(f"Successfully built {DOCKER_IMAGE}")
else:
print()
print("Build failed.")
if success:
print()
print("=" * 60)
print(" Setup completed successfully!")
print("=" * 60)
sys.exit(0)
else:
print()
print("=" * 60)
print(" Setup failed!")
print("=" * 60)
print()
if pull_failed_due_to_network:
print("Docker Hub is not accessible. Please fix the network issue and try again.")
else:
print("Please check the errors above and try again.")
print("You can also manually run:")
print(f" docker pull {DOCKER_IMAGE}")
print(f" docker build -t {DOCKER_IMAGE} -f <dockerfile_path> .")
sys.exit(1)
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""
Web Wireshark statistics collection utilities.
This module provides functions to collect and aggregate statistics
about Web Wireshark containers and sessions.
"""
import logging
import subprocess
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
async def collect_webwireshark_stats(projects: List) -> Dict:
"""
Collect Web Wireshark container statistics across all projects.
Args:
projects: List of project objects to check for containers
Returns:
Dictionary with aggregated statistics:
{
"total_containers": int,
"running_containers": int,
"active_sessions": int,
"containers": [list of container details]
}
"""
from .manager import WebWiresharkManager
stats = {
"total_containers": 0,
"running_containers": 0,
"active_sessions": 0,
"containers": []
}
# Create a single manager instance and reuse it
manager = WebWiresharkManager()
try:
# Check Web Wireshark containers for each opened project
for project in projects:
if project.status != "opened":
continue
container_name = f"gns3-wireshark-{project.id}"
container = await manager.docker.get_container(container_name)
if container:
stats["total_containers"] += 1
container_info = {
"project_id": project.id,
"project_name": project.name,
"container_id": container["Id"][:12],
"status": container["State"]["Status"],
"running": container["State"]["Running"],
}
if container["State"]["Running"]:
stats["running_containers"] += 1
# Get container resource limits from HostConfig
host_config = container.get("HostConfig", {})
memory_limit = host_config.get("Memory", 0)
cpu_quota = host_config.get("NanoCpus", 0)
pids_limit = host_config.get("PidsLimit", 0)
# Count active capture sessions
active_sessions = sum(
1 for link in project.links.values()
if getattr(link, "capturing", False)
)
stats["active_sessions"] += active_sessions
container_info["active_sessions"] = active_sessions
# Add resource limits
container_info["memory_limit"] = f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited"
container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited"
# Get resource usage via docker stats
resource_stats = await _get_container_resource_stats(container["Id"])
if resource_stats:
container_info.update(resource_stats)
stats["containers"].append(container_info)
except Exception as e:
logger.warning(f"Could not retrieve Web Wireshark statistics: {e}")
finally:
# Always close the manager to cleanup aiohttp sessions
await manager.close()
return stats
async def _get_container_resource_stats(container_id: str) -> Optional[Dict]:
"""
Get resource usage statistics for a container.
Args:
container_id: Docker container ID
Returns:
Dictionary with memory, cpu, and pids, or None if failed
"""
try:
result = subprocess.run(
["docker", "stats", "--no-stream", "--format",
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}",
container_id],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0:
parts = result.stdout.strip().split("\t")
if len(parts) >= 3:
return {
"memory": parts[0],
"cpu": parts[1],
"pids": int(parts[2])
}
except subprocess.TimeoutExpired:
logger.debug(f"Docker stats timeout for container {container_id[:12]}")
except Exception as e:
logger.debug(f"Failed to get stats for container {container_id[:12]}: {e}")
return None
+35
View File
@@ -292,6 +292,41 @@ async def delete_session(
)
@router.post(
"/sessions/{session_id}/abort",
status_code=status.HTTP_200_OK,
summary="Abort a streaming session",
description="Abort an ongoing streaming session for a specific session."
)
async def abort_session(
session_id: str,
project: Project = Depends(dep_project),
current_user: schemas.User = Depends(get_current_active_user),
):
"""
Abort a streaming session.
Sets the abort flag for the session, which will be checked on the next
conditional edge evaluation. The streaming will stop at that point.
"""
# Check if project is opened
if project.status != "opened":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Project must be opened to abort chat session. Current status: {project.status}"
)
# Get AgentService for this project
agent_manager = await get_project_agent_manager()
agent_service = await agent_manager.get_agent(str(project.id), project.path)
# Abort the session
agent_service.abort_session(session_id)
return {"status": "ok", "session_id": session_id}
@router.patch(
"/sessions/{session_id}",
response_model=schemas.ChatSession,
@@ -28,6 +28,7 @@ from typing import List, Dict
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.agent.web_wireshark.stats import collect_webwireshark_stats
from gns3server.version import __version__
from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError
from gns3server import schemas
@@ -241,11 +242,15 @@ async def statistics() -> dict:
"capturing": link_capturing,
}
# Web Wireshark container statistics
webwireshark_stats = await collect_webwireshark_stats(projects)
return {
"computes": compute_statistics,
"projects": project_stats,
"nodes": node_stats,
"links": link_stats,
"webwireshark": webwireshark_stats,
}
@@ -90,7 +90,16 @@ async def get_current_active_user_from_websocket(
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> Optional[schemas.User]:
await websocket.accept()
# Extract requested subprotocols from headers for proper WebSocket negotiation
# This is critical for protocols like xpra that require specific subprotocols
scope = websocket.scope
headers = dict(scope.get("headers", []))
requested_protocols_header = headers.get(b"sec-websocket-protocol", b"")
requested_protocols = [p.decode().strip() for p in requested_protocols_header.split(b",") if p.strip()]
# Accept the connection with the first requested subprotocol (if any)
subprotocol = requested_protocols[0] if requested_protocols else None
await websocket.accept(subprotocol=subprotocol)
try:
token_data = auth_service.get_token_data(token)
+150 -7
View File
@@ -18,11 +18,13 @@
API routes for links.
"""
import asyncio
import os
import multidict
import aiohttp
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, Request, status, WebSocket
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.encoders import jsonable_encoder
from typing import List, Union
from uuid import UUID
@@ -32,10 +34,12 @@ from gns3server.controller.controller_error import ControllerError
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller.link import Link
from gns3server.utils.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port
from gns3server.utils.websocket_to_websocket import websocket_proxy
from gns3server import schemas
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
from .dependencies.rbac import has_privilege, has_privilege_on_websocket
import logging
@@ -214,16 +218,26 @@ async def reset_link(link: Link = Depends(dep_link)) -> schemas.Link:
response_model=schemas.Link,
dependencies=[Depends(has_privilege("Link.Capture"))]
)
async def start_capture(capture_data: dict, link: Link = Depends(dep_link)) -> schemas.Link:
async def start_capture(
capture_data: schemas.LinkCapture,
http_request: Request,
link: Link = Depends(dep_link)
) -> schemas.Link:
"""
Start packet capture on the link.
Required privilege: Link.Capture
"""
# Extract JWT token from Authorization header
auth_header = http_request.headers.get("Authorization", "")
jwt_token = auth_header.replace("Bearer ", "") if auth_header else None
await link.start_capture(
data_link_type=capture_data.get("data_link_type", "DLT_EN10MB"),
capture_file_name=capture_data.get("capture_file_name"),
data_link_type=capture_data.data_link_type,
capture_file_name=capture_data.capture_file_name,
wireshark=capture_data.wireshark,
jwt_token=jwt_token
)
return link.asdict()
@@ -243,6 +257,33 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None:
await link.stop_capture()
@router.post(
"/{link_id}/capture/wireshark/restart",
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Link.Capture"))]
)
async def restart_wireshark(
http_request: Request,
link: Link = Depends(dep_link)
) -> dict:
"""
Restart Wireshark window without stopping the capture.
This allows recovery after accidentally closing the Wireshark window.
Required privilege: Link.Capture
"""
# Extract JWT token from Authorization header
auth_header = http_request.headers.get("Authorization", "")
jwt_token = auth_header.replace("Bearer ", "") if auth_header else None
if not jwt_token:
raise ControllerError("JWT token is required for Web Wireshark restart")
await link._restart_web_wireshark(jwt_token)
return {"status": "restarted"}
@router.get(
"/{link_id}/capture/stream",
dependencies=[Depends(has_privilege("Link.Capture"))]
@@ -254,7 +295,10 @@ async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> Strea
Required privilege: Link.Capture
"""
if not link.capturing:
# Check both capturing flag and capture_node to avoid race condition
# when stop_capture() sets _capture_node = None before this check completes
if not link.capturing or not link.capture_node:
log.info(f"Stream pcap ended for link {link.id}: capture stopped before stream completed")
raise ControllerError("This link has no active packet capture")
compute = link.compute
@@ -287,6 +331,105 @@ async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> Strea
return StreamingResponse(compute_pcap_stream(), media_type="application/vnd.tcpdump.pcap")
@router.get(
"/{link_id}/capture/file",
dependencies=[Depends(has_privilege("Link.Capture"))],
response_class=FileResponse
)
async def download_capture_file(link: Link = Depends(dep_link)):
"""
Download the PCAP capture file.
This endpoint allows downloading the capture file even while capture is active.
The file is streamed directly, so partial data may be received if capture is still running.
Required privilege: Link.Capture
"""
if not link.capture_file_path:
raise ControllerError("No capture file path set for this link")
if not os.path.exists(link.capture_file_path):
raise ControllerError(f"Capture file not found: {link.capture_file_path}")
return FileResponse(
path=link.capture_file_path,
filename=os.path.basename(link.capture_file_path),
media_type="application/vnd.tcpdump.pcap"
)
@router.websocket("/{link_id}/capture/web-wireshark")
async def web_wireshark_websocket(
websocket: WebSocket,
link_id: str,
project_id: str,
current_user: schemas.User = Depends(has_privilege_on_websocket("Link.Capture"))
):
"""
WebSocket proxy endpoint for xpra container (Web Wireshark).
Path: ws://host/v3/projects/{project_id}/links/{link_id}/capture/web-wireshark?token=<jwt_token>
Required privilege: Link.Capture
Note: The WebSocket connection is accepted by the authentication dependency
(get_current_active_user_from_websocket) with the proper subprotocol negotiation.
"""
log.info(f"New WebSocket connection for project {project_id}, link {link_id}, user {current_user.username}")
try:
# Get container information
container_name = f"gns3-wireshark-{project_id}"
# Calculate xpra port (using deterministic hash)
xpra_port = link_id_to_port(link_id)
# Get container IP
from gns3server.compute.docker import Docker
docker_manager = Docker.instance()
container_info = await docker_manager.query(
"GET",
f"containers/{container_name}/json"
)
networks = container_info["NetworkSettings"]["Networks"]
container_ip = None
# Find gns3-wireshark network
for network_name, network_config in networks.items():
if "wireshark" in network_name.lower():
container_ip = network_config["IPAddress"]
break
if not container_ip:
log.error(f"Container {container_name} not found in wireshark network")
await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
return
# Build container WebSocket URL
container_ws_url = f"ws://{container_ip}:{xpra_port}"
log.info(f"Proxying WebSocket to container: {container_ws_url}")
# Get client's requested subprotocols from request headers
scope = websocket.scope
headers = dict(scope.get("headers", []))
requested_protocols_header = headers.get(b"sec-websocket-protocol", b"")
requested_protocols = [p.decode().strip() for p in requested_protocols_header.split(b",") if p.strip()]
log.info(f"Client requested subprotocols: {requested_protocols}")
# The WebSocket connection has already been accepted by the authentication dependency
# with the proper subprotocol. Now we just proxy data to the backend.
await websocket_proxy(websocket, container_ws_url, requested_protocols)
except Exception as e:
log.error(f"Error in WebSocket proxy for link {link_id}: {e}")
try:
await websocket.close(code=status.WS_1011_INTERNAL_ERROR, reason=str(e))
except:
pass
@router.get(
"/{link_id}/iface",
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
+16 -1
View File
@@ -160,4 +160,19 @@ enable_hardware_acceleration = True
; Require hardware acceleration in order to start VMs
require_hardware_acceleration = False
; Allow unsafe additional command line options
allow_unsafe_options = False
allow_unsafe_options = False
[WebWireshark]
; Enable Web Wireshark feature (container-based Wireshark in browser)
enabled = True
; Docker image for Web Wireshark container
image = gns3/web-wireshark:latest
; Docker network subnet for Web Wireshark containers (default: 172.31.0.0/22)
; Change this if it conflicts with your existing network
network_subnet = 172.31.0.0/22
; Memory limit per container (e.g., "1g", "2g", "512m")
memory = 2g
; CPU cores per container (e.g., 1.0, 2.0)
cpus = 1.0
; Process limit per container
pids_limit = 1000
@@ -50,6 +50,9 @@ class ApplianceToTemplate:
if "symbol" in appliance_config:
new_template["symbol"] = appliance_config.get("symbol")
if "tags" in appliance_config:
new_template["tags"] = appliance_config.get("tags")
if new_template.get("symbol") is None:
if appliance_config["category"] == "guest":
if "docker" in appliance_config:
+119 -8
View File
@@ -21,6 +21,8 @@ import uuid
import html
from .controller_error import ControllerError, ControllerNotFoundError
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
from gns3server.config import Config
import logging
@@ -86,6 +88,7 @@ class Link:
self._suspended = False
self._filters = {}
self._link_style = {}
self._wireshark = False
@property
def filters(self):
@@ -94,13 +97,6 @@ class Link:
"""
return self._filters
@property
def nodes(self):
"""
Get the current nodes attached to this link
"""
return self._nodes
@property
def project(self):
"""
@@ -284,10 +280,14 @@ class Link:
raise NotImplementedError
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None):
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
"""
Start capture on the link
:param data_link_type: PCAP data link type
:param capture_file_name: PCAP capture file name
:param wireshark: Enable Web Wireshark
:param jwt_token: JWT token for authentication
:returns: Capture object
"""
@@ -295,14 +295,124 @@ class Link:
self._capture_file_name = capture_file_name
self._project.emit_notification("link.updated", self.asdict())
# Start Web Wireshark if requested
if wireshark:
await self._start_web_wireshark(jwt_token)
async def stop_capture(self):
"""
Stop capture on the link
"""
# Stop Web Wireshark
if self._capturing:
await self._stop_web_wireshark()
self._capturing = False
self._project.emit_notification("link.updated", self.asdict())
async def _start_web_wireshark(self, jwt_token: str):
"""Start Web Wireshark.
Args:
jwt_token: JWT authentication token
Raises:
ControllerError: If startup fails
"""
if not jwt_token:
raise ControllerError("JWT token is required for Web Wireshark")
# Load WebWireshark configuration from config file
webwireshark_config = Config.instance().settings.WebWireshark
manager = WebWiresharkManager()
try:
log.info(f"Starting Web Wireshark for link {self.id}")
result = await manager.start_wireshark_session(
project_id=self._project.id,
link_id=self.id,
jwt_token=jwt_token,
memory=webwireshark_config.memory,
cpus=webwireshark_config.cpus,
pids_limit=webwireshark_config.pids_limit
)
# Send notification
self._project.emit_notification("link.web_wireshark_started", {
"link_id": self.id,
"ws_url": result.get("ws_url", result.get("url"))
})
log.info(f"Web Wireshark started for link {self.id}: {result.get('ws_url')}")
self._wireshark = True
except ControllerError:
# Re-raise ControllerError to return error to client
raise
except Exception as e:
error_msg = f"Error starting Web Wireshark: {str(e)}"
log.error(error_msg)
raise ControllerError(error_msg)
finally:
await manager.close()
async def _stop_web_wireshark(self):
"""Stop Web Wireshark"""
manager = WebWiresharkManager()
try:
log.info(f"Stopping Web Wireshark for link {self.id}")
await manager.stop_wireshark_session(
project_id=self._project.id,
link_id=self.id
)
log.info(f"Web Wireshark stopped for link {self.id}")
self._wireshark = False
except Exception as e:
log.error(f"Error stopping Web Wireshark: {e}")
finally:
await manager.close()
async def _restart_web_wireshark(self, jwt_token: str):
"""Restart Web Wireshark after window was closed.
Args:
jwt_token: JWT authentication token
Raises:
ControllerError: If restart fails
"""
# Get capture stream URL from compute
manager = WebWiresharkManager()
try:
log.info(f"Restarting Web Wireshark for link {self.id}")
result = await manager.restart_wireshark_session(
project_id=self._project.id,
link_id=self.id,
jwt_token=jwt_token
)
self._project.emit_notification("link.web_wireshark_started", {
"link_id": self.id,
"ws_url": result.get("ws_url", result.get("url"))
})
log.info(f"Web Wireshark restarted for link {self.id}: {result.get('ws_url')}")
except ControllerError:
raise
except Exception as e:
error_msg = f"Error restarting Web Wireshark: {str(e)}"
log.error(error_msg)
raise ControllerError(error_msg)
finally:
await manager.close()
def pcap_streaming_url(self):
"""
Get the PCAP streaming URL on compute
@@ -458,4 +568,5 @@ class Link:
"filters": self._filters,
"suspend": self._suspended,
"link_style": self._link_style,
"wireshark": self._wireshark,
}
+74 -2
View File
@@ -47,6 +47,7 @@ from ..utils.asyncio import wait_run_in_executor
from .export_project import export_project
from .import_project import import_project, update_snapshots, regenerate_topology_ids
from .controller_error import ControllerError, ControllerForbiddenError, ControllerNotFoundError
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
import logging
@@ -897,6 +898,9 @@ class Project:
if not ignore_notification:
self.emit_controller_notification("project.closed", self.asdict())
# Stop Web Wireshark container (all xpra sessions terminate with container)
await self._stop_web_wireshark_container()
# Cleanup GNS3 Copilot AgentService for this project
await self._cleanup_copilot_agent()
@@ -936,6 +940,70 @@ class Project:
except OSError as e:
log.warning(f"Could not delete unused pictures: {e}")
async def _cleanup_web_wireshark_xpra_sessions(self):
"""
Cleanup all Web Wireshark xpra sessions (without deleting container).
Called when project is closed to stop all xpra sessions and Wireshark processes,
while keeping the container for quick reuse when project is reopened.
"""
try:
log.info("Stopping xpra sessions for project '%s' (%s)", self.name, self._id)
manager = WebWiresharkManager()
try:
await manager.stop_all_sessions(self._id)
log.info("Web Wireshark xpra sessions stopped successfully")
finally:
await manager.close()
except Exception as e:
# Don't raise exception to avoid affecting project close flow
log.warning("Failed to cleanup xpra sessions for project '%s': %s", self.name, e)
async def _stop_web_wireshark_container(self):
"""
Stop Web Wireshark container (without deleting).
Called when project is closed to stop the container and free memory,
while keeping the container for quick startup when project is reopened.
"""
try:
container_name = f"gns3-wireshark-{self._id}"
log.info("Stopping Web Wireshark container '%s' for project '%s'", container_name, self.name)
manager = WebWiresharkManager()
try:
await manager.stop_container(self._id)
log.info("Web Wireshark container stopped successfully")
finally:
await manager.close()
except Exception as e:
# Don't fail project close if container stop fails
log.warning("Failed to stop container for project '%s': %s", self.name, e)
async def _cleanup_web_wireshark_container(self):
"""
Delete Web Wireshark container.
Called when project is deleted to stop and remove the container.
"""
try:
container_name = f"gns3-wireshark-{self._id}"
log.info("Deleting Web Wireshark container '%s' for project '%s'", container_name, self.name)
manager = WebWiresharkManager()
try:
await manager.delete_container(self._id)
log.info("Web Wireshark container deleted successfully")
finally:
await manager.close()
except Exception as e:
# Don't fail project delete if container cleanup fails
log.warning("Failed to delete container for project '%s': %s", self.name, e)
async def _cleanup_copilot_agent(self):
"""
Cleanup GNS3 Copilot AgentService for this project.
@@ -947,11 +1015,11 @@ class Project:
agent_manager = await get_project_agent_manager()
if agent_manager.has_agent(self._id):
log.info(f"Cleaning up AgentService for project '{self.name}' ({self._id})")
log.info("Cleaning up AgentService for project '%s' (%s)", self.name, self._id)
await agent_manager.remove_agent(self._id)
except Exception as e:
# Don't fail project close if agent cleanup fails
log.warning(f"Failed to cleanup AgentService for project '{self.name}': {e}")
log.warning("Failed to cleanup AgentService for project '%s': %s", self.name, e)
async def delete(self):
@@ -963,6 +1031,10 @@ class Project:
log.warning(f"Conflict while deleting project: {e}")
await self.delete_on_computes()
await self.close()
# Delete Web Wireshark container
await self._cleanup_web_wireshark_container()
try:
project_directory = get_default_project_directory()
if not os.path.commonprefix([project_directory, self.path]) == project_directory:
+2 -2
View File
@@ -175,7 +175,7 @@ class UDPLink(Link):
await self.delete()
await self.create()
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None):
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
"""
Start capture on a link
"""
@@ -189,7 +189,7 @@ class UDPLink(Link):
),
data=data,
)
await super().start_capture(data_link_type=data_link_type, capture_file_name=capture_file_name)
await super().start_capture(data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token)
async def stop_capture(self):
"""
+1 -1
View File
@@ -20,7 +20,7 @@ from .common import ErrorMessage
from .version import Version
# Controller schemas
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture
from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
from .controller.images import Image, ImageType
+12
View File
@@ -94,6 +94,17 @@ class VMwareSettings(BaseModel):
return self
class WebWiresharkSettings(BaseModel):
enabled: bool = True
image: str = "gns3/web-wireshark:latest"
network_subnet: str = "172.31.0.0/22"
memory: str = "2g"
cpus: float = 1.0
pids_limit: int = 1000
model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
class ServerProtocol(str, Enum):
http = "http"
@@ -196,3 +207,4 @@ class ServerConfig(BaseModel):
Qemu: QemuSettings = QemuSettings()
VirtualBox: VirtualBoxSettings = VirtualBoxSettings()
VMware: VMwareSettings = VMwareSettings()
WebWireshark: WebWiresharkSettings = WebWiresharkSettings()
@@ -634,6 +634,7 @@ class ApplianceV1_6(BaseModel):
iou: Optional[Iou] = Field(None, title='IOU specific options')
dynamips: Optional[Dynamips] = Field(None, title='Dynamips specific options')
qemu: Optional[Qemu] = Field(None, title='Qemu specific options')
tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance')
images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')
versions: Optional[List[ApplianceVersion]] = Field(None, title='Versions of the appliance')
@@ -672,6 +673,7 @@ class ApplianceV8(BaseModel):
default_username: Optional[str] = Field(None, title='Default username for the appliance')
default_password: Optional[str] = Field(None, title='Default password for the appliance')
symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance')
settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance')
images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')
versions: Optional[List[ApplianceVersionV8]] = Field(None, title='Versions of the appliance')
+2 -1
View File
@@ -55,7 +55,8 @@ class ChatResponse(BaseModel):
"tool_end", # Tool execution completed
"error", # Error message
"done", # Stream ended
"heartbeat" # Keep-alive signal
"heartbeat", # Keep-alive signal
"abort" # Stream aborted
] = Field(..., description="Response message type")
content: Optional[str] = Field(None, description="Text content (for type=content)")
message_id: Optional[str] = Field(None, description="Message ID")
+14
View File
@@ -96,6 +96,10 @@ class Link(LinkBase):
None,
description="Read only property. The compute identifier where a capture is running"
)
wireshark: Optional[bool] = Field(
False,
description="Read only property. True if a Web Wireshark session is active on the link"
)
class UDPPortInfo(BaseModel):
@@ -117,3 +121,13 @@ class EthernetPortInfo(BaseModel):
node_id: UUID
interface: str
type: str
class LinkCapture(BaseModel):
"""
Link capture data.
"""
data_link_type: str = "DLT_EN10MB"
capture_file_name: Optional[str] = None
wireshark: bool = False
+1 -1
View File
@@ -110,7 +110,7 @@ BUILTIN_TEMPLATES = [
"base_script_file": "vpcs_base_config.txt",
"compute_id": None,
"builtin": True,
"tags": ["platform:vpcs", "device_type:gns3_vpcs_telnet"],
"tags": ["device_type:gns3_vpcs_telnet"],
},
{
"template_id": uuid.uuid5(uuid.NAMESPACE_X500, "ethernet_switch"),
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Deterministic port allocation utilities
#
import hashlib
# Display/port allocation range (stable across process restarts)
DISPLAY_RANGE = 10000
DISPLAY_MODULO = 10000
def link_id_to_display(link_id: str) -> int:
"""Convert link_id to display number using deterministic hash.
Uses MD5 to ensure stable display assignment across process restarts.
Args:
link_id: The link ID (UUID)
Returns:
Display number (10000-19999)
"""
hash_value = int(hashlib.md5(link_id.encode()).hexdigest(), 16)
return DISPLAY_RANGE + (hash_value % DISPLAY_MODULO)
def link_id_to_port(link_id: str) -> int:
"""Convert link_id to TCP port using deterministic hash.
Uses same algorithm as link_id_to_display for consistency.
Args:
link_id: The link ID (UUID)
Returns:
TCP port (10000-19999)
"""
return link_id_to_display(link_id)
+33
View File
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# UUID validation utilities
#
import argparse
import re
UUID_PATTERN = re.compile(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
re.IGNORECASE
)
def validate_uuid(value: str) -> str:
"""Validate UUID format for argparse.
Args:
value: UUID string to validate
Returns:
The validated UUID string
Raises:
argparse.ArgumentTypeError: If UUID format is invalid
"""
if not UUID_PATTERN.match(value):
raise argparse.ArgumentTypeError(
f"Invalid UUID format: '{value}'. "
f"Expected 8-4-4-4-12 hex groups (e.g., 5af0fe00-f39d-4985-8669-7e8c512d729c)"
)
return value
+299
View File
@@ -0,0 +1,299 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
WebSocket-to-WebSocket proxy utility functions.
Similar pattern to VNC console implementation in base_node.py:
- Bidirectional forwarding
- Binary data only (for protocols like xpra, VNC, RDP)
- Graceful error handling
"""
import asyncio
import logging
import sys
from typing import Optional
import aiohttp
from fastapi import WebSocket, status
from fastapi.websockets import WebSocketDisconnect
from starlette.websockets import WebSocket as StarletteWebSocket
log = logging.getLogger(__name__)
async def websocket_proxy(
client_ws: WebSocket,
target_url: str,
requested_protocols: list = None,
buffer_size: int = 65536,
timeout: Optional[float] = None
) -> None:
"""
Proxy binary WebSocket data between client and target WebSocket server.
Designed for binary protocols like xpra, VNC, RDP.
Text data is not supported.
Args:
client_ws: Client WebSocket connection (FastAPI)
target_url: Target WebSocket URL to proxy to
requested_protocols: List of subprotocols requested by client (default: ["binary"])
buffer_size: Buffer size for binary data (default: 65536)
timeout: Connection timeout in seconds (default: None)
Raises:
aiohttp.ClientError: If connection to target fails
"""
client_info = f"{client_ws.client.host}:{client_ws.client.port}"
async def forward_client_to_target(target_ws):
"""Client → Target: Forward binary WebSocket data."""
try:
while True:
data = await client_ws.receive_bytes()
if data:
await target_ws.send_bytes(data)
except WebSocketDisconnect:
log.info(f"Client {client_info} disconnected from WebSocket proxy")
except Exception as e:
log.warning(f"Error forwarding client to target: {e}")
async def forward_target_to_client(target_ws):
"""Target → Client: Forward binary WebSocket data."""
try:
async for msg in target_ws:
if msg.type == aiohttp.WSMsgType.BINARY:
try:
await client_ws.send_bytes(msg.data)
except Exception as e:
log.debug(f"Failed to send to client (possibly disconnected): {e}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
log.warning(f"Target WebSocket error: {msg.data}")
break
elif msg.type == aiohttp.WSMsgType.CLOSE:
log.info("Target WebSocket closed")
# Don't try to forward close to client if already disconnected
break
except Exception as e:
log.warning(f"Error forwarding target to client: {e}")
try:
# Connect to target WebSocket FIRST to negotiate subprotocol
log.info(f"Connecting to target WebSocket: {target_url}")
# Use requested protocols from client (usually "binary" for xpra)
subprotocols = requested_protocols or ["binary"]
log.info(f"Client requested subprotocols: {subprotocols}")
timeout_config = {}
if timeout:
timeout_config = {"timeout": aiohttp.ClientTimeout(total=timeout)}
async with aiohttp.ClientSession() as session:
async with session.ws_connect(target_url, protocols=subprotocols, **timeout_config) as target_ws:
negotiated_protocol = target_ws.protocol
log.info(f"Target WebSocket negotiated protocol: {negotiated_protocol}")
log.info(f"WebSocket proxy established: {client_info} → {target_url}")
# Run both forwarding tasks in parallel
# Similar pattern to base_node.py VNC implementation
if sys.version_info >= (3, 11, 0):
aws = [
asyncio.create_task(forward_client_to_target(target_ws)),
asyncio.create_task(forward_target_to_client(target_ws))
]
else:
aws = [
forward_client_to_target(target_ws),
forward_target_to_client(target_ws)
]
try:
done, pending = await asyncio.wait(
aws,
return_when=asyncio.ALL_COMPLETED
)
except Exception as e:
log.error(f"asyncio.wait raised exception: {e}")
# Check for exceptions
for task in done:
if task.exception():
log.warning(
f"WebSocket proxy task exception: {task.exception()}"
)
# Cancel pending tasks
for task in pending:
task.cancel()
except aiohttp.ClientError as e:
log.error(f"WebSocket proxy connection error: {e}")
await client_ws.close(
code=status.WS_1011_INTERNAL_ERROR,
reason=f"Proxy connection failed: {e}"
)
except Exception as e:
log.error(f"WebSocket proxy unexpected error: {e}")
await client_ws.close(
code=status.WS_1011_INTERNAL_ERROR,
reason=str(e)
)
async def websocket_proxy_with_manual_accept(
client_ws: StarletteWebSocket,
target_url: str,
requested_protocols: list = None,
buffer_size: int = 65536,
timeout: Optional[float] = None
) -> None:
"""
Proxy binary WebSocket data between client and target WebSocket server.
This version manually accepts the client WebSocket connection after negotiating
the subprotocol with the backend server. This is required for protocols like xpra
that depend on proper subprotocol negotiation.
Args:
client_ws: Client WebSocket connection (Starlette WebSocket)
target_url: Target WebSocket URL to proxy to
requested_protocols: List of subprotocols requested by client (default: ["binary"])
buffer_size: Buffer size for binary data (default: 65536)
timeout: Connection timeout in seconds (default: None)
Raises:
aiohttp.ClientError: If connection to target fails
"""
client_info = f"{client_ws.client.host}:{client_ws.client.port}" if hasattr(client_ws, 'client') else "unknown"
async def forward_client_to_target(target_ws):
"""Client → Target: Forward binary WebSocket data."""
log.info("forward_client_to_target: started")
try:
while True:
log.info("forward_client_to_target: waiting for client data")
data = await client_ws.receive_bytes()
log.info(f"forward_client_to_target: received {len(data)} bytes from client")
if data:
await target_ws.send_bytes(data)
except Exception as e:
log.info(f"Client disconnected or error: {e}")
raise
async def forward_target_to_client(target_ws):
"""Target → Client: Forward binary WebSocket data."""
log.info("forward_target_to_client: started, about to iterate")
try:
async for msg in target_ws:
log.info(f"forward_target_to_client: got message type={msg.type}")
if msg.type == aiohttp.WSMsgType.BINARY:
try:
await client_ws.send_bytes(msg.data)
except Exception as e:
log.debug(f"Failed to send to client (possibly disconnected): {e}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
log.warning(f"Target WebSocket error: {msg.data}")
break
elif msg.type == aiohttp.WSMsgType.CLOSE:
log.info("Target WebSocket closed")
break
log.info("forward_target_to_client: iteration ended")
except Exception as e:
log.warning(f"Error forwarding target to client: {e}")
try:
# Connect to target WebSocket FIRST to negotiate subprotocol
log.info(f"Connecting to target WebSocket: {target_url}")
# Use requested protocols from client (usually "binary" for xpra)
subprotocols = requested_protocols or ["binary"]
log.info(f"Client requested subprotocols: {subprotocols}")
timeout_config = {}
if timeout:
timeout_config = {"timeout": aiohttp.ClientTimeout(total=timeout)}
async with aiohttp.ClientSession() as session:
log.info(f"About to ws_connect to {target_url}")
ws_conn = session.ws_connect(target_url, protocols=subprotocols, **timeout_config)
log.info("ws_connect coroutine created, about to enter")
async with ws_conn as target_ws:
negotiated_protocol = target_ws.protocol
log.info(f"Target WebSocket negotiated protocol: {negotiated_protocol}")
log.info(f"target_ws.closed = {target_ws.closed}")
# Accept client WebSocket with the negotiated subprotocol
# This is CRITICAL for xpra to work properly
log.info(f"Accepting client WebSocket with subprotocol: {negotiated_protocol}")
await client_ws.accept(subprotocol=negotiated_protocol)
log.info("Client WebSocket accepted")
log.info(f"WebSocket proxy established: {client_info} → {target_url}")
# Run both forwarding tasks in parallel
if sys.version_info >= (3, 11, 0):
aws = [
asyncio.create_task(forward_client_to_target(target_ws)),
asyncio.create_task(forward_target_to_client(target_ws))
]
else:
aws = [
forward_client_to_target(target_ws),
forward_target_to_client(target_ws)
]
log.info(f"About to call asyncio.wait with {len(aws)} tasks")
try:
done, pending = await asyncio.wait(
aws,
return_when=asyncio.ALL_COMPLETED
)
log.info(f"asyncio.wait returned. done={len(done)}, pending={len(pending)}")
except Exception as e:
log.error(f"asyncio.wait raised exception: {e}")
log.info("After asyncio.wait check")
# Check for exceptions
for task in done:
if task.exception():
log.warning(
f"WebSocket proxy task exception: {task.exception()}"
)
# Cancel pending tasks
for task in pending:
log.info("Cancelling pending task")
task.cancel()
log.info("Cleanup complete, exiting websocket_proxy_with_manual_accept")
except aiohttp.ClientError as e:
log.error(f"WebSocket proxy connection error: {e}")
try:
await client_ws.close(code=status.WS_1011_INTERNAL_ERROR)
except Exception:
pass
except Exception as e:
log.error(f"WebSocket proxy unexpected error: {e}")
try:
await client_ws.close(code=status.WS_1011_INTERNAL_ERROR)
except Exception:
pass