diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 000000000..b1891785a --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,20 @@ +# GNS3 Server Project Memory + +> **Note**: This directory stores important project-related memories and case studies, managed with the code repository. +> +> **How to record**: Use the `/memory` skill to record important information to the project memory directory. + +## Quick Reference +- **Memory directory**: `.claude/memory/` +- **Skill file**: `.claude/skills/memory/SKILL.md` +- **Main index**: `MEMORY.md` (this file) + +## Topics + +### Web Wireshark Integration +- **[JWT Token Flow](./web-wireshark-jwt-token-flow.md)** - JWT token transmission path in Web Wireshark + - Key point: UDPLink only passes through jwt_token, ultimately used by curl command inside Web Wireshark container to authenticate with GNS3 capture stream API +- **[Xpra HTML5 Client](./xpra-html5-client.md)** - Xpra HTML5 client menu control parameters for customizing the web interface + +### uBridge Permission +- **[uBridge Permission Issue](./gns3-ubridge-permission.md)** - Docker containers fail to start due to missing CAP_NET_ADMIN/CAP_NET_RAW capabilities on uBridge diff --git a/.claude/memory/gns3-ubridge-permission.md b/.claude/memory/gns3-ubridge-permission.md new file mode 100644 index 000000000..2cdb01549 --- /dev/null +++ b/.claude/memory/gns3-ubridge-permission.md @@ -0,0 +1,25 @@ +# GNS3 uBridge Permission Issue + +## Problem +Docker container node fails to start with error: `uBridge requires root access or the capability to interact with network adapters` + +## Root Cause +- uBridge needs to create TAP network interfaces to connect Docker containers to GNS3 virtual network +- This requires `CAP_NET_ADMIN` and `CAP_NET_RAW` capabilities +- Other node types (like QEMU) may have their own network implementation and don't need uBridge + +## Solution +```bash +sudo setcap cap_net_admin,cap_net_raw=eip /usr/bin/ubridge +``` + +## Code Locations +- uBridge path check: `gns3server/compute/base_manager.py:298` (`has_privileged_access`) +- Permission check call: `gns3server/compute/base_node.py:851` +- Error message definition: `gns3server/compute/base_node.py:852` + +## Error Propagation Flow +Error is propagated via `NodeError` exception: +1. `NodeError` is caught at `gns3server/api/routes/compute/__init__.py:131-137` +2. Returns HTTP 409 with JSON: `{"message": "...", "exception": "NodeError"}` +3. Frontend should read error info from `message` field diff --git a/.claude/memory/web-wireshark-jwt-token-flow.md b/.claude/memory/web-wireshark-jwt-token-flow.md new file mode 100644 index 000000000..8ed59f472 --- /dev/null +++ b/.claude/memory/web-wireshark-jwt-token-flow.md @@ -0,0 +1,53 @@ +# Web Wireshark JWT Token Flow + +## Background + +Question: What is the purpose of `jwt_token` parameter in UDPLink and how is it transmitted? + +## JWT Token Complete Flow + +1. **User initiates request** → HTTP request with `Authorization: Bearer ` + +2. **API layer extracts token** → `gns3server/api/routes/controller/links.py`: + ```python + auth_header = http_request.headers.get("Authorization", "") + jwt_token = auth_header.replace("Bearer ", "") if auth_header else None + ``` + +3. **Pass to Link layer** → `Link.start_capture(wireshark=True, jwt_token=xxx)` + +4. **UDPLink forwards** → `UDPLink.start_capture()` calls `super().start_capture(jwt_token=jwt_token)` + +5. **Start Web Wireshark container** → `Link._start_web_wireshark(jwt_token)` calls management script + +6. **Container uses token** → `manage_wireshark.py` executes inside container: + ```bash + curl -N -H 'Authorization: Bearer {jwt_token}' \ + 'http://controller:3080/v3/projects/{project_id}/links/{link_id}/capture/stream' | \ + wireshark -i - -k -display :{display} + ``` + +## Why More Verbose Than gns3-copilot? + +**gns3-copilot approach**: +- All code within the same process +- Uses `contextvars` to store and retrieve token +- Downstream code directly calls `get_current_jwt_token()`, no need to pass through layers + +**Web Wireshark approach**: +- Token needs to be passed across processes (management script is separate process) +- Token needs to be passed across containers (Docker container isolation) +- Cannot use `contextvars`, must pass through command line arguments + +## Conclusion + +UDPLink does not use this token, it only forwards it to the parent class. Ultimately used by the curl command inside the Web Wireshark container to authenticate with the GNS3 controller's capture stream API. + +Token flow: **Client → GNS3 API → Link → UDPLink → Web Wireshark container → GNS3 capture stream API** + +## Related Files + +- `gns3server/api/routes/controller/links.py:114-115` - JWT token extraction +- `gns3server/controller/link.py:321-360` - Web Wireshark startup logic +- `gns3server/controller/udp_link.py:178` - UDPLink start_capture signature +- `gns3server/agent/web_wireshark/manage_wireshark.py:527-529` - Container curl command with JWT diff --git a/.claude/memory/xpra-html5-client.md b/.claude/memory/xpra-html5-client.md new file mode 100644 index 000000000..6d529ff34 --- /dev/null +++ b/.claude/memory/xpra-html5-client.md @@ -0,0 +1,148 @@ +# Xpra HTML5 Client Configuration + +## Overview + +Web Wireshark integration uses xpra's built-in HTML5 client to provide browser-based packet capture viewing. The xpra HTML5 client is located at `/usr/share/xpra/www` in the container. + +## Configuration Methods + +### 1. URL Parameters (Per-Session) + +Control toolbar menu items via URL query parameters (default: true): + +| Parameter | Menu Item | Description | +|-----------|-----------|-------------| +| xpramenu | Xpra Menu | Main menu (Server, Information submenus) | +| open_windows | Open Windows | List of open windows | +| fullscreen_button | Fullscreen | Fullscreen toggle button | +| keyboard_button | Keyboard | Keyboard layout/shortcuts | +| clipboard_button | Clipboard Copy | Clipboard copy functionality | +| sound_button | Audio | Audio toggle | +| cursor_lock_button | Lock Cursor | Game cursor lock mode | + +```bash +# Examples +?fullscreen_button=false&sound_button=false +?xpramenu=false&keyboard_button=false&clipboard_button=false&sound_button=false&cursor_lock_button=false +``` + +### 2. default-settings.txt (Recommended - Global/Persistent) + +Modify `html5/default-settings.txt` (INI format) for all users. + +**Features:** + +| Parameter | Default | Description | +|-----------|---------|-------------| +| keyboard | auto-detect | Enable keyboard input | +| keyboard_layout | us | Keyboard layout | +| clipboard | yes | Clipboard sharing | +| printing | yes | Printer forwarding | +| file_transfer | yes | File transfer | +| swap_keys | MacOS yes | Swap Command/Control keys | +| scroll_reverse_x | no | Reverse mouse X-axis | +| floating_menu | yes | Show floating menu | +| toolbar_position | top-left | Toolbar position | +| autohide | no | Auto-hide toolbar | +| sound | yes | Audio forwarding | +| video | 64-bit yes | Video decoding | + +**Connection Options:** + +| Parameter | Description | +|-----------|-------------| +| server | Server address | +| port | Port number | +| username | Username | +| password | Password | +| ssl | Enable SSL | +| encryption | Encryption type (AES-CBC/CTR/CFB) | +| key | AES encryption key | +| sharing | Allow session sharing | +| steal | Steal session | +| reconnect | Auto-reconnect | +| bandwidth_limit | Bandwidth limit (bits/s) | +| override_width | Client desktop width | + +**Advanced Options:** + +| Parameter | Default | Description | +|-----------|---------|-------------| +| audio_codec | auto-detect | Audio codec | +| encoding | auto | Image encoding (png/jpeg/webp/etc) | +| remote_logging | yes | Send logs to server | +| action | connect | Connection mode (start/shadow) | +| submit | yes | Show diagnostics on disconnect | + +### 3. default_settings in Code + +Set directly in ` \ No newline at end of file diff --git a/gns3server/agent/__init__.py b/gns3server/agent/__init__.py new file mode 100644 index 000000000..1861ed37e --- /dev/null +++ b/gns3server/agent/__init__.py @@ -0,0 +1,85 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received copy of the GNU General Public License +# along with this program. If not, see . + +""" +Agent module with optional AI Copilot support. + +This module provides the AI Copilot functionality as an optional feature. +If the AI dependencies are not installed, the module will be disabled but +will not prevent the server from starting. + +Installation: + pip install gns3-server[ai-copilot] +""" + +import logging +import threading + +# Feature flag: AI Copilot is available +AI_COPILOT_AVAILABLE = False + +# Try to import AI Copilot components +try: + from .gns3_copilot.project_agent_manager import get_project_agent_manager + from .gns3_copilot.project_agent_manager import ProjectAgentManager + AI_COPILOT_AVAILABLE = True + + # Start skills repository initialization in background. + # Clones/pulls GNS3-Skills during server startup. + # Git network timeouts are configured in SkillsManager (_GIT_TIMEOUT_ENV). + # The daemon thread auto-terminates when the process exits. + from gns3server.agent.gns3_copilot.skills.registry import _ensure_skills_manager + + threading.Thread(target=_ensure_skills_manager, daemon=True).start() + +except ImportError as e: + # AI dependencies not installed, disable AI Copilot feature + logging.warning( + f"AI Copilot dependencies not installed: {e}. " + "AI features will be disabled. Install with: pip install gns3-server[ai-copilot]" + ) + AI_COPILOT_AVAILABLE = False + + # Provide stub functions that raise helpful errors + async def get_project_agent_manager(): + """ + Get the global ProjectAgentManager singleton instance. + + Raises: + RuntimeError: If AI Copilot dependencies are not installed + """ + raise RuntimeError( + "AI Copilot is not available. " + "Install AI dependencies with: pip install gns3-server[ai-copilot]" + ) + + class ProjectAgentManager: + """ + Stub class for ProjectAgentManager when AI dependencies are not installed. + """ + + def __init__(self): + raise RuntimeError( + "AI Copilot is not available. " + "Install AI dependencies with: pip install gns3-server[ai-copilot]" + ) + + +__all__ = [ + "AI_COPILOT_AVAILABLE", + "get_project_agent_manager", + "ProjectAgentManager", +] diff --git a/gns3server/agent/gns3_copilot/__init__.py b/gns3server/agent/gns3_copilot/__init__.py new file mode 100644 index 000000000..682ee1ebf --- /dev/null +++ b/gns3server/agent/gns3_copilot/__init__.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3-Copilot - AI-powered network automation assistant for GNS3. + +This package provides a command-line interface for launching the GNS3-Copilot +Streamlit application with support for Streamlit parameter passthrough. +""" + +# Dynamic version management +__version__: str = "unknown" +try: + from importlib.metadata import version + + __version__ = str(version("gns3-copilot")) +except Exception: + __version__ = "unknown" + +__author__ = "Yue Guobin (岳国宾)" +__description__ = "AI-powered network automation assistant for GNS3" +__url__ = "https://github.com/yueguobin/gns3-copilot" diff --git a/gns3server/agent/gns3_copilot/agent/__init__.py b/gns3server/agent/gns3_copilot/agent/__init__.py new file mode 100644 index 000000000..f72bc8b6e --- /dev/null +++ b/gns3server/agent/gns3_copilot/agent/__init__.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3-Copilot Agent Package + +This package contains the main GNS3-Copilot agent implementation for +network automation tasks using LangGraph workflow orchestration. + +""" + +from .gns3_copilot import agent_builder + +# Dynamic version management +try: + from importlib.metadata import version + + __version__ = version("gns3-copilot") +except Exception: + __version__ = "unknown" + +__author__ = "Yue Guobin (岳国宾)" +__description__ = "AI-powered network automation assistant for GNS3" +__url__ = "https://github.com/yueguobin/gns3-copilot" + +__all__ = [ + "agent_builder", +] diff --git a/gns3server/agent/gns3_copilot/agent/context_manager.py b/gns3server/agent/gns3_copilot/agent/context_manager.py new file mode 100644 index 000000000..e9427c4bc --- /dev/null +++ b/gns3server/agent/gns3_copilot/agent/context_manager.py @@ -0,0 +1,641 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Context Manager for GNS3-Copilot - Using LangGraph pre_model_hook + +This module provides context window management using LangGraph's recommended +pre_model_hook approach with accurate token counting: + +Key Features: +- Accurate token counting using tiktoken (95%+ accuracy) +- Template variable injection for topology info +- Context strategy ratios (conservative/balanced/aggressive) +- Message trimming using LangChain's native trim_messages +- Tool definition token estimation +- Detailed token breakdown logging + +Requirements: +- tiktoken>=0.8.0 (required) +""" + +import json +import logging +import os +import warnings +from pathlib import Path +from typing import Any +from typing import Callable + +# Configure tiktoken cache directory (must be set before importing tiktoken) +_cache_dir = Path(__file__).parent.parent / "cache" / "tiktoken" +_cache_dir.mkdir(parents=True, exist_ok=True) +os.environ["TIKTOKEN_CACHE_DIR"] = str(_cache_dir) + +import tiktoken +from langchain_core.messages import BaseMessage +from langchain_core.messages import SystemMessage +from langchain_core.messages import trim_messages + +logger = logging.getLogger(__name__) + +# ============================================================================ +# Token Counter Setup +# ============================================================================ + +# Initialize tiktoken encoding (required dependency) +import time +logger.debug("Initializing tiktoken encoding (cl100k_base)...") +logger.debug(f"Cache directory: {_cache_dir}") +logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)") +start_time = time.time() +_tiktoken_encoding = tiktoken.get_encoding("cl100k_base") +elapsed = time.time() - start_time +logger.debug(f"✓ tiktoken encoding loaded successfully (took {elapsed:.2f}s)") + +# ============================================================================ +# Constants +# ============================================================================ + +# Context strategy ratios (percentage of context limit to use for input) +CONTEXT_STRATEGY_RATIOS = { + "conservative": 0.60, + "balanced": 0.75, + "aggressive": 0.85, +} + +DEFAULT_CONTEXT_STRATEGY = "balanced" + +# Token unit conversion +TOKENS_PER_K = 1000 + + +# ============================================================================ +# Token Counting Functions +# ============================================================================ + + +def count_tokens(text: str) -> int: + """ + Count tokens in text using tiktoken. + + Uses cl100k_base encoding (GPT-4) which provides accurate counting + for most modern LLMs (OpenAI, Anthropic, DeepSeek, etc.). + + Args: + text: Text to count tokens for + + Returns: + Exact token count (not estimated) + """ + if not text: + return 0 + + return len(_tiktoken_encoding.encode(text)) + + +def estimate_tool_tokens(tools: list[Any]) -> int: + """ + Estimate token consumption for tool definitions. + + Tool definitions are serialized as JSON and sent to LLM with each request. + This function estimates their token cost. + + Args: + tools: List of LangChain tool objects + + Returns: + Estimated token count for all tool definitions + """ + if not tools: + return 0 + + total_tokens = 0 + + for tool in tools: + try: + # Build tool schema in OpenAI format + tool_schema = { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + }, + } + + # Add parameters schema if available + if hasattr(tool, "args_schema") and tool.args_schema: + try: + # Try Pydantic v2 method (model_json_schema) + tool_schema["function"]["parameters"] = ( + tool.args_schema.model_json_schema() + ) + except AttributeError: + # Fallback to Pydantic v1 method (schema) + try: + tool_schema["function"]["parameters"] = ( + tool.args_schema.schema() + ) + except Exception: + # Both methods failed, use empty schema + tool_name = getattr(tool, "name", "unknown") + logger.debug( + "Failed to get schema for tool %s, using empty " + "parameters", + tool_name, + ) + tool_schema["function"]["parameters"] = {} + except Exception as e: + # model_json_schema() raised an exception + tool_name = getattr(tool, "name", "unknown") + logger.debug( + "model_json_schema() failed for tool %s: %s, " + "trying v1 fallback", + tool_name, + e, + ) + try: + tool_schema["function"]["parameters"] = ( + tool.args_schema.schema() + ) + except Exception: + tool_schema["function"]["parameters"] = {} + + # Serialize to JSON and count tokens + schema_str = json.dumps(tool_schema, ensure_ascii=False) + tokens = count_tokens(schema_str) + total_tokens += tokens + + except Exception as e: + tool_name = getattr(tool, "name", "unknown") + logger.debug( + "Failed to estimate tokens for tool %s: %s", + tool_name, + e, + ) + # Rough fallback: 1000 tokens per tool + total_tokens += 1000 + + return total_tokens + + +def _count_tokens_for_message(message: BaseMessage) -> int: + """ + Token counter for LangChain's trim_messages. + + This function is called by trim_messages for each message. + + Args: + message: A single message (HumanMessage, AIMessage, SystemMessage, + etc.) + + Returns: + Estimated token count for the message + """ + content = "" + if hasattr(message, "content") and message.content: + content = str(message.content) + + return count_tokens(content) + + +# ============================================================================ +# Pre-Model Hook Factory +# ============================================================================ + + +def create_pre_model_hook( + system_prompt: str, + get_topology_func: Callable[[dict], Any] | None = None, + get_llm_config_func: Callable[[], dict[str, Any] | None] | None = None, + get_tools_func: Callable[[], list[Any]] | None = None, +) -> Callable[[dict], dict]: + """ + Create a pre_model_hook function for LangGraph agent. + + This hook will be automatically called before each LLM invocation, + handling topology injection, tool token estimation, and message + trimming. + + IMPORTANT USAGE REQUIREMENTS: + This hook MUST be passed via model.invoke() config, NOT used as + a Node return value. + + CORRECT Usage: + model.invoke(messages, + config={"configurable": {"pre_model_hook": pre_hook}}) + + INCORRECT Usage: + # ❌ Don't use as Node return value + def my_node(state): + return pre_hook(state) # Wrong! + + # ❌ Don't use with StateGraph if state uses add_messages reducer + class State(TypedDict): + messages: Annotated[list, add_messages] # Incompatible! + + COMPATIBILITY: + - Works with: model.invoke(config={"configurable": + {"pre_model_hook": ...}}) + - Does NOT work as: StateGraph Node return value + - Does NOT work with: Annotated[list, add_messages] state reducers + + The hook returns a complete message list that overwrites the model + input, which is correct for invoke() but wrong for state updates with + add_messages. + + Args: + system_prompt: System prompt template (must contain + {{topology_info}} placeholder) + get_topology_func: Function to extract topology from state + get_llm_config_func: Function to get LLM config from context + get_tools_func: Optional function to get tools list for token + estimation + + Returns: + A pre_model_hook function for use with model.invoke(config=...) + + Thread Safety: + Thread-safe if get_llm_config_func uses request-scoped context + (e.g., contextvars) + + Performance Notes: + - SystemMessage is reconstructed on each LLM call (intentional + design) + - Overhead: ~1-2ms per call (negligible compared to LLM latency) + - Trade-off: Simplicity > micro-optimization + - In ReAct loops with multiple LLM calls, topology is re-injected + each time + - This is acceptable for GNS3-Copilot's usage patterns (low + concurrency, short conversations) + """ + + def pre_model_hook(state: dict) -> dict: + """ + LangGraph pre_model_hook - called before each LLM invocation. + + This function is NOT a StateGraph Node. It is a preprocessing hook + that modifies the input to the LLM, not the agent state. + + Usage Context: + Called automatically by LangChain when passed via: + model.invoke(messages, + config={"configurable": + {"pre_model_hook": this}}) + + Args: + state: Current agent state containing messages and topology_info + + Returns: + dict with 'messages' key containing prepared and trimmed messages + Note: This return value is used by LangChain to replace the model + input, NOT to update the agent state. + + Raises: + ValueError: If context_limit is missing or invalid + """ + logger.debug( + "pre_model_hook invoked: messages=%d", + len(state.get("messages", [])), + ) + + messages = state.get("messages", []) + if not messages: + logger.debug("No messages in state, returning empty list") + return {"messages": []} + + # Get LLM config + llm_config = get_llm_config_func() if get_llm_config_func else None + + if not llm_config: + logger.error("LLM config not found. context_limit is required.") + raise ValueError( + "LLM config not found. context_limit is required." + ) + + if "context_limit" not in llm_config: + logger.error( + "context_limit not found in LLM config. " + "This is a required field. Please configure context_limit." + ) + raise ValueError("context_limit is required in LLM config") + + limit = llm_config["context_limit"] + if not isinstance(limit, int) or limit <= 0: + logger.error("Invalid context_limit: %s", limit) + raise ValueError(f"Invalid context_limit: {limit}") + + context_limit_k = limit + strategy = llm_config.get("context_strategy", DEFAULT_CONTEXT_STRATEGY) + + if strategy not in CONTEXT_STRATEGY_RATIOS: + logger.warning( + "Invalid context_strategy '%s', using '%s'", + strategy, + DEFAULT_CONTEXT_STRATEGY, + ) + strategy = DEFAULT_CONTEXT_STRATEGY + + # Step 1: Estimate tool tokens + tool_tokens = 0 + if get_tools_func: + try: + tools = get_tools_func() + tool_tokens = estimate_tool_tokens(tools) + logger.debug( + "Tool definitions estimated at ~%d tokens (%d tools)", + tool_tokens, + len(tools), + ) + except Exception as e: + logger.warning("Failed to estimate tool tokens: %s", e) + + # Step 2: Inject topology into system prompt + messages_with_system = _inject_topology_into_system( + messages=messages, + system_prompt=system_prompt, + state=state, + get_topology_func=get_topology_func, + ) + + # Step 3: Calculate token breakdown for logging and validation + system_message = messages_with_system[0] + system_tokens = _count_tokens_for_message(system_message) + + # Calculate tokens for messages_with_system (including system) + messages_with_system_tokens = sum( + _count_tokens_for_message(m) for m in messages_with_system + ) + + # Calculate available budget + model_limit_tokens = context_limit_k * TOKENS_PER_K + strategy_ratio = CONTEXT_STRATEGY_RATIOS[strategy] + max_input_tokens = int(model_limit_tokens * strategy_ratio) + + # Calculate max tokens for trim_messages + # Important: trim_messages counts ALL messages (including system), + # so we only subtract tool_tokens here, NOT system_tokens. + # Let trim_messages handle system. + max_tokens_for_trim = max_input_tokens - tool_tokens + + # Validate budget and provide actionable warnings + if system_tokens + tool_tokens > max_input_tokens: # noqa: E501 + logger.error( + "System prompt (%d tokens) + tools (%d tokens) EXCEED input " + "budget (%d tokens). This will likely cause LLM call failures. " # noqa: E501 + "Recommendations: 1) Reduce system prompt length, 2) Reduce " + "number of tools, 3) Use a model with larger context window, " + "or 4) Switch to 'conservative' strategy.", + system_tokens, + tool_tokens, + max_input_tokens, + ) + elif max_tokens_for_trim < system_tokens * 1.5: + # Less than 1.5x system tokens means very little room for history + logger.warning( + "System prompt (%d tokens) + tools (%d tokens) leave minimal " + "room for conversation history (%d tokens remaining). " + "Consider reducing system prompt length or number of tools.", + system_tokens, + tool_tokens, + max_tokens_for_trim - system_tokens, + ) + + # Debug log with clear terminology + logger.debug( + "Token breakdown: system=%d, all_messages=%d (system+history), " + "tools=%d, trim_budget=%d (limit=%dK, strategy=%s)", + system_tokens, + messages_with_system_tokens, + tool_tokens, + max_tokens_for_trim, + context_limit_k, + strategy, + ) + + # Step 4: Trim messages to fit + # Note: max_tokens_for_trim INCLUDES system message, trim_messages + # will handle it + try: + trimmed = trim_messages( + messages=messages_with_system, + max_tokens=max_tokens_for_trim, + strategy="last", + token_counter=_count_tokens_for_message, + include_system=True, + ) + + # Calculate final token counts + final_total = sum(_count_tokens_for_message(m) for m in trimmed) + usage_percent = ( + (final_total + tool_tokens) / model_limit_tokens * 100 + ) + + if len(trimmed) < len(messages_with_system): + logger.info( + "Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d " + "tools = %d / %dK (%.1f%%), strategy=%s", + len(messages_with_system), + len(trimmed), + final_total, + tool_tokens, + final_total + tool_tokens, + context_limit_k, + usage_percent, + strategy, + ) + else: + logger.info( + "Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK " + "(%.1f%%), strategy=%s", + len(trimmed), + final_total, + tool_tokens, + final_total + tool_tokens, + context_limit_k, + usage_percent, + strategy, + ) + + return {"messages": trimmed} + + except Exception as e: + logger.error("Failed to trim messages: %s", e) + logger.warning("Returning original messages due to trimming error") + return {"messages": messages_with_system} + + return pre_model_hook + + +def _inject_topology_into_system( + messages: list, + system_prompt: str, + state: dict, + get_topology_func: Callable[[dict], Any] | None = None, +) -> list: + """ + Inject topology information into system prompt. + + Args: + messages: Message list + system_prompt: System prompt template + state: Current state + get_topology_func: Optional function to extract topology + + Returns: + Message list with system message prepended + """ + if "{{topology_info}}" not in system_prompt: + logger.warning("system_prompt missing {{topology_info}} placeholder") + return messages + + topology_data = None + if get_topology_func: + try: + topology_data = get_topology_func(state) + except Exception as e: + logger.warning("Failed to get topology: %s", e) + + if topology_data: + topology_str = str(topology_data) + formatted_prompt = system_prompt.replace( + "{{topology_info}}", f"\n\n## Current Topology\n{topology_str}" + ) + logger.info( + "✓ Topology injected: %d chars, nodes: %s", + len(topology_str), + list(topology_data.get("nodes", {}).keys())[:5], + ) # Show first 5 node names + logger.debug( + "Full topology data: %s", topology_str[:500] + ) # First 500 chars + else: + formatted_prompt = system_prompt.replace( + "{{topology_info}}", "(No topology information available)" + ) + logger.warning("✗ Topology data is None, injecting placeholder") + + # Filter out existing SystemMessage instances + non_system_messages = [ + m for m in messages if not isinstance(m, SystemMessage) + ] + + filtered_count = len(messages) - len(non_system_messages) + if filtered_count > 0: + logger.debug( + "Filtered out %d existing SystemMessage(s)", filtered_count + ) + + return [SystemMessage(content=formatted_prompt)] + non_system_messages + + +# ============================================================================ +# Legacy Compatibility (Deprecated) +# ============================================================================ + + +def prepare_context_messages( + state_messages: list[Any], + system_prompt: str, + topology_context: str | None = None, + model_name: str = "gpt-4o", + llm_config: dict[str, Any] | None = None, + tools: list[Any] | None = None, +) -> list[Any]: + """ + **DEPRECATED**: Use create_pre_model_hook() instead. + """ + warnings.warn( + "prepare_context_messages() is deprecated. Use " + "create_pre_model_hook() instead.", + DeprecationWarning, + stacklevel=2, + ) + + if "{{topology_info}}" not in system_prompt: + formatted_prompt = system_prompt + elif topology_context: + formatted_prompt = system_prompt.replace( + "{{topology_info}}", f"\n\n## Current Topology\n{topology_context}" + ) + else: + formatted_prompt = system_prompt.replace( + "{{topology_info}}", "(No topology information available)" + ) + + return [SystemMessage(content=formatted_prompt)] + state_messages + + +# ============================================================================ +# Module Test +# ============================================================================ + +if __name__ == "__main__": + from langchain_core.messages import HumanMessage + + print("=== Context Manager Module Test ===\n") + + # Test token counting + print("Test 1: Token Counting") + print(" tiktoken encoding: cl100k_base") + + test_text = "Hello world 你好世界" + tokens = count_tokens(test_text) + print(f" '{test_text}' → {tokens} tokens") + + # Test hook creation + print("\nTest 2: Create pre_model_hook") + system_prompt = "You are GNS3 Copilot.\n\n{{topology_info}}" + + def mock_get_topology(state): + return {"project_id": "test", "nodes": 5} + + def mock_get_config(): + return {"context_limit": 8, "context_strategy": "conservative"} + + def mock_get_tools(): + return [] # No tools for test + + hook = create_pre_model_hook( + system_prompt=system_prompt, + get_topology_func=mock_get_topology, + get_llm_config_func=mock_get_config, + get_tools_func=mock_get_tools, + ) + print("✓ pre_model_hook created successfully") + + # Test invocation + print("\nTest 3: Invoke pre_model_hook") + test_state = { + "messages": [ + HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5) + ], + "topology_info": {"project_id": "test123", "nodes": 3}, + } + + result = hook(test_state) + print(f"✓ Hook invoked: {len(result['messages'])} messages returned") + + print("\n=== All Tests Passed ===") diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py new file mode 100644 index 000000000..a675d2074 --- /dev/null +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -0,0 +1,713 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +# mypy: ignore-errors + +""" +GNS3 Network Automation Assistant - LangGraph Agent + +This module implements the core LangGraph agent workflow for GNS3-Copilot, +an AI-powered assistant for GNS3 network automation and management. + +The agent provides: +- LangGraph-based state management and workflow +- Mode-aware tool orchestration for GNS3 operations +- Context-aware conversation handling +- Automatic conversation title generation +- Integration with GNS3 topology management + +Copilot Modes: +- "teaching_assistant" (default): Diagnostic tools only, no configuration + changes +- "lab_automation_assistant": Full diagnostic and configuration tools + +""" + +# Standard library imports +import json +import logging +import operator +from datetime import datetime +from typing import Annotated +from typing import Literal + +# Third-party imports +from langchain.messages import AnyMessage +from langchain.messages import SystemMessage +from langchain.messages import ToolMessage +from langchain_core.runnables import RunnableConfig +from langgraph.graph import END +from langgraph.graph import START +from langgraph.graph import StateGraph +from langgraph.managed.is_last_step import RemainingSteps +from typing_extensions import TypedDict + +# Local imports +from gns3server.agent.gns3_copilot.agent.context_manager import ( + create_pre_model_hook, +) +from gns3server.agent.gns3_copilot.agent.model_factory import ( + create_base_model_with_tools, +) +from gns3server.agent.gns3_copilot.agent.model_factory import ( + create_title_model, +) +from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool +from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + get_current_llm_config, +) +from gns3server.agent.gns3_copilot.prompts import load_system_prompt +from gns3server.agent.gns3_copilot.skills.registry import get_prompt +from gns3server.agent.gns3_copilot.tools_v2 import ( + ExecuteMultipleDeviceCommands, +) +from gns3server.agent.gns3_copilot.tools_v2 import ( + ExecuteMultipleDeviceConfigCommands, +) +from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool +from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands +from gns3server.agent.gns3_copilot.tools_v2 import PacketAnalysisTool +from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool +from gns3server.agent.gns3_copilot.skills import InjectionSkillsTool +from gns3server.agent.gns3_copilot.skills import PacketAnalysisSkillsTool + +# Set up logger for GNS3-Copilot +logger = logging.getLogger(__name__) + +# Note: LLM model configuration is now managed by the new llm_model_configs +# system. The model_factory module handles model creation with configuration +# from the database. + +# Define tools for different copilot modes +# Teaching assistant mode: READ-ONLY diagnostic tools only +TEACHING_ASSISTANT_MODE_TOOLS = [ + GNS3TemplateTool(), # Get GNS3 node templates + GNS3CreateNodeTool(), # Create new nodes in GNS3 + GNS3LinkTool(), # Create links between nodes + GNS3StartNodeTool(), # Start GNS3 nodes + GNS3UpdateNodeNameTool(), # Update node name + ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands + # (READ-ONLY) + PacketAnalysisTool(), # Protocol-oriented packet analysis with tshark + PacketAnalysisSkillsTool(), # Query packet analysis protocol definitions + DeviceSkillsTool(), # Get device-specific skills and command knowledge +] + +# Lab automation assistant mode: Full diagnostic AND configuration tools +LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [ + GNS3TemplateTool(), # Get GNS3 node templates + GNS3CreateNodeTool(), # Create new nodes in GNS3 + GNS3LinkTool(), # Create links between nodes + GNS3StartNodeTool(), # Start GNS3 nodes + GNS3StopNodeTool(), # Stop GNS3 nodes + GNS3SuspendNodeTool(), # Suspend GNS3 nodes (preserve state) + GNS3UpdateNodeNameTool(), # Update node name + ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands + # (READ-ONLY) + ExecuteMultipleDeviceConfigCommands(), # Execute configuration commands + VPCSCommands(), # Execute VPCS commands using Netmiko + PacketAnalysisTool(), # Protocol-oriented packet analysis with tshark + PacketAnalysisSkillsTool(), # Query packet analysis protocol definitions + DeviceSkillsTool(), # Get device-specific skills and command knowledge +] + +# Troubleshooting injection mode: Tools for injecting network faults +# Focused on configuration commands and injection skills for fault injection +TROUBLESHOOTING_INJECTION_MODE_TOOLS = [ + ExecuteMultipleDeviceCommands(), # Get device configurations (READ-ONLY) + ExecuteMultipleDeviceConfigCommands(), # Inject configuration changes + InjectionSkillsTool(), # Query injection skills and fault types + GNS3TopologyTool(), # Get topology information +] + +# Default tools (legacy support - will be overridden by mode-specific tools) +tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS + +# Create combined tool lookup for tool_node (supports all modes) +# tool_node will receive tool calls based on mode-specific tools bound to the +# model +# Combine all tools from all modes and deduplicate by tool name +ALL_TOOLS = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS +tools_by_name = {tool.name: tool for tool in ALL_TOOLS} + +# Add troubleshooting injection tools to the global tool registry +for tool in TROUBLESHOOTING_INJECTION_MODE_TOOLS: + if tool.name not in tools_by_name: + tools_by_name[tool.name] = tool + +# Log application startup +logger.info("GNS3-Copilot application starting up") + +# Constants for conversation title management +DEFAULT_CONVERSATION_TITLE = "New Conversation" +UNTITLED_SESSION_FALLBACK = "Untitled Session" +TITLE_MAX_LENGTH = 40 + +# Abort flags storage for session-level abort tracking +# This is checked by conditional edge functions to stop the graph +_abort_flags: dict[str, bool] = {} + + +def check_abort_flag(session_id: str) -> bool: + """ + Check if abort flag is set for a session. + + Args: + session_id: Session identifier + + Returns: + True if abort is requested, False otherwise + """ + return _abort_flags.get(session_id, False) + + +def set_abort_flag(session_id: str): + """ + Set abort flag for a session. + + Args: + session_id: Session identifier + """ + _abort_flags[session_id] = True + logger.debug("Abort flag set for session: %s", session_id) + + +def clear_abort_flag(session_id: str): + """ + Clear abort flag for a session. + + Args: + session_id: Session identifier + """ + _abort_flags[session_id] = False + logger.debug("Abort flag cleared for session: %s", session_id) + + +# Define state +class MessagesState(TypedDict): + """ + GNS3-Copilot conversation state management class. + + Maintains the conversation state for the LangGraph workflow, including + message history, call counters, and session titles for comprehensive + dialogue management. + + Attributes: + messages: List of conversation messages with cumulative updates using + operator.add + llm_calls: Counter for tracking the number of LLM invocations + remaining_steps: Is automatically managed by LangGraph's RemainingSteps + to track and limit recursion depth. + conversation_title: Optional conversation title for session + identification and management + topology_info: Dictionary containing GNS3 project topology information + session_id: Session identifier for abort tracking + abort: Flag to signal abort request + """ + + messages: Annotated[list[AnyMessage], operator.add] + + llm_calls: int + + remaining_steps: RemainingSteps + + # Optional conversation title + conversation_title: str | None + + # Store GNS3 topology information + topology_info: dict | None + + # Session identifier for abort tracking + session_id: str | None + + # Abort flag to signal stop request + abort: bool + + +# Define llm call node +def llm_call(state: dict, config: RunnableConfig | None = None): + """ + LLM decides whether to call a tool or not. + + Uses pre_model_hook pattern for automatic topology injection and + message trimming, ensuring separation of concerns and complete + history preservation in state["messages"]. + """ + + logger.debug("LLM call node invoked") + + # Get llm_config from request-scoped context variable + llm_config = get_current_llm_config() + + if not llm_config: + logger.error("LLM config not found in context") + return { + "messages": [], + "llm_calls": state.get("llm_calls", 0), + "topology_info": None, + } + + logger.debug( + "LLM config retrieved from context: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), + ) + + # Defensive check: skip LLM call if no user messages + messages = state.get("messages", []) + if not messages or len(messages) == 0: + logger.warning("No messages in state, skipping LLM call") + return { + "messages": [], + "llm_calls": state.get("llm_calls", 0), + "topology_info": None, + } + + # Get project_id from config configurable (set when starting the chat) + project_id = None + topology_info = None + if config and config.get("configurable"): + project_id = config["configurable"].get("project_id") + + # Retrieve topology information if available + if project_id: + try: + topology_tool = GNS3TopologyTool() + topology = topology_tool._run(project_id=project_id) + + if topology and "error" not in topology: + topology_info = topology + logger.info( + "Successfully retrieved topology for project_id: %s, " + "name: %s", + project_id, + topology.get("name"), + ) + else: + logger.warning( + "Failed to retrieve topology for project_id %s: %s", + project_id, + topology.get("error", "Unknown error") + if topology + else "No result", + ) + except Exception as e: + logger.warning( + "Error retrieving topology for project_id %s: %s", + project_id, + e, + ) + + # Store topology_info in state for pre_model_hook to access + state["topology_info"] = topology_info + + # Select tools based on copilot_mode + copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() + if copilot_mode == "lab_automation_assistant": + mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS + logger.info( + "Using LAB_AUTOMATION_ASSISTANT mode tools (includes " + "configuration tools)" + ) + elif copilot_mode == "troubleshooting_injection": + mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS + logger.info( + "Using TROUBLESHOOTING_INJECTION mode tools (fault injection)" + ) + else: # teaching_assistant mode (default) + mode_tools = TEACHING_ASSISTANT_MODE_TOOLS + logger.info( + "Using TEACHING_ASSISTANT mode tools (diagnostic tools only)" + ) + + # Create pre_model_hook for automatic topology injection and trimming + # Load system prompt based on copilot_mode configuration + system_prompt = load_system_prompt(llm_config) + pre_hook = create_pre_model_hook( + system_prompt=system_prompt, + get_topology_func=lambda s: s.get("topology_info"), + get_llm_config_func=get_current_llm_config, + get_tools_func=lambda: mode_tools, # Pass mode-specific tools for + # token estimation + ) + + # Create fresh model with tools for each LLM call + logger.debug( + "Creating model with tools: provider=%s, model=%s, mode=%s, tools=%d", + llm_config.get("provider"), + llm_config.get("model"), + copilot_mode, + len(mode_tools), + ) + model_with_tools = create_base_model_with_tools( + mode_tools, llm_config=llm_config + ) + + # Call pre_hook directly to prepare messages (topology injection + + # trimming) + # Note: LangGraph's pre_model_hook only works with prebuilt agents, not + # custom StateGraph + logger.debug("Calling pre_hook to prepare %d messages", len(messages)) + prepared_state = pre_hook( + {"messages": messages, "topology_info": topology_info} + ) + prepared_messages = prepared_state["messages"] + logger.info( + "Messages prepared: %d → %d", len(messages), len(prepared_messages) + ) + + # Invoke model with prepared messages + response = model_with_tools.invoke(prepared_messages) + + # Add metadata with created_at timestamp to AI response + if hasattr(response, "metadata"): + existing_metadata = response.metadata or {} + response.metadata = { + **existing_metadata, + "created_at": datetime.utcnow().isoformat(), + } + else: + # LangChain messages should have metadata attribute, but defensive + # fallback + try: + response.metadata = {"created_at": datetime.utcnow().isoformat()} + except Exception: + logger.warning("Could not add metadata to AI response") + + logger.info( + "LLM call completed: tool_calls=%d", + len(response.tool_calls) if hasattr(response, "tool_calls") else 0, + ) + + return { + "messages": [response], + "llm_calls": state.get("llm_calls", 0) + 1, + "topology_info": topology_info, + "session_id": state.get("session_id"), + } + + +# Define generate title node +def generate_title( + state: MessagesState, config: RunnableConfig | None = None +) -> dict: + """ + Generate a conversation title using a lightweight assistant LLM + (title_model). This node is only executed when no title has been set yet + (first round only). + """ + + # Get llm_config from request-scoped context variable + llm_config = get_current_llm_config() + + if not llm_config: + logger.error("LLM config not found in context, cannot generate title") + return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")} + + # Only generate a title if it hasn't been set yet + current_title = state.get("conversation_title") + if current_title in [None, "New Conversation"]: + logger.debug("Title generation triggered for session") + messages = state["messages"] + + # Load title prompt from external repository + title_prompt = get_prompt("title") + if not title_prompt: + logger.error("Title prompt not found in external repository") + return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")} + + # Build the prompt for title generation + title_prompt_messages = [ + SystemMessage(content=title_prompt), + messages[0], # User's first message + messages[-1], # Assistant's final response in this turn + ] + + # Call the title generation model (create fresh instance for each call) + try: + title_model = create_title_model(llm_config=llm_config) + response = title_model.invoke( + title_prompt_messages, + config={"configurable": {"foo_temperature": 1.0}}, + ) + raw_content = response.content + + new_title = raw_content.strip() + + # Validate the generated title + if not new_title or len(new_title) < 3: + raise ValueError( + f"Generated title too short or empty: '{new_title}'" + ) + + if new_title in [ + "New Conversation", + "Untitled Session", + "GNS3 Session", + ]: + raise ValueError( + f"Generated title is a default value: '{new_title}'" + ) + + # Safety: truncate long titles and avoid line breaks + if len(new_title) > TITLE_MAX_LENGTH: + new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..." + + # Remove unwanted characters + new_title = ( + new_title.replace("\n", " ").replace('"', "").replace("'", "") + ) + + logger.debug("Generated new title: %s", new_title) + return {"conversation_title": new_title, "session_id": state.get("session_id")} + + except Exception as e: + logger.error(f"Title generation failed: {e}, using fallback") + + # Improved fallback: Use user's first message content + if messages and len(messages) > 0: + first_message = messages[0] + if hasattr(first_message, "content"): + fallback_title = first_message.content[:30].strip() + # Remove newlines and extra spaces + fallback_title = fallback_title.replace("\n", " ").strip() + # Collapse multiple spaces + while " " in fallback_title: + fallback_title = fallback_title.replace(" ", " ") + + # Truncate if needed + if len(fallback_title) > 28: + fallback_title = fallback_title[:28] + ".." + + if fallback_title: + logger.info( + "Using fallback title from user message: '%s'", + fallback_title, + ) + return {"conversation_title": fallback_title, "session_id": state.get("session_id")} + + # Final fallback + logger.info( + "Using final fallback title: '%s'", + UNTITLED_SESSION_FALLBACK, + ) + return {"conversation_title": UNTITLED_SESSION_FALLBACK, "session_id": state.get("session_id")} + + # Title already exists → no update needed + return {} + + +# Define tool node +def tool_node(state: dict, config: RunnableConfig | None = None): + """Performs the tool call""" + + tool_calls = state["messages"][-1].tool_calls + logger.debug("Tool node invoked: tool_calls=%d", len(tool_calls)) + + result = [] + for tool_call in tool_calls: + tool_name = tool_call["name"] + logger.debug( + "Executing tool: %s with args: %s", tool_name, tool_call["args"] + ) + tool = tools_by_name[tool_name] + try: + observation = tool.invoke(tool_call["args"]) + logger.debug( + "Tool %s completed: output_length=%d", + tool_name, + len(str(observation)) if observation else 0, + ) + except Exception as e: + logger.error("Tool %s failed: %s", tool_name, e, exc_info=True) + observation = f"Error: {str(e)}" + + # Serialize observation to JSON string if it's not already a string + # This ensures ToolMessage.content is always JSON format, not Python + # str() + if not isinstance(observation, str): + observation = json.dumps(observation, ensure_ascii=False, indent=2) + + # Create ToolMessage with metadata including created_at + tool_msg = ToolMessage( + content=observation, + tool_call_id=tool_call["id"], + name=tool_call["name"], + metadata={"created_at": datetime.utcnow().isoformat()}, + ) + result.append(tool_msg) + + return {"messages": result, "session_id": state.get("session_id")} + + +# Abort handler node - provides tool results when aborting +def abort_handler_node(state: dict) -> dict: + """ + Handles abort by providing tool result messages for pending tool_calls. + This ensures message history consistency and prevents checkpoint corruption. + """ + messages = state.get("messages", []) + if not messages: + return {"session_id": state.get("session_id")} + + last_message = messages[-1] + if not hasattr(last_message, "tool_calls") or not last_message.tool_calls: + return {"session_id": state.get("session_id")} + + result = [] + for tool_call in last_message.tool_calls: + tool_msg = ToolMessage( + content=json.dumps({ + "status": "aborted", + "message": "Tool execution was aborted by user", + "tool_call_id": tool_call["id"], + }), + tool_call_id=tool_call["id"], + name=tool_call["name"], + metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True}, + ) + result.append(tool_msg) + + logger.info("Abort handler: generated %d aborted tool messages", len(result)) + return {"messages": result, "session_id": state.get("session_id")} + + +# Routing logic after the LLM node +def should_continue( + state: MessagesState, +) -> Literal["tool_node", "title_generator_node", "abort_handler_node", END]: + """ + Determine the next step after the LLM has produced a response. + + - If abort flag is set → end the conversation + - If the LLM requested any tool calls → route to tool_node + - If this is the first complete turn (llm_calls == 1) and no title + exists → generate a title + - Otherwise → conversation is complete, go to END + """ + # Check abort flag first + session_id = state.get("session_id") + if session_id and check_abort_flag(session_id): + last_message = state["messages"][-1] + # If there's a tool_calls message, we need to handle it properly + if hasattr(last_message, "tool_calls") and last_message.tool_calls: + return "abort_handler_node" + return END + + last_message = state["messages"][-1] + current_title = state.get("conversation_title") + + # LLM requested one or more tool executions + if last_message.tool_calls: + return "tool_node" + + # First full interaction completed and title not yet generated + if current_title in [None, "New Conversation"]: + return "title_generator_node" + + # Normal completion (multi-turn conversation or title already exists) + return END + + +# Routing logic after the tool node, Check remaining_steps +def recursion_limit_continue(state: MessagesState) -> Literal["llm_call", END]: + """ + Routing logic after tool execution to prevent infinite recursion. + + Determines whether to continue with another LLM call or end the + conversation based on remaining steps and message type. + + Args: + state: Current conversation state with messages and remaining steps + + Returns: + "llm_call" to continue processing, END to terminate conversation + + Logic: + - If abort flag is set → end the conversation + - If the last message is ToolMessage and steps >= 4: continue to LLM + - Otherwise: end the conversation to prevent infinite loops + """ + # Check abort flag first + session_id = state.get("session_id") + if session_id and check_abort_flag(session_id): + return END + + last_message = state["messages"][-1] + if isinstance(last_message, ToolMessage): + if state["remaining_steps"] < 4: + return END + return "llm_call" + + return END + + +# Build and compile the agent +# Build workflow +agent_builder = StateGraph(MessagesState) + +# Add nodes +agent_builder.add_node("llm_call", llm_call) +agent_builder.add_node("tool_node", tool_node) +agent_builder.add_node("title_generator_node", generate_title) +agent_builder.add_node("abort_handler_node", abort_handler_node) + +# Add edges to connect nodes +agent_builder.add_edge(START, "llm_call") +# Conditional routing after LLM response +# Determines the next step based on whether LLM needs to call tools or +# generate title +agent_builder.add_conditional_edges( + "llm_call", + should_continue, + { + "tool_node": "tool_node", # Route to tool execution if LLM requested + # tools + "title_generator_node": "title_generator_node", # Generate title on + # first interaction + "abort_handler_node": "abort_handler_node", # Handle abort with tool calls + END: END, # End conversation if no tools needed + }, +) +# Conditional routing after tool execution +# Prevents infinite recursion by checking remaining steps before continuing +agent_builder.add_conditional_edges( + "tool_node", + recursion_limit_continue, + { + "llm_call": "llm_call", # Continue to LLM if tools executed and steps + # remain + END: END, # End conversation to prevent infinite loops + }, +) + +agent_builder.add_edge("title_generator_node", END) +agent_builder.add_edge("abort_handler_node", END) diff --git a/gns3server/agent/gns3_copilot/agent/model_factory.py b/gns3server/agent/gns3_copilot/agent/model_factory.py new file mode 100644 index 000000000..86a087654 --- /dev/null +++ b/gns3server/agent/gns3_copilot/agent/model_factory.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Model Factory for GNS3-Copilot Agent + +This module provides factory functions to create fresh LLM model instances. +Configuration is passed directly from the database. + +""" + +import logging +from typing import Any +from typing import Optional + +from langchain.chat_models import init_chat_model + +logger = logging.getLogger(__name__) + + +def _load_llm_config( + llm_config: Optional[dict[str, Any]] = None, +) -> dict[str, str]: + """ + Convert llm_config dict to model factory format. + + Args: + llm_config: Configuration dictionary from database + + Returns: + Dictionary containing model configuration. + + Raises: + ValueError: If configuration is missing or invalid. + """ + if not llm_config: + raise ValueError("LLM configuration is required") + + logger.info( + "Using LLM config: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), + ) + + return { + "model_name": llm_config.get("model", ""), + "model_provider": llm_config.get("provider", ""), + "api_key": llm_config.get("api_key", ""), + "base_url": llm_config.get("base_url", ""), + "temperature": str(llm_config.get("temperature", "0")), + } + + +def create_base_model( + llm_config: Optional[dict[str, Any]] = None, +) -> Any: + """ + Create a fresh base LLM model instance. + + Args: + llm_config: Configuration dictionary from database + + Returns: + Any: A new LLM model instance configured with current settings. + The actual type depends on the provider (e.g., ChatOpenAI, etc.). + + Raises: + ValueError: If required configuration fields are missing or invalid. + RuntimeError: If model creation fails. + """ + config_vars = _load_llm_config(llm_config) + + # Log the loaded configuration (mask sensitive data) + logger.info( + "Creating base model: name=%s, provider=%s, base_url=%s, " + "temperature=%s", + config_vars["model_name"], + config_vars["model_provider"], + config_vars["base_url"] if config_vars["base_url"] else "default", + config_vars["temperature"], + ) + + # Validate required fields + if not config_vars["model_name"]: + raise ValueError("LLM configuration requires 'model' field") + + if not config_vars["model_provider"]: + raise ValueError("LLM configuration requires 'provider' field") + + try: + # Prepare parameters for init_chat_model + init_params = { + "model": config_vars["model_name"], + "model_provider": config_vars["model_provider"], + "api_key": config_vars["api_key"], + "base_url": config_vars["base_url"], + "temperature": config_vars["temperature"], + "configurable_fields": "any", + "config_prefix": "foo", + } + + # Disable DeepSeek thinking mode to avoid reasoning_content handling issues + # DeepSeek models enable thinking mode by default, which returns reasoning_content + # that must be passed back to the API in subsequent requests. To simplify + # message handling and avoid 400 errors, we disable it here. + if config_vars["model_provider"] == "deepseek": + init_params["extra_body"] = {"thinking": {"type": "disabled"}} + logger.debug("DeepSeek thinking mode disabled") + + model = init_chat_model(**init_params) + + logger.debug("Base model created successfully") + return model + + except Exception as e: + logger.error("Failed to create base model: %s", e) + raise RuntimeError(f"Failed to create base model: {e}") from e + + +def create_title_model( + llm_config: Optional[dict[str, Any]] = None, +) -> Any: + """ + Create a fresh title generation model instance. + + This creates a model instance suitable for generating conversation titles. + It uses the same configuration as the base model but with a higher + temperature for more creative output. + + Args: + llm_config: Configuration dictionary from database + + Returns: + Any: A new LLM model instance for title generation. + The actual type depends on the provider. + + Raises: + ValueError: If required configuration fields are missing or invalid. + RuntimeError: If model creation fails. + """ + config_vars = _load_llm_config(llm_config) + + logger.info( + "Creating title model: name=%s, provider=%s, base_url=%s, " + "temperature=1.0", + config_vars["model_name"], + config_vars["model_provider"], + config_vars["base_url"] if config_vars["base_url"] else "default", + ) + + # Validate required fields + if not config_vars["model_name"]: + raise ValueError("LLM configuration requires 'model' field") + + if not config_vars["model_provider"]: + raise ValueError("LLM configuration requires 'provider' field") + + try: + # Prepare parameters for init_chat_model + init_params = { + "model": config_vars["model_name"], + "model_provider": config_vars["model_provider"], + "api_key": config_vars["api_key"], + "base_url": config_vars["base_url"], + "temperature": "1.0", # Higher temperature for more creative titles + "configurable_fields": "any", + "config_prefix": "foo", + } + + # Disable DeepSeek thinking mode to avoid reasoning_content handling issues + # DeepSeek models enable thinking mode by default, which returns reasoning_content + # that must be passed back to the API in subsequent requests. To simplify + # message handling and avoid 400 errors, we disable it here. + if config_vars["model_provider"] == "deepseek": + init_params["extra_body"] = {"thinking": {"type": "disabled"}} + logger.debug("DeepSeek thinking mode disabled for title model") + + model = init_chat_model(**init_params) + + logger.debug("Title model created successfully") + return model + + except Exception as e: + logger.error("Failed to create title model: %s", e) + raise RuntimeError(f"Failed to create title model: {e}") from e + + +def create_model_with_tools( + model: Any, + tools: list[Any], +) -> Any: + """ + Bind tools to a model instance. + + Args: + model: The base model instance. + tools: List of tools to bind to the model. + + Returns: + Any: A model instance with tools bound (type varies by provider). + + Raises: + RuntimeError: If tool binding fails. + """ + try: + model_with_tools = model.bind_tools(tools) + logger.debug("Model bound with %d tools successfully", len(tools)) + return model_with_tools + except Exception as e: + logger.error("Failed to bind tools to model: %s", e) + raise RuntimeError(f"Failed to bind tools to model: {e}") from e + + +def create_base_model_with_tools( + tools: list[Any], + llm_config: Optional[dict[str, Any]] = None, +) -> Any: + """ + Create a fresh base model instance with tools bound. + + This is a convenience function that combines creating the base model + and binding tools to it. + + Args: + tools: List of tools to bind to the model. + llm_config: Configuration dictionary from database + + Returns: + Any: A new model instance with tools bound (type varies by provider). + + Raises: + ValueError: If required configuration fields are missing. + RuntimeError: If model creation or tool binding fails. + """ + base_model = create_base_model(llm_config) + return create_model_with_tools(base_model, tools) diff --git a/gns3server/agent/gns3_copilot/agent_service.py b/gns3server/agent/gns3_copilot/agent_service.py new file mode 100644 index 000000000..627ea2f69 --- /dev/null +++ b/gns3server/agent/gns3_copilot/agent_service.py @@ -0,0 +1,782 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 Copilot Agent Service + +Provides project-level Agent instances with SQLite checkpoint management. +Each project has its own AgentService with a dedicated checkpoint database +in the project directory. +""" + +import asyncio +import json +import logging +import os +from datetime import datetime +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Optional +from uuid import uuid4 + +import aiosqlite +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + +from gns3server.agent.gns3_copilot.agent.gns3_copilot import agent_builder +from gns3server.agent.gns3_copilot.chat_sessions_repository import ( + ChatSessionsRepository, +) +from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + set_current_jwt_token, +) +from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + set_current_llm_config, +) +from gns3server.agent.gns3_copilot.utils.error_handler import format_error_message +from gns3server.agent.gns3_copilot.utils.message_converters import ( + convert_langchain_to_openai, +) +from gns3server.agent.gns3_copilot.utils.tool_call_stream import ( + ToolCallStreamAccumulator, +) + +log = logging.getLogger(__name__) + + +class AgentService: + """ + Project-level Agent Service with async checkpoint management. + + Manages a LangGraph agent instance with SQLite-based state persistence + for a single GNS3 project. + """ + + def __init__(self, project_path: str): + """ + Initialize AgentService for a project. + + Args: + project_path: Path to the GNS3 project directory + """ + self.project_path = project_path + self._checkpointer: Optional[AsyncSqliteSaver] = None + self._checkpointer_conn: Optional[aiosqlite.Connection] = None + self._checkpointer_path: Optional[str] = None + self._graph = None + self._init_lock = asyncio.Lock() + self._initialized = False + + def abort_session(self, session_id: str): + """ + Signal abort for a session. + + Args: + session_id: Session identifier + """ + from gns3server.agent.gns3_copilot.agent.gns3_copilot import ( + set_abort_flag, + ) + + set_abort_flag(session_id) + log.info("Abort signal set for session: %s", session_id) + + def _get_checkpoint_dir(self) -> str: + """Get or create the checkpoint directory for this project.""" + checkpoint_dir = os.path.join(self.project_path, "gns3-copilot") + os.makedirs(checkpoint_dir, exist_ok=True) + return checkpoint_dir + + async def _get_checkpointer(self) -> AsyncSqliteSaver: + """ + Get or create the SQLite checkpointer for this project. + + Returns: + AsyncSqliteSaver instance + """ + async with self._init_lock: + if self._checkpointer is not None: + return self._checkpointer + + checkpoint_dir = self._get_checkpoint_dir() + checkpointer_path = os.path.join( + checkpoint_dir, "copilot_checkpoints.db" + ) + + log.debug("Creating checkpointer at: %s", checkpointer_path) + + # Close existing connection if switching projects + if self._checkpointer_conn: + try: + await self._checkpointer_conn.close() + log.debug("Closed previous checkpointer connection") + except Exception as e: + log.warning( + "Error closing old checkpointer connection: %s", e + ) + + # Create new connection + conn = await aiosqlite.connect(checkpointer_path) + # Enable WAL mode for better concurrent performance + await conn.execute("PRAGMA journal_mode=WAL;") + self._checkpointer_conn = ( + conn # Save connection reference to prevent GC + ) + self._checkpointer = AsyncSqliteSaver(conn) + + # CRITICAL: Initialize database schema + await self._checkpointer.setup() + + # Create chat_sessions table in the same database + await self._create_chat_sessions_table(conn) + + self._checkpointer_path = checkpointer_path + self._initialized = True + + log.info("Project checkpointer created at: %s", checkpointer_path) + return self._checkpointer + + async def _create_chat_sessions_table(self, conn: aiosqlite.Connection): + """ + Create the chat_sessions table in the checkpoint database. + + Args: + conn: aiosqlite connection + """ + await conn.execute(""" + CREATE TABLE IF NOT EXISTS chat_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL, + title TEXT DEFAULT 'New Conversation', + + -- Statistics + message_count INTEGER DEFAULT 0, + llm_calls_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + + -- Timestamps + last_message_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Reserved fields (JSON strings) + metadata TEXT DEFAULT '{}', + stats TEXT DEFAULT '{}', + + -- Pin feature + pinned BOOLEAN DEFAULT FALSE + ) + """) + + # Create indexes + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_thread_id ON " + "chat_sessions(thread_id)" + ) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_user_project ON " + "chat_sessions(user_id, project_id)" + ) + + # Check if pinned column exists, add it if not (migration for existing + # databases) + cursor = await conn.execute("PRAGMA table_info(chat_sessions)") + columns = await cursor.fetchall() + column_names = [col[1] for col in columns] + + if "pinned" not in column_names: + log.debug("Adding pinned column to existing chat_sessions table") + await conn.execute( + "ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT " + "FALSE" + ) + await conn.commit() + + # Create pinned index (after column is guaranteed to exist) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_pinned_updated ON " + "chat_sessions(pinned DESC, updated_at DESC)" + ) + + await conn.commit() + log.debug("chat_sessions table created in checkpoint database") + + async def _get_graph(self): + """Get or compile the LangGraph agent.""" + if self._graph is None: + checkpointer = await self._get_checkpointer() + self._graph = agent_builder.compile(checkpointer=checkpointer) + log.info( + "LangGraph agent compiled for project: %s", self.project_path + ) + return self._graph + + async def stream_chat( + self, + message: str, + session_id: str, + project_id: Optional[str] = None, + user_id: Optional[str] = None, + jwt_token: Optional[str] = None, + mode: str = "text", + llm_config: Optional[Dict[str, Any]] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """ + Stream chat responses from the agent. + + Args: + message: User message + session_id: Session/thread ID for conversation continuity + project_id: GNS3 project ID (optional, for context) + user_id: User ID for metadata tracking + jwt_token: JWT token for API authentication (optional) + mode: Interaction mode (default: "text") + llm_config: LLM configuration dict (provider, model, api_key, + etc.) + + Yields: + Dict containing SSE-compatible response chunks + """ + log.info( + "Stream chat started: project_id=%s, user_id=%s, session_id=%s, " + "mode=%s", + project_id, + user_id, + session_id, + mode, + ) + + # Ensure checkpointer is initialized + if not self._checkpointer_conn: + await self._get_checkpointer() + + # Get or create chat session + repo = ChatSessionsRepository(self._checkpointer_conn) + session = await repo.get_session_by_thread(session_id) + is_new_session = session is None + + if is_new_session: + # Create new session + copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() + session = await repo.create_session( + thread_id=session_id, + user_id=user_id or "", + project_id=project_id or "", + title="New Conversation", + copilot_mode=copilot_mode, + ) + log.debug( + "Created new chat session: thread_id=%s, copilot_mode=%s", + session_id, + copilot_mode + ) + + # Set request-scoped context variables (memory-only, not persisted) + if jwt_token: + set_current_jwt_token(jwt_token) + log.debug("JWT token set in context") + if llm_config: + set_current_llm_config(llm_config) + log.debug( + "LLM config set in context: provider=%s, model=%s", + llm_config.get("provider"), + llm_config.get("model"), + ) + + # Build config - only thread-safe identifiers + # Determine recursion_limit based on copilot_mode + copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower() + if copilot_mode == "troubleshooting_injection": + recursion_limit = 100 # Need more recursion depth for fault injection workflow + log.debug("Using extended recursion_limit for troubleshooting_injection mode: 100") + else: + recursion_limit = 25 # Default recursion limit + + config = { + "configurable": { + "thread_id": session_id, + "project_id": project_id, + }, + "metadata": { + "user_id": user_id, + "copilot_mode": copilot_mode, # Store mode in checkpoint metadata + }, + "recursion_limit": recursion_limit, + } + + # Build inputs + # Determine remaining_steps based on copilot_mode + # Troubleshooting injection mode needs more steps for multi-tool workflow + if copilot_mode == "troubleshooting_injection": + remaining_steps = 50 # Need more steps for fault injection workflow + log.debug("Using extended remaining_steps for troubleshooting_injection mode: 50") + else: + remaining_steps = 20 # Default for other modes + + inputs = { + "messages": [ + HumanMessage( + content=message, + id=str(uuid4()), + metadata={"created_at": datetime.utcnow().isoformat()}, + ) + ], + "llm_calls": 0, + "remaining_steps": remaining_steps, + "session_id": session_id, + "abort": False, + } + + # Clear abort flag for this session at the start of stream + from gns3server.agent.gns3_copilot.agent.gns3_copilot import ( + clear_abort_flag, + ) + + clear_abort_flag(session_id) + log.debug("Abort flag cleared for session: %s", session_id) + + # Get the compiled graph + graph = await self._get_graph() + log.debug("LangGraph graph obtained, starting stream") + + # Track statistics for session update + message_count = 1 # User message + llm_calls_count = 0 + input_tokens = 0 + output_tokens = 0 + last_message_at = datetime.utcnow().isoformat() + + # Track if we've counted the AI response for this turn + ai_response_counted = False + tool_messages_counted = 0 + + # Initialize tool call stream accumulator for handling progressive + # tool call arguments + tool_call_accumulator = ToolCallStreamAccumulator() + + # Track if stream was aborted + stream_aborted = False + + # Stream events + try: + async for event in graph.astream_events( + inputs, config=config, version="v2" + ): + event_type = event.get("event", "") + data = event.get("data", {}) + + # Track LLM calls and tokens + if event_type == "on_chat_model_start": + # Filter out title_generator_node from statistics + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) + if langgraph_node != "title_generator_node": + llm_calls_count += 1 + log.debug( + "LLM call started, count=%d", llm_calls_count + ) + else: + log.debug( + "Skipping LLM call count for internal node: " + "title_generator_node" + ) + + elif event_type == "on_chat_model_end": + # Filter out title_generator_node from token counting + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) + if langgraph_node == "title_generator_node": + log.debug( + "Skipping token counting for internal node: " + "title_generator_node" + ) + else: + # Extract token usage from response metadata + # Try multiple possible locations where token usage + # might be stored + token_info_found = False + + # Method 1: response.usage_metadata + response = data.get("response", {}) + if hasattr(response, "usage_metadata"): + usage = response.usage_metadata + if usage: + input_tokens += usage.get("input_tokens", 0) + output_tokens += usage.get("output_tokens", 0) + token_info_found = True + + # Method 2: output.usage_metadata + if not token_info_found: + output_msg = data.get("output", {}) + if hasattr(output_msg, "usage_metadata"): + usage = output_msg.usage_metadata + if usage: + input_tokens += usage.get( + "input_tokens", 0 + ) + output_tokens += usage.get( + "output_tokens", 0 + ) + token_info_found = True + + # Method 3: Check data directly for token usage fields + if not token_info_found: + if "input_tokens" in data: + input_tokens += data.get("input_tokens", 0) + if "output_tokens" in data: + output_tokens += data.get("output_tokens", 0) + if ( + "input_tokens" in data + or "output_tokens" in data + ): + token_info_found = True + + # Count AI response as one message (only once per turn) + if not ai_response_counted: + message_count += 1 + ai_response_counted = True + + # Track tool messages + elif event_type == "on_tool_end": + message_count += 1 # Tool result message + tool_messages_counted += 1 + log.debug( + "Tool message counted, message_count=%d", message_count + ) + + # Convert event to chunk for SSE streaming + # Use accumulator for on_chat_model_stream events to handle + # progressive tool calls + + # Filter out internal nodes (title_generator_node) from + # streaming to frontend + langgraph_node = event.get("metadata", {}).get( + "langgraph_node", "" + ) + if langgraph_node == "title_generator_node": + # Skip all events from the title_generator_node (internal + # use only) + log.debug( + "Skipping event from internal node: " + "title_generator_node" + ) + continue + + if event_type == "on_chat_model_stream": + chunks = tool_call_accumulator.process_event(event) + for chunk in chunks: + # Add session_id to each chunk + chunk["session_id"] = session_id + log.debug( + "Yielding accumulated chunk: type=%s", + chunk.get("type"), + ) + yield chunk + else: + # Use stateless converter for other events + chunk = self._convert_event_to_chunk(event, session_id) + if chunk: + log.debug("Yielding chunk: type=%s", chunk.get("type")) + yield chunk + + # Check if stream was aborted and yield tool_end events for aborted tools + from gns3server.agent.gns3_copilot.agent.gns3_copilot import ( + check_abort_flag, + ) + + if check_abort_flag(session_id): + log.info("Stream aborted, yielding tool_end events: session_id=%s", session_id) + # Get final state to find aborted tool messages + final_state = await graph.aget_state(config) + if final_state and "messages" in final_state.values: + for msg in final_state.values["messages"]: + # Check if this is an aborted tool message + if hasattr(msg, "metadata") and msg.metadata.get("aborted"): + yield { + "type": "tool_end", + "tool_name": getattr(msg, "name", "unknown"), + "tool_output": msg.content if hasattr(msg, "content") else "", + "session_id": session_id, + } + + # Update session statistics after successful stream + await repo.update_session( + thread_id=session_id, + message_count=message_count, + llm_calls_count=llm_calls_count, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + last_message_at=last_message_at, + ) + log.info( + "Session statistics updated: thread_id=%s, messages=%d, " + "llm_calls=%d, tokens=%d+%d=%d", + session_id, + message_count, + llm_calls_count, + input_tokens, + output_tokens, + input_tokens + output_tokens, + ) + + # Sync auto-generated title from checkpoint state + final_state = await graph.aget_state(config) + if final_state and "conversation_title" in final_state.values: + generated_title = final_state.values["conversation_title"] + current_session = await repo.get_session_by_thread(session_id) + if ( + current_session + and current_session.title != generated_title + ): + await repo.update_session( + thread_id=session_id, title=generated_title + ) + log.info( + "Auto-generated title synced: thread_id=%s, title=%s", + session_id, + generated_title, + ) + + except Exception as e: + log.error("Error in stream_chat: %s", e, exc_info=True) + yield { + "type": "error", + "error": format_error_message(e), + "session_id": session_id, + } + + def _convert_event_to_chunk( + self, event: Dict[str, Any], session_id: str + ) -> Optional[Dict[str, Any]]: + """ + Convert LangGraph event to API response chunk. + + Args: + event: LangGraph event from astream_events + session_id: Session ID for the response + + Returns: + Dict for SSE response or None if event should be filtered + + Note: + on_chat_model_stream events are handled by + ToolCallStreamAccumulator before calling this method, so they are + not processed here. + """ + event_type = event.get("event", "") + data = event.get("data", {}) + + if event_type == "on_tool_start": + # Tool execution started + # Extract tool_call_id from event metadata to associate with + # tool_call event + tool_call_id = event.get("metadata", {}).get("tool_call_id", "") + return { + "type": "tool_start", + "tool_name": event.get("name", ""), + "tool_call_id": tool_call_id, + "session_id": session_id, + } + + elif event_type == "on_tool_end": + # Tool execution completed + # Extract tool output and convert to JSON string + output = data.get("output", "") + # Convert output to JSON string if it's not already a string + # This ensures dict/list outputs are properly serialized for + # frontend parsing + if not isinstance(output, str): + output = json.dumps(output, ensure_ascii=False, indent=2) + return { + "type": "tool_end", + "tool_name": event.get("name", ""), + "tool_output": output, + "session_id": session_id, + } + + return None + + async def get_history( + self, session_id: str, limit: int = 100 + ) -> Dict[str, Any]: + """ + Get conversation history for a session. + + Args: + session_id: Session/thread ID + limit: Maximum number of messages to retrieve + + Returns: + Dict containing thread_id, title, and messages + """ + config = {"configurable": {"thread_id": session_id}} + + try: + graph = await self._get_graph() + state = await graph.aget_state(config) + + if state and "messages" in state.values: + messages = [] + for msg in state.values["messages"][-limit:]: + messages.append(self._convert_message_to_dict(msg)) + + title = state.values.get( + "conversation_title", "New Conversation" + ) + + return { + "thread_id": session_id, + "title": title, + "messages": messages, + } + except Exception as e: + log.error("Error getting history: %s", e, exc_info=True) + + return { + "thread_id": session_id, + "title": "New Conversation", + "messages": [], + } + + def _convert_message_to_dict(self, msg) -> Dict[str, Any]: + """Convert a LangChain message to OpenAI-compatible dict format.""" + return convert_langchain_to_openai(msg) + + async def list_sessions( + self, user_id: Optional[str] = None, copilot_mode: Optional[str] = None, limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + List chat sessions for this project. + + Args: + user_id: Filter by user ID (optional) + copilot_mode: Filter by copilot mode (optional) + limit: Maximum number of sessions to return + + Returns: + List of session dictionaries + """ + if not self._checkpointer_conn: + await self._get_checkpointer() + + repo = ChatSessionsRepository(self._checkpointer_conn) + sessions = await repo.list_sessions(user_id=user_id, copilot_mode=copilot_mode, limit=limit) + return [s.to_dict() for s in sessions] + + async def delete_session(self, session_id: str) -> bool: + """ + Delete a chat session and its checkpoints. + + Args: + session_id: Thread ID to delete + + Returns: + True if deleted, False if not found + """ + if not self._checkpointer_conn: + await self._get_checkpointer() + + repo = ChatSessionsRepository(self._checkpointer_conn) + return await repo.delete_session(session_id) + + async def rename_session( + self, session_id: str, new_title: str + ) -> Optional[Dict[str, Any]]: + """ + Rename a chat session. + + Args: + session_id: Thread ID + new_title: New title + + Returns: + Updated session dictionary or None + """ + if not self._checkpointer_conn: + await self._get_checkpointer() + + repo = ChatSessionsRepository(self._checkpointer_conn) + session = await repo.update_session( + thread_id=session_id, title=new_title + ) + return session.to_dict() if session else None + + async def pin_session( + self, session_id: str, pinned: bool = True + ) -> Optional[Dict[str, Any]]: + """ + Pin or unpin a chat session. + + Args: + session_id: Thread ID + pinned: True to pin, False to unpin + + Returns: + Updated session dictionary or None + """ + if not self._checkpointer_conn: + await self._get_checkpointer() + + repo = ChatSessionsRepository(self._checkpointer_conn) + session = await repo.pin_session(thread_id=session_id, pinned=pinned) + return session.to_dict() if session else None + + async def close(self): + """ + Close the checkpointer connection and cleanup resources. + """ + async with self._init_lock: + if self._checkpointer_conn: + try: + # Add timeout to prevent blocking on database close + await asyncio.wait_for( + self._checkpointer_conn.close(), + timeout=5.0 + ) + log.debug( + "Checkpointer connection closed for: %s", + self.project_path, + ) + except asyncio.TimeoutError: + log.warning( + "Checkpointer connection close timeout for: %s (forcing cleanup)", + self.project_path, + ) + except Exception as e: + log.warning("Error closing checkpointer connection: %s", e) + finally: + self._checkpointer_conn = None + self._checkpointer = None + self._graph = None + self._initialized = False diff --git a/gns3server/agent/gns3_copilot/chat_sessions_repository.py b/gns3server/agent/gns3_copilot/chat_sessions_repository.py new file mode 100644 index 000000000..691971052 --- /dev/null +++ b/gns3server/agent/gns3_copilot/chat_sessions_repository.py @@ -0,0 +1,447 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +Chat Sessions Repository for managing chat session data. + +Provides CRUD operations for the chat_sessions table in the project's +checkpoint database. +""" + +import json +import logging +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +import aiosqlite + +log = logging.getLogger(__name__) + + +class ChatSession: + """Chat session model.""" + + def __init__( + self, + id: Optional[int] = None, + thread_id: str = "", + user_id: str = "", + project_id: str = "", + title: str = "New Conversation", + message_count: int = 0, + llm_calls_count: int = 0, + input_tokens: int = 0, + output_tokens: int = 0, + total_tokens: int = 0, + last_message_at: Optional[str] = None, + created_at: Optional[str] = None, + updated_at: Optional[str] = None, + metadata: str = "{}", + stats: str = "{}", + pinned: bool = False, + ): + self.id = id + self.thread_id = thread_id + self.user_id = user_id + self.project_id = project_id + self.title = title + self.message_count = message_count + self.llm_calls_count = llm_calls_count + self.input_tokens = input_tokens + self.output_tokens = output_tokens + self.total_tokens = total_tokens + self.last_message_at = last_message_at + self.created_at = created_at + self.updated_at = updated_at + self.metadata = metadata + self.stats = stats + self.pinned = pinned + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "id": self.id, + "thread_id": self.thread_id, + "user_id": self.user_id, + "project_id": self.project_id, + "title": self.title, + "message_count": self.message_count, + "llm_calls_count": self.llm_calls_count, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "total_tokens": self.total_tokens, + "last_message_at": self.last_message_at, + "created_at": self.created_at, + "updated_at": self.updated_at, + "metadata": json.loads(self.metadata) if self.metadata else {}, + "stats": json.loads(self.stats) if self.stats else {}, + "pinned": self.pinned, + } + + +class ChatSessionsRepository: + """ + Repository for managing chat sessions in the checkpoint database. + """ + + def __init__(self, conn: aiosqlite.Connection): + """ + Initialize repository with a database connection. + + Args: + conn: aiosqlite connection to the checkpoint database + """ + self.conn = conn + + async def create_session( + self, + thread_id: str, + user_id: str, + project_id: str, + title: str = "New Conversation", + copilot_mode: Optional[str] = None, + ) -> ChatSession: + """ + Create a new chat session. + + Args: + thread_id: Unique thread identifier + user_id: User ID + project_id: Project ID + title: Session title + copilot_mode: Copilot mode (optional) + + Returns: + Created ChatSession + """ + now = datetime.utcnow().isoformat() + # Build metadata JSON + metadata = {"copilot_mode": copilot_mode} if copilot_mode else {} + metadata_json = json.dumps(metadata) + + cursor = await self.conn.execute( + """ + INSERT INTO chat_sessions ( + thread_id, user_id, project_id, title, + metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (thread_id, user_id, project_id, title, metadata_json, now, now), + ) + await self.conn.commit() + + session_id = cursor.lastrowid + log.info( + "Created chat session: id=%s, thread_id=%s, copilot_mode=%s", + session_id, thread_id, copilot_mode + ) + + return await self.get_session_by_id(session_id) + + async def get_session_by_id( + self, session_id: int + ) -> Optional[ChatSession]: + """ + Get a session by its database ID. + + Args: + session_id: Database row ID + + Returns: + ChatSession or None + """ + cursor = await self.conn.execute( + "SELECT * FROM chat_sessions WHERE id = ?", (session_id,) + ) + row = await cursor.fetchone() + + if row: + return self._row_to_session(row) + return None + + async def get_session_by_thread( + self, thread_id: str + ) -> Optional[ChatSession]: + """ + Get a session by thread_id. + + Args: + thread_id: Thread identifier + + Returns: + ChatSession or None + """ + cursor = await self.conn.execute( + "SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,) + ) + row = await cursor.fetchone() + + if row: + return self._row_to_session(row) + return None + + async def list_sessions( + self, + user_id: Optional[str] = None, + project_id: Optional[str] = None, + copilot_mode: Optional[str] = None, + limit: int = 100, + ) -> List[ChatSession]: + """ + List sessions with optional filters. + + Args: + user_id: Filter by user ID + project_id: Filter by project ID + copilot_mode: Filter by copilot mode (metadata field) + limit: Maximum number of sessions to return + + Returns: + List of ChatSession + """ + query = "SELECT * FROM chat_sessions" + params = [] + + conditions = [] + if user_id: + conditions.append("user_id = ?") + params.append(user_id) + if project_id: + conditions.append("project_id = ?") + params.append(project_id) + if copilot_mode: + # Filter by JSON metadata field + conditions.append("json_extract(metadata, '$.copilot_mode') = ?") + params.append(copilot_mode) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + # Sort by pinned status first, then by updated_at + query += " ORDER BY pinned DESC, updated_at DESC LIMIT ?" + params.append(limit) + + cursor = await self.conn.execute(query, params) + rows = await cursor.fetchall() + + return [self._row_to_session(row) for row in rows] + + async def update_session( + self, + thread_id: str, + title: Optional[str] = None, + message_count: Optional[int] = None, + llm_calls_count: Optional[int] = None, + input_tokens: Optional[int] = None, + output_tokens: Optional[int] = None, + total_tokens: Optional[int] = None, + last_message_at: Optional[str] = None, + ) -> Optional[ChatSession]: + """ + Update a session. + + Args: + thread_id: Thread identifier + title: New title + message_count: Increment message count + llm_calls_count: Increment LLM call count + input_tokens: Add to input tokens + output_tokens: Add to output tokens + total_tokens: Add to total tokens + last_message_at: Last message timestamp + + Returns: + Updated ChatSession or None + """ + updates = [] + params = [] + + now = datetime.utcnow().isoformat() + + if title is not None: + updates.append("title = ?") + params.append(title) + + if message_count is not None: + updates.append("message_count = message_count + ?") + params.append(message_count) + + if llm_calls_count is not None: + updates.append("llm_calls_count = llm_calls_count + ?") + params.append(llm_calls_count) + + if input_tokens is not None: + updates.append("input_tokens = input_tokens + ?") + params.append(input_tokens) + + if output_tokens is not None: + updates.append("output_tokens = output_tokens + ?") + params.append(output_tokens) + + if total_tokens is not None: + updates.append("total_tokens = total_tokens + ?") + params.append(total_tokens) + + if last_message_at is not None: + updates.append("last_message_at = ?") + params.append(last_message_at) + + if not updates: + return await self.get_session_by_thread(thread_id) + + updates.append("updated_at = ?") + params.append(now) + params.append(thread_id) + + query = ( + f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id " + f"= ?" + ) + + await self.conn.execute(query, params) + await self.conn.commit() + + log.debug("Updated chat session: thread_id=%s", thread_id) + return await self.get_session_by_thread(thread_id) + + async def delete_session(self, thread_id: str) -> bool: + """ + Delete a session by thread_id. + + Args: + thread_id: Thread identifier + + Returns: + True if deleted, False if not found + """ + # First, delete the checkpoint data + await self.conn.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) + ) + + # Then delete the session + cursor = await self.conn.execute( + "DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,) + ) + await self.conn.commit() + + deleted = cursor.rowcount > 0 + if deleted: + log.info( + "Deleted chat session and checkpoints: thread_id=%s", thread_id + ) + + return deleted + + async def delete_all_sessions(self, project_id: str) -> int: + """ + Delete all sessions for a project. + + Args: + project_id: Project ID + + Returns: + Number of sessions deleted + """ + # Get all thread_ids for this project + cursor = await self.conn.execute( + "SELECT thread_id FROM chat_sessions WHERE project_id = ?", + (project_id,), + ) + rows = await cursor.fetchall() + thread_ids = [row[0] for row in rows] + + # Delete checkpoints and sessions + for thread_id in thread_ids: + await self.conn.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) + ) + + cursor = await self.conn.execute( + "DELETE FROM chat_sessions WHERE project_id = ?", (project_id,) + ) + await self.conn.commit() + + deleted_count = cursor.rowcount + if deleted_count > 0: + log.info( + "Deleted %d sessions for project: %s", + deleted_count, + project_id, + ) + + return deleted_count + + async def pin_session( + self, thread_id: str, pinned: bool = True + ) -> Optional[ChatSession]: + """ + Pin or unpin a session. + + Args: + thread_id: Thread identifier + pinned: True to pin, False to unpin + + Returns: + Updated ChatSession or None + """ + now = datetime.utcnow().isoformat() + await self.conn.execute( + "UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE " + "thread_id = ?", + (1 if pinned else 0, now, thread_id), + ) + await self.conn.commit() + + log.debug( + "Session pin status updated: thread_id=%s, pinned=%s", + thread_id, + pinned, + ) + return await self.get_session_by_thread(thread_id) + + def _row_to_session(self, row) -> ChatSession: + """Convert database row to ChatSession object.""" + return ChatSession( + id=row[0], + thread_id=row[1], + user_id=row[2], + project_id=row[3], + title=row[4], + message_count=row[5], + llm_calls_count=row[6], + input_tokens=row[7], + output_tokens=row[8], + total_tokens=row[9], + last_message_at=row[10], + created_at=row[11], + updated_at=row[12], + metadata=row[13], + stats=row[14], + pinned=bool(row[15]) if len(row) > 15 else False, + ) diff --git a/gns3server/agent/gns3_copilot/configs/__init__.py b/gns3server/agent/gns3_copilot/configs/__init__.py new file mode 100644 index 000000000..25162581a --- /dev/null +++ b/gns3server/agent/gns3_copilot/configs/__init__.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Copyright (C) 2025 Yue Guobin +# + +""" +Configuration package for GNS3 Copilot. +""" + +from .skills_config import ( + SKILLS_CONFIG, + get_skills_config, + update_skills_config, +) + +__all__ = [ + "SKILLS_CONFIG", + "get_skills_config", + "update_skills_config", +] diff --git a/gns3server/agent/gns3_copilot/configs/skills_config.py b/gns3server/agent/gns3_copilot/configs/skills_config.py new file mode 100644 index 000000000..077b1e870 --- /dev/null +++ b/gns3server/agent/gns3_copilot/configs/skills_config.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin +# Author: Yue Guobin +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Skills Configuration + +This module contains configuration for the external skills repository. +""" + +from gns3server.config import Config + +# Default skills repository configuration +SKILLS_CONFIG = { + # Git repository URL for skills + "repo_url": "https://github.com/yueguobin/GNS3-Skills.git", + + # Git branch to use + "branch": "main", + + # Automatically pull updates on reload + "auto_update": True, + + # Enable external skills loading + # If False, use the built-in hardcoded skills + "enabled": True, +} + + +def get_skills_config() -> dict: + """ + Get the skills configuration. + + Priority: + 1. GNS3 server config (gns3_server.conf → [Server] → skills_*) + 2. Hardcoded defaults + + Returns: + Dictionary containing skills configuration + """ + config = SKILLS_CONFIG.copy() + try: + server = Config.instance().settings.Server + if server.skills_repo_url: + config["repo_url"] = server.skills_repo_url + if server.skills_repo_branch: + config["branch"] = server.skills_repo_branch + config["auto_update"] = server.skills_auto_update + except Exception: + pass + return config + + +def update_skills_config(**kwargs): + """ + Update skills configuration. + + Args: + **kwargs: Configuration key-value pairs to update + """ + for key, value in kwargs.items(): + if key in SKILLS_CONFIG: + SKILLS_CONFIG[key] = value + else: + raise ValueError(f"Unknown configuration key: {key}") diff --git a/gns3server/agent/gns3_copilot/gns3_client/__init__.py b/gns3server/agent/gns3_copilot/gns3_client/__init__.py new file mode 100644 index 000000000..57b618f61 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/__init__.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3 Client Package + +This package provides a Python interface for interacting with GNS3 servers. +Adapted from the upstream gns3fy project with modifications for compatibility +with langchain and reduced dependency conflicts. + +Main classes: +- Gns3Connector: Connector for GNS3 server API interaction +- Project: GNS3 Project management +- Node: GNS3 Node management +- Link: GNS3 Link management +- GNS3TopologyTool: GNS3 topology reading tool +- GNS3ProjectInfoTool: GNS3 project info tool + +Main functions: +- get_gns3_connector: Factory function to create Gns3Connector +- get_gns3_connector_with_llm_config: Create connector AND retrieve LLM config +- get_gns3_server_host: Get GNS3 server hostname from Controller or Config +- get_llm_config: Get LLM model configuration for a user + +Upstream gns3fy: https://github.com/davidban77/gns3fy +""" + +from .connector_factory import get_gns3_connector +from .connector_factory import get_gns3_connector_with_llm_config +from .connector_factory import get_gns3_server_host +from .connector_factory import get_llm_config +from .context_helpers import get_current_jwt_token +from .context_helpers import get_current_llm_config +from .context_helpers import set_current_jwt_token +from .context_helpers import set_current_llm_config +from .custom_gns3fy import CONSOLE_TYPES +from .custom_gns3fy import LINK_TYPES +from .custom_gns3fy import NODE_TYPES +from .custom_gns3fy import Gns3Connector +from .custom_gns3fy import Link +from .custom_gns3fy import Node +from .custom_gns3fy import Project +from .gns3_project_info import GNS3ProjectInfoTool +from .gns3_topology_reader import GNS3TopologyTool + +# Dynamic version management +try: + from importlib.metadata import version + + __version__ = version("gns3-copilot") +except Exception: + __version__ = "unknown" + +__author__ = "Yue Guobin (岳国宾)" +__description__ = "AI-powered network automation assistant for GNS3" +__url__ = "https://github.com/yueguobin/gns3-copilot" + +__all__ = [ + "Gns3Connector", + "Project", + "Node", + "Link", + "NODE_TYPES", + "CONSOLE_TYPES", + "LINK_TYPES", + "GNS3TopologyTool", + "GNS3ProjectInfoTool", + "get_gns3_connector", + "get_gns3_connector_with_llm_config", + "get_gns3_server_host", + "get_llm_config", + "set_current_jwt_token", + "get_current_jwt_token", + "set_current_llm_config", + "get_current_llm_config", +] diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py new file mode 100644 index 000000000..a2144b2b9 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -0,0 +1,472 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3 Connector Factory Module + +This module provides factory functions for creating Gns3Connector instances +with JWT token authentication and context-aware configuration management. + +Features: +- Context variable based request-scoped data management (JWT tokens, LLM + config) +- Auto-detection of GNS3 server URL from Controller/Config +- Fallback URL strategy for flexible deployment +- LLM configuration retrieval for users + +""" + +# Standard library imports +import asyncio +import concurrent.futures +import logging +from typing import Optional +from uuid import UUID + +from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + get_current_jwt_token, +) + +# Local imports +from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import ( + Gns3Connector, +) + +logger = logging.getLogger(__name__) + +# Fallback default URL +DEFAULT_GNS3_URL = "http://127.0.0.1:3080" + + +def _get_url_from_controller() -> Optional[str]: + """Try to get GNS3 server URL from running Controller instance. + + Returns: + URL string if successful, None otherwise + """ + try: + from gns3server.controller import Controller + + controller = Controller.instance() + local_compute = controller.get_compute("local") + + url = ( + f"{local_compute.protocol}://{local_compute.host}:" + f"{local_compute.port}" + ) + logger.debug( + "Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)", + url, + local_compute.protocol, + local_compute.host, + local_compute.port, + ) + return url + except ImportError as e: + logger.debug("Cannot import Controller: %s", str(e)) + return None + except AttributeError as e: + logger.debug("Controller instance not available: %s", str(e)) + return None + except KeyError as e: + logger.debug("Local compute not found in Controller: %s", str(e)) + return None + except Exception as e: + logger.warning( + "Unexpected error getting URL from Controller: %s", str(e) + ) + return None + + +def _get_url_from_config() -> Optional[str]: + """Try to get GNS3 server URL from Config settings. + + Returns: + URL string if successful, None otherwise + """ + try: + from gns3server.config import Config + + server_config = Config.instance().settings.Server + url = ( + f"{server_config.protocol.value}://{server_config.host}:" + f"{server_config.port}" + ) + logger.debug( + "Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)", + url, + server_config.protocol.value, + server_config.host, + server_config.port, + ) + return url + except ImportError as e: + logger.debug("Cannot import Config: %s", str(e)) + return None + except AttributeError as e: + logger.debug("Config settings not available: %s", str(e)) + return None + except Exception as e: + logger.warning("Unexpected error getting URL from Config: %s", str(e)) + return None + + +def get_gns3_connector( + jwt_token: Optional[str] = None, url: Optional[str] = None +) -> Optional[Gns3Connector]: + """Create and return a Gns3Connector instance with JWT authentication. + + URL Resolution Strategy (in order): + 1. Explicitly provided `url` parameter + 2. Runtime configuration from Controller.instance().compute("local") + 3. Static configuration from Config.instance().settings.Server + 4. Fallback to DEFAULT_GNS3_URL (http://127.0.0.1:3080) + + Args: + jwt_token: JWT token for authentication (optional, will be retrieved + from context if not provided) + url: GNS3 server URL (optional, auto-detected if not provided) + + Returns: + Gns3Connector instance if parameters are valid, None otherwise + + Example: + # Auto-detect URL from Controller or Config + connector = get_gns3_connector(jwt_token="your_jwt_token") + + # Or specify custom URL + connector = get_gns3_connector( + jwt_token="your_jwt_token", + url="http://custom-server:3080" + ) + + if connector: + projects = connector.projects + else: + logger.error("Failed to create GNS3 connector") + """ + try: + # Validate JWT token + # If not provided, try to get from current request context + if not jwt_token: + jwt_token = get_current_jwt_token() + if not jwt_token: + logger.error("JWT token is required") + return None + + # Resolve URL with fallback strategy + if url is None: + logger.debug("No URL provided, attempting auto-detection...") + + # Strategy 1: Try to get from running Controller + url = _get_url_from_controller() + if url: + logger.info("Using GNS3 server URL from Controller: %s", url) + else: + # Strategy 2: Fall back to Config + logger.debug("Controller not available, trying Config...") + url = _get_url_from_config() + if url: + logger.info("Using GNS3 server URL from Config: %s", url) + else: + # Strategy 3: Use default fallback + logger.debug("Config not available, using default URL") + url = DEFAULT_GNS3_URL + logger.warning( + "Using fallback default URL: %s. This may not be " + "correct if your GNS3 server is configured " + "differently. Consider providing the URL explicitly.", + url, + ) + else: + logger.info("Using explicitly provided GNS3 server URL: %s", url) + + # Validate URL + if not url: + logger.error("Failed to resolve GNS3 server URL") + return None + + # Create connector + logger.debug("Creating Gns3Connector with URL=%s", url) + connector = Gns3Connector( + url=url, + jwt_token=jwt_token, + api_version=3, + ) + logger.info("Successfully created Gns3Connector for URL: %s", url) + return connector + + except Exception as e: + logger.error( + "Failed to create Gns3Connector: %s", str(e), exc_info=True + ) + return None + + +async def get_gns3_connector_with_llm_config( + user_id, jwt_token: str, url: Optional[str] = None, app=None +) -> Optional[dict]: + """ + Create Gns3Connector and retrieve LLM model configuration for the user. + + This is a convenience function that combines: + 1. get_gns3_connector() - Create GNS3 API connector + 2. get_llm_config() - Retrieve user's default LLM config with API key + + Args: + user_id: User UUID (can be string or UUID object) + jwt_token: JWT token for authentication + url: GNS3 server URL (optional, auto-detected if not provided) + app: FastAPI application instance (optional, for direct database + access) + + Returns: + Dictionary with keys: + - connector: Gns3Connector instance + - llm_config: Dict with provider, api_key, model, etc. + Or None if failed + + Example: + result = await get_gns3_connector_with_llm_config(user_id, jwt_token) + if result: + connector = result["connector"] + llm_config = result["llm_config"] + + # Use connector for GNS3 operations + projects = connector.projects + + # Use LLM config for AI operations + provider = llm_config["provider"] + api_key = llm_config["api_key"] + model = llm_config["model"] + else: + logger.error("Failed to initialize GNS3 connector or LLM config") + """ + try: + # Convert user_id to UUID if it's a string + if isinstance(user_id, str): + user_id = UUID(user_id) + + # Step 1: Create GNS3 connector + connector = get_gns3_connector(jwt_token=jwt_token, url=url) + if not connector: + logger.error("Failed to create GNS3 connector") + return None + + # Step 2: Detect URL if not provided + if url is None: + url = _detect_url_for_api() + + # Step 3: Get LLM config + llm_config = get_llm_config( + user_id=user_id, jwt_token=jwt_token, app=app + ) + + if not llm_config: + logger.warning(f"No LLM config found for user {user_id}") + # Still return result with connector only + return {"connector": connector, "llm_config": None} + + logger.info( + f"Successfully initialized GNS3 connector and LLM config for " + f"user {user_id}: connector_url={connector.url}, " + f"llm_provider={llm_config.get('provider')}, " + f"llm_model={llm_config.get('model')}" + ) + + return {"connector": connector, "llm_config": llm_config} + + except Exception as e: + logger.error( + f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True + ) + return None + + +def _detect_url_for_api() -> Optional[str]: + """ + Detect GNS3 server URL for API calls. + + Uses the same priority order as get_gns3_connector: + 1. Controller.instance().compute("local") + 2. Config.instance().settings.Server + 3. Fallback to DEFAULT_GNS3_URL + + Returns: + URL string, or None if detection failed + """ + logger.debug("Detecting GNS3 server URL for API calls") + + # Try Controller first + url = _get_url_from_controller() + if url: + logger.debug("Using URL from Controller for API call: %s", url) + return url + + # Try Config + url = _get_url_from_config() + if url: + logger.debug("Using URL from Config for API call: %s", url) + return url + + # Fallback + logger.debug("Using fallback URL for API call: %s", DEFAULT_GNS3_URL) + return DEFAULT_GNS3_URL + + +def get_gns3_server_host() -> str: + """ + Get GNS3 server hostname from Controller or Config. + + This is a convenience function for extracting the hostname only, useful + for Nornir tools that need the GNS3 server address. + + Uses the same priority order as get_gns3_connector: + 1. Controller.instance().compute("local") + 2. Config.instance().settings.Server + 3. Fallback to DEFAULT_GNS3_URL host + + Returns: + Hostname/IP address string + + Example: + from gns3server.agent.gns3_copilot.gns3_client import ( + get_gns3_server_host, + ) + + host = get_gns3_server_host() + print(f"GNS3 server host: {host}") + """ + url = _detect_url_for_api() + + # Extract host from URL + # URL format: protocol://host:port + try: + # Remove protocol prefix + host_part = url.split("://")[1] + # Extract host (before the port) + host = host_part.split(":")[0] + logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url) + return host + except Exception as e: + logger.warning( + "Failed to extract host from URL %s: %s, using fallback", url, e + ) + return DEFAULT_GNS3_URL.split("://")[1].split(":")[0] + + +def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]: + """ + Get LLM model configuration for a user. + + This function retrieves the user's LLM configuration from the database. + When `app` is provided, it directly accesses the database (bypassing API + restrictions). Otherwise, it falls back to API call (which may mask + group config API keys). + + Args: + user_id: User UUID (can be string or UUID object) + jwt_token: JWT token for authentication + app: FastAPI application instance (optional, for direct database + access) + + Returns: + Dictionary with LLM configuration keys (provider, model, api_key, + etc.), or None if not found. + + Example: + from gns3server.agent.gns3_copilot.gns3_client import ( + get_llm_config, + ) + + config = get_llm_config(user_id, jwt_token) + if config: + provider = config['provider'] + model = config['model'] + api_key = config['api_key'] + """ + try: + # Convert user_id to UUID if it's a string + if isinstance(user_id, str): + user_id = UUID(user_id) + + # If app is provided, use direct database access (preferred) + if app is not None: + from gns3server.agent.gns3_copilot.utils.llm_config_helper import ( + get_user_llm_config_with_app, + ) + + # Run the async function in sync context + try: + loop = asyncio.get_running_loop() + # We're in an async context with a running loop + # This shouldn't happen since this is a sync function + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, get_user_llm_config_with_app(user_id, app) + ) + return future.result(timeout=10) + except RuntimeError: + # No running event loop - we're in a sync context + pass + + # Try to get an existing event loop, or create a new one + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # Loop is running but not the running loop (edge case) + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + asyncio.run, + get_user_llm_config_with_app(user_id, app), + ) + return future.result(timeout=10) + else: + # Loop exists but not running - use it + return loop.run_until_complete( + get_user_llm_config_with_app(user_id, app) + ) + except RuntimeError: + # No event loop exists - create a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + get_user_llm_config_with_app(user_id, app) + ) + finally: + loop.close() + + # Fallback: No app provided, try API call (will mask group config API + # keys) + logger.warning( + "No app provided for get_llm_config, group config API keys may be " + "masked" + ) + return None + + except Exception as e: + logger.error("Failed to get LLM config for user %s: %s", user_id, e) + return None diff --git a/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py new file mode 100644 index 000000000..73e275767 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/context_helpers.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Request-Scoped Context Helpers for GNS3-Copilot + +This module provides context variable management for request-scoped data +(JWT tokens and LLM configurations) using Python's contextvars module. + +These functions are separated into their own module to avoid circular imports +between agent_service.py, gns3_copilot.py, and gns3_client modules. + +Key Features: +- Thread-safe and async-safe request-scoped context management +- Automatic cleanup when request context ends +- No manual cleanup required + +Usage: + from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + set_current_jwt_token, + get_current_jwt_token, + set_current_llm_config, + get_current_llm_config, + ) + + # In request handler + set_current_jwt_token(token) + set_current_llm_config(config) + + # In downstream code + token = get_current_jwt_token() + config = get_current_llm_config() +""" + +import logging +from contextvars import ContextVar +from typing import Optional + +logger = logging.getLogger(__name__) + +# Context variables for request-scoped data +# Automatically cleaned up when request context ends +_jwt_token_context: ContextVar[Optional[str]] = ContextVar( + "_jwt_token_context", default=None +) +_llm_config_context: ContextVar[Optional[dict]] = ContextVar( + "_llm_config_context", default=None +) + + +def set_current_jwt_token(token: str) -> None: + """Set the JWT token for the current request context. + + Args: + token: JWT token string + """ + _jwt_token_context.set(token) + logger.debug("JWT token set in context") + + +def get_current_jwt_token() -> Optional[str]: + """Get the JWT token for the current request context. + + Returns: + JWT token string if available, None otherwise + """ + token = _jwt_token_context.get() + if token: + logger.debug("JWT token retrieved from context") + else: + logger.warning("JWT token not found in context") + return token + + +def set_current_llm_config(config: dict) -> None: + """Set the LLM config for the current request context. + + Args: + config: LLM configuration dictionary with provider, model, api_key, + etc. + """ + _llm_config_context.set(config) + logger.debug( + "LLM config set in context: provider=%s, model=%s", + config.get("provider"), + config.get("model"), + ) + + +def get_current_llm_config() -> Optional[dict]: + """Get the LLM config for the current request context. + + Returns: + LLM configuration dict if available, None otherwise + """ + config = _llm_config_context.get() + if config: + logger.debug( + "LLM config retrieved from context: provider=%s, model=%s", + config.get("provider"), + config.get("model"), + ) + else: + logger.warning("LLM config not found in context") + return config + + +__all__ = [ + "set_current_jwt_token", + "get_current_jwt_token", + "set_current_llm_config", + "get_current_llm_config", +] diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py new file mode 100644 index 000000000..cb3770d9f --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -0,0 +1,3032 @@ +# 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 . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Adapted gns3fy module for GNS3-Copilot + +This module is based on the upstream gns3fy project +(https://github.com/davidban77/gns3fy). + +Modifications made for GNS3-Copilot: +- Adjusted pydantic usages and dataclass configuration to reduce dependency + conflicts with langchain (pydantic version/api differences) +- Kept the original API surface where possible but simplified + validators/config +- Added JWT token authentication support +- Integrated with context-aware connector factory + +Note: This file is adapted from upstream gns3fy for compatibility with +GNS3-Copilot's architecture. + +Upstream: https://github.com/davidban77/gns3fy +""" + +import os +import time +from collections.abc import Callable +from dataclasses import field +from functools import wraps +from math import cos +from math import pi +from math import sin +from typing import Any +from typing import ParamSpec +from typing import TypeVar +from typing import cast +from urllib.parse import urlparse + +import jwt +import requests +import urllib3 +from pydantic import ConfigDict +from pydantic import field_validator +from pydantic.dataclasses import dataclass +from requests import HTTPError + +P = ParamSpec("P") +R = TypeVar("R") +F = TypeVar("F", bound=Callable[..., Any]) + +config = ConfigDict(validate_assignment=True, extra="ignore") + +NODE_TYPES = [ + "cloud", + "nat", + "ethernet_hub", + "ethernet_switch", + "frame_relay_switch", + "atm_switch", + "docker", + "dynamips", + "vpcs", + "traceng", + "virtualbox", + "vmware", + "iou", + "qemu", +] + +CONSOLE_TYPES = [ + "vnc", + "telnet", + "http", + "https", + "spice", + "spice+agent", + "none", + "null", +] + +LINK_TYPES = ["ethernet", "serial"] + + +class Gns3Connector: + """ + Connector to be use for interaction against GNS3 server controller API. + + **Attributes:** + + - `url` (str): URL of the GNS3 server (**required**) + - `user` (str): User used for authentication + - `cred` (str): Password used for authentication + - `jwt_token` (str): JWT token for direct authentication (API v3) + - `verify` (bool): Whether or not to verify SSL + - `api_version` (int): GNS3 server REST API version + - `api_calls`: Counter of amount of `http_calls` has been performed + - `base_url`: url passed + api_version + - `session`: Requests Session object + + **Returns:** + + `Gns3Connector` instance + + **Example:** + + ```python + >>> # API v2 with basic auth + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=2 + ... ) + >>> # API v3 with username/password (auto-fetches JWT token) + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=3 + ... ) + >>> # API v3 with direct JWT token + >>> server = Gns3Connector( + ... url="http://
:3080", + ... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + ... api_version=3 + ... ) + >>> print(server.get_version()) + {'local': False, 'version': '2.2.0b4'} + ``` + """ + + access_token: str | None + token_expiry: float | None + + def __init__( + self, + url: str | None = None, + user: str | None = None, + cred: str | None = None, + jwt_token: str | None = None, + verify: bool = False, + api_version: int = 2, + ) -> None: + # Disable SSL warnings + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + if url is None: + raise ValueError("URL is required for Gns3Connector") + self.url = url.strip("/") # Store original URL for reference + self.base_url = f"{self.url}/v{api_version}" + self.user = user + self.cred = cred + self.headers = {"Content-Type": "application/json"} + self.verify = verify + self.api_calls = 0 + + # v3 authentication attributes + # If jwt_token is provided directly, use it; otherwise will be + # fetched via username/password + self.access_token = jwt_token + self.token_expiry = None + self.auth_type = "basic" if api_version == 2 else "jwt" + self.api_version = api_version + + # Create session object + self._create_session() + + def _create_session(self) -> None: + """ + Creates the requests.Session object and applies the necessary parameters + """ + self.session = requests.Session() # pragma: no cover + self.session.headers["Accept"] = "application/json" # pragma: no cover + + # Set authentication based on API version + if ( + self.auth_type == "basic" + and self.user is not None + and self.cred is not None + ): + self.session.auth = (self.user, self.cred) # pragma: no cover + + elif self.auth_type == "jwt" and self.access_token: + self.session.headers["Authorization"] = ( + f"Bearer {self.access_token}" + ) + + def _authenticate_v3(self) -> None: + """ + Performs v3 API authentication using username and password to get JWT token. + Skips authentication if a JWT token is already provided. + """ + # If token is already provided, skip authentication + if self.access_token: + return + + if not self.user or not self.cred: + raise ValueError( + "Username and password are required for v3 authentication " + "when no JWT token is provided" + ) + + # Construct authentication URL (v3 API uses different base URL) + auth_url = ( + f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate" + ) + auth_data = {"username": self.user, "password": self.cred} + + # Use temporary session for authentication + temp_session = requests.Session() + temp_session.headers["Content-Type"] = "application/json" + + try: + response = temp_session.post( + auth_url, json=auth_data, verify=self.verify, timeout=10.0 + ) + if response.status_code == 200: + auth_result = response.json() + self.access_token = auth_result["access_token"] + # Update session with new token + self.session.headers["Authorization"] = ( + f"Bearer {self.access_token}" + ) + # print(f"Successfully authenticated to v3 API, token obtained") + else: + raise HTTPError( + f"v3 API authentication failed: {response.status_code} - " + f"{response.text}" + ) + except Exception as e: + raise HTTPError(f"v3 API authentication error: {str(e)}") from e + + def _is_token_expired(self) -> bool: + """ + Check if the JWT token is expired (basic implementation) + """ + token = self.access_token + if not token: + return True + + try: + # Decode token without verification to check expiry + decoded: dict[str, Any] = jwt.decode( + token, options={"verify_signature": False} + ) + exp = decoded.get("exp") + if exp is not None: + return time.time() > float(exp) + return False + except (jwt.PyJWTError, ValueError, TypeError): + return True + + def _refresh_token(self) -> None: + """ + Refresh the JWT token (for now, just re-authenticate) + """ + print("Refreshing v3 API token...") + self._authenticate_v3() + + def http_call( + self, + method: str, + url: str, + data: Any | None = None, + json_data: dict[str, Any] | list[Any] | None = None, + headers: dict[str, str] | None = None, + verify: bool = False, + params: dict[str, Any] | None = None, + ) -> requests.Response: + """ + Executes HTTP operations and handles GNS3-specific error logic. + """ + # Handle JWT authentication + if ( + self.auth_type == "jwt" + and not self.access_token + and self.user + and self.cred + ): + self._authenticate_v3() + + # Get request function (e.g., session.get, session.post) + caller = getattr(self.session, method.lower()) + + # Prepare request parameters, avoiding multiple repeated calls to caller + kwargs: dict[str, Any] = { + "headers": headers, + "params": params, + "verify": verify, + "timeout": 10.0, # Fixed 10-second timeout for all GNS3 API requests + } + if data is not None: + kwargs["data"] = data + elif json_data is not None: + kwargs["json"] = json_data + + # Execute request + _response: requests.Response = caller(url, **kwargs) + + self.api_calls += 1 + + try: + _response.raise_for_status() + except HTTPError as e: + # Throw enhanced error + raise self._extract_gns3_error(e) from e + + return _response + + def _extract_gns3_error(self, e: HTTPError) -> HTTPError: + """ + Extract GNS3-specific JSON error information from HTTPError. + If parsing fails, return the original error. + """ + # e.response might be None, need explicit check + response = e.response + if response is None: + return e + + try: + # Only attempt parsing when Content-Type is JSON + if ( + "application/json" + in response.headers.get("Content-Type", "").lower() + ): + error_json = response.json() + status = error_json.get("status", "Unknown Status") + message = error_json.get( + "message", "No message provided in JSON." + ) + # Construct a more descriptive new error + new_err = HTTPError( + f"{status}: {message} (Original {response.status_code} Error)", + response=response, + ) + return new_err + except Exception: + # If JSON parsing fails, return error with original text + return HTTPError( + f"Original Error: {str(e)}. GNS3 response text: {response.text}", + response=response, + ) + return e + + def get_version(self) -> dict[str, Any]: + """ + Returns the version information of GNS3 server + """ + response = self.http_call("get", url=f"{self.base_url}/version") + return cast(dict[str, Any], response.json()) + + def projects_summary( + self, is_print: bool = True + ) -> list[tuple[str, str, int, int, str]] | None: + """ + Returns a summary of the projects in the server. If `is_print` is `False`, it + will return a list of tuples like: + + `[(name, project_id, total_nodes, total_links, status) ...]` + """ + _projects_summary = [] + for _p in self.get_projects(): + # Retrieve the project stats + _stats = self.http_call( + "get", f"{self.base_url}/projects/{_p['project_id']}/stats" + ).json() + if is_print: + print( + f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- " + f"Links: {_stats['links']} -- Status: {_p['status']}" + ) + _projects_summary.append( + ( + _p["name"], + _p["project_id"], + _stats["nodes"], + _stats["links"], + _p["status"], + ) + ) + + return _projects_summary if not is_print else None + + def get_projects(self) -> list[dict[str, Any]]: + """ + Returns the list of the projects on the server + """ + response = self.http_call( + "get", url=f"{self.base_url}/projects" + ).json() + return cast(list[dict[str, Any]], response) + + def get_project( + self, name: str | None = None, project_id: str | None = None + ) -> dict[str, Any] | None: + """ + Retrieves a project from either a name or ID + + **Required Attributes:** + + - `name` or `project_id` + """ + if project_id: + _response = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}" + ) + return cast(dict[str, Any], _response.json()) + elif name: + try: + return next( + p for p in self.get_projects() if p["name"] == name + ) + except StopIteration: + # Project not found + return None + else: + raise ValueError("Must provide either a name or project_id") + + def templates_summary( + self, is_print: bool = True + ) -> list[tuple[str, str, str, bool, str, str]] | None: + """ + Returns a summary of the templates in the server. If `is_print` is `False`, it + will return a list of tuples like: + + `[(name, template_id, template_type, builtin, console_type, category) ...]` + """ + _templates_summary = [] + for _t in self.get_templates(): + if "console_type" not in _t: + _t["console_type"] = "N/A" + if is_print: + print( + f"{_t['name']}: {_t['template_id']} -- Type: {_t['template_type']}" + f" -- Builtin: {_t['builtin']} -- Console: {_t['console_type']} -- " + f"Category: {_t['category']}" + ) + _templates_summary.append( + ( + _t["name"], + _t["template_id"], + _t["template_type"], + _t["builtin"], + _t["console_type"], + _t["category"], + ) + ) + + return _templates_summary if not is_print else None + + def get_templates(self) -> list[dict[str, Any]]: + """ + Returns the templates defined on the server. + """ + _response_data = self.http_call( + "get", url=f"{self.base_url}/templates" + ).json() + return cast(list[dict[str, Any]], _response_data) + + def get_template( + self, name: str | None = None, template_id: str | None = None + ) -> dict[str, Any] | None: + """ + Retrieves a template from either a name or ID + + **Required Attributes:** + + - `name` or `template_id` + """ + if template_id: + _response_json = self.http_call( + "get", url=f"{self.base_url}/templates/{template_id}" + ).json() + return cast(dict[str, Any], _response_json) + elif name: + try: + return next( + t for t in self.get_templates() if t["name"] == name + ) + except StopIteration: + # Template name not found + return None + else: + raise ValueError("Must provide either a name or template_id") + + def update_template( + self, + name: str | None = None, + template_id: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Updates a template by giving its name or UUID. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templatestemplateid.html#put-v2-templates-template-id) + + **Required Attributes:** + + - `name` or `template_id` + + **Optional Attributes (can be passed via kwargs):** + + - `tags` (list): List of tags for the template (e.g., + ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - Any other template attributes supported by GNS3 API + """ + # Get existing template + _template = self.get_template(name=name, template_id=template_id) + # Type check: handle case where get_template might return None + if _template is None: + raise ValueError( + f"Template not found (name={name}, id={template_id})" + ) + # Update local dictionary and send request + _template.update(**kwargs) + + response = self.http_call( + "put", + url=f"{self.base_url}/templates/{_template['template_id']}", + json_data=_template, + ) + # Return JSON and handle Any type errors + return cast(dict[str, Any], response.json()) + + def create_template(self, **kwargs: Any) -> dict[str, Any]: + """ + Creates a template by giving its attributes. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templates.html#post-v2-templates) + + **Required Attributes:** + + - `name` + - `compute_id` by default is 'local' + - `template_type` + + **Optional Attributes (can be passed via kwargs):** + + - `tags` (list): List of tags for the template (e.g., + ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - Any other template attributes supported by GNS3 API + + **Example:** + + ```python + >>> connector.create_template( + ... name="cisco_router", + ... template_type="dynamips", + ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"] + ... ) + ``` + """ + # kwargs["name"] might raise KeyError at runtime, for more robust + # code we can use get first + template_name = kwargs.get("name") + if not template_name: + raise ValueError( + "Attribute 'name' is required to create a template" + ) + + # Check if template already exists + _template = self.get_template(name=kwargs["name"]) + if _template: + raise ValueError(f"Template already used: {kwargs['name']}") + + # Set default values + if "compute_id" not in kwargs: + kwargs["compute_id"] = "local" + + # Send request + response = self.http_call( + "post", url=f"{self.base_url}/templates", json_data=kwargs + ) + # Return and convert type + return cast(dict[str, Any], response.json()) + + def delete_template( + self, name: str | None = None, template_id: str | None = None + ) -> None: + """ + Deletes a template by giving its attributes. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templatestemplateid.html#id16) + + **Required Attributes:** + + - `name` or `template_id` + """ + # Logic handling: if only name is given, need to first get template_id + if name and not template_id: + _template = self.get_template(name=name) + # Type narrowing: check if _template is None + if _template is None: + raise ValueError(f"Template with name '{name}' not found.") + + template_id = _template["template_id"] + + # Final check: ensure template_id has a value at this point + if not template_id: + raise ValueError( + "Must provide either a 'name' or 'template_id' to delete a template." + ) + + self.http_call( + "delete", url=f"{self.base_url}/templates/{template_id}" + ) + + def get_nodes(self, project_id: str) -> list[dict[str, Any]]: + """ + Retieves the nodes defined on the project + + **Required Attributes:** + + - `project_id` + """ + _response_data = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/nodes" + ).json() + + return cast(list[dict[str, Any]], _response_data) + + def get_node(self, project_id: str, node_id: str) -> dict[str, Any]: + """ + Returns the node by locating its ID. + + **Required Attributes:** + + - `project_id` + - `node_id` + """ + _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" + _response_data = self.http_call("get", _url).json() + return cast(dict[str, Any], _response_data) + + def get_links(self, project_id: str) -> list[dict[str, Any]]: + """ + Retrieves the links defined in the project. + + **Required Attributes:** + + - `project_id` + """ + _response_data = self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/links" + ).json() + + return cast(list[dict[str, Any]], _response_data) + + def get_link(self, project_id: str, link_id: str) -> dict[str, Any]: + """ + Returns the link by locating its ID. + + **Required Attributes:** + + - `project_id` + - `link_id` + """ + _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" + _response_data = self.http_call("get", _url).json() + + return cast(dict[str, Any], _response_data) + + def create_project(self, **kwargs: Any) -> dict[str, Any]: + """ + Pass a dictionary type object with the project parameters to be created. + + **Required Attributes:** + + - `name` + + **Returns** + + JSON project information + """ + _url = f"{self.base_url}/projects" + if "name" not in kwargs: + raise ValueError("Parameter 'name' is mandatory") + _response = self.http_call("post", _url, json_data=kwargs) + + return cast(dict[str, Any], _response.json()) + + def delete_project(self, project_id: str) -> None: + """ + Deletes a project from server. + + **Required Attributes:** + + - `project_id` + """ + _url = f"{self.base_url}/projects/{project_id}" + self.http_call("delete", _url) + return None + + def get_computes(self) -> list[dict[str, Any]]: + """ + Returns a list of computes. + + **Returns:** + + List of dictionaries of the computes attributes like cpu/memory usage + """ + _url = f"{self.base_url}/computes" + _response_data = self.http_call("get", _url).json() + + return cast(list[dict[str, Any]], _response_data) + + def get_compute(self, compute_id: str = "local") -> dict[str, Any]: + """ + Returns a compute. + + **Returns:** + + Dictionary of the compute attributes like cpu/memory usage + """ + _url = f"{self.base_url}/computes/{compute_id}" + _response_data = self.http_call("get", _url).json() + + return cast(dict[str, Any], _response_data) + + def get_compute_images( + self, emulator: str, compute_id: str = "local" + ) -> list[dict[str, Any]]: + """ + Returns a list of images available for a compute. + + **Required Attributes:** + + - `emulator`: the likes of 'qemu', 'iou', 'docker' ... + - `compute_id` By default is 'local' + + **Returns:** + + List of dictionaries with images available for the compute for the specified + emulator + """ + _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images" + _response_data = self.http_call("get", _url).json() + + return cast(list[dict[str, Any]], _response_data) + + def upload_compute_image( + self, emulator: str, file_path: str, compute_id: str = "local" + ) -> None: + """ + uploads an image for use by a compute. + + **Required Attributes:** + + - `emulator`: the likes of 'qemu', 'iou', 'docker' ... + - `file_path`: path of file to be uploaded + - `compute_id` By default is 'local' + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"Could not find file: {file_path}") + + _filename = os.path.basename(file_path) + _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images/{_filename}" + with open(file_path, "rb") as f: + self.http_call("post", _url, data=f) + + return None + + def get_compute_ports(self, compute_id: str = "local") -> dict[str, Any]: + """ + Returns ports used and configured by a compute. + + **Required Attributes:** + + - `compute_id` By default is 'local' + + **Returns:** + + Dictionary of `console_ports` used and range, as well as the `udp_ports` + """ + _url = f"{self.base_url}/computes/{compute_id}/ports" + _response_data = self.http_call("get", _url).json() + + return cast(dict[str, Any], _response_data) + + +def verify_connector_and_id(f: F) -> F: + """ + Main checker for connector object and respective object's ID for their retrieval + or actions methods. + """ + + @wraps(f) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + _conn = self.connector + _project_id = self.project_id + + if _conn is None: + raise ValueError("Gns3Connector not assigned under 'connector'") + if _project_id is None: + raise ValueError("Need to submit project_id") + + # Checks for Node + if self.__class__.__name__ == "Node": + if not self.node_id: + if not self.name: + raise ValueError("Need to either submit node_id or name") + + # Try to retrieve the node_id + _url = f"{_conn.base_url}/projects/{_project_id}/nodes" + _response = _conn.http_call("get", _url) + + extracted = [ + node + for node in _response.json() + if node["name"] == self.name + ] + if len(extracted) > 1: # pragma: no cover + raise ValueError( + "Multiple nodes found with same name. Need to submit node_id" + ) + self.node_id = extracted[0]["node_id"] + # Checks for Link + if self.__class__.__name__ == "Link": + if not self.link_id: + raise ValueError("Need to submit link_id") + return f(self, *args, **kwargs) + + return cast(F, wrapper) + + +@dataclass(config=config) +class Link: + """ + GNS3 Link API object. For more information visit: [Links Endpoint API information]( + http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html) + + **Attributes:** + + - `link_id` (str): Link UUID (**required** to be set when using `get` method) + - `link_type` (enum): Possible values: ethernet, serial + - `link_style` (dict): Describes the visual style of the link + - `project_id` (str): Project UUID (**required**) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `suspend` (bool): Suspend the link + - `nodes` (list): List of the Nodes and ports (**required** when using `create` + method, see Features/Link creation on the docs) + - `filters` (dict): Packet filter. This allow to simulate latency and errors + - `capturing` (bool): Read only property. True if a capture running on the link + - `capture_file_path` (str): Read only property. The full path of the capture file + if capture is running + - `capture_file_name` (str): Read only property. The name of the capture file if + capture is running + + **Returns:** + + `Link` instance + + **Example:** + + ```python + >>> link = Link(project_id=, link_id= connector=) + >>> link.get() + >>> print(link.link_type) + 'ethernet' + ``` + """ + + link_id: str | None = None + link_type: str | None = None + link_style: Any | None = None + project_id: str | None = None + suspend: bool | None = None + nodes: list[Any] | None = None + filters: dict | None = None + capturing: bool | None = None + capture_file_path: str | None = None + capture_file_name: str | None = None + capture_compute_id: str | None = None + + connector: Any | None = field(default=None, repr=False) + + @field_validator("link_type") + @classmethod + def _valid_link_type(cls, value: str | None) -> str | None: + if value not in LINK_TYPES and value is not None: + raise ValueError(f"Not a valid link_type - {value}") + return value + + @field_validator("suspend") + @classmethod + def _valid_suspend(cls, value: bool | None) -> bool | None: + if type(value) is not bool and value is not None: + raise ValueError(f"Not a valid suspend - {value}") + return value + + @field_validator("filters") + @classmethod + def _valid_filters( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: + if type(value) is not dict and value is not None: + raise ValueError(f"Not a valid filters - {value}") + return value + + def _update(self, data_dict: dict[str, Any]) -> None: + for k, v in data_dict.items(): + if k in self.__dict__.keys(): + self.__setattr__(k, v) + + @verify_connector_and_id + def get(self) -> None: + """ + Retrieves the information from the link endpoint. + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + """ + _conn = self.connector + _project_id = self.project_id + + if _conn is None: + raise ValueError("Gns3Connector not assigned under 'connector'") + if _project_id is None: + raise ValueError("Need to submit project_id") + + _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" + _response = _conn.http_call("get", _url) + + # Update object + self._update(_response.json()) + + @verify_connector_and_id + def delete(self) -> None: + """ + Deletes a link endpoint from the project. It sets to `None` the attributes + `link_id` when executed sucessfully + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + """ + _conn = self.connector + _project_id = self.project_id + _link_id = self.link_id + + if _conn is None: + raise ValueError("Gns3Connector not assigned under 'connector'") + if _project_id is None: + raise ValueError("Need to submit project_id") + if _link_id is None: + raise ValueError( + "Link ID is missing. The link might have already been deleted." + ) + + _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" + + _conn.http_call("delete", _url) + + self.project_id = None + self.link_id = None + + def create(self) -> None: + """ + Creates a link endpoint + + **Required Attributes:** + + - `project_id` + - `connector` + - `nodes` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = f"{self.connector.base_url}/projects/{self.project_id}/links" + + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("connector", "__initialised__") + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + + # Now update it + self._update(_response.json()) + + @verify_connector_and_id + def update(self, **kwargs: Any) -> None: + """ + Updates the link instance by passing the keyword arguments of the attributes + you want updated + + Example: + + ```python + link1.update(suspend=True) + ``` + + This will update the link `suspend` attribute to `True` + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/links/" + f"{self.link_id}" + ) + + # TODO: Verify that the passed kwargs are supported ones + _response = self.connector.http_call("put", _url, json_data=kwargs) + + # Update object + self._update(_response.json()) + + +@dataclass(config=config) +class Node: + """ + GNS3 Node API object. For more information visit: [Node Endpoint API information]( + http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html) + + **Attributes:** + + - `name` (str): Node name (**required** when using `create` method) + - `project_id` (str): Project UUID (**required**) + - `node_id` (str): Node UUID (**required** when using `get` method) + - `compute_id` (str): Compute identifier (**required**, default=local) + - `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs, + traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `template_id`: Template UUID from the which the node is from. + - `template`: Template name from the which the node is from. + - `node_directory` (str): Working directory of the node. Read only + - `status` (enum): Possible values: stopped, started, suspended + - `ports` (list): List of node ports, READ only + - `port_name_format` (str): Formating for port name {0} will be replace by port + number + - `port_segment_size` (int): Size of the port segment + - `first_port_name` (str): Name of the first port + - `properties` (dict): Properties specific to an emulator + - `locked` (bool): Whether the element locked or not + - `label` (dict): TBC + - `console` (int): Console TCP port + - `console_host` (str): Console host + - `console_auto_start` (bool): Automatically start the console when the node has + started + - `command_line` (str): Command line use to start the node + - `custom_adapters` (list): TBC + - `height` (int): Height of the node, READ only + - `width` (int): Width of the node, READ only + - `symbol` (str): Symbol of the node + - `x` (int): X position of the node + - `y` (int): Y position of the node + - `z (int): Z position of the node + + **Returns:** + + `Node` instance + + **Example:** + + ```python + >>> alpine = Node(name="alpine1", node_type="docker", template="alpine", + project_id=, connector=) + >>> alpine.create() + >>> print(alpine.node_id) + 'SOME-UUID-GENERATED' + ``` + """ + + name: str | None = None + project_id: str | None = None + node_id: str | None = None + compute_id: str = "local" + node_type: str | None = None + node_directory: str | None = None + status: str | None = None + ports: list | None = None + port_name_format: str | None = None + port_segment_size: int | None = None + first_port_name: str | None = None + locked: bool | None = None + label: Any | None = None + console: int | None = None + console_host: str | None = None + console_type: str | None = None + console_auto_start: bool | None = None + command_line: str | None = None + custom_adapters: list[Any] | None = None + height: int | None = None + width: int | None = None + symbol: str | None = None + x: int | None = None + y: int | None = None + z: int | None = None + template_id: str | None = None + properties: Any | None = None + tags: list[str] | None = None + + template: str | None = None + links: list[Link] = field(default_factory=list, repr=False) + connector: Any | None = field(default=None, repr=False) + + @field_validator("node_type") + @classmethod + def _valid_node_type(cls, value: Any) -> Any: + if value not in NODE_TYPES and value is not None: + raise ValueError(f"Not a valid node_type - {value}") + return value + + @field_validator("console_type") + @classmethod + def _valid_console_type(cls, value: Any) -> Any: + if value not in CONSOLE_TYPES and value is not None: + raise ValueError(f"Not a valid console_type - {value}") + return value + + @field_validator("status") + @classmethod + def _valid_status(cls, value: Any) -> Any: + if ( + value not in ("stopped", "started", "suspended") + and value is not None + ): + raise ValueError(f"Not a valid status - {value}") + return value + + def _update(self, data_dict: dict[str, Any]) -> None: + for k, v in data_dict.items(): + if k in self.__dict__: + setattr(self, k, v) + + @verify_connector_and_id + def get(self, get_links: bool = True) -> None: + """ + Retrieves the node information. When `get_links` is `True` it also retrieves the + links respective to the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes/" + f"{self.node_id}" + ) + _response = self.connector.http_call("get", _url) + + # Update object + self._update(_response.json()) + + if get_links: + self.get_links() + + @verify_connector_and_id + def get_links(self) -> None: + """ + Retrieves the links of the respective node. They will be saved at the `links` + attribute + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/links" + ) + _response = self.connector.http_call("get", _url) + + # Create the Link array but cleanup cache if there is one + if self.links: + self.links = [] + for _link in _response.json(): + self.links.append(Link(connector=self.connector, **_link)) + + @verify_connector_and_id + def start(self) -> bool | None: + """ + Starts the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/start" + if "v2" in _url.lower(): # api_version 2 + _response = _conn.http_call( + "post", + _url, + ) + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "started": + self._update(_response.json()) + else: + self.get() # pragma: no cover + + return True + + else: + # api_version 3 + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) + # successful response code 204 + if _response.status_code in (204,): + self.get() + return True + else: + try: + error_detail = _response.json() + except Exception: + error_detail = getattr( + _response, "text", "No response body" + ) + + _msg = ( + "Failed to start node: " + f"{getattr(_response, 'status_code', 'Unknown Status')}, " + f"Detail: {error_detail}" + ) + raise RuntimeError(_msg) from None + + @verify_connector_and_id + def stop(self) -> bool | None: + """ + Stops the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/stop" + if "v2" in _url.lower(): # api_version 2 + _response = _conn.http_call( + "post", + _url, + ) + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "stopped": + self._update(_response.json()) + else: + self.get() # pragma: no cover + + return True + else: + # api_version 3 + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) + # successful response code 204 + if _response.status_code in (204,): + self.get() + return True + else: + try: + error_detail = _response.json() + except Exception: + error_detail = _response.text + _msg = ( + f"Failed to stop node: {_response.status_code}, " + f"Detail: {error_detail}" + ) + raise RuntimeError(_msg) from None + + @verify_connector_and_id + def reload(self) -> bool | None: + """ + Reloads the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = ( + f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload" + ) + _response = _conn.http_call("post", _url) + + if "v2" in _url.lower(): # api_version 2 + _response = _conn.http_call( + "post", + _url, + ) + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "started": + self._update(_response.json()) + else: + self.get() # pragma: no cover + return True + + else: + # api_version 3 + _response = _conn.http_call( + "post", _url, json_data={"additionalProp1": {}} + ) + # successful response code 204 + if _response.status_code in (204,): + self.get() + return True + else: + try: + error_detail = _response.json() + except Exception: + error_detail = _response.text + _msg = ( + f"Failed to reload node: {_response.status_code}, " + f"Detail: {error_detail}" + ) + raise RuntimeError(_msg) from None + + @verify_connector_and_id + def suspend(self) -> None: + """ + Suspends the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = ( + f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend" + ) + _response = _conn.http_call("post", _url) + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "suspended": + self._update(_response.json()) + else: + self.get() # pragma: no cover + + @verify_connector_and_id + def update(self, **kwargs: Any) -> None: + """ + Updates the node instance by passing the keyword arguments of the attributes + you want updated + + Example: + + ```python + router01.update(name="router01-CSX") + ``` + + This will update the project `auto_close` attribute to `True` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" + + # TODO: Verify that the passed kwargs are supported ones + _response = _conn.http_call("put", _url, json_data=kwargs) + + # Update object + self._update(_response.json()) + + def create(self) -> None: + """ + Creates a node. + + By default it will fetch the nodes properties for creation based on the + `template` or `template_id` attribute supplied. This can be overriden/updated + by sending a dictionary of the properties under `extra_properties`. + + **Required Node instance attributes:** + + - `project_id` + - `connector` + - `compute_id`: Defaults to "local" + - `template` or `template_id` - if not passed as arguments + """ + if self.node_id: + raise ValueError("Node already created") + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Node object needs to have project_id attribute") + if not self.template_id: + if self.template: + _template = self.connector.get_template(name=self.template) + if _template is None: + raise ValueError(f"Template {self.template} not found") + self.template_id = self.connector.get_template( + name=self.template + ).get("template_id") + else: + raise ValueError("Need either 'template' of 'template_id'") + + cached_data = { + k: v + for k, v in self.__dict__.items() + if k + not in ( + "project_id", + "template", + "template_id", + "links", + "connector", + "__initialised__", + ) + if v is not None + } + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/" + f"templates/{self.template_id}" + ) + + _response = self.connector.http_call( + "post", + _url, + json_data={"x": 0, "y": 0, "compute_id": self.compute_id}, + ) + + self._update(_response.json()) + + # Update the node attributes based on cached data + self.update(**cached_data) + + @verify_connector_and_id + def delete(self) -> None: + """ + Deletes the node from the project. It sets to `None` the attributes `node_id` + and `name` when executed successfully + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" + + _conn.http_call("delete", _url) + + self.project_id = None + self.node_id = None + self.name = None + + @verify_connector_and_id + def get_file(self, path: str) -> str: + """ + Retrieve a file in the node directory. + + **Required Attributes:** + + - `project_id` + - `connector` + - `path`: Node's relative path of the file + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" + + return cast(str, _conn.http_call("get", _url).text) + + @verify_connector_and_id + def write_file(self, path: str, data: Any) -> None: + """ + Places a file content on a specified node file path. Used mainly for docker + images. + + Example to update an alpine docker network interfaces: + + ```python + >>> data = ''' + auto eth0 + iface eth0 inet dhcp + ''' + + >>> alpine_node.write_file(path='/etc/network/interfaces', data=data) + ``` + + **Required Attributes:** + + - `project_id` + - `connector` + - `path`: Node's relative path of the file + - `data`: Data to be included in the file + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + _node_id = self.node_id + assert _node_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" + + _conn.http_call("post", _url, data=data) + + +@dataclass(config=config) +class Project: + """ + GNS3 Project API object. For more information visit: [Project Endpoint API + information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html) + + **Attributes:** + + - `name`: Project name (**required** when using `create` method) + - `project_id` (str): Project UUID (**required**) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `status` (enum): Possible values: opened, closed + - `path` (str): Path of the project on the server + - `filename` (str): Project filename + - `auto_start` (bool): Project start when opened + - `auto_close` (bool): Project auto close when client cut off the notifications feed + - `auto_open` (bool): Project open when GNS3 start + - `drawing_grid_size` (int): Grid size for the drawing area for drawings + - `grid_size` (int): Grid size for the drawing area for nodes + - `scene_height` (int): Height of the drawing area + - `scene_width` (int): Width of the drawing area + - `show_grid` (bool): Show the grid on the drawing area + - `show_interface_labels` (bool): Show interface labels on the drawing area + - `show_layers` (bool): Show layers on the drawing area + - `snap_to_grid` (bool): Snap to grid on the drawing area + - `supplier` (dict): Supplier of the project + - `variables` (list): Variables required to run the project + - `zoom` (int): Zoom of the drawing area + - `stats` (dict): Project stats + -.`drawings` (list): List of drawings present on the project + - `nodes` (list): List of `Node` instances present on the project + - `links` (list): List of `Link` instances present on the project + + **Returns:** + + `Project` instance + + **Example:** + + ```python + >>> lab = Project(name="lab", connector=) + >>> lab.create() + >>> print(lab.status) + 'opened' + ``` + """ + + name: str | None = None + project_id: str | None = None + status: str | None = None + locked: bool | None = None + path: str | None = None + filename: str | None = None + auto_start: bool | None = None + auto_close: bool | None = None + auto_open: bool | None = None + drawing_grid_size: int | None = None + grid_size: int | None = None + scene_height: int | None = None + scene_width: int | None = None + show_grid: bool | None = None + show_interface_labels: bool | None = None + show_layers: bool | None = None + snap_to_grid: bool | None = None + supplier: Any | None = None + variables: list | None = None + zoom: int | None = None + + stats: dict[str, Any] | None = None + snapshots: list[dict] | None = None + drawings: list[dict] | None = None + nodes: list[Node] = field(default_factory=list, repr=False) + links: list[Link] = field(default_factory=list, repr=False) + connector: Any | None = field(default=None, repr=False) + + @field_validator("status") + @classmethod + def _valid_status(cls, value: Any) -> Any: + if value != "opened" and value != "closed" and value is not None: + raise ValueError("status must be opened or closed") + return value + + def _update(self, data_dict: dict[str, Any]) -> None: + for k, v in data_dict.items(): + if k in self.__dict__: + setattr(self, k, v) + + def get( + self, + get_links: bool = True, + get_nodes: bool = True, + get_stats: bool = True, + ) -> None: + """ + Retrieves the projects information. + + - `get_links`: When true it also queries for the links inside the project + - `get_nodes`: When true it also queries for the nodes inside the project + - `get_stats`: When true it also queries for the stats inside the project + + It `get_stats` is set to `True`, it also verifies if snapshots and drawings are + inside the project and stores them in their respective attributes + (`snapshots` and `drawings`) + + **Required Attributes:** + + - `connector` + - `project_id` or `name` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + + # Get projects if no ID was provided by the name + if not self.project_id: + if not self.name: + raise ValueError("Need to submit either project_id or name") + _url = f"{self.connector.base_url}/projects" + # Get all projects and filter the respective project + _response = self.connector.http_call("get", _url) + + # Filter the respective project + for _project in _response.json(): + if _project.get("name") == self.name: + self.project_id = _project.get("project_id") + + # Get project + _url = f"{self.connector.base_url}/projects/{self.project_id}" + _response = self.connector.http_call("get", _url) + + # Update object + self._update(_response.json()) + + if get_stats: + self.get_stats() + if self.stats is not None: + if self.stats.get("snapshots", 0) > 0: + self.get_snapshots() + if self.stats.get("drawings", 0) > 0: + self.get_drawings() + if get_nodes: + self.get_nodes() + if get_links: + self.get_links() + + def create(self) -> None: + """ + Creates the project. + + **Required Attributes:** + + - `name` + - `connector` + """ + if not self.name: + raise ValueError("Need to submit project name") + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + + _url = f"{self.connector.base_url}/projects" + + data = { + k: v + for k, v in self.__dict__.items() + if k + not in ( + "stats", + "nodes", + "links", + "connector", + "__initialised__", + ) + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + + # Now update it + self._update(_response.json()) + + @verify_connector_and_id + def update(self, **kwargs: Any) -> None: + """ + Updates the project instance by passing the keyword arguments of the attributes + you want updated + + Example: + + ```python + lab.update(auto_close=True) + ``` + + This will update the project `auto_close` attribute to `True` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}" + + # TODO: Verify that the passed kwargs are supported ones + _response = _conn.http_call("put", _url, json_data=kwargs) + + # Update object + self._update(_response.json()) + + @verify_connector_and_id + def delete(self) -> None: + """ + Deletes the project from the server. It sets to `None` the attributes + `project_id` and `name` when executed successfully + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}" + + _conn.http_call("delete", _url) + + self.project_id = None + self.name = None + + @verify_connector_and_id + def close(self) -> None: + """ + Closes the project on the server. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/close" + + _response = _conn.http_call("post", _url) + + # Update object + if _response.status_code == 204: + self.status = "closed" + + @verify_connector_and_id + def open(self) -> None: + """ + Opens the project on the server. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/open" + + _response = _conn.http_call("post", _url) + + # Update object + self._update(_response.json()) + + @verify_connector_and_id + def get_stats(self) -> None: + """ + Retrieve the stats of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/stats" + + _response = _conn.http_call("get", _url) + + # Update object + self.stats = _response.json() + + @verify_connector_and_id + def get_file(self, path: str) -> str: + """ + Retrieve a file in the project directory. Beware you have warranty to be able to + access only to file global to the project. + + **Required Attributes:** + + - `project_id` + - `connector` + - `path`: Project's relative path of the file + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" + + return cast(str, _conn.http_call("get", _url).text) + + @verify_connector_and_id + def write_file(self, path: str, data: Any) -> None: + """ + Places a file content on a specified project file path. Beware you have warranty + to be able to access only to file global to the project. + + Example to create a README.txt for the project: + + ```python + >>> data = ''' + This is a README description! + ''' + + >>> project.write_file(path='README.txt', data=data) + ``` + + **Required Attributes:** + + - `project_id` + - `connector` + - `path`: Project's relative path of the file + - `data`: Data to be included in the file + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" + + _conn.http_call("post", _url, data=data) + + @verify_connector_and_id + def get_nodes(self) -> None: + """ + Retrieve the nodes of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes" + + _response = _conn.http_call("get", _url) + + # Create the Nodes array but cleanup cache if there is one + if self.nodes: + self.nodes = [] + for _node in _response.json(): + _n = Node(connector=self.connector, **_node) + _n.project_id = self.project_id + self.nodes.append(_n) + + @verify_connector_and_id + def get_links(self) -> None: + """ + Retrieve the links of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/links" + + _response = _conn.http_call("get", _url) + + # Create the Nodes array but cleanup cache if there is one + if self.links: + self.links = [] + for _link in _response.json(): + _l = Link(connector=self.connector, **_link) + _l.project_id = self.project_id + self.links.append(_l) + + @verify_connector_and_id + def start_nodes(self, poll_wait_time: int = 5) -> None: + """ + Starts all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/start" + + _conn.http_call("post", _url) + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + @verify_connector_and_id + def stop_nodes(self, poll_wait_time: int = 5) -> None: + """ + Stops all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/stop" + + _conn.http_call("post", _url) + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + @verify_connector_and_id + def reload_nodes(self, poll_wait_time: int = 5) -> None: + """ + Reloads all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/reload" + + _conn.http_call("post", _url) + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + @verify_connector_and_id + def suspend_nodes(self, poll_wait_time: int = 5) -> None: + """ + Suspends all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/nodes/suspend" + + _conn.http_call("post", _url) + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + def nodes_summary( + self, is_print: bool = True + ) -> list[tuple[Any, ...]] | None: + """ + Returns a summary of the nodes insode the project. If `is_print` is `False`, it + will return a list of tuples like: + + `[(node_name, node_status, node_console, node_id) ...]` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + + if not self.nodes: + self.get_nodes() + + _nodes_summary = [] + for _n in self.nodes: + if is_print: + print( + f"{_n.name}: {_n.status} -- Console: {_n.console} -- " + f"ID: {_n.node_id}" + ) + _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) + + return _nodes_summary if not is_print else None + + def nodes_inventory(self) -> dict[str | None, Any]: + """ + Returns an inventory-style dictionary of the nodes + + Example: + + `{ + "router01": { + "server": "127.0.0.1", + "name": "router01", + "node_id": uuid, + "console_port": 5077, + "type": "vEOS", + "ports": "[port detila]", + "x": 100, + "y": 200 + } + }` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + + if not self.nodes: + self.get_nodes() + + _nodes_inventory = {} + conn = self.connector + if not conn: + raise ValueError( + "Gns3Connector not assigned. Please set the connector first." + ) + + _server = urlparse(conn.base_url).hostname + + for _n in self.nodes: + _nodes_inventory.update( + { + _n.name: { + "server": _server, + "name": _n.name, + "node_id": _n.node_id, + "console_port": _n.console, + "console_type": _n.console_type, + "type": _n.node_type, + "ports": _n.ports, + "status": _n.status, + # "template": _n.template, + "x": _n.x, + "y": _n.y, + "tags": _n.tags if _n.tags else [], + } + } + ) + + return _nodes_inventory + + def links_summary( + self, is_print: bool = True + ) -> list[dict[str, str]] | None: + """ + Returns a summary of the links inside the project. If `is_print` is False, + it will return a list of dicts like: + + `[{"link_id": "xxx", "node_a": "R1", "port_a": "Eth0/0", "node_b": "R2", "port_b": "Eth0/0"}, ...]` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + # Ensure data is loaded + if not self.nodes: + self.get_nodes() + if not self.links: + self.get_links() + # If None, program errors here instead of continuing + assert self.links is not None, "Links must be loaded" + assert self.nodes is not None, "Nodes must be loaded" + + _links_summary: list[dict[str, str]] = [] + + for _l in self.links: + if not _l.nodes: + continue + _side_a = _l.nodes[0] + _side_b = _l.nodes[1] + + try: + # Add type-safe lookup logic + _node_a = next( + x for x in self.nodes if x.node_id == _side_a["node_id"] + ) + # Ensure getting str to resolve [return-value] error + _port_a = str( + next( + x["name"] + for x in (_node_a.ports or []) + if x["port_number"] == _side_a["port_number"] + and x["adapter_number"] == _side_a["adapter_number"] + ) + ) + + _node_b = next( + x for x in self.nodes if x.node_id == _side_b["node_id"] + ) + _port_b = str( + next( + x["name"] + for x in (_node_b.ports or []) + if x["port_number"] == _side_b["port_number"] + and x["adapter_number"] == _side_b["adapter_number"] + ) + ) + + # Ensure name is not None + name_a = str(_node_a.name) if _node_a.name else "Unknown" + name_b = str(_node_b.name) if _node_b.name else "Unknown" + + endpoint_a = f"{name_a}: {_port_a}" + endpoint_b = f"{name_b}: {_port_b}" + + if is_print: + print(f"{endpoint_a} ---- {endpoint_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 + continue + return _links_summary if not is_print else None + + def _search_node(self, key: str, value: Any) -> Any | None: + "Performs a search based on a key and value" + # Retrive nodes if neccesary + if not self.nodes: + self.get_nodes() + + try: + return [_p for _p in self.nodes if getattr(_p, key) == value][0] + except IndexError: + return None + + def get_node( + self, name: str | None = None, node_id: str | None = None + ) -> Any | None: + """ + Returns the Node object by searching for the `name` or the `node_id`. + + **Required Attributes:** + + - `project_id` + - `connector` + + **Required keyword arguments:** + - `name` or `node_id` + + **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if + necessary + """ + if node_id: + return self._search_node(key="node_id", value=node_id) + elif name: + return self._search_node(key="name", value=name) + else: + raise ValueError("name or node_ide must be provided") + + def _search_link(self, key: str, value: Any) -> Any | None: + "Performs a search based on a key and value" + # Retrive links if neccesary + if not self.links: + self.get_links() + + try: + return next(_p for _p in self.links if getattr(_p, key) == value) + except StopIteration: + return None + + def get_link(self, link_id: str) -> Any | None: + """ + Returns the Link object by locating its ID + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + + **NOTE:** Run method `get_links()` manually to refresh list of links if + necessary + """ + return self._search_link(key="link_id", value=link_id) + + def create_node(self, **kwargs: Any) -> None: + """ + Creates a node. To know available parameters see `Node` object, specifically + the `create` method. The most basic example would be: + + ```python + project.create_node(name='test-switch01', template='Ethernet switch') + ``` + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `template` or `template_id` + """ + if not self.nodes: + self.get_nodes() + + _node = Node( + project_id=self.project_id, connector=self.connector, **kwargs + ) + + _node.create() + self.nodes.append(_node) + print( + f"Created: {_node.name} -- Type: {_node.node_type} -- " + f"Console: {_node.console}" + ) + + def create_link( + self, node_a: str, port_a: str, node_b: str, port_b: str + ) -> None: + """ + Creates a link. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_a`: Node name of the A side + - `port_a`: Port name of the A side (must match the `name` attribute of the + port) + - `node_b`: Node name of the B side + - `port_b`: Port name of the B side (must match the `name` attribute of the + port) + """ + if not self.nodes: + self.get_nodes() + if not self.links: + self.get_links() + + _node_a = self.get_node(name=node_a) + if not _node_a: + raise ValueError(f"node_a: {node_a} not found") + try: + _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] + except IndexError: + raise ValueError(f"port_a: {port_a} not found") from None + + _node_b = self.get_node(name=node_b) + if not _node_b: + raise ValueError(f"node_b: {node_b} not found") + try: + _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] + except IndexError: + raise ValueError(f"port_b: {port_b} not found") from None + + _matches = [] + for _l in self.links: + if not _l.nodes: + continue + if ( + _l.nodes[0]["node_id"] == _node_a.node_id + and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] + and _l.nodes[0]["port_number"] == _port_a["port_number"] + ): + _matches.append(_l) + elif ( + _l.nodes[1]["node_id"] == _node_b.node_id + and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] + and _l.nodes[1]["port_number"] == _port_b["port_number"] + ): + _matches.append(_l) # pragma: no cover + if _matches: + raise ValueError( + f"At least one port is used, ID: {_matches[0].link_id}" + ) + + # Now create the link! + _link = Link( + project_id=self.project_id, + connector=self.connector, + nodes=[ + { + "node_id": _node_a.node_id, + "adapter_number": _port_a["adapter_number"], + "port_number": _port_a["port_number"], + "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.get("short_name") or _port_b["name"]}, + }, + ], + ) + + _link.create() + self.links.append(_link) + print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") + + def delete_link( + self, node_a: str, port_a: str, node_b: str, port_b: str + ) -> None: + """ + Deletes a link. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_a`: Node name of the A side + - `port_a`: Port name of the A side (must match the `name` attribute of the + port) + - `node_b`: Node name of the B side + - `port_b`: Port name of the B side (must match the `name` attribute of the + port) + """ + if not self.nodes: + self.get_nodes() # pragma: no cover + if not self.links: + self.get_links() # pragma: no cover + + # checking link info + _node_a = self.get_node(name=node_a) + if not _node_a: + raise ValueError(f"node_a: {node_a} not found") + try: + _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] + except IndexError: + raise ValueError(f"port_a: {port_a} not found") from None + + _node_b = self.get_node(name=node_b) + if not _node_b: + raise ValueError(f"node_b: {node_b} not found") + try: + _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] + except IndexError: + raise ValueError(f"port_b: {port_b} not found") from None + + _matches = [] + for _l in self.links: + if not _l.nodes: + continue + if ( + _l.nodes[0]["node_id"] == _node_a.node_id + and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] + and _l.nodes[0]["port_number"] == _port_a["port_number"] + ): + _matches.append(_l) + elif ( + _l.nodes[1]["node_id"] == _node_b.node_id + and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] + and _l.nodes[1]["port_number"] == _port_b["port_number"] + ): + _matches.append(_l) + if not _matches: + raise ValueError( + f"Link not found: {node_a, port_a, node_b, port_b}" + ) # pragma: no cover + + # now to delete the link via GNS3_api + _link = _matches[0] + self.links.remove(_link) + _link_id = _link.link_id + _link.delete() + print( + f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> " + f"to node {node_b}, port: {port_b}" + ) + + @verify_connector_and_id + def get_snapshots(self) -> None: + """ + Retrieves list of snapshots of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/snapshots" + + _response = _conn.http_call("get", _url) + self.snapshots = _response.json() + + def _search_snapshot(self, key: str, value: Any) -> dict[str, Any] | None: + "Performs a search based on a key and value" + if not self.snapshots: + self.get_snapshots() + + try: + return next( + _p for _p in (self.snapshots or []) if _p[key] == value + ) + except StopIteration: + return None + + def get_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> dict[str, Any] | None: + """ + Returns the Snapshot by searching for the `name` or the `snapshot_id`. + + **Required Attributes:** + + - `project_id` + - `connector` + + **Required keyword arguments:** + - `name` or `snapshot_id` + """ + if snapshot_id: + return self._search_snapshot(key="snapshot_id", value=snapshot_id) + elif name: + return self._search_snapshot(key="name", value=name) + else: + raise ValueError("name or snapshot_id must be provided") + + @verify_connector_and_id + def create_snapshot(self, name: str) -> None: + """ + Creates a snapshot of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `name` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + self.get_snapshots() + + _snapshot = self.get_snapshot(name=name) + if _snapshot: + raise ValueError("Snapshot already created") + + _url = f"{_conn.nector.base_url}/projects/{_project_id}/snapshots" + + _response = _conn.http_call("post", _url, json_data={"name": name}) + + _snapshot = _response.json() + + if self.snapshots is None: + self.snapshots = [] + + self.snapshots.append(_snapshot) + print(f"Created snapshot: {_snapshot['name']}") + + @verify_connector_and_id + def delete_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> None: + """ + Deletes a snapshot of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `name` or `snapshot_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + self.get_snapshots() + + _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) + if not _snapshot: + raise ValueError("Snapshot not found") + + _url = ( + f"{_conn.base_url}/projects/{_project_id}/snapshots/" + f"{_snapshot['snapshot_id']}" + ) + + _conn.http_call("delete", _url) + + self.get_snapshots() + + @verify_connector_and_id + def restore_snapshot( + self, name: str | None = None, snapshot_id: str | None = None + ) -> None: + """ + Restore a snapshot from disk + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `name` or `snapshot_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + self.get_snapshots() + + _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) + if not _snapshot: + raise ValueError("Snapshot not found") + + _url = ( + f"{_conn.base_url}/projects/{_project_id}/snapshots/" + f"{_snapshot['snapshot_id']}/restore" + ) + + _conn.http_call("post", _url) + + # Update the whole project + self.get() + + def arrange_nodes_circular(self, radius: int = 120) -> None: + """ + Re-arrgange the existing nodes + in a circular fashion + + **Attributes:** + + - project instance created + + **Example** + + ```python + >>> proj = Project(name='project_name', connector=Gns3connector) + >>> proj.arrange_nodes() + ``` + """ + + self.get() + if self.status != "opened": + self.open() # pragma: no cover + + _angle = (2 * pi) / len(self.nodes) + # The Y Axis is inverted in GNS3, so the -Y is UP + for index, n in enumerate(self.nodes): + _x = int(radius * (sin(_angle * index))) + _y = int(radius * (-cos(_angle * index))) + n.update(x=_x, y=_y) + + def get_drawing( + self, drawing_id: str | None = None + ) -> dict[str, Any] | None: + """ + Returns the drawing by searching for the `svg` or the `drawing_id`. + + **Required Attributes:** + + - `project_id` + - `connector` + + **Required keyword arguments:** + - `svg` or `drawing_id` + """ + if not self.drawings: + self.get_drawings() + + try: + return next( + _drawing + for _drawing in (self.drawings or []) + if _drawing["drawing_id"] == drawing_id + ) + except (StopIteration, KeyError, TypeError): + return None + + @verify_connector_and_id + def get_drawings(self) -> None: + """ + Retrieves list of drawings of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/drawings" + + _response = _conn.http_call("get", _url) + self.drawings = _response.json() + + @verify_connector_and_id + def create_drawing( + self, + svg: str, + x: int = 0, + y: int = 0, + z: int = 0, + locked: bool = False, + rotation: int = 0, + ) -> dict[str, Any]: + """ + Creates a new drawing in the project + + API: POST /v2/projects/{project_id}/drawings + + Required Project instance attributes: + + - `project_id` + - `connector` + + Required parameters: + + - `svg`: SVG content string + + Optional parameters: + + - `x`: X coordinate (default: 0) + - `y`: Y coordinate (default: 0) + - `z`: Z layer (default: 0) + - `locked`: Whether to lock the drawing (default: False) + - `rotation`: Rotation angle in degrees, range -359 to 359 (default: 0) + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/drawings" + + # Prepare request body + request_body = { + "svg": svg, + "x": x, + "y": y, + "z": z, + "locked": locked, + "rotation": rotation, + } + + # Send POST request to create drawing + _response = _conn.http_call("post", _url, json_data=request_body) + + # Refresh drawings list + self.get_drawings() + + return cast(dict[str, Any], _response.json()) + + @verify_connector_and_id + def update_drawing( + self, + drawing_id: str, + svg: str | None = None, + locked: bool | None = None, + x: int | None = None, + y: int | None = None, + z: int | None = None, + ) -> dict[str, Any]: + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + _url = f"{_conn.base_url}/projects/{_project_id}/drawings/{drawing_id}" + + # Ensure data exists + if not self.drawings: + self.get_drawings() + + # Type guard: inform Mypy that self.drawings is now an iterable list + # Use or [] with next to find target object + current_drawing = next( + ( + d + for d in (self.drawings or []) + if d.get("drawing_id") == drawing_id + ), + None, + ) + + if current_drawing is None: + raise ValueError( + f"Drawing with ID {drawing_id} not found in project." + ) + + # If parameter is None, get original value from current object + # This way, Mypy won't report errors for list comprehensions of each field + final_svg = svg if svg is not None else current_drawing.get("svg") + final_locked = ( + locked if locked is not None else current_drawing.get("locked") + ) + final_x = x if x is not None else current_drawing.get("x") + final_y = y if y is not None else current_drawing.get("y") + final_z = z if z is not None else current_drawing.get("z") + + # Execute update + response = _conn.http_call( + "put", + _url, + json_data={ + "svg": final_svg, + "locked": final_locked, + "x": final_x, + "y": final_y, + "z": final_z, + }, + ) + + # Update local cache + self.get_drawings() + + return cast(dict[str, Any], response.json()) + + @verify_connector_and_id + def delete_drawing(self, drawing_id: str | None = None) -> None: + """ + Deletes a drawing of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `drawing_id` + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + self.get_drawings() + + _drawing = self.get_drawing(drawing_id=drawing_id) + if not _drawing: + raise ValueError("drawing not found") + + _url = ( + f"{_conn.base_url}/projects/{_project_id}/drawings/" + f"{_drawing['drawing_id']}" + ) + + _conn.http_call("delete", _url) + + self.get_drawings() + + @verify_connector_and_id + def get_locked(self) -> bool: + """ + Retrieve locked status of the project. + + Returns whether the project is locked or not. + + API: GET /v3/projects/{project_id}/locked + + Required Attributes: + + - `project_id` + - `connector` + + Returns: + bool: True if project is locked, False otherwise + + Raises: + ValueError: If called with GNS3 API v2 (not supported) + + Note: + This method is only available in GNS3 v3 API + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + # Check API version - only v3 supports lock operations + if _conn.api_version != 3: + raise ValueError( + "Project lock/unlock operations are only supported in GNS3 API v3. " + f"Current API version: v{_conn.api_version}" + ) + + _url = f"{_conn.base_url}/projects/{_project_id}/locked" + + _response = _conn.http_call("get", _url) + locked_status = cast(bool, _response.json()) + + # Update the locked attribute + self.locked = locked_status + + return locked_status + + @verify_connector_and_id + def lock_project(self) -> None: + """ + Lock all drawings and nodes in the project. + + API: POST /v3/projects/{project_id}/lock + + Required Attributes: + + - `project_id` + - `connector` + + Raises: + ValueError: If called with GNS3 API v2 (not supported) + + Note: + This method is only available in GNS3 v3 API + Returns 204 on success (no content) + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + # Check API version - only v3 supports lock operations + if _conn.api_version != 3: + raise ValueError( + "Project lock/unlock operations are only supported in GNS3 API v3. " + f"Current API version: v{_conn.api_version}" + ) + + _url = f"{_conn.base_url}/projects/{_project_id}/lock" + + _conn.http_call("post", _url) + + # Update the locked attribute + self.locked = True + + @verify_connector_and_id + def unlock_project(self) -> None: + """ + Unlock all drawings and nodes in the project. + + API: POST /v3/projects/{project_id}/unlock + + Required Attributes: + + - `project_id` + - `connector` + + Raises: + ValueError: If called with GNS3 API v2 (not supported) + + Note: + This method is only available in GNS3 v3 API + Returns 204 on success (no content) + """ + _conn = self.connector + assert _conn is not None + _project_id = self.project_id + assert _project_id is not None + + # Check API version - only v3 supports lock operations + if _conn.api_version != 3: + raise ValueError( + "Project lock/unlock operations are only supported in GNS3 API v3. " + f"Current API version: v{_conn.api_version}" + ) + + _url = f"{_conn.base_url}/projects/{_project_id}/unlock" + + _conn.http_call("post", _url) + + # Update the locked attribute + self.locked = False diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py new file mode 100644 index 000000000..f9029489a --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3 Project Info Tool + +This module provides a LangChain BaseTool to retrieve basic information of a +specific GNS3 project by project ID. Returns project name, status, node count, +and link count. + +""" + +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool + +from gns3server.agent.gns3_copilot.gns3_client import Project +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3ProjectInfoTool(BaseTool): + """LangChain tool for retrieving GNS3 project basic information.""" + + name: str = "gns3_project_info" + description: str = """ + Retrieves basic information of a GNS3 project including name, status, + node count and link count. + + Input: `project_id` (str, required): UUID of the GNS3 project. + + Output: Dictionary with: + - `project_id`: Project UUID + - `name`: Project name + - `status`: Project status (opened/closed) + - `node_count`: Number of nodes in the project + - `link_count`: Number of links in the project + + Returns a tuple format: (name, project_id, node_count, link_count, status) + """ + + def _run( + self, + tool_input: Any = None, + run_manager: Any = None, + project_id: str | None = None, + ) -> dict: + """ + Synchronous method to retrieve basic information of a specific GNS3 project. + + Args: + tool_input : Input parameters, typically a dict or Pydantic model. + run_manager : Callback manager for tool run. + project_id : The UUID of the specific GNS3 project. + + Returns: + dict: A dictionary containing project info (name, project_id, node_count, + link_count, status), or an error dictionary if an exception occurs + or project_id is not provided. + """ + + # Log received input + logger.info( + "Received tool_input: %s, project_id: %s", tool_input, project_id + ) + + try: + # Validate project_id parameter + if not project_id: + logger.error("project_id parameter is required.") + return { + "error": ( + "project_id parameter is required. Please provide a valid " + "project UUID." + ) + } + + # Initialize Gns3Connector using factory function + logger.debug("Connecting to GNS3 server...") + server = get_gns3_connector() + + if server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": ( + "Failed to connect to GNS3 server. Please check your " + "configuration." + ) + } + + # Use the provided project_id directly + logger.info( + f"Retrieving project info for project_id: {project_id}" + ) + project = Project(project_id=project_id, connector=server) + project.get() # Load project details + + # Get node and link counts + nodes_inventory = project.nodes_inventory() + links_summary = project.links_summary(is_print=False) + + node_count = len(nodes_inventory) if nodes_inventory else 0 + link_count = len(links_summary) if links_summary else 0 + + # Build result in tuple format consistent with GNS3ProjectList + result = { + "project_id": project.project_id, + "name": project.name, + "status": project.status, + "node_count": node_count, + "link_count": link_count, + "tuple": ( + project.name, + project.project_id, + node_count, + link_count, + project.status, + ), + } + + # Log result + logger.info( + "Project info retrieved: name=%s, status=%s, nodes=%d, links=%d", + project.name, + project.status, + node_count, + link_count, + ) + + return result + + except Exception as e: + logger.error("Error retrieving GNS3 project info: %s", str(e)) + return {"error": f"Failed to retrieve project info: {str(e)}"} + + +if __name__ == "__main__": + # Test the tool + tool = GNS3ProjectInfoTool() + + # Example usage with project_id + # Replace with an actual project UUID from your GNS3 server + example_project_id = "0c0fde25-6ead-4413-a283-ea8fd2324291" + + print("Testing GNS3ProjectInfoTool with project_id...") + result = tool._run(project_id=example_project_id) + pprint(result) + + # Test without project_id (should return error) + print("\nTesting without project_id (should return error)...") + error_result = tool._run() + pprint(error_result) diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py new file mode 100644 index 000000000..6365e6be4 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3 Topology Reader Tool + +This module provides a LangChain BaseTool to retrieve the topology of a +specific GNS3 project by project ID. Returns nodes, links, and project +metadata. + +""" + +import copy +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool + +from gns3server.agent.gns3_copilot.gns3_client import Project +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +# Define LangChain tool class +class GNS3TopologyTool(BaseTool): + """LangChain tool for retrieving GNS3 project topology information.""" + + name: str = "gns3_topology_reader" + description: str = """ + Retrieves the topology of a GNS3 project including nodes and links. + + Input: `project_id` (str, required): UUID of the GNS3 project. + + Output: Dictionary with: + - `project_id`, `name`, `status`: Project metadata + - `nodes`: Dict of node details (node_id, name, ports, console_port, + type, etc.) + - `links`: List of link connections + + Use this to understand network structure before making changes. + """ + + def _run( + self, + tool_input: Any = None, + run_manager: Any = None, + project_id: str | None = None, + ) -> dict: + """ + Synchronous method to retrieve the topology of a specific GNS3 project. + + Args: + tool_input: Input parameters, typically a dict or Pydantic model + containing server_url. + run_manager: Callback manager for tool run. + project_id: The UUID of the specific GNS3 project to retrieve + topology from. + + Returns: + dict: A dictionary containing the project ID, name, status, nodes, + and links, or an error dictionary if an exception occurs + or project_id is not provided. + """ + + # Log received input + logger.info( + "Received tool_input: %s, project_id: %s", tool_input, project_id + ) + + try: + # Validate project_id parameter + if not project_id: + logger.error("project_id parameter is required.") + return { + "error": "project_id parameter is required. " + "Please provide a valid project UUID." + } + + # Initialize Gns3Connector using factory function + logger.debug("Connecting to GNS3 server...") + server = get_gns3_connector() + + if server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. Please check " + "your configuration." + } + + # Use the provided project_id directly + logger.info(f"Retrieving topology for project_id: {project_id}") + project = Project(project_id=project_id, connector=server) + project.get() # Load project details + + # Get topology JSON: includes nodes (devices), links, etc. + topology = { + "project_id": project.project_id, + "name": project.name, + "status": project.status, + "nodes": self._clean_nodes_ports( + copy.deepcopy(project.nodes_inventory()) + ), + "links": project.links_summary(is_print=False), + } + + # Log topology result + logger.info( + "Topology retrieved: project_id=%s, name=%s, nodes=%d, " + "links=%d", + topology.get("project_id"), + topology.get("name"), + len(topology.get("nodes", {})), + len(topology.get("links", [])), + ) + logger.debug("Topology details: %s", topology) + + return topology + + except Exception as e: + logger.error("Error retrieving GNS3 topology: %s", str(e)) + return {"error": f"Failed to retrieve topology: {str(e)}"} + + def _clean_nodes_ports(self, data: dict) -> dict: + """ + Clean and simplify the nodes data structure. + + Simplify each node's ports list to only keep name and short_name + fields. + """ + for node in data.values(): # Iterate through R-1, R-2, R-3, R-4 + if "ports" in node and isinstance(node["ports"], list): + node["ports"] = [ + {"name": port["name"], "short_name": port["short_name"]} + for port in node["ports"] + ] + return data + + +if __name__ == "__main__": + # Test the tool + tool = GNS3TopologyTool() + + # Example usage with project_id + # Replace with an actual project UUID from your GNS3 server + example_project_id = "0c0fde25-6ead-4413-a283-ea8fd2324291" + + print("Testing GNS3TopologyTool with project_id...") + result = tool._run(project_id=example_project_id) + pprint(result) + + # Test without project_id (should return error) + print("\nTesting without project_id (should return error)...") + error_result = tool._run() + pprint(error_result) diff --git a/gns3server/agent/gns3_copilot/project_agent_manager.py b/gns3server/agent/gns3_copilot/project_agent_manager.py new file mode 100644 index 000000000..edc452f32 --- /dev/null +++ b/gns3server/agent/gns3_copilot/project_agent_manager.py @@ -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 . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +Project Agent Manager + +Manages AgentService instances for GNS3 projects using a singleton pattern. +Each project has its own AgentService with a dedicated SQLite checkpoint +database. +""" + +import asyncio +import logging +from typing import Dict +from typing import Optional + +from gns3server.agent.gns3_copilot.agent_service import AgentService + +log = logging.getLogger(__name__) + + +class ProjectAgentManager: + """ + Singleton manager for project-level Agent services. + + Manages the lifecycle of AgentService instances, ensuring that each project + has exactly one AgentService instance. Handles cleanup of resources when + projects are closed. + """ + + _instance: Optional["ProjectAgentManager"] = None + _lock: asyncio.Lock = asyncio.Lock() + _agents: Dict[str, AgentService] = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._agents = {} + cls._instance._lock = asyncio.Lock() + return cls._instance + + async def get_agent( + self, project_id: str, project_path: str + ) -> AgentService: + """ + Get or create an AgentService for a project. + + Args: + project_id: GNS3 project ID + project_path: Path to the GNS3 project directory + + Returns: + AgentService instance for the project + """ + async with self._lock: + if project_id not in self._agents: + log.info( + "Creating new AgentService for project: %s at %s", + project_id, + project_path, + ) + self._agents[project_id] = AgentService(project_path) + return self._agents[project_id] + + async def remove_agent(self, project_id: str): + """ + Remove and cleanup an AgentService for a project. + + Should be called when a project is closed to free resources. + + Args: + project_id: GNS3 project ID + """ + async with self._lock: + if project_id in self._agents: + log.info("Removing AgentService for project: %s", project_id) + agent = self._agents.pop(project_id) + await agent.close() + + async def close_all(self): + """ + Close all AgentService instances and cleanup resources. + + Should be called on server shutdown. + """ + async with self._lock: + log.info( + "Closing all AgentService instances (%d projects)", + len(self._agents), + ) + for project_id, agent in self._agents.items(): + log.debug("Closing AgentService for project: %s", project_id) + await agent.close() + self._agents.clear() + + def has_agent(self, project_id: str) -> bool: + """ + Check if an AgentService exists for a project. + + Args: + project_id: GNS3 project ID + + Returns: + True if AgentService exists, False otherwise + """ + return project_id in self._agents + + @property + def active_projects(self) -> list[str]: + """ + Get list of project IDs with active AgentService instances. + + Returns: + List of project IDs + """ + return list(self._agents.keys()) + + +# Global singleton instance +_project_agent_manager: Optional[ProjectAgentManager] = None +_manager_lock = asyncio.Lock() + + +async def get_project_agent_manager() -> ProjectAgentManager: + """ + Get the global ProjectAgentManager singleton instance. + + Returns: + ProjectAgentManager instance + """ + global _project_agent_manager + async with _manager_lock: + if _project_agent_manager is None: + _project_agent_manager = ProjectAgentManager() + log.info("ProjectAgentManager singleton created") + return _project_agent_manager diff --git a/gns3server/agent/gns3_copilot/prompts/__init__.py b/gns3server/agent/gns3_copilot/prompts/__init__.py new file mode 100644 index 000000000..26607487a --- /dev/null +++ b/gns3server/agent/gns3_copilot/prompts/__init__.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Prompts Module for GNS3-Copilot + +This package provides system prompts loading utilities for the GNS3-Copilot AI agent. + +All system prompts are now loaded from the external GNS3-Skills repository: +https://github.com/yueguobin/GNS3-Skills + +Available prompts (loaded from external repository): +- lab_automation_assistant.md: Lab automation mode (diagnostics + config) +- teaching_assistant.md: Teaching assistant mode (diagnostics only) +- troubleshooting_injection.md: Fault injection specialist +- title.md: Title generation prompt template + +Use the SkillsManager to load these prompts: + from gns3server.agent.gns3_copilot.skills.manager import skills_manager + + # Load prompts + lab_prompt = skills_manager.load_prompt("lab_automation_assistant") + teaching_prompt = skills_manager.load_prompt("teaching_assistant") + injection_prompt = skills_manager.load_prompt("troubleshooting_injection") + title_prompt = skills_manager.load_prompt("title") +""" + +from .prompt_loader import load_system_prompt + +__all__ = ["load_system_prompt"] diff --git a/gns3server/agent/gns3_copilot/prompts/prompt_loader.py b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py new file mode 100644 index 000000000..110f76dae --- /dev/null +++ b/gns3server/agent/gns3_copilot/prompts/prompt_loader.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (� Yue Guobin) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Prompt Loader for GNS3-Copilot + +This module provides utilities for loading system prompts from the external +GNS3-Skills repository. +Supports multiple prompt variants based on LLM model configuration. + +Available Modes (controlled by config.copilot_mode in llm_model_configs): +- "teaching_assistant" (default): Teaching assistant mode - diagnostics only, + no configuration +- "lab_automation_assistant": Full lab automation assistant mode - diagnostics + and configuration enabled +- "troubleshooting_injection": Troubleshooting issue injection mode + +All prompts are loaded from the external GNS3-Skills repository via SkillsManager. +""" + +import logging + +from gns3server.agent.gns3_copilot.skills.registry import get_prompt + +logger = logging.getLogger(__name__) + + +def load_system_prompt(llm_config: dict | None = None) -> str: + """ + Load the system prompt for GNS3-Copilot from the external repository. + + The prompt mode is controlled by the `copilot_mode` field in the LLM + model config: + - "teaching_assistant" (default): Teaching assistant mode - diagnostics + only, no configuration + - "lab_automation_assistant": Full lab automation assistant mode - + diagnostics and configuration enabled + - "troubleshooting_injection": Troubleshooting issue injection mode - + inject network faults for practice + + Prompts are loaded from the external GNS3-Skills repository via SkillsManager. + + Args: + llm_config: LLM model configuration dictionary (flattened structure + from get_user_llm_config_full) + + Returns: + str: The system prompt string, or empty string if not found. + """ + if not llm_config: + logger.debug( + "No LLM config provided, using default TEACHING_ASSISTANT " + "prompt mode" + ) + return _load_prompt("teaching_assistant") + + # llm_config is a flattened dict with copilot_mode at the top level + # Example: {"provider": "...", "model": "...", "copilot_mode": "...", ...} + mode = llm_config.get("copilot_mode", "teaching_assistant").lower() + + if mode == "lab_automation_assistant": + logger.debug( + "Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + " + "configuration)" + ) + return _load_prompt("lab_automation_assistant") + elif mode == "troubleshooting_injection": + logger.debug( + "Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)" + ) + return _load_prompt("troubleshooting_injection") + else: + logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)") + return _load_prompt("teaching_assistant") + + +def _load_prompt(prompt_name: str) -> str: + """ + Load a prompt from the external skills repository. + + Args: + prompt_name: Name of the prompt (e.g., "teaching_assistant") + + Returns: + Prompt content as string, or empty string if not found + """ + prompt = get_prompt(prompt_name) + if prompt: + logger.debug(f"Loaded prompt '{prompt_name}' from external repository") + return prompt + + logger.warning(f"Prompt '{prompt_name}' not found in external repository") + return "" diff --git a/gns3server/agent/gns3_copilot/skills/__init__.py b/gns3server/agent/gns3_copilot/skills/__init__.py new file mode 100644 index 000000000..c23c9d8b8 --- /dev/null +++ b/gns3server/agent/gns3_copilot/skills/__init__.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin +# Author: Yue Guobin +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Skills Package + +This package provides skills management for GNS3 Copilot. +All skills are loaded from the external GNS3-Skills repository. + +Directory Structure: +- skills/ + - registry.py # SKILLS_REGISTRY and get_skill() + - manager.py # SkillsManager - Git clone/pull and hot reload + - loader.py # SkillsLoader - YAML/Markdown file loading +""" + +from .registry import ( + SKILLS_REGISTRY, + INJECTION_SKILLS_REGISTRY, + get_skill, + get_injection_skill, + DeviceSkillsTool, + InjectionSkillsTool, + PacketAnalysisSkillsTool, + set_skills_manager, + get_skills_manager, + reload_injection_skills, + reload_forbidden_commands, + reload_skills_repository, + get_skills_repository_info, +) +from .manager import SkillsManager +from .loader import SkillsLoader + +__all__ = [ + "SKILLS_REGISTRY", + "INJECTION_SKILLS_REGISTRY", + "get_skill", + "get_injection_skill", + "DeviceSkillsTool", + "InjectionSkillsTool", + "PacketAnalysisSkillsTool", + "SkillsManager", + "SkillsLoader", + "set_skills_manager", + "get_skills_manager", + "reload_injection_skills", + "reload_skills_repository", + "get_skills_repository_info", +] diff --git a/gns3server/agent/gns3_copilot/skills/loader.py b/gns3server/agent/gns3_copilot/skills/loader.py new file mode 100644 index 000000000..307b81e89 --- /dev/null +++ b/gns3server/agent/gns3_copilot/skills/loader.py @@ -0,0 +1,279 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin +# Author: Yue Guobin +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Skills Loader + +This module provides functionality to load skills from YAML files. +Supports loading injection skills and device/feature skills. +""" + +import logging +import os +from pathlib import Path +from typing import Any, Dict + +try: + import yaml +except ImportError: + yaml = None + +logger = logging.getLogger(__name__) + + +class SkillsLoader: + """ + Load skills from YAML files in the skills directory. + + This loader reads YAML files and converts them to the skill dictionary + format expected by the skills registry. + """ + + def __init__(self, skills_dir: str): + """ + Initialize the skills loader. + + Args: + skills_dir: Path to the skills directory containing YAML files + """ + self.skills_dir = Path(skills_dir) + if not self.skills_dir.exists(): + logger.warning(f"Skills directory does not exist: {self.skills_dir}") + + def load_injection_skills(self) -> Dict[str, Dict[str, Any]]: + """ + Load all injection skills from YAML files. + + Returns: + Dictionary mapping skill keys to skill definitions + """ + if yaml is None: + logger.error("PyYAML is not installed. Cannot load skills from YAML.") + return {} + + skills = {} + injection_dir = self.skills_dir / "injection" + + if not injection_dir.exists(): + logger.warning(f"Injection skills directory not found: {injection_dir}") + return {} + + for yaml_file in injection_dir.glob("*.yaml"): + try: + skill_data = self._load_yaml(yaml_file) + if not skill_data: + logger.warning(f"Skipping empty YAML file: {yaml_file}") + continue + # Generate key from filename (e.g., "ospf_issues.yaml" -> "injection_ospf") + skill_key = f"injection_{yaml_file.stem}" + skills[skill_key] = skill_data + logger.debug(f"Loaded injection skill: {skill_key} from {yaml_file}") + except Exception as e: + logger.error(f"Failed to load skill from {yaml_file}: {e}") + + logger.debug(f"Loaded {len(skills)} injection skills from {injection_dir}") + return skills + + def load_device_skills(self) -> Dict[str, Dict[str, Any]]: + """ + Load all device/feature skills from YAML files. + + Returns: + Dictionary mapping skill keys to skill definitions + """ + if yaml is None: + logger.error("PyYAML is not installed. Cannot load skills from YAML.") + return {} + + skills = {} + device_dir = self.skills_dir / "device" + + if not device_dir.exists(): + logger.warning(f"Device skills directory not found: {device_dir}") + return {} + + for yaml_file in device_dir.glob("*.yaml"): + try: + skill_data = self._load_yaml(yaml_file) + if not skill_data: + logger.warning(f"Skipping empty YAML file: {yaml_file}") + continue + # Use device_type from YAML content as the key + # Fallback to filename stem if device_type not present + skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None + if not skill_key: + skill_key = yaml_file.stem + logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key") + skills[skill_key] = skill_data + logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}") + except Exception as e: + logger.error(f"Failed to load skill from {yaml_file}: {e}") + + logger.debug(f"Loaded {len(skills)} device skills from {device_dir}") + return skills + + def load_prompt(self, prompt_name: str) -> str: + """ + Load a prompt from a markdown file. + + Args: + prompt_name: Name of the prompt file (without .md extension) + + Returns: + Prompt content as string, or empty string if not found + """ + prompts_dir = self.skills_dir / "prompts" + prompt_file = prompts_dir / f"{prompt_name}.md" + + if not prompt_file.exists(): + logger.warning(f"Prompt file not found: {prompt_file}") + return "" + + try: + with open(prompt_file, "r", encoding="utf-8") as f: + content = f.read() + logger.debug(f"Loaded prompt: {prompt_name} from {prompt_file}") + return content + except Exception as e: + logger.error(f"Failed to load prompt from {prompt_file}: {e}") + return "" + + def load_forbidden_commands(self) -> list: + """ + Load forbidden commands from the config directory. + + Returns: + List of forbidden command patterns, or empty list if not found + """ + config_dir = self.skills_dir / "config" + config_file = config_dir / "forbidden_commands.txt" + + if not config_file.exists(): + logger.warning(f"Forbidden commands file not found: {config_file}") + return [] + + try: + commands = [] + with open(config_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + commands.append(line.lower()) + logger.debug(f"Loaded {len(commands)} forbidden commands from {config_file}") + return commands + except Exception as e: + logger.error(f"Failed to load forbidden commands from {config_file}: {e}") + return [] + + def load_packet_analysis_protocols(self) -> Dict[str, Dict[str, Any]]: + """ + Load packet analysis protocol definitions from YAML files. + + Returns: + Dictionary mapping protocol keys to protocol definitions. + Each protocol contains tshark_field, display_filter, and filter_examples. + """ + if yaml is None: + logger.error("PyYAML is not installed. Cannot load packet analysis protocols.") + return {} + + protocols = {} + packet_analysis_dir = self.skills_dir / "packet_analysis" + + if not packet_analysis_dir.exists(): + logger.debug(f"Packet analysis directory not found: {packet_analysis_dir}") + return {} + + for yaml_file in packet_analysis_dir.glob("*.yaml"): + try: + protocol_data = self._load_yaml(yaml_file) + if not protocol_data: + logger.warning(f"Skipping empty YAML file: {yaml_file}") + continue + + # Use protocol_key field from YAML as the key + protocol_key = protocol_data.get("protocol_key") + if not protocol_key: + logger.warning(f"No 'protocol_key' field in {yaml_file}, skipping") + continue + + protocols[protocol_key] = protocol_data + logger.debug(f"Loaded packet analysis protocol: {protocol_key} from {yaml_file}") + except Exception as e: + logger.error(f"Failed to load protocol from {yaml_file}: {e}") + + logger.debug(f"Loaded {len(protocols)} packet analysis protocols from {packet_analysis_dir}") + return protocols + + def _load_yaml(self, file_path: Path) -> Dict[str, Any]: + """ + Load a YAML file and return its content. + + Args: + file_path: Path to the YAML file + + Returns: + Parsed YAML content as dictionary, or empty dict if file is empty + """ + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + logger.warning(f"YAML file {file_path} is empty or has invalid format") + return {} + return data + + def validate_skill_format(self, skill_data: Dict[str, Any]) -> bool: + """ + Validate that a skill dictionary has the required fields. + + Args: + skill_data: Skill dictionary to validate + + Returns: + True if valid, False otherwise + """ + required_fields = ["name", "description", "issues"] + for field in required_fields: + if field not in skill_data: + logger.error(f"Skill missing required field: {field}") + return False + + if not isinstance(skill_data["issues"], dict): + logger.error("Skill 'issues' field must be a dictionary") + return False + + for issue_key, issue_data in skill_data["issues"].items(): + if not isinstance(issue_data, dict): + logger.error(f"Issue '{issue_key}' must be a dictionary") + return False + + issue_required = ["name", "description"] + for field in issue_required: + if field not in issue_data: + logger.error(f"Issue '{issue_key}' missing required field: {field}") + return False + + return True diff --git a/gns3server/agent/gns3_copilot/skills/manager.py b/gns3server/agent/gns3_copilot/skills/manager.py new file mode 100644 index 000000000..52abdad13 --- /dev/null +++ b/gns3server/agent/gns3_copilot/skills/manager.py @@ -0,0 +1,459 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin +# Author: Yue Guobin +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Skills Manager + +This module provides functionality to manage the skills repository, +including Git operations and hot reload of skills. +""" + +import logging +import os +from pathlib import Path +from typing import Optional, Dict, Any + +try: + import git +except ImportError: + git = None + +from gns3server.config import Config +from .loader import SkillsLoader + +logger = logging.getLogger(__name__) + +# Git timeout settings for network operations. +# Applied per-command via the `env` parameter to avoid polluting +# the global process environment. +_GIT_TIMEOUT_ENV = { + 'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s) + 'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window + 'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort +} + + +class SkillsManager: + """ + Manage skills repository and hot reload functionality. + + This class handles: + - Cloning the skills repository + - Pulling latest updates + - Hot reloading skills into memory + - Version tracking + """ + + def __init__( + self, + repo_url: str = None, + branch: str = "main", + auto_update: bool = False + ): + """ + Initialize the skills manager. + + Args: + repo_url: Git repository URL (default: https://github.com/yueguobin/GNS3-Skills.git) + branch: Git branch to use (default: "main") + auto_update: Whether to automatically pull updates on reload + """ + if repo_url is None: + repo_url = "https://github.com/yueguobin/GNS3-Skills.git" + + # Get local path from GNS3 config directory + config_dir = Config.instance().config_dir + local_path = os.path.join(config_dir, "skills") + + self.repo_url = repo_url + self.local_path = Path(local_path) + self.branch = branch + self.auto_update = auto_update + self.loader = SkillsLoader(str(local_path)) + self._repo: Optional["git.Repo"] = None + self._prompt_count = 0 + + if git is None: + logger.warning("GitPython is not installed. Skills management features will be limited.") + + def initialize(self) -> bool: + """ + Initialize the skills repository. + + Behavior: + - If no local repo → clone from remote + - If local repo exists → open and run _update_if_needed() + + Returns: + True if successful, False otherwise + """ + try: + if not self.local_path.exists(): + logger.info(f"Creating skills directory: {self.local_path}") + self.local_path.mkdir(parents=True, exist_ok=True) + + if not (self.local_path / ".git").exists(): + return self._clone() + + # Repo exists, open it + try: + self._repo = git.Repo(self.local_path) + except Exception as e: + logger.error(f"Failed to open existing repository: {e}") + return False + + self._update_if_needed() + return True + except Exception as e: + logger.error(f"Failed to initialize skills repository: {e}") + return False + + def _update_if_needed(self) -> None: + """ + Check local repo status and pull updates if safe. + + - Uncommitted changes → warn and skip + - Behind remote → pull + - Up to date → nothing + - Network error → use existing files, log warning + """ + if git is None or self._repo is None: + return + + # Check for uncommitted changes + if self._repo.is_dirty(untracked_files=True): + logger.warning( + "Skills repository has uncommitted changes, skipping pull. " + "Commit or stash changes in %s to enable automatic updates.", + self.local_path + ) + return + + # Fetch remote + try: + origin = self._repo.remotes.origin + origin.fetch(env=_GIT_TIMEOUT_ENV) + except Exception as e: + logger.warning(f"Failed to fetch remote: {e}. Using local files.") + return + + # Check if behind and pull + try: + behind_commits = list(self._repo.iter_commits( + f'{self.branch}..origin/{self.branch}' + )) + if behind_commits: + logger.info( + "Skills repository is behind by %d commit(s), pulling...", + len(behind_commits) + ) + origin.pull(self.branch, env=_GIT_TIMEOUT_ENV) + logger.info(f"Updated to commit {self.get_current_version()}") + else: + logger.info("Skills repository is up to date") + except Exception as e: + logger.warning(f"Failed to pull updates: {e}. Using local files.") + + def _clone(self) -> bool: + """ + Clone the skills repository. + + Returns: + True if successful, False otherwise + """ + if git is None: + logger.error("GitPython is not installed. Cannot clone repository.") + return False + + try: + self._repo = git.Repo.clone_from( + self.repo_url, + self.local_path, + branch=self.branch, + env=_GIT_TIMEOUT_ENV + ) + logger.info(f"Successfully cloned skills repository to {self.local_path}") + return True + except git.GitCommandError as e: + logger.error(f"Git clone failed: {e}") + return False + + def reload_skills(self) -> bool: + """ + Hot reload skills from YAML files into the registry. + + Loads the latest skill definitions from YAML files and updates + the INJECTION_SKILLS_REGISTRY. + + Returns: + True if successful, False otherwise + """ + try: + # Import here to avoid circular dependency + from .registry import INJECTION_SKILLS_REGISTRY, SKILLS_REGISTRY + + # Load new injection skills from YAML files + new_injection_skills = self.loader.load_injection_skills() + + if not new_injection_skills: + logger.warning("No injection skills loaded, keeping existing skills") + return False + + # Validate injection skills + for skill_key, skill_data in new_injection_skills.items(): + if not self.loader.validate_skill_format(skill_data): + logger.error(f"Invalid skill format for {skill_key}, skipping") + continue + + # Load new device/feature skills from YAML files + new_device_skills = self.loader.load_device_skills() + + # Update registries (safe replace - never leaves dict empty) + for k in list(INJECTION_SKILLS_REGISTRY): + if k not in new_injection_skills: + del INJECTION_SKILLS_REGISTRY[k] + INJECTION_SKILLS_REGISTRY.update(new_injection_skills) + + for k in list(SKILLS_REGISTRY): + if k not in new_device_skills: + del SKILLS_REGISTRY[k] + SKILLS_REGISTRY.update(new_device_skills) + + logger.info(f"Loaded {len(new_injection_skills)} injection skills and {len(new_device_skills)} device skills") + return True + except Exception as e: + logger.error(f"Failed to reload skills: {e}") + return False + + def reload_packet_analysis_protocols(self) -> bool: + """ + Hot reload packet analysis protocol definitions from YAML files. + + Loads the latest protocol definitions from YAML files and updates + the PACKET_ANALYSIS_REGISTRY. + + Returns: + True if successful, False otherwise + """ + try: + from .registry import PACKET_ANALYSIS_REGISTRY + + # Load new packet analysis protocols from YAML files + new_protocols = self.loader.load_packet_analysis_protocols() + + # Update registry (safe replace - never leaves dict empty) + for k in list(PACKET_ANALYSIS_REGISTRY): + if k not in new_protocols: + del PACKET_ANALYSIS_REGISTRY[k] + PACKET_ANALYSIS_REGISTRY.update(new_protocols) + + logger.info(f"Successfully reloaded {len(new_protocols)} packet analysis protocols") + return True + except Exception as e: + logger.error(f"Failed to reload packet analysis protocols: {e}") + return False + + def reload_prompts(self) -> bool: + """ + Hot reload prompts from Markdown files. + + Loads the latest prompt definitions from Markdown files in + the skills repository. + + Returns: + True if successful, False otherwise + """ + try: + # Available prompt names + prompt_names = [ + "lab_automation_assistant", + "teaching_assistant", + "troubleshooting_injection", + "title" + ] + + # Load all prompts + loaded_count = 0 + for prompt_name in prompt_names: + prompt_content = self.loader.load_prompt(prompt_name) + if prompt_content: + loaded_count += 1 + else: + logger.warning(f"Failed to load prompt: {prompt_name}") + + if loaded_count == 0: + logger.warning("No prompts loaded, keeping existing prompts") + return False + + self._prompt_count = loaded_count + logger.info(f"Loaded {loaded_count} prompts") + return True + except Exception as e: + logger.error(f"Failed to reload prompts: {e}") + return False + + def load_prompt(self, prompt_name: str) -> str: + """ + Load a specific prompt from the skills repository. + + Args: + prompt_name: Name of the prompt (without .md extension) + + Returns: + Prompt content as string, or empty string if not found + """ + try: + return self.loader.load_prompt(prompt_name) + except Exception as e: + logger.error(f"Failed to load prompt '{prompt_name}': {e}") + return "" + + def load_forbidden_commands(self) -> list: + """ + Load forbidden command patterns from the skills repository. + + Returns: + List of forbidden command patterns, or empty list if not found + """ + try: + return self.loader.load_forbidden_commands() + except Exception as e: + logger.error(f"Failed to load forbidden commands: {e}") + return [] + + def get_current_version(self) -> str: + """ + Get the current git commit hash of the skills repository. + + Returns: + Commit hash as string, or empty string if not available + """ + if git is None or self._repo is None: + try: + self._repo = git.Repo(self.local_path) + except Exception: + return "" + + try: + return self._repo.head.commit.hexsha + except Exception: + return "" + + def get_skill_count(self) -> int: + """ + Get the number of currently loaded injection skills. + + Returns: + Number of skills in the registry + """ + try: + from .registry import INJECTION_SKILLS_REGISTRY + return len(INJECTION_SKILLS_REGISTRY) + except Exception: + return 0 + + def get_prompt_count(self) -> int: + """ + Get the number of currently loaded prompts. + + Returns: + Number of prompts loaded + """ + return self._prompt_count + + def get_repository_info(self) -> Dict[str, Any]: + """ + Get information about the skills repository. + + Returns: + Dictionary containing repository information + """ + return { + "repo_url": self.repo_url, + "local_path": str(self.local_path), + "branch": self.branch, + "current_version": self.get_current_version(), + "skill_count": self.get_skill_count(), + "prompt_count": self.get_prompt_count(), + "auto_update": self.auto_update, + "is_initialized": (self.local_path / ".git").exists() + } + + def rollback(self, commit_hash: str) -> bool: + """ + Rollback the skills repository to a specific commit. + + Args: + commit_hash: Git commit hash to rollback to + + Returns: + True if successful, False otherwise + """ + if git is None: + logger.error("GitPython is not installed. Cannot rollback.") + return False + + try: + if self._repo is None: + self._repo = git.Repo(self.local_path) + + self._repo.git.reset("--hard", commit_hash) + logger.info(f"Successfully rolled back to commit {commit_hash}") + + # Reload skills after rollback + return self.reload_skills() + except git.GitCommandError as e: + logger.error(f"Git rollback failed: {e}") + return False + + def get_available_versions(self, limit: int = 10) -> list: + """ + Get a list of recent commit hashes. + + Args: + limit: Maximum number of commits to return + + Returns: + List of commit information dictionaries + """ + if git is None: + return [] + + try: + if self._repo is None: + self._repo = git.Repo(self.local_path) + + commits = [] + for commit in self._repo.iter_commits(max_count=limit): + commits.append({ + "hash": commit.hexsha, + "message": commit.message.strip(), + "author": str(commit.author), + "date": commit.committed_datetime.isoformat() + }) + return commits + except Exception as e: + logger.error(f"Failed to get commit history: {e}") + return [] diff --git a/gns3server/agent/gns3_copilot/skills/registry.py b/gns3server/agent/gns3_copilot/skills/registry.py new file mode 100644 index 000000000..4d0cf1a53 --- /dev/null +++ b/gns3server/agent/gns3_copilot/skills/registry.py @@ -0,0 +1,857 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin +# Author: Yue Guobin +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Skill Registry and Tools + +This module provides: +- SKILLS_REGISTRY: Device/feature skills (VPCS, topology, etc.) +- INJECTION_SKILLS_REGISTRY: Fault injection skills only +- get_skill(): Function to retrieve device/feature skills +- get_injection_skill(): Function to retrieve injection skills +- DeviceSkillsTool: LangChain tool for device/feature skills +- InjectionSkillsTool: LangChain tool for injection skills +""" + +import json +import logging +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +# command_filter imports are done locally in functions +# to avoid circular import (command_filter also imports from registry) + +logger = logging.getLogger(__name__) + +# Device/Feature skills registry - loaded from external repository +SKILLS_REGISTRY: dict[str, dict[str, Any]] = {} + +# Injection skills registry - fault injection skills only +# This registry can be hot-reloaded via SkillsManager +INJECTION_SKILLS_REGISTRY: dict[str, dict[str, Any]] = {} + +# Packet analysis protocols registry - loaded from external repository +# Contains protocol definitions for tshark-based packet analysis +PACKET_ANALYSIS_REGISTRY: dict[str, dict[str, Any]] = {} + +# Global skills manager instance for hot reload +_skills_manager = None +_init_in_progress = False + + +def set_skills_manager(manager): + """ + Set the global skills manager instance. + + The skills manager handles Git operations and hot reload of skills. + + Args: + manager: SkillsManager instance + """ + global _skills_manager + _skills_manager = manager + + +def _ensure_skills_manager(): + """ + Initialize the SkillsManager (idempotent, retryable on failure). + + Reads config, creates SkillsManager, clones/pulls repo, + and loads skills/prompts into memory. Safe to call from + background threads - uses _init_in_progress to prevent + concurrent initialization. + + On failure, resets _init_in_progress so future calls + (e.g., /reload/skills API) can retry. On success, the + manager is stored in _skills_manager and subsequent + calls return immediately. + """ + global _skills_manager, _init_in_progress + + if _skills_manager is not None: + return + + if _init_in_progress: + return + + _init_in_progress = True + + try: + from gns3server.agent.gns3_copilot.configs.skills_config import get_skills_config + from gns3server.agent.gns3_copilot.skills.manager import SkillsManager + + config = get_skills_config() + + if not config.get("enabled", False): + logger.info("External skills repository is disabled") + return + + logger.info("Initializing SkillsManager") + + manager = SkillsManager( + repo_url=config.get("repo_url"), + branch=config.get("branch", "main"), + auto_update=config.get("auto_update", False) + ) + + if not manager.initialize(): + logger.error("Failed to initialize skills repository") + return + + if manager.reload_skills(): + logger.debug(f"Loaded {manager.get_skill_count()} injection skills") + else: + logger.warning("Failed to reload injection skills") + + if manager.reload_prompts(): + logger.debug(f"Loaded {manager.get_prompt_count()} prompts") + else: + logger.warning("Failed to reload prompts") + + if manager.reload_packet_analysis_protocols(): + logger.debug(f"Loaded {len(PACKET_ANALYSIS_REGISTRY)} packet analysis protocols") + else: + logger.warning("Failed to reload packet analysis protocols") + + _skills_manager = manager + logger.debug("SkillsManager initialized successfully") + + except Exception as e: + logger.error(f"Error initializing skills manager: {e}", exc_info=True) + finally: + _init_in_progress = False + + +def get_skills_manager(): + """ + Get the global skills manager instance, initializing on first access. + + Returns: + SkillsManager instance or None + """ + _ensure_skills_manager() + return _skills_manager + + +def reload_skills_repository() -> dict[str, Any]: + """ + Reload the entire skills repository. + + Performs one git update check, then reloads all skills, prompts, + and forbidden commands from local files. + + Returns: + Dictionary with combined reload results. + """ + manager = get_skills_manager() + if manager is None: + return { + "success": False, + "message": "Skills manager not initialized", + } + + # One git update for the entire repository + manager._update_if_needed() + + # Reload everything from local files + skills_ok = manager.reload_skills() + prompts_ok = manager.reload_prompts() + protocols_ok = manager.reload_packet_analysis_protocols() + + # Reload forbidden commands (local import to avoid circular dependency) + from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload_fc + from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands + + _reload_fc() + forbidden_commands = get_forbidden_commands() + + return { + "success": skills_ok or prompts_ok or protocols_ok, + "skills": skills_ok, + "skill_count": manager.get_skill_count(), + "prompts": prompts_ok, + "prompt_count": manager.get_prompt_count(), + "protocols": protocols_ok, + "protocol_count": len(PACKET_ANALYSIS_REGISTRY), + "forbidden_commands": len(forbidden_commands), + "version": manager.get_current_version(), + } + + +def reload_injection_skills() -> dict[str, Any]: + """ + Trigger hot reload of injection skills. + + This function uses the global skills manager to pull latest changes + from the skills repository and reload the INJECTION_SKILLS_REGISTRY. + + Returns: + Dictionary with status information: + { + "success": bool, + "message": str, + "skill_count": int, + "version": str + } + """ + manager = get_skills_manager() + if manager is None: + return { + "success": False, + "message": "Skills manager not initialized", + "skill_count": len(INJECTION_SKILLS_REGISTRY), + "version": "" + } + + try: + success = manager.reload_skills() + return { + "success": success, + "message": "Skills reloaded successfully" if success else "Failed to reload skills", + "skill_count": manager.get_skill_count(), + "version": manager.get_current_version() + } + except Exception as e: + logger.error(f"Error during skills reload: {e}") + return { + "success": False, + "message": f"Error: {str(e)}", + "skill_count": len(INJECTION_SKILLS_REGISTRY), + "version": "" + } + + +def reload_prompts() -> dict[str, Any]: + """ + Trigger hot reload of system prompts. + + This function uses the global skills manager to pull latest changes + from the skills repository and reload prompts from disk. + + Returns: + Dictionary with status information: + { + "success": bool, + "message": str, + "prompt_count": int, + "version": str + } + """ + manager = get_skills_manager() + if manager is None: + return { + "success": False, + "message": "Skills manager not initialized", + "prompt_count": 0, + "version": "" + } + + try: + success = manager.reload_prompts() + return { + "success": success, + "message": "Prompts reloaded successfully" if success else "Failed to reload prompts", + "prompt_count": manager.get_prompt_count(), + "version": manager.get_current_version() + } + except Exception as e: + logger.error(f"Error during prompts reload: {e}") + manager = get_skills_manager() + return { + "success": False, + "message": f"Error: {str(e)}", + "prompt_count": manager.get_prompt_count() if manager else 0, + "version": "" + } + + +def reload_forbidden_commands() -> dict[str, Any]: + """ + Hot reload forbidden commands from the skills repository. + + Directly loads and caches commands from the skills repository. + + Returns: + Dictionary with status information: + { + "success": bool, + "message": str, + "command_count": int, + "version": str + } + """ + try: + from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands as _reload + from gns3server.agent.gns3_copilot.utils.command_filter import get_forbidden_commands + + _reload() + commands = get_forbidden_commands() + manager = get_skills_manager() + return { + "success": True, + "message": "Forbidden commands reloaded", + "command_count": len(commands), + "version": manager.get_current_version() if manager else "" + } + except Exception as e: + logger.error(f"Error during forbidden commands reload: {e}") + return { + "success": False, + "message": f"Error: {str(e)}", + "command_count": 0, + "version": "" + } + + +def get_prompt(prompt_name: str) -> str: + """ + Get a system prompt by name, always loading from disk. + + Args: + prompt_name: Name of the prompt (e.g., "teaching_assistant") + + Returns: + Prompt content as string, or empty string if not found + """ + # Always load from skills manager (no cache), triggers lazy init + manager = get_skills_manager() + if manager: + try: + prompt = manager.load_prompt(prompt_name) + if prompt: + return prompt + except Exception as e: + logger.error(f"Error loading prompt '{prompt_name}': {e}") + + logger.warning(f"Prompt not found: {prompt_name}") + return "" + + +def get_skills_repository_info() -> dict[str, Any]: + """ + Get information about the skills repository. + + Returns: + Dictionary with repository information + """ + manager = get_skills_manager() + if manager is None: + return { + "initialized": False, + "message": "Skills manager not initialized" + } + + return manager.get_repository_info() + + +def get_skill( + device_type: str, + category: str | None = None, + detail: str = "full", + issue: str | None = None, +) -> dict[str, Any]: + """ + Get skill by device_type, with configurable detail level. + + Args: + device_type: The device type identifier + category: Optional category filter + detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all) + issue: Optional specific issue key to retrieve + + Returns: + Skill dictionary (detail varies by level), or error dict + """ + skill = SKILLS_REGISTRY.get(device_type, {}) + + if not skill: + for did, s in SKILLS_REGISTRY.items(): + if s.get("name", "").lower() == device_type.lower(): + skill = s + break + + if not skill: + return { + "error": f"Unknown device_type: {device_type}", + "available_device_types": list(SKILLS_REGISTRY.keys()), + } + + if category: + skill_category = skill.get("category", "") + if category.lower() != skill_category.lower(): + return { + "error": f"device_type '{device_type}' is not in category '{category}'", + "device_category": skill.get("category"), + "available_in_category": [ + did for did, s in SKILLS_REGISTRY.items() + if s.get("category", "").lower() == category.lower() + ], + } + + issues = skill.get("issues", {}) + + # Single issue lookup (most token-efficient) + if issue: + issue_def = issues.get(issue) + if not issue_def: + return { + "error": f"Unknown issue '{issue}' in {device_type}", + "available_issues": list(issues.keys()), + } + return { + "device_type": device_type, + "skill_name": skill.get("name"), + "issue": {issue: issue_def}, + } + + if detail == "index": + # Minimal: only issue keys and names (90%+ token savings) + return { + "device_type": device_type, + "name": skill.get("name"), + "description": skill.get("description"), + "issues": {k: v["name"] for k, v in issues.items()}, + } + + if detail == "summary": + # Moderate: names + description + severity + difficulty + return { + "device_type": device_type, + "name": skill.get("name"), + "description": skill.get("description"), + "issues": { + k: { + "name": v["name"], + "description": v.get("description", ""), + "severity": v.get("severity", ""), + "difficulty": v.get("difficulty", ""), + } + for k, v in issues.items() + }, + } + + # Full detail (original behavior) + result = dict(skill) + result["device_type"] = device_type + return result + + +def list_available_skills(category: str | None = None) -> list[dict[str, str]]: + """List all available device/feature skills, optionally filtered by category.""" + skills = [] + for did, skill in SKILLS_REGISTRY.items(): + if category: + if skill.get("category", "").lower() == category.lower(): + skills.append({ + "device_type": did, + "name": skill.get("name", did), + "category": skill.get("category"), + }) + else: + skills.append({ + "device_type": did, + "name": skill.get("name", did), + "category": skill.get("category"), + }) + return skills + + +def get_injection_skill( + device_type: str, + detail: str = "full", + issue: str | None = None, +) -> dict[str, Any]: + """ + Get injection fault skill by device_type, with configurable detail level. + + Args: + device_type: The injection fault type (e.g., "injection_ospf") + detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all) + issue: Optional specific issue key to retrieve + + Returns: + Skill dictionary (detail varies by level), or error dict + """ + skill = INJECTION_SKILLS_REGISTRY.get(device_type, {}) + + if not skill: + return { + "error": f"Unknown injection fault type: {device_type}", + "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), + "hint": "Use {'action': 'list'} to see all available fault types" + } + + issues = skill.get("issues", {}) + + # Single issue lookup (most token-efficient) + if issue: + issue_def = issues.get(issue) + if not issue_def: + return { + "error": f"Unknown issue '{issue}' in {device_type}", + "available_issues": list(issues.keys()), + } + return { + "device_type": device_type, + "skill_name": skill.get("name"), + "issue": {issue: issue_def}, + } + + if detail == "index": + # Minimal: only issue keys and names (90%+ token savings) + return { + "device_type": device_type, + "name": skill.get("name"), + "description": skill.get("description"), + "issues": {k: v["name"] for k, v in issues.items()}, + } + + if detail == "summary": + # Moderate: names + description + severity + difficulty + return { + "device_type": device_type, + "name": skill.get("name"), + "description": skill.get("description"), + "issues": { + k: { + "name": v["name"], + "description": v.get("description", ""), + "severity": v.get("severity", ""), + "difficulty": v.get("difficulty", ""), + } + for k, v in issues.items() + }, + } + + # Full detail (original behavior) + result = dict(skill) + result["device_type"] = device_type + return result + + +def list_available_injection_skills(context: list[str] | None = None) -> list[dict[str, str]]: + """ + List available injection fault skills, optionally filtered by context. + + Args: + context: List of protocol/service keywords (e.g., ["ospf", "bgp", "vlan"]). + Only returns skills whose category matches any keyword. + + Returns: + List of skill info dicts with device_type, name, and category. + """ + skills = [] + for did, skill in INJECTION_SKILLS_REGISTRY.items(): + category = skill.get("category", "") + if context: + # Match if skill category contains any context keyword + category_lower = category.lower() + if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context): + continue + skills.append({ + "device_type": did, + "name": skill.get("name", did), + "category": category, + }) + return skills + + +def get_packet_analysis_protocol(protocol: str) -> dict[str, Any]: + """ + Get a packet analysis protocol definition. + + Args: + protocol: The protocol key (e.g., "ospf", "bgp", "icmp") + + Returns: + Protocol definition dictionary with available_fields, base_filter, etc. + Returns error dict if protocol not found. + """ + protocol_data = PACKET_ANALYSIS_REGISTRY.get(protocol) + + if not protocol_data: + # Try case-insensitive match + for key, data in PACKET_ANALYSIS_REGISTRY.items(): + if key.lower() == protocol.lower(): + protocol_data = data + protocol = key + break + + if not protocol_data: + return { + "error": f"Unknown protocol: {protocol}", + "available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()), + } + + return protocol_data + + +def list_available_packet_analysis_protocols() -> list[dict[str, str]]: + """ + List all available packet analysis protocols. + + Returns: + List of protocol info dicts with protocol, name, and description. + """ + protocols = [] + for key, data in PACKET_ANALYSIS_REGISTRY.items(): + protocols.append({ + "protocol": key, + "name": data.get("name", key), + "description": data.get("description", ""), + }) + return protocols + + +class DeviceSkillsTool(BaseTool): + """ + LangChain tool for querying device/feature skills. + + Use this tool to get device command knowledge, topology planning skills, etc. + For fault injection skills, use InjectionSkillsTool instead. + """ + + name: str = "device_skills" + description: str = """ + Get or list device and feature specific skills. + + Provides access to device command knowledge (VPCS), topology planning, etc. + For fault injection skills, use the injection_skills tool. + + INPUT FORMAT (JSON string): + { + "action": "get", # "get" (default) or "list" + "device_type": "gns3_vpcs_telnet", # Required for action="get" + "detail": "full" # "full" (default) for complete skill information + } + + For action="list": + {"action": "list"} # Lists all available device/feature skills + """ + + def _run( + self, + tool_input: str | dict[str, Any], + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> str: + """Execute the device skills lookup.""" + logger.debug("DeviceSkillsTool invoked with input: %s", tool_input) + + if isinstance(tool_input, str): + try: + params = json.loads(tool_input) + except json.JSONDecodeError as e: + return json.dumps({ + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' + }, ensure_ascii=False, indent=2) + else: + params = tool_input + + action = params.get("action", "get") + + if action == "list": + skills = list_available_skills() + return json.dumps({ + "count": len(skills), + "skills": skills + }, ensure_ascii=False, indent=2) + + device_type = params.get("device_type") + if not device_type: + return json.dumps({ + "error": "Missing required field: device_type", + "available_device_types": list(SKILLS_REGISTRY.keys()), + "hint": 'Use {"action": "list"} to see all available device types' + }, ensure_ascii=False, indent=2) + + category = params.get("category") + detail = params.get("detail", "full") + issue = params.get("issue") + + skill = get_skill(device_type, category, detail=detail, issue=issue) + + return json.dumps(skill, ensure_ascii=False, indent=2) + + +class InjectionSkillsTool(BaseTool): + """ + LangChain tool for querying fault injection skills. + + Use this tool to list available injection fault types and get fault details. + """ + + name: str = "injection_skills" + description: str = """ + Get or list network fault injection skills for troubleshooting practice. + + REQUIRED: When action="list", you MUST always pass "context" with the + protocols/services found in your topology analysis. + Example: {"action": "list", "context": ["ospf", "bgp", "mpls", "vlan", "stp"]} + + TOKEN-EFFICIENT USAGE: + 1. List faults for YOUR topology protocols (REQUIRED): + {"action": "list", "context": ["ospf", "bgp"]} + 2. Get specific fault details: + {"device_type": "injection_ospf", "issue": "ospf_hello_dead_mismatch"} + {"device_type": "injection_ospf", "detail": "index"} + + PARAMETERS: + - action: "list" or "get" + - context: [str] - REQUIRED for action="list". Protocols from your topology. + - device_type: Required for action="get" (e.g., "injection_ospf") + - detail: "index" | "summary" | "full" + - issue: Get single fault detail by key + """ + + def _run( + self, + tool_input: str | dict[str, Any], + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> str: + """Execute the injection skills lookup.""" + logger.debug("InjectionSkillsTool invoked with input: %s", tool_input) + + if isinstance(tool_input, str): + try: + params = json.loads(tool_input) + except json.JSONDecodeError as e: + return json.dumps({ + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}' + }, ensure_ascii=False, indent=2) + else: + params = tool_input + + action = params.get("action", "get") + + if action == "list": + context = params.get("context") + if not context or not isinstance(context, list) or len(context) == 0: + return json.dumps({ + "error": "context parameter is required when action='list'", + "hint": "Analyze the topology and device configurations first, " + "then pass the protocols/services you found as context. " + 'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}', + "available_categories": sorted(set( + skill.get("category", "") + for skill in INJECTION_SKILLS_REGISTRY.values() + )) + }, ensure_ascii=False, indent=2) + + skills = list_available_injection_skills(context=context) + logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching") + return json.dumps({ + "count": len(skills), + "total_available": len(INJECTION_SKILLS_REGISTRY), + "context": context, + "fault_types": skills + }, ensure_ascii=False, indent=2) + + device_type = params.get("device_type") + if not device_type: + return json.dumps({ + "error": "Missing required field: device_type", + "available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()), + "hint": 'Use {"action": "list"} to see all available fault types' + }, ensure_ascii=False, indent=2) + + detail = params.get("detail", "full") + issue = params.get("issue") + + skill = get_injection_skill(device_type, detail=detail, issue=issue) + + return json.dumps(skill, ensure_ascii=False, indent=2) + + +class PacketAnalysisSkillsTool(BaseTool): + """ + LangChain tool for querying packet analysis protocol definitions. + + Use this tool to list available protocols and get protocol-specific + tshark fields, display filters, and check rules. + """ + + name: str = "packet_analysis_skills" + description: str = """ + Get or list packet analysis protocol definitions. + + Before calling packet_analysis tool, use this to query the protocol's + available tshark fields, display filters, and check rules. + + USAGE: + - List available protocols: + {"action": "list"} + + - Get protocol definition with fields: + {"action": "get", "protocol": "ospf"} + + PARAMETERS: + - action: "list" or "get" (required) + - protocol: Protocol key for action="get" (e.g., "ospf", "bgp", "arp", "icmp") + """ + + def _run( + self, + tool_input: str | dict[str, Any], + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> str: + """Execute the packet analysis skills lookup.""" + logger.debug("PacketAnalysisSkillsTool invoked with input: %s", tool_input) + + if isinstance(tool_input, str): + try: + params = json.loads(tool_input) + except json.JSONDecodeError as e: + return json.dumps({ + "error": f"Invalid JSON input: {e}", + "hint": 'Expected format: {"action": "get", "protocol": "ospf"}' + }, ensure_ascii=False, indent=2) + else: + params = tool_input + + action = params.get("action", "get") + + if action == "list": + protocols = list_available_packet_analysis_protocols() + return json.dumps({ + "count": len(protocols), + "protocols": protocols + }, ensure_ascii=False, indent=2) + + protocol = params.get("protocol") + if not protocol: + return json.dumps({ + "error": "Missing required field: protocol", + "available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()), + }, ensure_ascii=False, indent=2) + + result = get_packet_analysis_protocol(protocol) + return json.dumps(result, ensure_ascii=False, indent=2) diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py new file mode 100644 index 000000000..f99450be4 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3-Copilot Tools Package + +This package provides various tools for interacting with GNS3 network simulator: +- Device configuration command execution +- Display command execution +- Multiple device command execution using Nornir +- VPCS device configuration using Netmiko +- Node and link management + +Main modules: +- config_tools_nornir: Multiple device configuration command execution tool using Nornir +- display_tools_nornir: Multiple device command execution tool using Nornir +- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko +- gns3_create_node: GNS3 node creation tool +- gns3_create_link: GNS3 link creation tool +- gns3_start_node: GNS3 node startup tool +- gns3_get_node_temp: GNS3 template retrieval tool +- gns3_update_node_name: GNS3 node name update tool + +Note: GNS3TopologyTool is now available from gns3_client package + +Author: Yue Guobin (岳国宾) +""" + +# Import main tool classes +from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands +from .display_tools_nornir import ExecuteMultipleDeviceCommands +from .gns3_create_link import GNS3LinkTool +from .gns3_create_node import GNS3CreateNodeTool +from .gns3_get_node_temp import GNS3TemplateTool +from .gns3_start_node import GNS3StartNodeQuickTool +from .gns3_start_node import GNS3StartNodeTool +from .gns3_stop_node import GNS3StopNodeTool +from .gns3_suspend_node import GNS3SuspendNodeTool +from .gns3_update_node_name import GNS3UpdateNodeNameTool +from .packet_analysis_tool import PacketAnalysisTool + +# Dynamic version management +try: + from importlib.metadata import version + + __version__ = version("gns3-copilot") +except Exception: + __version__ = "unknown" + +__author__ = "Yue Guobin (岳国宾)" +__description__ = "AI-powered network automation assistant for GNS3" +__url__ = "https://github.com/yueguobin/gns3-copilot" + +# Export main tool classes +__all__ = [ + "ExecuteMultipleDeviceConfigCommands", + "ExecuteMultipleDeviceCommands", + "GNS3CreateNodeTool", + "GNS3LinkTool", + "GNS3StartNodeTool", + "GNS3StartNodeQuickTool", + "GNS3StopNodeTool", + "GNS3SuspendNodeTool", + "GNS3UpdateNodeNameTool", + "GNS3TemplateTool", + "PacketAnalysisTool", +] + +# Package initialization message +# print(f"GNS3-Copilot Tools package loaded (version {__version__})") diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py new file mode 100644 index 000000000..44af6a703 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -0,0 +1,776 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +This module provides a tool to execute configuration commands on multiple + devices in a GNS3 topology using Nornir. +""" + +import json +import logging +import re +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun +from netmiko.exceptions import ReadTimeout +from nornir import InitNornir +from nornir.core import Nornir +from nornir.core.task import AggregatedResult +from nornir.core.task import Result +from nornir.core.task import Task +from nornir_netmiko.tasks import netmiko_send_config + +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host +from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology +from gns3server.agent.gns3_copilot.utils.command_filter import ( + filter_forbidden_commands, +) + +# Import custom Netmiko device types for GNS3 emulation +# This registers gns3_huawei_telnet_ce and other custom device types +# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered +from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401 + +# Explicitly register custom device types to ensure they are available +# This is a safety measure in case the auto-registration on import doesn't work +try: + from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce + from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( + register_custom_device_type as register_ruijie_device_type, + ) + + # Re-register to ensure device types are available + huawei_ce.register_custom_device_type() + register_ruijie_device_type() + + # CRITICAL: Update netmiko.ssh_dispatcher platforms lists + # The platforms variable is calculated at module import time in ssh_dispatcher + # We need to update it after registering custom device types. + # NOTE: netmiko.ssh_dispatcher is aliased to ConnectHandler function, + # so we use importlib to get the actual module + import importlib + + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Recalculate platforms lists to include custom device types + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + + # Update platform strings used in error messages + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) +except Exception: + # Fail silently - the import-time registration should have worked + pass + +# config log +logger = logging.getLogger(__name__) + +# Suppress nornir INFO logs in console (reduce verbosity) +# The logging={"enabled": False} in InitNornir only disables plugin +# internal logs, but nornir.core still logs task execution at INFO level. +# Set to WARNING to suppress these. +logging.getLogger("nornir.core").setLevel(logging.WARNING) +logging.getLogger("nornir").setLevel(logging.WARNING) + + +# Local Nornir configuration functions for network devices +def _get_nornir_defaults() -> dict[str, Any]: + """Get Nornir default configuration.""" + return {"data": {"location": "gns3"}} + + +class ExecuteMultipleDeviceConfigCommands(BaseTool): + """ + A tool to execute configuration commands on multiple devices in a GNS3 + topology using Nornir. + This class uses Nornir to manage connections and execute configuration + commands on multiple devices concurrently. + + IMPORTANT SAFETY NOTE: + This tool is intended for configuration changes only. Use extreme caution + when executing configuration commands. + """ + + name: str = "execute_multiple_device_config_commands" + description: str = """ + Executes CONFIGURATION commands on multiple devices in the current GNS3 + topology. + Use this tool ONLY for changing device settings (e.g., 'configure', + 'interface', 'ip address', 'router ospf'). + For viewing information, use the 'execute_multiple_device_commands' tool. + Input should be a JSON object containing project_id and device configs. + Example input: + { + "project_id": "", + "device_configs": [ + { + "device_name": "R-1", + "config_commands": [ + "interface Loopback0", + "ip address 1.1.1.1 255.255.255.255", + "description CONFIG_BY_TOOL" + ] + }, + { + "device_name": "R-2", + "config_commands": [ + "interface Loopback0", + "ip address 2.2.2.2 255.255.255.255", + "description CONFIG_BY_TOOL" + ] + } + ] + } + Returns a list of dicts with device name and config results. + + IMPORTANT SAFETY WARNING: + Do NOT use this tool for dangerous operations that could reboot, erase, + or factory-reset devices. + Forbidden operations include but are not limited to: + - reload / reboot commands + - write erase / erase startup-config + - format / erase nvram / delete flash: + - boot system commands + - factory-reset commands + - any commands that require user confirmation prompts + - NEVER use 'exit' command - this disconnects the session and breaks subsequent commands + """ + + def _run( + self, + tool_input: str, # or Union[str, List[Any], Dict[str, Any]] + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """ + Executes configuration commands on multiple devices in GNS3 topology. + + Args: + tool_input (str): A JSON string containing project_id and device + configuration commands to execute. + + Returns: + List[Dict[str, Any]]: A list of dicts containing device names and + configuration results. + """ + # Log received input + logger.debug("Received input: %s", tool_input) + + # Validate input + device_configs_list, project_id = self._validate_tool_input(tool_input) + if ( + isinstance(device_configs_list, list) + and len(device_configs_list) > 0 + and "error" in device_configs_list[0] + ): + return device_configs_list + + # Filter forbidden commands and store blocked commands info + device_configs_list, blocked_commands_map = ( + self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) + ) + + # Expand multiline commands (e.g., banner commands with embedded newlines) + # This converts commands like "banner motd #\nline1\nline2\n#" into + # separate commands: ["banner motd #", "line1", "line2", "#"] + device_configs_list = self._expand_multiline_commands( + device_configs_list + ) + + # Create a mapping of device names to their configuration commands + device_configs_map = self._configs_map(device_configs_list) + + # Prepare device hosts data + try: + hosts_data = self._prepare_device_hosts_data( + device_configs_list, project_id + ) + except ValueError as e: + logger.error("Failed to prepare device hosts data: %s", e) + return [{"error": str(e)}] + + # Check if any devices have errors (e.g., missing device_type tag) + error_devices = { + name: data + for name, data in hosts_data.items() + if "error" in data + } + if error_devices: + logger.error( + "Devices with configuration errors: %s", + list(error_devices.keys()) + ) + return [ + { + "device_name": name, + "status": "failed", + "error": data["error"] + } + for name, data in error_devices.items() + ] + + # Initialize Nornir + try: + dynamic_nr = self._initialize_nornir(hosts_data) + except ValueError as e: + logger.error("Failed to initialize Nornir: %s", e) + return [{"error": str(e)}] + + results = [] + + # Execute all devices concurrently in a single run + try: + task_result = dynamic_nr.run( + task=self._run_all_device_configs_with_single_retry, + device_configs_map=device_configs_map, + ) + + # Process results for all devices + results = self._process_task_results( + device_configs_list, + hosts_data, + task_result, + blocked_commands_map, + ) + + except Exception as e: + # Overall execution failed + logger.error( + "Error executing configurations on all devices: %s", e + ) + return [{"error": f"Execution error: {str(e)}"}] + + logger.info( + "Multiple device configuration execution completed. Results: %s", + json.dumps(results, indent=2, ensure_ascii=False), + ) + + return results + + def _run_all_device_configs_with_single_retry( + self, task: Task, device_configs_map: dict[str, list[str]] + ) -> Result: + """Execute configuration commands with single retry mechanism.""" + device_name = task.host.name + config_commands = device_configs_map.get(device_name, []) + + if not config_commands: + return Result( + host=task.host, result="No configuration commands to execute" + ) + + try: + _result = task.run( + task=netmiko_send_config, config_commands=config_commands + ) + return Result(host=task.host, result=_result.result) + + except ReadTimeout as e: + # Log ReadTimeout exception with full details + logger.error( + "ReadTimeout occurred for device %s: %s", + device_name, + str(e), + ) + return Result( + host=task.host, + result=f"Configuration failed (ReadTimeout): {str(e)}", + failed=True, + ) + + except Exception as e: + # Handle Cisco IOSv L2 where '#' prompt char may be delayed, + # causing Netmiko failures. Implements retry logic. + if "netmiko_send_config (failed)" in str(e): + _result = task.run( + task=netmiko_send_config, config_commands=config_commands + ) + return Result(host=task.host, result=_result.result) + + # Log any other exceptions with full details + logger.error( + "Configuration failed for device %s: %s (Exception type: %s)", + device_name, + str(e), + type(e).__name__, + ) + return Result( + host=task.host, + result=f"Configuration failed (Unhandled Exception): {str(e)}", + failed=True, + ) + + def _validate_tool_input( + self, tool_input: str | bytes | list[Any] | dict[str, Any] + ) -> tuple[list[dict[str, Any]], str | None]: + """ + Validate device config command input, handling new and legacy formats. + Supports new format with project_id and device_configs. + + Args: + tool_input: Input from the LangChain/LangGraph tool call. + + Returns: + Tuple of (device_configs_list, project_id) or (error_list, None) + """ + + parsed_input = None + + # Compatibility Check and Parsing --- + # Check if the input is a string (or bytes) which needs to be parsed. + if isinstance(tool_input, (str, bytes, bytearray)): + # Handle models (like DeepSeek) that return a raw JSON string. + try: + parsed_input = json.loads(tool_input) + logger.debug("Successfully parsed tool input from JSON string.") + except json.JSONDecodeError as e: + logger.error( + "Invalid JSON string received as tool input: %s", e + ) + return ( + [{"error": f"Invalid JSON string input from model: {e}"}], + None, + ) + else: + # Handle standard models (like GPT/OpenAI) where the framework + # has already parsed the JSON into a Python object (dict or list). + parsed_input = tool_input + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) + + # Handle new format: {"project_id": "...", "device_configs": [...]} + if isinstance(parsed_input, dict): + project_id = parsed_input.get("project_id") + device_configs = parsed_input.get("device_configs") + + # Validate project_id + if not project_id: + error_msg = "Missing required 'project_id' field in input" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not self._validate_project_id(project_id): + error_msg = ( + f"Invalid project_id format: {project_id}. " + "Expected UUID format." + ) + logger.error(error_msg) + return ([{"error": error_msg}], None) + + # Validate device_configs + if not isinstance(device_configs, list): + error_msg = "'device_configs' must be an array" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not device_configs: + logger.warning("Device configs list is empty.") + return [], project_id + + return device_configs, project_id + + # Handle legacy format: [...] + elif isinstance(parsed_input, list): + logger.warning( + "Using legacy format without project_id. " + "Please use new format with project_id." + ) + return parsed_input, None + + else: + error_msg = ( + "Tool input must be a JSON object with 'project_id' and " + f"'device_configs' fields, or a legacy JSON array, but got " + f"{type(parsed_input).__name__}" + ) + logger.error(error_msg) + return ([{"error": error_msg}], None) + + def _validate_project_id(self, project_id: str) -> bool: + """ + Validate project_id format (UUID). + + Args: + project_id: The project ID to validate + + Returns: + True if valid UUID format, False otherwise + """ + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) + + def _filter_forbidden_commands_from_device_configs( + self, device_configs_list: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]: + """ + Filter out forbidden commands from device configurations. + + Args: + device_configs_list: List of device configs with config_commands. + + Returns: + Tuple of (filtered_configs, blocked_map): + - filtered_configs: Device configs with forbidden removed. + - blocked_map: Dict mapping device names to blocked info. + """ + filtered_list = [] + blocked_commands_map: dict[str, dict[str, str]] = {} + + for device_config in device_configs_list: + device_name = device_config["device_name"] + commands = device_config["config_commands"] + + # Filter commands + allowed_commands, blocked_info = filter_forbidden_commands( + commands + ) + + # Update device config with allowed commands only + filtered_config = device_config.copy() + filtered_config["config_commands"] = allowed_commands + filtered_list.append(filtered_config) + + # Store blocked commands info if any + if blocked_info: + blocked_commands_map[device_name] = blocked_info + logger.info( + "Device %s: %d command(s) blocked: %s", + device_name, + len(blocked_info), + list(blocked_info.keys()), + ) + + return filtered_list, blocked_commands_map + + def _expand_multiline_commands( + self, device_configs_list: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Expand commands that contain embedded newline characters. + + Some commands (like banner) are passed as single strings with embedded + newlines. This method splits them into separate commands for proper + execution by netmiko_send_config. + + Note: netmiko_send_config automatically handles entering config mode, + so any config mode entry commands (configure terminal, system-view, etc.) + are left as-is and will be safely ignored by the device. + + Example: + Input: ["banner motd #\nline1\nline2\n#", "exit"] + Output: ["banner motd #", "line1", "line2", "#", "exit"] + + Args: + device_configs_list: List of device configs with config_commands. + + Returns: + List of device configs with multiline commands expanded. + """ + expanded_list = [] + + for device_config in device_configs_list: + device_name = device_config["device_name"] + config_commands = device_config["config_commands"] + + expanded_commands = [] + for cmd in config_commands: + # Check if command contains embedded newlines + if "\n" in cmd: + # Split by newline and expand + lines = cmd.split("\n") + # Filter out empty lines but keep all content lines + expanded_commands.extend([line for line in lines if line.strip()]) + logger.info( + "Device %s: Expanded multiline command (%d lines)", + device_name, + len(lines), + ) + else: + # Keep single-line commands as-is + expanded_commands.append(cmd) + + # Create updated device config + expanded_config = device_config.copy() + expanded_config["config_commands"] = expanded_commands + expanded_list.append(expanded_config) + + return expanded_list + + def _configs_map( + self, device_config_list: list[dict[str, Any]] + ) -> dict[str, list[str]]: + """Create a mapping of device names to their configuration commands.""" + device_configs_map = {} + for device_config in device_config_list: + device_name = device_config["device_name"] + config_commands = device_config["config_commands"] + device_configs_map[device_name] = config_commands + + return device_configs_map + + def _prepare_device_hosts_data( + self, + device_config_list: list[dict[str, Any]], + project_id: str | None = None, + ) -> dict[str, dict[str, Any]]: + """Prepare device hosts data from topology information.""" + # Extract device names list + device_names = [ + device_config["device_name"] + for device_config in device_config_list + ] + + # Get device port information with project_id + hosts_data = get_device_ports_from_topology(device_names, project_id) + + if not hosts_data: + error_msg = ( + "Failed to get device info from topology or no valid devices. " + f"Project ID: {project_id}, Devices: {device_names}" + ) + raise ValueError(error_msg) + + # Check for missing devices + missing_devices = set(device_names) - set(hosts_data.keys()) + if missing_devices: + logger.warning( + "Some devices not found in topology (Project ID: %s): %s", + project_id or "default", + missing_devices, + ) + + return hosts_data + + def _initialize_nornir( + self, hosts_data: dict[str, dict[str, Any]] + ) -> Nornir: + """ + Initialize Nornir with the provided hosts data. + + Each host now has its own connection_options (device_type), so we only + need a single generic group for shared configuration (hostname, timeout, etc.). + This is the Nornir best practice for multi-vendor environments. + """ + try: + defaults = _get_nornir_defaults() + gns3_host = get_gns3_server_host() + + # Create a single generic group for shared configuration + # Individual device types are handled at host level via connection_options + groups_data = { + "network_devices": { + "hostname": gns3_host, + "timeout": 120, + "username": "", + "password": "", + } + } + + # Log device types being configured + device_types = [ + host["connection_options"]["netmiko"]["extras"]["device_type"] + for host in hosts_data.values() + ] + logger.info( + "Initializing Nornir: host=%s, device_types=%s, hosts=%d", + gns3_host, + list(set(device_types)), + len(hosts_data), + ) + + return InitNornir( + inventory={ + "plugin": "DictInventory", + "options": { + "hosts": hosts_data, + "groups": groups_data, + "defaults": defaults, + }, + }, + runner={ + "plugin": "threaded", + "options": {"num_workers": 10}, + }, + logging={"enabled": False}, + ) + except Exception as e: + logger.error("Failed to initialize Nornir: %s", e) + raise ValueError(f"Failed to initialize Nornir: {e}") from e + + def _process_task_results( + self, + device_configs_list: list[dict[str, Any]], + hosts_data: dict[str, dict[str, Any]], + task_result: AggregatedResult, + blocked_commands_map: dict[str, dict[str, str]], + ) -> list[dict[str, Any]]: + """Process the task results and format them for return.""" + results = [] + + for device_config in device_configs_list: + device_name = device_config["device_name"] + config_commands = device_config["config_commands"] + blocked_commands_info = blocked_commands_map.get(device_name, {}) + + # Check if device is in topology + if device_name not in hosts_data: + device_result = { + "device_name": device_name, + "status": "failed", + "error": ( + f"Device '{device_name}' not found in topology or " + "missing console_port" + ), + } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + results.append(device_result) + continue + + # Check if device has execution results + if device_name not in task_result: + device_result = { + "device_name": device_name, + "status": "failed", + "error": ( + f"Device '{device_name}' not found in task results" + ), + } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + results.append(device_result) + continue + + # Process execution results + multi_result = task_result[device_name] + device_result = {"device_name": device_name} + + if multi_result[0].failed: + # Execution failed + device_result["status"] = "failed" + device_result["error"] = ( + f"Configuration execution failed: {multi_result[0].result}" + ) + device_result["output"] = multi_result[0].result + else: + # Execution successful + device_result["status"] = "success" + device_result["output"] = multi_result[0].result + device_result["config_commands"] = config_commands + + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + # Update status if some commands were blocked but succeeded + if device_result["status"] == "success": + device_result["status"] = "partial_success" + + results.append(device_result) + + return results + + +if __name__ == "__main__": + # Example usage with new format + # example tool_input with project_id + input_paras = json.dumps( + { + "project_id": "", + "device_configs": [ + { + "device_name": "R-1", + "config_commands": [ + "interface Loopback1110", + "ip address 201.201.201.201 255.255.255.255", + "description CONFIG_BY_TOOL", + ], + }, + { + "device_name": "R-2", + "config_commands": [ + "interface Loopback1110", + "ip address 202.202.202.202 255.255.255.255", + "description CONFIG_BY_TOOL", + ], + }, + { + "device_name": "SW-2", + "config_commands": [ + "interface Loopback1110", + "ip address 202.202.202.202 255.255.255.255", + "description CONFIG_BY_TOOL", + ], + }, + { + "device_name": "SW-1", + "config_commands": [ + "interface Loopback1110", + "ip address 202.202.202.202 255.255.255.255", + "description CONFIG_BY_TOOL", + ], + }, + ], + } + ) + + exe_config = ExecuteMultipleDeviceConfigCommands() + + failed_count = 0 + + for _i in range(0, 5): + exe_results = exe_config._run(tool_input=input_paras) + for result in exe_results: + for result in exe_results: + if result.get("status") == "failed": + failed_count += 1 + + print(f"Failed Count: {failed_count}") + + # print("Execution results:") + # print(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py new file mode 100644 index 000000000..abe7eae47 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -0,0 +1,702 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +This module provides a tool to execute display commands on multiple devices + in a GNS3 topology using Nornir. +""" + +import json +import logging +import re +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun +from netmiko.exceptions import ReadTimeout +from nornir import InitNornir +from nornir.core import Nornir +from nornir.core.task import AggregatedResult +from nornir.core.task import Result +from nornir.core.task import Task +from nornir_netmiko.tasks import netmiko_multiline + +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host +from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology +from gns3server.agent.gns3_copilot.utils.command_filter import ( + filter_forbidden_commands, +) + +# Import custom Netmiko device types for GNS3 emulation +# This registers gns3_huawei_telnet_ce and other custom device types +# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered +from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401 + +# Explicitly register custom device types to ensure they are available +# This is a safety measure in case the auto-registration on import doesn't work +try: + from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce + from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( + register_custom_device_type as register_ruijie_device_type, + ) + + # Re-register to ensure device types are available + huawei_ce.register_custom_device_type() + register_ruijie_device_type() + + # CRITICAL: Update netmiko.ssh_dispatcher platforms lists + # The platforms variable is calculated at module import time in ssh_dispatcher + # We need to update it after registering custom device types. + # NOTE: netmiko.ssh_dispatcher is aliased to ConnectHandler function, + # so we use importlib to get the actual module + import importlib + + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Recalculate platforms lists to include custom device types + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + + # Update platform strings used in error messages + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) +except Exception: + # Fail silently - the import-time registration should have worked + pass + +# config log +logger = logging.getLogger(__name__) + +# Suppress nornir INFO logs in console (reduce verbosity) +# The logging={"enabled": False} in InitNornir only disables plugin +# internal logs, but nornir.core still logs task execution at INFO level. +# Set to WARNING to suppress these. +logging.getLogger("nornir.core").setLevel(logging.WARNING) +logging.getLogger("nornir").setLevel(logging.WARNING) + + +# Local Nornir configuration functions for network devices +def _get_nornir_defaults() -> dict[str, Any]: + """Get Nornir default configuration.""" + return {"data": {"location": "gns3"}} + + +class ExecuteMultipleDeviceCommands(BaseTool): + """ + A READ-ONLY diagnostic tool for viewing network device configurations. + + **CRITICAL: DIAGNOSIS ONLY - NO CONFIGURATION PERMISSIONS** + + This tool is exclusively designed for read-only operations to inspect and + diagnose network devices. It CANNOT and MUST NOT be used for configuration. + + **Allowed Command Types:** + - Display commands: show, display (Cisco/Huawei) + - Debug commands: debug ip routing, debug ospf events + - Verification commands: ping, traceroute, telnet + - Status commands: show version, show running-config, show ip route + + **Strictly Prohibited:** + - Configuration mode: configure terminal, config t + - Interface configuration: interface, ip address + - Protocol configuration: router ospf, router bgp + - Any command that modifies device state + """ + + name: str = "execute_multiple_device_commands" + description: str = """ + **READ-ONLY DIAGNOSTIC TOOL** - View network device configurations. + + Use this tool to inspect device information without making any changes. + + **PERMITTED USE CASES:** + - View device status: show version, show running-config + - Check routing: show ip route, show ip ospf neighbor, show bgp summary + - Interface status: show ip interface brief, show interfaces + - Protocol diagnostics: show ospf database, show bgp routes, debug commands + - Connectivity testing: ping, traceroute + + **STRICTLY FORBIDDEN:** + - NO configuration commands (configure terminal, interface, router, etc.) + - NO commands that modify device state + - NEVER use 'exit' command - this disconnects the session and breaks subsequent commands + - If you need to configure, provide guidance to the student instead + + **Input Format:** + { + "project_id": "", + "device_configs": [ + { + "device_name": "R-1", + "commands": ["show version", "show ip interface brief"] + }, + { + "device_name": "R-2", + "commands": ["show version", "show ip ospf neighbor"] + } + ] + } + + **Returns:** List of device outputs for diagnostic analysis. + """ + + def _run( + self, + tool_input: str | bytes | list[Any] | dict[str, Any], + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """ + Executes READ-ONLY diagnostic commands on multiple devices in GNS3. + + This method only permits display/show commands and does not allow + configuration changes to network devices. + + Args: + tool_input: JSON string with project_id and diagnostic commands. + + Returns: + List[Dict]: A list of dicts with device names and outputs. + """ + # Log received input + logger.debug("Received input: %s", tool_input) + + # Validate input + device_configs_list, project_id = self._validate_tool_input(tool_input) + if ( + isinstance(device_configs_list, list) + and len(device_configs_list) > 0 + and "error" in device_configs_list[0] + ): + return device_configs_list + + # Filter forbidden commands and store blocked commands info + device_configs_list, blocked_commands_map = ( + self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) + ) + + # Create a mapping of device names to their display commands + device_configs_map = self._configs_map(device_configs_list) + + # Prepare device hosts data + try: + hosts_data = self._prepare_device_hosts_data( + device_configs_list, project_id + ) + except ValueError as e: + logger.error("Failed to prepare device hosts data: %s", e) + return [{"error": str(e)}] + + # Check if any devices have errors (e.g., missing device_type tag) + error_devices = { + name: data + for name, data in hosts_data.items() + if "error" in data + } + if error_devices: + logger.error( + "Devices with configuration errors: %s", + list(error_devices.keys()) + ) + return [ + { + "device_name": name, + "status": "failed", + "error": data["error"] + } + for name, data in error_devices.items() + ] + + # Initialize Nornir + try: + dynamic_nr = self._initialize_nornir(hosts_data) + except ValueError as e: + logger.error("Failed to initialize Nornir: %s", e) + return [{"error": str(e)}] + + results = [] + + # Execute all devices concurrently in a single run + try: + task_result = dynamic_nr.run( + task=self._run_all_device_configs_with_single_retry, + device_configs_map=device_configs_map, + ) + + # Process results for all devices + results = self._process_task_results( + device_configs_list, + hosts_data, + task_result, + blocked_commands_map, + ) + + except Exception as e: + # Overall execution failed + logger.error("Error executing display on all devices: %s", e) + return [{"error": f"Execution error: {str(e)}"}] + + logger.debug( + "Multiple device display execution completed. Results: %s", + json.dumps(results, indent=2, ensure_ascii=False), + ) + + return results + + def _run_all_device_configs_with_single_retry( + self, task: Task, device_configs_map: dict[str, list[str]] + ) -> Result: + """Execute READ-ONLY diagnostic commands with single retry.""" + device_name = task.host.name + diagnostic_commands = device_configs_map.get(device_name, []) + + if not diagnostic_commands: + return Result( + host=task.host, result="No diagnostic commands to execute" + ) + + try: + _result = task.run( + task=netmiko_multiline, + commands=diagnostic_commands, + enable=True, + read_timeout=60, + ) + return Result(host=task.host, result=_result.result) + + except ReadTimeout as e: + # Log ReadTimeout exception with full details + logger.error( + "ReadTimeout occurred for device %s: %s", + device_name, + str(e), + ) + return Result( + host=task.host, + result=f"diagnostic command failed (ReadTimeout): {str(e)}", + failed=True, + ) + + except Exception as e: + # Handle Cisco IOSv L2 where '#' prompt char may be delayed, + # causing Netmiko failures. Implements retry logic. + if "netmiko_multiline (failed)" in str(e): + _result = task.run( + task=netmiko_multiline, + commands=diagnostic_commands, + enable=True, + read_timeout=60, + ) + return Result(host=task.host, result=_result.result) + + # Log any other exceptions with full details + logger.error( + "diagnostic command failed for device %s: %s (Exception: %s)", + device_name, + str(e), + type(e).__name__, + ) + return Result( + host=task.host, + result=f"diagnostic command failed (Unhandled): {str(e)}", + failed=True, + ) + + def _validate_tool_input( + self, tool_input: str | bytes | list[Any] | dict[str, Any] + ) -> tuple[list[dict[str, Any]], str | None]: + """ + Validate diagnostic command input for read-only device inspection. + + Handles both new and legacy input formats. Supports new format with + project_id and device_configs, as well as legacy array format. + + Args: + tool_input: Input from LangChain/LangGraph tool call. + + Returns: + Tuple of (device_configs_list, project_id) or (error_list, None) + """ + + parsed_input = None + + # Compatibility Check and Parsing --- + # Check if the input is a string (or bytes) which needs to be parsed. + if isinstance(tool_input, (str, bytes, bytearray)): + # Handle models (like DeepSeek) that return a raw JSON string. + try: + parsed_input = json.loads(tool_input) + logger.info("Successfully parsed tool input from JSON string.") + except json.JSONDecodeError as e: + logger.error( + "Invalid JSON string received as tool input: %s", e + ) + return ( + [{"error": f"Invalid JSON string input from model: {e}"}], + None, + ) + else: + # Handle standard models (like GPT/OpenAI) where the framework + # has already parsed the JSON into a Python object (dict or list). + parsed_input = tool_input + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) + + # Handle new format: {"project_id": "...", "device_configs": [...]} + if isinstance(parsed_input, dict): + project_id = parsed_input.get("project_id") + device_configs = parsed_input.get("device_configs") + + # Validate project_id + if not project_id: + error_msg = "Missing required 'project_id' field in input" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not self._validate_project_id(project_id): + error_msg = f"Invalid project_id: {project_id}. Expected UUID." + logger.error(error_msg) + return ([{"error": error_msg}], None) + + # Validate device_configs + if not isinstance(device_configs, list): + error_msg = "'device_configs' must be an array" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not device_configs: + logger.warning("Device configs list is empty.") + return [], project_id + + return device_configs, project_id + + # Handle legacy format: [...] + elif isinstance(parsed_input, list): + logger.warning( + "Legacy input format without project_id. Use new format." + ) + return parsed_input, None + + else: + error_msg = ( + "Tool input must be JSON with project_id and device_configs, " + f"or legacy JSON array, got {type(parsed_input).__name__}" + ) + logger.error(error_msg) + return ([{"error": error_msg}], None) + + def _validate_project_id(self, project_id: str) -> bool: + """ + Validate project_id format (UUID). + + Args: + project_id: The project ID to validate + + Returns: + True if valid UUID format, False otherwise + """ + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) + + def _filter_forbidden_commands_from_device_configs( + self, device_configs_list: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]: + """ + Filter out forbidden commands from device configurations. + + Args: + device_configs_list: List of device configurations with commands. + + Returns: + Tuple of (filtered_configs, blocked_map): + - filtered_configs: Device configs with forbidden removed. + - blocked_map: Dict mapping device names to blocked commands. + """ + filtered_list = [] + blocked_commands_map: dict[str, dict[str, str]] = {} + + for device_config in device_configs_list: + device_name = device_config["device_name"] + commands = device_config["commands"] + + # Filter commands + allowed_commands, blocked_info = filter_forbidden_commands( + commands + ) + + # Update device config with allowed commands only + filtered_config = device_config.copy() + filtered_config["commands"] = allowed_commands + filtered_list.append(filtered_config) + + # Store blocked commands info if any + if blocked_info: + blocked_commands_map[device_name] = blocked_info + logger.info( + "Device %s: %d command(s) blocked: %s", + device_name, + len(blocked_info), + list(blocked_info.keys()), + ) + + return filtered_list, blocked_commands_map + + def _configs_map( + self, device_config_list: list[dict[str, Any]] + ) -> dict[str, list[str]]: + """Create a mapping of device names to their diagnostic commands.""" + device_diagnostic_map = {} + for device_config in device_config_list: + device_name = device_config["device_name"] + diagnostic_commands = device_config["commands"] + device_diagnostic_map[device_name] = diagnostic_commands + + return device_diagnostic_map + + def _prepare_device_hosts_data( + self, + device_config_list: list[dict[str, Any]], + project_id: str | None = None, + ) -> dict[str, dict[str, Any]]: + """Prepare device hosts data from topology information.""" + # Extract device names list + device_names = [ + device_config["device_name"] + for device_config in device_config_list + ] + + # Get device port information with project_id + hosts_data = get_device_ports_from_topology(device_names, project_id) + + if not hosts_data: + error_msg = ( + f"Failed to get device info from topology. " + f"Project: {project_id}, Devices: {device_names}" + ) + raise ValueError(error_msg) + + # Check for missing devices + missing_devices = set(device_names) - set(hosts_data.keys()) + if missing_devices: + logger.warning( + "Some devices not found in topology (Project ID: %s): %s", + project_id or "default", + missing_devices, + ) + + return hosts_data + + def _initialize_nornir( + self, hosts_data: dict[str, dict[str, Any]] + ) -> Nornir: + """ + Initialize Nornir with the provided hosts data. + + Each host now has its own connection_options (device_type), so we only + need a single generic group for shared configuration (hostname, timeout, etc.). + This is the Nornir best practice for multi-vendor environments. + """ + try: + defaults = _get_nornir_defaults() + gns3_host = get_gns3_server_host() + + # Create a single generic group for shared configuration + # Individual device types are handled at host level via connection_options + groups_data = { + "network_devices": { + "hostname": gns3_host, + "timeout": 120, + "username": "", + "password": "", + } + } + + # Log device types being configured + device_types = [ + host["connection_options"]["netmiko"]["extras"]["device_type"] + for host in hosts_data.values() + ] + logger.info( + "Initializing Nornir: host=%s, device_types=%s, hosts=%d", + gns3_host, + list(set(device_types)), + len(hosts_data), + ) + + return InitNornir( + inventory={ + "plugin": "DictInventory", + "options": { + "hosts": hosts_data, + "groups": groups_data, + "defaults": defaults, + }, + }, + runner={ + "plugin": "threaded", + "options": {"num_workers": 10}, + }, + logging={"enabled": False}, + ) + except Exception as e: + logger.error("Failed to initialize Nornir: %s", e) + raise ValueError(f"Failed to initialize Nornir: {e}") from e + + def _process_task_results( + self, + device_configs_list: list[dict[str, Any]], + hosts_data: dict[str, dict[str, Any]], + task_result: AggregatedResult, + blocked_commands_map: dict[str, dict[str, str]], + ) -> list[dict[str, Any]]: + """Process the task results and format them for return.""" + results = [] + + for device_config in device_configs_list: + device_name = device_config["device_name"] + diagnostic_commands = device_config["commands"] + blocked_commands_info = blocked_commands_map.get(device_name, {}) + + # Check if device is in topology + if device_name not in hosts_data: + device_result = { + "device_name": device_name, + "status": "failed", + "error": ( + f"Device '{device_name}' not found in topology " + "or missing console_port" + ), + } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + results.append(device_result) + continue + + # Check if device has execution results + if device_name not in task_result: + device_result = { + "device_name": device_name, + "status": "failed", + "error": ( + f"Device '{device_name}' not found in task results" + ), + } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + results.append(device_result) + continue + + # Process execution results + multi_result = task_result[device_name] + device_result = {"device_name": device_name} + + if multi_result[0].failed: + # Execution failed + device_result["status"] = "failed" + device_result["error"] = ( + f"Diagnostic command failed: {multi_result[0].result}" + ) + device_result["output"] = multi_result[0].result + else: + # Execution successful + device_result["status"] = "success" + device_result["output"] = multi_result[0].result + device_result["diagnostic_commands"] = diagnostic_commands + + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list( + blocked_commands_info.keys() + ) + device_result["blocked_info"] = blocked_commands_info + # Update status if some commands were blocked but succeeded + if device_result["status"] == "success": + device_result["status"] = "partial_success" + + results.append(device_result) + + return results + + +if __name__ == "__main__": + # Example usage with new format + device_commands = json.dumps( + { + "project_id": "", + "device_configs": [ + { + "device_name": "R-2", + "commands": ["show version", "show ip interface brief"], + }, + { + "device_name": "R-1", + "commands": ["show version", "show ip interface brief"], + }, + { + "device_name": "SW-1", + "commands": ["show version", "show ip interface brief"], + }, + { + "device_name": "SW-2", + "commands": ["show version", "show ip interface brief"], + }, + ], + } + ) + + exe_cmd = ExecuteMultipleDeviceCommands() + + failed_count = 0 + + for _i in range(0, 1): + exe_results = exe_cmd._run(tool_input=device_commands) + for result in exe_results: + for result in exe_results: + if result.get("status") == "failed": + failed_count += 1 + + print(f"Failed Count: {failed_count}") + + # print("Execution results:") + # print(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py new file mode 100644 index 000000000..677f7d642 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py @@ -0,0 +1,319 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 link creation tool for connecting network nodes. + +Provides functionality to create links between nodes in GNS3 projects +using the GNS3 API connector. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Link +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3LinkTool(BaseTool): + """ + Tool for creating network links between GNS3 nodes. + + Creates one or more links between specified nodes in a GNS3 project + by connecting their network ports. Supports batch link creation + with error handling for individual link failures. + """ + + name: str = "create_gns3_link" + description: str = """ + Creates one or more links between nodes in a GNS3 project. + + Input: A JSON string with: + - `project_id` (str): The UUID of the GNS3 project. + - `links` (list): A non-empty array of link definitions, each containing: + - `node_id1` (str): UUID of the first node. + - `port1` (str): Port name of the first node (e.g., 'Ethernet0/0'). + - `node_id2` (str): UUID of the second node. + - `port2` (str): Port name of the second node (e.g., 'Ethernet0/0'). + Note: Port names must match those from `gns3_topology_reader` tool. + + Example Input: + { + "project_id": "uuid-of-project", + "links": [ + { + "node_id1": "uuid-of-node1", + "port1": "Ethernet0/0", + "node_id2": "uuid-of-node2", + "port2": "Ethernet0/0" + } + ] + } + Output: A list of dicts, each containing: + - For successful links: link_id, node_id1, port1, node_id2, port2. + - For failed links: error(str) with error message. + + Example Output: + [ + { + "link_id": "uuid-of-link", + "node_id1": "uuid-of-node1", + "port1" : "Ethernet0/0", + "node_id2": "uuid-of-node2", + "port2": "Ethernet0/0" + }, + { + "error": "Port not found in link 1" + }, + ] + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> list[dict[str, Any]]: + """ + Creates one or multiple links between nodes in a GNS3 project. + + Args: + tool_input: JSON string with project_id and links array. + run_manager: LangChain run manager (unused). + + Returns: + list: A list with created link details or error messages. + """ + # Log received input + logger.debug("Received input: %s", tool_input) + + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + links_data = input_data.get("links", []) + + # Validate input + if not project_id: + logger.error("Missing required field: project_id") + return [{"error": "Missing required field: project_id"}] + + if not isinstance(links_data, list) or len(links_data) == 0: + logger.error("Invalid links data: must be a non-empty array") + return [ + {"error": "Invalid links data: must be a non-empty array"} + ] + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return [ + { + "error": ( + "Failed to connect to GNS3 server. " + "Please check your configuration." + ) + } + ] + + created_links = [] + + # Process each link definition + for i, link_data in enumerate(links_data): + try: + logger.info("Creating link %d/%d", i + 1, len(links_data)) + + # Extract link parameters + node_id1 = link_data.get("node_id1") + port1 = link_data.get("port1") + node_id2 = link_data.get("node_id2") + port2 = link_data.get("port2") + + # Validate link parameters + if not all([node_id1, port1, node_id2, port2]): + error_msg = ( + f"Missing required fields in link definition {i}" + ) + logger.error(error_msg) + created_links.append({"error": error_msg}) + continue + + # Get node details + node1 = gns3_server.get_node( + project_id=project_id, node_id=node_id1 + ) + node2 = gns3_server.get_node( + project_id=project_id, node_id=node_id2 + ) + if not node1 or not node2: + error_msg = f"Node not found in link {i}" + logger.error(error_msg) + created_links.append({"error": error_msg}) + continue + + # Find port information - match by name or short_name + port1_info = next( + ( + port + for port in node1.get("ports", []) + if port.get("name") == port1 or port.get("short_name") == port1 + ), + None, + ) + port2_info = next( + ( + port + for port in node2.get("ports", []) + if port.get("name") == port2 or port.get("short_name") == port2 + ), + None, + ) + if not port1_info or not port2_info: + error_msg = f"Port not found in link {i}" + logger.error(error_msg) + created_links.append({"error": error_msg}) + continue + + # Create the link + link = Link( + project_id=project_id, + connector=gns3_server, + nodes=[ + { + "node_id": node_id1, + "adapter_number": port1_info.get( + "adapter_number", 0 + ), + "port_number": port1_info.get( + "port_number", 0 + ), + "label": {"text": port1_info.get("short_name") or port1}, + }, + { + "node_id": node_id2, + "adapter_number": port2_info.get( + "adapter_number", 0 + ), + "port_number": port2_info.get( + "port_number", 0 + ), + "label": {"text": port2_info.get("short_name") or port2}, + }, + ], + ) + link.create() + link.get() + + # Collect link details + link_info = { + "link_id": link.link_id, + "node_id1": node_id1, + "port1": port1, + "node_id2": node_id2, + "port2": port2, + } + created_links.append(link_info) + + except Exception as e: + error_msg = f"Failed to create link {i}: {str(e)}" + logger.error(error_msg) + created_links.append({"error": error_msg}) + + # Log final results + success_count = len( + [link for link in created_links if "error" not in link] + ) + logger.info( + "Link creation completed: %d successful, %d failed", + success_count, + len(links_data) - success_count, + ) + + return created_links + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return [{"error": f"Invalid JSON input: {e}"}] + except Exception as e: + logger.error("Failed to process link creation: %s", e) + return [{"error": f"Failed to process link creation: {str(e)}"}] + + +if __name__ == "__main__": + # Test with single link + single_link_input = json.dumps( + { + "project_id": "your-project-uuid", + "links": [ + { + "node_id1": "your-node1-uuid", + "port1": "Ethernet0/0", + "node_id2": "your-node2-uuid", + "port2": "Ethernet0/0", + } + ], + } + ) + + # Test with multiple links + multiple_links_input = json.dumps( + { + "project_id": "your-project-uuid", + "links": [ + { + "node_id1": "your-node1-uuid", + "port1": "Ethernet0/0", + "node_id2": "your-node2-uuid", + "port2": "Ethernet0/0", + }, + { + "node_id1": "your-node1-uuid", + "port1": "Ethernet0/1", + "node_id2": "your-node3-uuid", + "port2": "Ethernet0/0", + }, + ], + } + ) + + tool = GNS3LinkTool() + + print("=== Testing Single Link Creation ===") + result = tool._run(single_link_input) + pprint(result) + + print("\n=== Testing Multiple Links Creation ===") + result = tool._run(multiple_links_input) + pprint(result) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py new file mode 100644 index 000000000..49f7188fe --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py @@ -0,0 +1,325 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 node creation tool for network topology building. + +Provides functionality to create multiple nodes in GNS3 projects +using specified templates and coordinates through the GNS3 API. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Node +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3CreateNodeTool(BaseTool): + """ + A LangChain tool to create multiple nodes in a GNS3 project + using specified templates and coordinates. + + **Input:** + A JSON object with project_id and array of nodes with template_id, + x, y coordinates, and optional name. + + Example input: + { + "project_id": "uuid-of-project", + "nodes": [ + { + "template_id": "uuid-of-template", + "x": 100, + "y": -200, + "name": "R1" + }, + { + "template_id": "uuid-of-template2", + "x": -200, + "y": 300, + "name": "R2" + } + ] + } + + **Output:** + A dictionary containing the creation results for all nodes. + Example output: + { + "project_id": "uuid-of-project", + "created_nodes": [ + { + "node_id": "uuid-of-node1", + "name": "R1", + "status": "success" + }, + { + "node_id": "uuid-of-node2", + "name": "R2", + "status": "success" + } + ], + "total_nodes": 2, + "successful_nodes": 2, + "failed_nodes": 0 + } + If error occurs during validation, returns dict with error message. + """ + + name: str = "create_gns3_node" + description: str = """ + Creates multiple nodes in a GNS3 project using templates and coordinates. + Input is a JSON object with project_id and array of nodes. + Each node requires: template_id, x, y. Optional: name (to set node name directly). + Example input: + { + "project_id": "uuid-of-project", + "nodes": [ + { + "template_id": "uuid-of-template", + "x": 100, + "y": -200, + "name": "R1" + }, + { + "template_id": "uuid-of-template2", + "x": -200, + "y": 300, + "name": "R2" + } + ] + } + IMPORTANT: Ensure distance between any two nodes is greater than 250 px. + This spacing is necessary to display interface numbers clearly for better + topology visualization. + Returns a dictionary with creation results for all nodes, including + success/failure status. + If the operation fails during input validation, returns a dictionary with + an error message. + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + Creates nodes in a GNS3 project with templates and coordinates. + + Args: + tool_input: A JSON string with project_id and an array of nodes. + run_manager: LangChain run manager (unused). + + Returns: + dict: A dictionary with creation results for all nodes or an error + message. + """ + # Log received input + logger.info("Received input: %s", tool_input) + + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + nodes = input_data.get("nodes", []) + + # Validate input + if not project_id: + logger.error("Invalid input: Missing project_id.") + return {"error": "Missing project_id."} + + if not isinstance(nodes, list) or len(nodes) == 0: + logger.error("Invalid input: nodes must be a non-empty array.") + return {"error": "nodes must be a non-empty array."} + + # Validate each node in the array + for i, node_data in enumerate(nodes): + if not isinstance(node_data, dict): + logger.error( + "Invalid input: Node %d must be a dictionary.", i + 1 + ) + return {"error": f"Node {i + 1} must be a dictionary."} + + template_id = node_data.get("template_id") + x = node_data.get("x") + y = node_data.get("y") + name = node_data.get("name") + + if not all( + [ + template_id, + isinstance(x, (int, float)), + isinstance(y, (int, float)), + ] + ): + logger.error( + "Invalid input: Node %d missing or invalid " + "template_id, x, or y.", + i + 1, + ) + return { + "error": f"Node {i + 1} missing or invalid " + f"template_id, x, or y." + } + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # Create nodes + logger.info( + "Creating %d nodes in project %s...", len(nodes), project_id + ) + results: list[dict[str, Any]] = [] + + for i, node_data in enumerate(nodes): + try: + template_id = node_data.get("template_id") + x = node_data.get("x") + y = node_data.get("y") + name = node_data.get("name") + + logger.info( + "Creating node %d/%d with template %s at (%s, %s), name=%s...", + i + 1, + len(nodes), + template_id, + x, + y, + name, + ) + + # Create node + node = Node( + project_id=project_id, + template_id=template_id, + x=x, + y=y, + name=name, + connector=gns3_server, + ) + node.create() + + # Retrieve node details + node.get() + node_info = { + "node_id": node.node_id, + "name": node.name, + "status": "success", + } + + results.append(node_info) + + except Exception as e: + error_info = { + "error": f"Node {i + 1} creation failed: {str(e)}", + "status": "failed", + } + results.append(error_info) + logger.error("Failed to create node %d: %s", i + 1, e) + # Continue with next node even if one fails + + # Calculate summary statistics + successful_nodes = len( + [r for r in results if r.get("status") == "success"] + ) + failed_nodes = len( + [r for r in results if r.get("status") == "failed"] + ) + + # Prepare final result + final_result = { + "project_id": project_id, + "created_nodes": results, + "total_nodes": len(nodes), + "successful_nodes": successful_nodes, + "failed_nodes": failed_nodes, + } + + # Log the final result + logger.info( + "Node creation completed: %d successful, %d failed, %d total.", + successful_nodes, + failed_nodes, + len(nodes), + ) + + # Return JSON-formatted result + return final_result + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to process node creation request: %s", e) + return { + "error": f"Failed to process node creation request: {str(e)}" + } + + +if __name__ == "__main__": + # Test the tool locally with multiple nodes + test_input = json.dumps( + { + # TODO: Replace with actual project UUID + "project_id": "d7fc094c-685e-4db1-ac11-5e33a1b2e066", + "nodes": [ + { + # TODO: Replace with actual template UUID + "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", + "x": 100, + "y": -200, + "name": "R1", + }, + { + # TODO: Replace with actual template UUID + "template_id": "b923a635-b7cc-4cb5-9a86-9357e04c02f7", + "x": 200, + "y": -300, + "name": "R2", + }, + ], + } + ) + tool = GNS3CreateNodeTool() + result = tool._run(test_input) + pprint(result) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py new file mode 100644 index 000000000..fabd78f5a --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 template retrieval tool for device discovery. + +Provides functionality to retrieve all available device templates +from a GNS3 server, including template names, IDs, and types. +Filters out built-in utility templates that are not useful for network labs. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + +# Built-in templates to filter out (utility templates, not actual network devices) +FILTERED_TEMPLATES = { + "atm_switch", # ATM switch + "cloud", # Cloud + "ethernet_hub", # Ethernet hub + "ethernet_switch", # Ethernet switch (built-in) + "frame_relay_switch", # Frame Relay switch + "nat", # NAT device +} + + +def should_filter_template(template: dict[str, Any]) -> bool: + """ + Determine whether a template should be filtered out. + + Filter condition: + 1. Template type is in the filter list (exact match) + + Args: + template: Template dictionary + + Returns: + True if template should be filtered, False if it should be kept + """ + template_type = template.get("template_type", "") + + # Check if template_type is in filter list + # This is the most reliable way to identify built-in utility templates + return template_type in FILTERED_TEMPLATES + + +class GNS3TemplateTool(BaseTool): + """ + LangChain tool to retrieve available device templates from GNS3 server. + Connects to GNS3 server and extracts name, template_id, and template_type. + Filters out built-in utility templates that are not useful for network labs. + + **Input:** + No input required. Connects to GNS3 server at default URL. + + **Output:** + Dict with list of dicts (name, template_id, template_type). + If error, returns dict with error message. + + **Filtered Templates:** + The following built-in utility templates are excluded: + - ATM switch + - Cloud + - Ethernet hub + - Ethernet switch (built-in) + - Frame Relay switch + - NAT + """ + + name: str = "get_gns3_templates" + description: str = """ + Retrieves available device templates from GNS3 server for network labs. + Returns dict with list of dicts (name, template_id, template_type). + Filters out built-in utility templates (ATM switch, Cloud, Ethernet hub, + Ethernet switch, Frame Relay switch, NAT) as they are not useful for + network device configuration. + No input required. + If connection fails, returns dict with error message. + """ + + def _run( + self, + tool_input: str = "", + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + """ + Connects to GNS3 server and retrieves available device templates. + + Args: + tool_input: Optional input (not used). + run_manager: LangChain run manager (unused). + + Returns: + dict: Dict with templates list or error message. + """ + try: + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": ( + "Failed to connect to GNS3 server. " + "Please check your configuration." + ) + } + + # Retrieve all available templates + templates = gns3_server.get_templates() + + # Filter out utility templates and extract relevant info + template_info = [] + filtered_count = 0 + for template in templates: + # Check if template should be filtered + if should_filter_template(template): + filtered_count += 1 + logger.debug("Filtered out template: %s", template.get("name")) + continue + + # Extract name, template_id, and template_type + template_info.append({ + "name": template.get("name", "N/A"), + "template_id": template.get("template_id", "N/A"), + "template_type": template.get("template_type", "N/A"), + }) + + # Return JSON-formatted result with full logging + result = {"templates": template_info} + logger.info( + "Template retrieval completed. Total: %d, Filtered: %d, Remaining: %d", + len(templates), + filtered_count, + len(template_info), + ) + logger.debug("Result: %s", json.dumps(result, indent=2, ensure_ascii=False)) + return result + + except Exception as e: + logger.error( + "Failed to connect to GNS3 server or retrieve templates: %s", e + ) + return {"error": f"Failed to retrieve templates: {str(e)}"} + + +if __name__ == "__main__": + # Test's tool locally + tool = GNS3TemplateTool() + result = tool._run("") + pprint(result) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py new file mode 100644 index 000000000..dc9a5fa10 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -0,0 +1,544 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 node startup tool for network device activation. + +Provides functionality to start one or multiple nodes in GNS3 projects +with progress tracking and status monitoring. +""" + +import json +import logging +import time +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Node +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + +# Node startup time configuration by device type +# Based on typical boot times for different emulators +# Conservative timing to account for slower hardware environments +NODE_STARTUP_TIME = { + "vpcs": {"base": 15, "extra_per_node": 2}, # VPCS: Very fast startup + "iou": {"base": 25, "extra_per_node": 3}, # IOU: Fast startup + "default": {"base": 120, "extra_per_node": 10}, # Other devices: Conservative time +} + + +def calculate_startup_time(nodes: list) -> int: + """ + Calculate startup wait time based on node types. + + Strategy: + - If all nodes are fast devices (VPCS/IOU): use fast startup time + - If any node is a slow device: use conservative startup time + + Args: + nodes: List of node objects with node_type attribute + + Returns: + Calculated wait time in seconds + """ + if not nodes: + return 60 # Default: 60 seconds for empty list + + # Get all node types + node_types = [getattr(node, "node_type", "default") for node in nodes] + + # Check if all nodes are fast startup devices (VPCS or IOU) + fast_types = {"vpcs", "iou"} + all_fast = all(node_type in fast_types for node_type in node_types) + + if all_fast: + # Use fast startup time: base + (count - 1) * extra_per_node + # Use the largest base time among the fast devices + max_fast_base = max( + NODE_STARTUP_TIME[nt]["base"] + for nt in node_types if nt in fast_types + ) + # Use the smallest extra_per_node among the fast devices + min_fast_extra = min( + NODE_STARTUP_TIME[nt]["extra_per_node"] + for nt in node_types if nt in fast_types + ) + total_time = max_fast_base + (len(nodes) - 1) * min_fast_extra + logger.info( + "All fast devices detected (%s), using fast startup time: %ds", + node_types, + total_time + ) + return total_time + else: + # Use conservative startup time for mixed or slow devices + config = NODE_STARTUP_TIME["default"] + total_time = config["base"] + (len(nodes) - 1) * config["extra_per_node"] + logger.info( + "Mixed or slow devices detected (%s), using conservative startup time: %ds", + node_types, + total_time + ) + return total_time + + +def show_progress_bar( + duration: int = 120, interval: int = 1, node_count: int = 1 +) -> None: + """ + Display a simple text progress bar for node startup. + + Args: + duration: Total duration of the progress bar in seconds + interval: Update interval in seconds + node_count: Number of nodes being started + """ + print(f"Starting {node_count} node(s), please wait...") + for elapsed in range(duration): + # Calculate progress percentage + progress = (elapsed + 1) / duration * 100 + + # Create progress bar display + bar_length = 30 + filled_length = int(bar_length * elapsed // duration) + progress_string = ( + "=" * filled_length + ">" + " " * (bar_length - filled_length - 1) + ) + + # Print progress bar with node count + print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True) + time.sleep(interval) + + print(f"\n{node_count} node(s) startup completed!") + + +class GNS3StartNodeTool(BaseTool): + """ + A LangChain tool to start one or multiple nodes in a GNS3 project. + + **Input**: + A JSON object with project_id and node_ids (list of node IDs). + Example: + { + "project_id": "uuid-of-project", + "node_ids": ["uuid-of-node-1", "uuid-of-node-2"] + } + + **Output**: + A dictionary with all nodes' details: + { + "project_id": "...", + "total_nodes": 2, + "successful": 2, + "failed": 0, + "nodes": [ + {"node_id": "...", "name": "...", "status": "..."}, + {"node_id": "...", "name": "...", "status": "..."} + ] + } + """ + + name: str = "start_gns3_node" + description: str = """ + Starts one or multiple nodes in a GNS3 project. + Input: JSON with project_id and node_ids (list of node IDs). + Returns: A dict with all nodes' details (success/failure status). + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + node_ids = input_data.get("node_ids") + + # Validate input + if not project_id or not node_ids: + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } + + if not isinstance(node_ids, list): + logger.error("node_ids must be a list.") + return {"error": "node_ids must be a list."} + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # First loop: Get node info and send start commands for all nodes + logger.info( + "Retrieving node info for %d nodes in project %s...", + len(node_ids), + project_id, + ) + nodes = [] + for node_id in node_ids: + try: + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) + # Get node info (including node_type) + node.get() + if node.node_id: + nodes.append(node) + logger.info( + "Node %s (%s) type: %s", + node_id, + node.name, + node.node_type, + ) + else: + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) + except Exception as e: + logger.error( + "Failed to get node info for %s: %s", + node_id, + e, + ) + + # Calculate startup time based on node types + wait_time = calculate_startup_time(nodes) + + # Send start commands for all nodes + logger.info( + "Sending start commands for %d nodes in project %s...", + len(nodes), + project_id, + ) + for node in nodes: + try: + node.start() + logger.info("Start command sent for node %s", node.node_id) + except Exception as e: + logger.error( + "Failed to send start command for node %s: %s", + node.node_id, + e, + ) + + # Show progress bar with calculated wait time + show_progress_bar( + duration=wait_time, interval=1, node_count=len(nodes) + ) + + # Second loop: Get status for all nodes + results = [] + logger.info("Retrieving status for %d nodes...", len(nodes)) + for node in nodes: + try: + node.get() # Get latest status + node_info = { + "node_id": node.node_id, + "name": node.name or "N/A", + "status": node.status or "unknown", + } + results.append(node_info) + except Exception as e: + logger.error( + "Failed to get status for node %s: %s", node.node_id, e + ) + results.append( + { + "node_id": node.node_id, + "name": getattr(node, "name", "N/A"), + "status": "error", + "error": str(e), + } + ) + + # Handle nodes that failed to be retrieved initially + retrieved_node_ids = {node.node_id for node in nodes} + for node_id in node_ids: + if node_id not in retrieved_node_ids: + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": "Node not found during info retrieval", + } + ) + + # Analyze results + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] + failed_nodes = [r for r in results if r.get("status") == "error"] + + # Construct final response + response = { + "project_id": project_id, + "total_nodes": len(node_ids), + "successful": len(successful_nodes), + "failed": len(failed_nodes), + "nodes": results, + } + + logger.info( + "Start operation completed: %d successful, %d failed", + len(successful_nodes), + len(failed_nodes), + ) + + return response + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to start nodes: %s", e) + return {"error": f"Failed to start nodes: {str(e)}"} + + +class GNS3StartNodeQuickTool(BaseTool): + """ + A LangChain tool to start nodes in a GNS3 project WITHOUT waiting. + + This tool sends start commands to all nodes and immediately returns status, + without blocking for startup completion. Suitable for automated deployment + workflows where long waits would cause HTTP timeouts. + + **Input**: + A JSON object with project_id and node_ids (list of node IDs). + Example: + { + "project_id": "uuid-of-project", + "node_ids": ["uuid-of-node-1", "uuid-of-node-2"] + } + + **Output**: + A dict with nodes' details immediately after sending start commands: + { + "project_id": "...", + "total_nodes": 2, + "successful": 2, + "failed": 0, + "nodes": [ + {"node_id": "...", "name": "...", "status": "started"}, + {"node_id": "...", "name": "...", "status": "started"} + ], + "note": "Start commands sent. Nodes are booting in background." + } + """ + + name: str = "start_gns3_node_quick" + description: str = """ + Starts nodes in a GNS3 project WITHOUT waiting for startup completion. + Use this for automated deployments to avoid HTTP timeouts. + Input: JSON with project_id and node_ids (list of node IDs). + Returns: Dict with nodes' details after start commands are sent. + NOTE: Nodes will continue booting in background after this tool returns. + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + node_ids = input_data.get("node_ids") + + # Validate input + if not project_id or not node_ids: + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } + + if not isinstance(node_ids, list): + logger.error("node_ids must be a list.") + return {"error": "node_ids must be a list."} + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # Send start commands for all nodes and collect initial status + logger.info( + "Sending start commands for %d nodes in project %s...", + len(node_ids), + project_id, + ) + results = [] + + for node_id in node_ids: + try: + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) + # Verify node exists and get current info + node.get() + if not node.node_id: + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": "Node not found", + } + ) + continue + + # Send start command + node.start() + logger.info( + "Start command sent for node %s (%s)", + node_id, + node.name, + ) + + # Get immediate status (likely 'starting' or 'stopped') + node.get() + node_info = { + "node_id": node.node_id, + "name": node.name or "N/A", + "status": node.status or "unknown", + } + results.append(node_info) + + except Exception as e: + logger.error("Failed to start node %s: %s", node_id, e) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": str(e), + } + ) + + # Analyze results (count based on successful command sending) + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] + failed_nodes = [r for r in results if r.get("status") == "error"] + + # Construct final response + response = { + "project_id": project_id, + "total_nodes": len(node_ids), + "successful": len(successful_nodes), + "failed": len(failed_nodes), + "nodes": results, + "note": ( + "Start commands sent. Nodes are booting in background. " + "Check node status later." + ), + } + + logger.info( + "Quick start commands sent: %d successful, %d failed", + len(successful_nodes), + len(failed_nodes), + ) + + return response + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to start nodes: %s", e) + return {"error": f"Failed to start nodes: {str(e)}"} + + +if __name__ == "__main__": + # Test with single node + print("=== Testing single node startup ===") + test_input_single = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90" + ], # Replace with actual node UUID + } + ) + tool = GNS3StartNodeTool() + result_single = tool._run(test_input_single) + pprint(result_single) + + # Test with multiple nodes + print("\n=== Testing multiple nodes startup ===") + test_input_multiple = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90", + # Replace with actual node UUIDs + "another-node-uuid-here", + "third-node-uuid-here", + ], + } + ) + result_multiple = tool._run(test_input_multiple) + pprint(result_multiple) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py new file mode 100644 index 000000000..66d6de515 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" + +GNS3 node stop tool for network device shutdown. + +Provides functionality to stop one or multiple nodes in GNS3 projects. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Node +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3StopNodeTool(BaseTool): + """ + A LangChain tool to stop one or multiple nodes in a GNS3 project. + + **Input**: + A JSON object with project_id and node_ids (list of node IDs). + Example: + { + "project_id": "uuid-of-project", + "node_ids": ["uuid-of-node-1", "uuid-of-node-2"] + } + + **Output**: + A dictionary with all nodes' details: + { + "project_id": "...", + "total_nodes": 2, + "successful": 2, + "failed": 0, + "nodes": [ + {"node_id": "...", "name": "...", "status": "stopped"}, + {"node_id": "...", "name": "...", "status": "stopped"} + ] + } + """ + + name: str = "stop_gns3_node" + description: str = """ + Stops one or multiple nodes in a GNS3 project. + Input: JSON with project_id and node_ids (list of node IDs). + Returns: A dict with all nodes' details (success/failure status). + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + node_ids = input_data.get("node_ids") + + # Validate input + if not project_id or not node_ids: + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } + + if not isinstance(node_ids, list): + logger.error("node_ids must be a list.") + return {"error": "node_ids must be a list."} + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # Stop all nodes and collect results + logger.info( + "Stopping %d nodes in project %s...", + len(node_ids), + project_id, + ) + results = [] + + for node_id in node_ids: + try: + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) + # Verify node exists and get current info + node.get() + if not node.node_id: + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": "Node not found", + } + ) + continue + + # Send stop command + node.stop() + logger.info( + "Stop command sent for node %s (%s)", + node_id, + node.name, + ) + + # Get updated status + node.get() + node_info = { + "node_id": node.node_id, + "name": node.name or "N/A", + "status": node.status or "unknown", + } + results.append(node_info) + + except Exception as e: + logger.error("Failed to stop node %s: %s", node_id, e) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": str(e), + } + ) + + # Analyze results + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] + failed_nodes = [r for r in results if r.get("status") == "error"] + + # Construct final response + response = { + "project_id": project_id, + "total_nodes": len(node_ids), + "successful": len(successful_nodes), + "failed": len(failed_nodes), + "nodes": results, + } + + logger.info( + "Stop operation completed: %d successful, %d failed", + len(successful_nodes), + len(failed_nodes), + ) + + return response + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to stop nodes: %s", e) + return {"error": f"Failed to stop nodes: {str(e)}"} + + +if __name__ == "__main__": + # Test with single node + print("=== Testing single node stop ===") + test_input_single = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90" + ], # Replace with actual node UUID + } + ) + tool = GNS3StopNodeTool() + result_single = tool._run(test_input_single) + pprint(result_single) + + # Test with multiple nodes + print("\n=== Testing multiple nodes stop ===") + test_input_multiple = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90", + # Replace with actual node UUIDs + "another-node-uuid-here", + "third-node-uuid-here", + ], + } + ) + result_multiple = tool._run(test_input_multiple) + pprint(result_multiple) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py new file mode 100644 index 000000000..0bef3190c --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" + +GNS3 node suspend tool for pausing network devices. + +Provides functionality to suspend one or multiple nodes in GNS3 projects. +Suspended nodes preserve their state in memory and can be quickly resumed. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Node +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3SuspendNodeTool(BaseTool): + """ + A LangChain tool to suspend one or multiple nodes in a GNS3 project. + + **Input**: + A JSON object with project_id and node_ids (list of node IDs). + Example: + { + "project_id": "uuid-of-project", + "node_ids": ["uuid-of-node-1", "uuid-of-node-2"] + } + + **Output**: + A dictionary with all nodes' details: + { + "project_id": "...", + "total_nodes": 2, + "successful": 2, + "failed": 0, + "nodes": [ + {"node_id": "...", "name": "...", "status": "suspended"}, + {"node_id": "...", "name": "...", "status": "suspended"} + ] + } + + **Note**: Suspended nodes preserve their state in memory and can be quickly + resumed. Use this when you want to pause a lab and continue later. + """ + + name: str = "suspend_gns3_node" + description: str = """ + Suspends one or multiple nodes in a GNS3 project. + Suspended nodes preserve their state and can be quickly resumed. + Use this to pause a lab temporarily and continue later. + Input: JSON with project_id and node_ids (list of node IDs). + Returns: A dict with all nodes' details (success/failure status). + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + node_ids = input_data.get("node_ids") + + # Validate input + if not project_id or not node_ids: + logger.error( + "Missing required fields: project_id or node_ids." + ) + return { + "error": "Missing required fields: " + "project_id and node_ids." + } + + if not isinstance(node_ids, list): + logger.error("node_ids must be a list.") + return {"error": "node_ids must be a list."} + + # Initialize Gns3Connector using factory function + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # Suspend all nodes and collect results + logger.info( + "Suspending %d nodes in project %s...", + len(node_ids), + project_id, + ) + results = [] + + for node_id in node_ids: + try: + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) + # Verify node exists and get current info + node.get() + if not node.node_id: + logger.error( + "Node %s not found in project %s", + node_id, + project_id, + ) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": "Node not found", + } + ) + continue + + # Send suspend command + node.suspend() + logger.info( + "Suspend command sent for node %s (%s)", + node_id, + node.name, + ) + + # Get updated status + node.get() + node_info = { + "node_id": node.node_id, + "name": node.name or "N/A", + "status": node.status or "unknown", + } + results.append(node_info) + + except Exception as e: + logger.error("Failed to suspend node %s: %s", node_id, e) + results.append( + { + "node_id": node_id, + "name": "N/A", + "status": "error", + "error": str(e), + } + ) + + # Analyze results + successful_nodes = [ + r for r in results if r.get("status") != "error" + ] + failed_nodes = [r for r in results if r.get("status") == "error"] + + # Construct final response + response = { + "project_id": project_id, + "total_nodes": len(node_ids), + "successful": len(successful_nodes), + "failed": len(failed_nodes), + "nodes": results, + "note": ( + "Suspended nodes preserve their state in memory. " + "Use resume to continue where you left off." + ), + } + + logger.info( + "Suspend operation completed: %d successful, %d failed", + len(successful_nodes), + len(failed_nodes), + ) + + return response + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to suspend nodes: %s", e) + return {"error": f"Failed to suspend nodes: {str(e)}"} + + +if __name__ == "__main__": + # Test with single node + print("=== Testing single node suspend ===") + test_input_single = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90" + ], # Replace with actual node UUID + } + ) + tool = GNS3SuspendNodeTool() + result_single = tool._run(test_input_single) + pprint(result_single) + + # Test with multiple nodes + print("\n=== Testing multiple nodes suspend ===") + test_input_multiple = json.dumps( + { + "project_id": "", # Replace with actual project UUID + "node_ids": [ + "fbeda109-9a74-4d8c-a749-cc3847911a90", + # Replace with actual node UUIDs + "another-node-uuid-here", + "third-node-uuid-here", + ], + } + ) + result_multiple = tool._run(test_input_multiple) + pprint(result_multiple) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py new file mode 100644 index 000000000..eca74795f --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3 node name update tool for renaming network devices. + +Provides functionality to update the name of one or multiple nodes + in GNS3 projects. +""" + +import json +import logging +from pprint import pprint +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client import Node +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector + +# Configure logging +logger = logging.getLogger(__name__) + + +class GNS3UpdateNodeNameTool(BaseTool): + """ + A LangChain tool to update the name of one or multiple nodes + in a GNS3 project. + + **Input**: + A JSON object with project_id and nodes array containing + node_id and new_name. + Example: + { + "project_id": "uuid-of-project", + "nodes": [ + {"node_id": "uuid-of-node-1", "new_name": "Router1"}, + {"node_id": "uuid-of-node-2", "new_name": "Switch1"} + ] + } + + **Output**: + A dictionary with all nodes' update results: + { + "project_id": "...", + "total_nodes": 2, + "successful": 2, + "failed": 0, + "nodes": [ + { + "node_id": "...", + "old_name": "...", + "new_name": "...", + "status": "success" + }, + { + "node_id": "...", + "old_name": "...", + "new_name": "...", + "status": "success" + } + ] + } + """ + + name: str = "update_gns3_node_name" + description: str = """ + Updates the name of one or multiple nodes in a GNS3 project. + Input: JSON with project_id and nodes array. + Each node must have node_id and new_name. + Returns: A dictionary with all nodes' update results + including success/failure status. + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + # Parse input JSON + input_data = json.loads(tool_input) + project_id = input_data.get("project_id") + nodes = input_data.get("nodes", []) + + # Validate input + if not project_id: + logger.error("Missing required field: project_id.") + return {"error": "Missing required field: project_id."} + + if not isinstance(nodes, list) or len(nodes) == 0: + logger.error("nodes must be a non-empty list.") + return {"error": "nodes must be a non-empty list."} + + # Validate each node entry + for i, node_data in enumerate(nodes): + if not isinstance(node_data, dict): + logger.error("Node %d must be a dictionary.", i + 1) + return {"error": f"Node {i + 1} must be a dictionary."} + if "node_id" not in node_data or "new_name" not in node_data: + logger.error("Node %d missing node_id or new_name.", i + 1) + return { + "error": f"Node {i + 1} missing node_id or new_name." + } + + # Initialize Gns3Connector + logger.info("Connecting to GNS3 server...") + gns3_server = get_gns3_connector() + + if gns3_server is None: + logger.error("Failed to create GNS3 connector") + return { + "error": "Failed to connect to GNS3 server. " + "Please check your configuration." + } + + # Update node names + logger.info( + "Updating names for %d nodes in project %s...", + len(nodes), + project_id, + ) + results = [] + + for i, node_data in enumerate(nodes): + try: + node_id = node_data.get("node_id") + new_name = node_data.get("new_name") + + logger.info( + "Updating node %d/%d: %s -> %s", + i + 1, + len(nodes), + node_id, + new_name, + ) + + # Get node to retrieve current name + node = Node( + project_id=project_id, + node_id=node_id, + connector=gns3_server, + ) + node.get() + old_name = node.name + + # Update node name + node.update(name=new_name) + + # Verify update + node.get() + if node.name == new_name: + node_info = { + "node_id": node_id, + "old_name": old_name, + "new_name": new_name, + "status": "success", + } + results.append(node_info) + logger.info( + "Successfully updated node name: %s -> %s", + old_name, + new_name, + ) + else: + error_info = { + "node_id": node_id, + "old_name": old_name, + "new_name": new_name, + "current_name": node.name, + "status": "failed", + "error": "Name verification failed", + } + results.append(error_info) + logger.error( + "Failed to update node name for %s", node_id + ) + + except Exception as e: + error_info = { + "node_id": node_data.get("node_id"), + "new_name": node_data.get("new_name"), + "status": "failed", + "error": str(e), + } + results.append(error_info) + logger.error("Failed to update node %d: %s", i + 1, e) + + # Analyze results + successful_nodes = [ + r for r in results if r.get("status") == "success" + ] + failed_nodes = [r for r in results if r.get("status") == "failed"] + + # Construct final response + response = { + "project_id": project_id, + "total_nodes": len(nodes), + "successful": len(successful_nodes), + "failed": len(failed_nodes), + "nodes": results, + } + + logger.info( + "Name update completed: %d successful, %d failed", + len(successful_nodes), + len(failed_nodes), + ) + + return response + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Failed to update node names: %s", e) + return {"error": f"Failed to update node names: {str(e)}"} + + +if __name__ == "__main__": + # Test with single node + print("=== Testing single node name update ===") + test_input_single = json.dumps( + { + # Replace with actual project UUID + "project_id": "your-project-uuid", + "nodes": [ + { + # Replace with actual node UUID + "node_id": "your-node-uuid", + "new_name": "Router1", + } + ], + } + ) + tool = GNS3UpdateNodeNameTool() + result_single = tool._run(test_input_single) + pprint(result_single) + + # Test with multiple nodes + print("\n=== Testing multiple nodes name update ===") + test_input_multiple = json.dumps( + { + # Replace with actual project UUID + "project_id": "your-project-uuid", + "nodes": [ + {"node_id": "node-uuid-1", "new_name": "Router1"}, + {"node_id": "node-uuid-2", "new_name": "Switch1"}, + {"node_id": "node-uuid-3", "new_name": "PC1"}, + ], + } + ) + result_multiple = tool._run(test_input_multiple) + pprint(result_multiple) diff --git a/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py new file mode 100644 index 000000000..233b24e56 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/packet_analysis_tool.py @@ -0,0 +1,496 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Protocol-Oriented Packet Analysis Tool + +Analyzes packets from an active GNS3 capture using tshark with user-provided arguments. +The LLM constructs tshark commands based on protocol knowledge from packet analysis skills. +""" + +import json +import logging +import os +import subprocess +import tempfile + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client.context_helpers import ( + get_current_jwt_token, +) + +logger = logging.getLogger(__name__) + +# Cache of valid tshark field names, loaded lazily from `tshark -G fields` +_tshark_valid_fields: set | None = None + + +class PacketAnalysisTool(BaseTool): + """ + LangChain tool for analyzing packets using tshark with user-provided arguments. + + The LLM constructs tshark command arguments based on protocol knowledge + from packet analysis skills (available_fields, base_filter, etc.). + + Input: + project_id (str, required): UUID of the GNS3 project + link_id (str, required): UUID of the link to analyze + tshark_args (str, required): tshark command arguments (after '-r ') + + Output: + str: tshark output in text format (tab-separated fields or JSON) + """ + + name: str = "packet_analysis" + description: str = """ + Analyze packets from an active GNS3 capture using tshark. + + Use this to analyze captured packets when diagnosing network issues. + + **Before calling this tool**, use `search_fields` action to look up + valid tshark field names from the live tshark field registry, or query + `get_packet_analysis_protocol` for protocol-level skill definitions. + Construct `tshark_args` using accurate field names. + + Input (JSON format) — capture analysis mode: + - project_id (str, required): UUID of the GNS3 project + - link_id (str, required): UUID of the link to analyze + - tshark_args (str, required): tshark command arguments (after '-r ') + + Input (JSON format) — field search mode (no capture needed): + - action (str): "search_fields" + - query (str): Single keyword, e.g. "ospf.lsa", "bgp.open", "bgp.type" + Use dot-notation like "protocol.field" for precise results. + + Common tshark argument patterns: + -Y "" Display filter + -T fields Tab-separated field output + -e Extract field (use multiple -e for multiple fields) + -T json JSON output + -c Limit packets READ (not matched count). Since -c limits + total packets scanned, it can hide results when used + with -Y. Prefer piping to head -10 or rely on -Y + alone instead. + + Instead of analysis, you can search tshark's field registry directly for + valid field names: + {"action": "search_fields", "query": "ospf.lsa"} + {"action": "search_fields", "query": "bgp.open"} + Uses literal substring matching (not regex). Only one keyword allowed. + Use dot-notation like "ospf", "ospf.lsa", "bgp.peer" for precise results. + Returns matching field names, types, and descriptions. Use this to find + correct -e field names before building tshark_args. + """ + + @classmethod + def _search_fields(cls, query: str) -> str: + """ + Search tshark field registry via `tshark -G fields | grep -i `. + + Args: + query: Search term + + Returns: + JSON string with matching fields. + """ + try: + if not query or not query.strip(): + return json.dumps({"error": "query is required"}) + + query = query.strip() + if len(query.split()) > 1: + return json.dumps({ + "error": "Only one keyword allowed", + "hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words', + }) + + tshark = subprocess.Popen( + ["tshark", "-G", "fields"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, + ) + grep = subprocess.Popen( + ["grep", "-iF", query, "-"], + stdin=tshark.stdout, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, + ) + tshark.stdout.close() + stdout, _ = grep.communicate(timeout=30) + tshark.wait(timeout=5) + except Exception as e: + return json.dumps({"error": f"tshark query failed: {e}"}) + + results = [] + seen = set() + for line in stdout.splitlines(): + parts = line.split("\t") + if len(parts) >= 3 and parts[0] == "F": + name = parts[2] + if name not in seen: + seen.add(name) + results.append({ + "field": name, + "type": parts[3] if len(parts) > 3 else "", + "protocol": parts[4] if len(parts) > 4 else "", + "description": parts[7] if len(parts) > 7 else "", + }) + if len(results) >= 40: + break + + return json.dumps({ + "query": query, + "count": len(results), + "fields": results, + "hint": "Use the 'field' values in tshark_args with -e", + }, ensure_ascii=False, indent=2) + + @classmethod + def _load_valid_tshark_fields(cls) -> set: + """ + Load and cache valid tshark field names from `tshark -G fields`. + + Returns: + Set of valid field names, or empty set on failure. + """ + global _tshark_valid_fields + if _tshark_valid_fields is not None: + return _tshark_valid_fields + + try: + result = subprocess.run( + ["tshark", "-G", "fields"], + capture_output=True, + text=True, + timeout=30, + ) + fields = set() + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) >= 3: + fields.add(parts[2]) + _tshark_valid_fields = fields + logger.debug(f"Loaded {len(fields)} valid tshark field names") + except Exception as e: + logger.warning(f"Could not load tshark field names: {e}") + _tshark_valid_fields = set() + + return _tshark_valid_fields + + def _validate_tshark_args(self, tshark_args: str) -> str | None: + """ + Validate tshark arguments before execution. + + Checks `-e` field names against the tshark field registry. + Returns an error JSON string if invalid, or None if valid. + + Args: + tshark_args: tshark command arguments string + + Returns: + Error JSON string, or None if validation passes. + """ + import shlex + + try: + args = shlex.split(tshark_args) + except ValueError as e: + return json.dumps({"error": f"Invalid tshark_args syntax: {e}"}) + + valid_fields = self._load_valid_tshark_fields() + if not valid_fields: + return None + + invalid_fields = [] + i = 0 + while i < len(args): + if args[i] == "-e" and i + 1 < len(args): + field = args[i + 1] + if field not in valid_fields: + invalid_fields.append(field) + i += 2 + else: + i += 1 + + if invalid_fields: + return json.dumps({ + "error": f"Invalid tshark field names: {', '.join(invalid_fields)}", + "hint": 'Use {"action": "search_fields", "query": ""} to look up valid field names', + "invalid_fields": invalid_fields, + }) + + return None + + def _run( + self, + project_id: str | None = None, + link_id: str | None = None, + tshark_args: str | None = None, + action: str | None = None, + query: str | None = None, + run_manager: CallbackManagerForToolRun | None = None, + ) -> str: + """ + Analyze packets using tshark with user-provided arguments. + + Args: + project_id: UUID of the GNS3 project + link_id: UUID of the link to analyze + tshark_args: tshark command arguments (after '-r ') + action: "search_fields" to query tshark field registry instead + query: Search term for action="search_fields" (e.g., "ospf") + run_manager: LangChain run manager (unused) + + Returns: + str: tshark output + """ + # Field search mode: query tshark directly without capture + if action == "search_fields": + return self._search_fields(query or "") + + logger.info( + f"PacketAnalysisTool invoked: project_id={project_id}, " + f"link_id={link_id}, tshark_args={tshark_args}" + ) + + # Validate inputs + if not project_id: + return '{"error": "project_id is required"}' + if not link_id: + return '{"error": "link_id is required"}' + if not tshark_args or not tshark_args.strip(): + return '{"error": "tshark_args is required"}' + + # Pre-validate tshark field names before downloading capture + validation_error = self._validate_tshark_args(tshark_args) + if validation_error: + return validation_error + + temp_file = None + try: + # Download capture file + temp_file = self._download_capture(project_id, link_id) + if not temp_file: + return '{"error": "Failed to download capture file"}' + + # Check if file exists and has content + if not os.path.exists(temp_file): + return '{"error": "Capture file not found"}' + + file_size = os.path.getsize(temp_file) + if file_size == 0: + return '{"error": "Capture file is empty, no packets captured yet"}' + + logger.info(f"Capture file downloaded: {temp_file}, size={file_size} bytes") + + # Run tshark with user-provided arguments + result = self._run_tshark(temp_file, tshark_args) + return result + + except Exception as e: + logger.error(f"PacketAnalysisTool error: {e}", exc_info=True) + return f'{{"error": "Analysis failed: {str(e)}"}}' + + finally: + # Clean up temp file + if temp_file and os.path.exists(temp_file): + try: + os.remove(temp_file) + logger.debug(f"Temporary file removed: {temp_file}") + except Exception as e: + logger.warning(f"Failed to remove temp file: {e}") + + def _download_capture(self, project_id: str, link_id: str) -> str | None: + """ + Download capture file from GNS3 server. + + Args: + project_id: Project UUID + link_id: Link UUID + + Returns: + str: Path to temporary capture file, or None on failure + """ + jwt_token = get_current_jwt_token() + if not jwt_token: + logger.error("JWT token not found in context") + return None + + # Detect GNS3 server URL + url = self._detect_gns3_url() + if not url: + return None + + capture_url = f"{url}/v3/projects/{project_id}/links/{link_id}/capture/file" + logger.info(f"Downloading capture from: {capture_url}") + + # Create temp file + temp_fd, temp_file = tempfile.mkstemp(suffix=".pcap", prefix="gns3_capture_") + os.close(temp_fd) + + try: + import requests + + headers = {"Authorization": f"Bearer {jwt_token}"} + # Use verify=False for HTTPS to skip certificate validation (for self-signed certs) + verify_cert = not capture_url.startswith("https://") + response = requests.get( + capture_url, + headers=headers, + stream=True, + timeout=30, + verify=verify_cert, + ) + + if response.status_code != 200: + logger.error(f"Failed to download capture: HTTP {response.status_code}") + os.remove(temp_file) + return None + + # Write to temp file + with open(temp_file, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + logger.info(f"Capture file saved: {temp_file}, size={os.path.getsize(temp_file)} bytes") + return temp_file + + except Exception as e: + logger.error(f"Failed to download capture: {e}", exc_info=True) + if os.path.exists(temp_file): + os.remove(temp_file) + return None + + def _detect_gns3_url(self) -> str | None: + """ + Detect GNS3 server URL from Controller or Config. + + Returns: + str: GNS3 server URL, or None on failure + """ + try: + from gns3server.controller import Controller + + controller = Controller.instance() + local_compute = controller.get_compute("local") + url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}" + logger.debug(f"Detected GNS3 URL from Controller: {url}") + return url + except Exception as e: + logger.debug(f"Cannot get URL from Controller: {e}") + + try: + from gns3server.config import Config + + server_config = Config.instance().settings.Server + url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}" + logger.debug(f"Detected GNS3 URL from Config: {url}") + return url + except Exception as e: + logger.debug(f"Cannot get URL from Config: {e}") + + # Fallback default + default_url = "http://127.0.0.1:3080" + logger.warning(f"Using fallback default URL: {default_url}") + return default_url + + def _run_tshark(self, pcap_file: str, tshark_args: str) -> str: + """ + Run tshark with user-provided arguments. + + Args: + pcap_file: Path to the capture file + tshark_args: tshark command arguments (after '-r ') + + Returns: + str: tshark output + """ + # Build command: tshark -r + import shlex + cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args) + + logger.info(f"Running tshark: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + output = result.stdout + stderr = result.stderr.strip() if result.stderr else "" + + # Surface tshark field/filter errors to the LLM + if stderr and "tshark:" in stderr.lower(): + stderr_clean = stderr.replace("tshark: ", "") + hints = [ + f"tshark reported: {stderr_clean}", + ] + if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower(): + hints.append( + 'Use {"action": "search_fields", "query": ""} ' + "to find correct field names." + ) + logger.warning(f"tshark stderr: {stderr}") + return json.dumps({"error": "tshark argument error", "hints": hints}) + + if stderr: + logger.warning(f"tshark stderr: {stderr}") + + if not output.strip(): + hints = [] + if "-c" in tshark_args: + hints.append( + "The -c flag limits total packets READ, not matched results. " + 'Try removing -c and pipe to "head -N" instead.' + ) + hints.append( + 'Use {"action": "search_fields", "query": ""} to ' + "verify your display filter and field names are correct." + ) + return json.dumps({"result": "No matching packets found", "hints": hints}) + + logger.info(f"tshark output: {len(output)} characters") + return output + + except subprocess.TimeoutExpired: + logger.error("tshark timeout after 30 seconds") + return '{"error": "tshark timeout after 30 seconds"}' + except FileNotFoundError: + logger.error("tshark not found. Please install tshark: apt install tshark") + return '{"error": "tshark not installed. Please install tshark: apt install tshark"}' + except Exception as e: + logger.error(f"tshark execution error: {e}", exc_info=True) + return f'{{"error": "tshark failed: {str(e)}"}}' + + +if __name__ == "__main__": + # Test the tool + tool = PacketAnalysisTool() + print("Testing PacketAnalysisTool...") + print("Note: Set project_id, link_id and tshark_args to test with actual GNS3 capture") + print(tool.description) diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py new file mode 100644 index 000000000..e143b700e --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -0,0 +1,612 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +This module provides a tool to execute commands on VPCS devices +in a GNS3 topology using Nornir with Netmiko. +""" + +import json +import logging +import re +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun +from netmiko.exceptions import ReadTimeout +from nornir import InitNornir +from nornir.core import Nornir +from nornir.core.task import AggregatedResult +from nornir.core.task import Result +from nornir.core.task import Task +from nornir_netmiko.tasks import netmiko_multiline + +from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host +from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology + +# Import custom Netmiko device types for GNS3 emulation +# This registers gns3_vpcs_telnet and other custom device types +# NOTE: Must be imported BEFORE any Nornir operations to ensure device types are registered +from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401 + +# Explicitly register VPCS device type to ensure it is available +try: + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( + register_custom_device_type as register_vpcs_device_type, + ) + + # Register VPCS device type + register_vpcs_device_type() + + # CRITICAL: Update netmiko.ssh_dispatcher platforms lists + import importlib + + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Recalculate platforms lists to include custom device types + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + + # Update platform strings used in error messages + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) +except Exception: + # Fail silently - the import-time registration should have worked + pass + +logger = logging.getLogger(__name__) + +# Suppress nornir INFO logs in console (reduce verbosity) +logging.getLogger("nornir.core").setLevel(logging.WARNING) +logging.getLogger("nornir").setLevel(logging.WARNING) + + +def _get_nornir_defaults() -> dict[str, Any]: + """Get Nornir default configuration.""" + return {"data": {"location": "gns3"}} + + +class VPCSCommands(BaseTool): + """ + A tool for VPCS (Virtual PC Simulator) devices. + + **VPCS-SPECIFIC TOOL** - This tool ONLY works with VPCS virtual PC devices. + + **IMPORTANT DISTINCTION:** + Unlike network devices (routers/switches), VPCS devices are lightweight virtual PCs. + Commands like 'ip' are basic PC IP config, NOT network device config. + + **Allowed VPCS Commands:** + - IP configuration: ip
/ + - View configuration: ip, show ip + - Connectivity testing: ping + - Display ARP: arp + - Display version: version + - Save/Load: save, load + """ + + name: str = "execute_vpcs_commands" + description: str = """ + **VPCS VIRTUAL PC TOOL** - Configure and test Virtual PC Simulator devices. + + This tool ONLY works with VPCS devices, NOT routers/switches. + + **IMPORTANT: VPCS vs Network Devices** + - VPCS = Lightweight virtual PCs for lab testing (NOT network infra) + - 'ip' command on VPCS = Basic PC IP config (like 'ipconfig' on Windows) + - NOT the same as configuring router interfaces or routing protocols + + **When to Use This Tool:** + - Configure IP addresses on virtual PCs: ip 10.10.0.12/24 10.10.0.254 + - Test connectivity from PCs: ping 10.10.0.254 + - View PC IP configuration: ip, show ip + - Display ARP table: arp + - Check PC version: version + + **Input Format:** + { + "project_id": "", + "device_configs": [ + { + "device_name": "PC1", + "commands": ["ip", "ping 10.10.0.254"] + }, + { + "device_name": "PC2", + "commands": ["show ip"] + } + ] + } + + **Returns:** PC command outputs for IP config and connectivity testing. + + **Note:** For network devices, use execute_multiple_device_commands. + """ + + def _run( + self, + tool_input: str | bytes | list[Any] | dict[str, Any], + run_manager: CallbackManagerForToolRun | None = None, + **kwargs: Any, + ) -> list[dict[str, Any]]: + """ + Execute commands on multiple VPCS devices in GNS3 topology. + + Args: + tool_input: JSON string with project_id and VPCS commands. + + Returns: + List of dicts with device names and command outputs. + """ + # Log received input + logger.debug("Received input: %s", tool_input) + + # Validate input + device_configs_list, project_id = self._validate_tool_input(tool_input) + if ( + isinstance(device_configs_list, list) + and len(device_configs_list) > 0 + and "error" in device_configs_list[0] + ): + return device_configs_list + + # Create a mapping of device names to their commands + device_configs_map = self._configs_map(device_configs_list) + + # Prepare device hosts data + try: + hosts_data = self._prepare_device_hosts_data( + device_configs_list, project_id + ) + except ValueError as e: + logger.error("Failed to prepare device hosts data: %s", e) + return [{"error": str(e)}] + + # Check if any devices have errors (e.g., missing device) + error_devices = { + name: data + for name, data in hosts_data.items() + if "error" in data + } + if error_devices: + logger.error( + "Devices with configuration errors: %s", + list(error_devices.keys()) + ) + return [ + { + "device_name": name, + "status": "failed", + "error": data["error"] + } + for name, data in error_devices.items() + ] + + # Initialize Nornir + try: + dynamic_nr = self._initialize_nornir(hosts_data) + except ValueError as e: + logger.error("Failed to initialize Nornir: %s", e) + return [{"error": str(e)}] + + results = [] + + # Execute all devices concurrently in a single run + try: + task_result = dynamic_nr.run( + task=self._run_vpcs_commands, + device_configs_map=device_configs_map, + ) + + # Process results for all devices + results = self._process_task_results( + device_configs_list, + hosts_data, + task_result, + ) + + except Exception as e: + # Overall execution failed + logger.error("Error executing commands on all VPCS devices: %s", e) + return [{"error": f"Execution error: {str(e)}"}] + + logger.debug( + "VPCS command execution completed. Results: %s", + json.dumps(results, indent=2, ensure_ascii=False), + ) + + return results + + def _run_vpcs_commands( + self, task: Task, device_configs_map: dict[str, list[str]] + ) -> Result: + """Execute VPCS commands with single retry.""" + device_name = task.host.name + commands = device_configs_map.get(device_name, []) + + if not commands: + return Result( + host=task.host, result="No commands to execute" + ) + + try: + # Use netmiko_multiline for VPCS commands + # This works well with simple commands and ping output + _result = task.run( + task=netmiko_multiline, + commands=commands, + read_timeout=30, + ) + return Result(host=task.host, result=_result.result) + + except ReadTimeout as e: + logger.error( + "ReadTimeout occurred for VPCS device %s: %s", + device_name, + str(e), + ) + return Result( + host=task.host, + result=f"Command failed (ReadTimeout): {str(e)}", + failed=True, + ) + + except Exception as e: + # Retry once for transient failures + logger.warning( + "First attempt failed for VPCS device %s, retrying: %s", + device_name, + str(e), + ) + try: + _result = task.run( + task=netmiko_multiline, + commands=commands, + read_timeout=30, + ) + return Result(host=task.host, result=_result.result) + except Exception as retry_e: + logger.error( + "VPCS command failed for device %s: %s (Exception: %s)", + device_name, + str(retry_e), + type(retry_e).__name__, + ) + return Result( + host=task.host, + result=f"Command failed: {str(retry_e)}", + failed=True, + ) + + def _validate_tool_input( + self, tool_input: str | bytes | list[Any] | dict[str, Any] + ) -> tuple[list[dict[str, Any]], str | None]: + """ + Validate VPCS command input. + + Args: + tool_input: Input from LangChain/LangGraph tool call. + + Returns: + Tuple of (device_configs_list, project_id) or (error_list, None) + """ + parsed_input = None + + # Compatibility Check and Parsing + if isinstance(tool_input, (str, bytes, bytearray)): + try: + parsed_input = json.loads(tool_input) + logger.info("Successfully parsed tool input from JSON string.") + except json.JSONDecodeError as e: + logger.error( + "Invalid JSON string received as tool input: %s", e + ) + return ( + [{"error": f"Invalid JSON string input from model: {e}"}], + None, + ) + else: + parsed_input = tool_input + logger.info( + "Using tool input directly as type: %s", + type(parsed_input).__name__, + ) + + # Handle new format: {"project_id": "...", "device_configs": [...]} + if isinstance(parsed_input, dict): + project_id = parsed_input.get("project_id") + device_configs = parsed_input.get("device_configs") + + # Validate project_id + if not project_id: + error_msg = "Missing required 'project_id' field in input" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not self._validate_project_id(project_id): + error_msg = f"Invalid project_id: {project_id}. Expected UUID." + logger.error(error_msg) + return ([{"error": error_msg}], None) + + # Validate device_configs + if not isinstance(device_configs, list): + error_msg = "'device_configs' must be an array" + logger.error(error_msg) + return ([{"error": error_msg}], None) + + if not device_configs: + logger.warning("Device configs list is empty.") + return [], project_id + + return device_configs, project_id + + else: + error_msg = ( + "Tool input must be JSON with project_id and device_configs, " + f"got {type(parsed_input).__name__}" + ) + logger.error(error_msg) + return ([{"error": error_msg}], None) + + def _validate_project_id(self, project_id: str) -> bool: + """ + Validate project_id format (UUID). + + Args: + project_id: The project ID to validate + + Returns: + True if valid UUID format, False otherwise + """ + uuid_pattern = ( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) + + def _configs_map( + self, device_config_list: list[dict[str, Any]] + ) -> dict[str, list[str]]: + """ + Create a mapping of device names to their command lists. + + Args: + device_config_list: List of device configurations + + Returns: + Dictionary mapping device names to command lists + """ + return { + config["device_name"]: config["commands"] + for config in device_config_list + } + + def _prepare_device_hosts_data( + self, device_configs_list: list[dict[str, Any]], project_id: str + ) -> dict[str, dict[str, Any]]: + """ + Prepare Nornir inventory hosts data for VPCS devices. + + Args: + device_configs_list: List of device configurations + project_id: GNS3 project ID + + Returns: + Dictionary mapping device names to their host data + + Raises: + ValueError: If device not found in topology or missing port + """ + # Get GNS3 server host + gns3_host = get_gns3_server_host() + + # Extract all device names from input + device_names = [config["device_name"] for config in device_configs_list] + + # Get device port mappings from topology + device_ports = get_device_ports_from_topology( + device_names, project_id=project_id + ) + + # Build Nornir inventory hosts data + hosts_data = {} + for device_name in device_names: + if device_name not in device_ports: + logger.error("Device '%s' not found in topology", device_name) + hosts_data[device_name] = { + "error": f"Device '{device_name}' not found in topology" + } + continue + + port = device_ports[device_name]["port"] + + # VPCS devices use gns3_vpcs_telnet device type + hosts_data[device_name] = { + "port": port, + "platform": "vpcs", + "groups": ["vpcs_devices"], # All VPCS devices share one group + "connection_options": { + "netmiko": { + "extras": { + "device_type": "gns3_vpcs_telnet", + "fast_cli": False, + "global_delay_factor": 2.0, + } + } + }, + } + + return hosts_data + + def _initialize_nornir( + self, hosts_data: dict[str, dict[str, Any]] + ) -> "Nornir": + """ + Initialize Nornir with VPCS device inventory. + + Args: + hosts_data: Dictionary of device host data + + Returns: + Initialized Nornir instance + + Raises: + ValueError: If initialization fails + """ + try: + defaults = _get_nornir_defaults() + gns3_host = get_gns3_server_host() + + # Create a single generic group for shared configuration + groups_data = { + "vpcs_devices": { + "hostname": gns3_host, + "timeout": 30, + "username": "", # VPCS doesn't require authentication + "password": "", # VPCS doesn't require authentication + } + } + + logger.info( + "Initializing Nornir for VPCS: host=%s, devices=%d", + gns3_host, + len(hosts_data), + ) + + return InitNornir( + inventory={ + "plugin": "DictInventory", + "options": { + "hosts": hosts_data, + "groups": groups_data, + "defaults": defaults, + }, + }, + runner={ + "plugin": "threaded", + "options": {"num_workers": 10}, + }, + logging={"enabled": False}, + ) + except Exception as e: + logger.error("Failed to initialize Nornir: %s", e) + raise ValueError(f"Failed to initialize Nornir: {e}") from e + + def _process_task_results( + self, + device_configs_list: list[dict[str, Any]], + hosts_data: dict[str, dict[str, Any]], + task_result: AggregatedResult, + ) -> list[dict[str, Any]]: + """ + Process Nornir task results into the format expected by the tool. + + Args: + device_configs_list: Original device configurations list + hosts_data: Device hosts data + task_result: Nornir aggregated task result + + Returns: + List of result dictionaries + """ + results = [] + + for device_config in device_configs_list: + device_name = device_config["device_name"] + + # Check if device had an error during preparation + if device_name in hosts_data and "error" in hosts_data[device_name]: + results.append({ + "device_name": device_name, + "status": "error", + "output": hosts_data[device_name]["error"], + "commands": device_config["commands"], + }) + continue + + # Get result from Nornir task + if device_name in task_result: + host_result = task_result[device_name] + + if host_result.failed: + # Task failed + error_msg = str(host_result.result) if host_result.result else "Unknown error" + results.append({ + "device_name": device_name, + "status": "error", + "output": error_msg, + "commands": device_config["commands"], + }) + else: + # Task succeeded + results.append({ + "device_name": device_name, + "status": "success", + "output": host_result.result, + "commands": device_config["commands"], + }) + else: + # Device not in task result (shouldn't happen) + results.append({ + "device_name": device_name, + "status": "error", + "output": f"Device '{device_name}' not in task results", + "commands": device_config["commands"], + }) + + return results + + +if __name__ == "__main__": + # Example usage + import sys + + command_groups = json.dumps( + { + "project_id": "", + "device_configs": [ + { + "device_name": "PC1", + "commands": [ + "ip 10.10.0.12/24 10.10.0.254", + "ping 10.10.0.254", + ], + }, + { + "device_name": "PC2", + "commands": ["ip 10.10.0.13/24 10.10.0.254"], + }, + ], + } + ) + + exe_cmd = VPCSCommands() + result = exe_cmd._run(tool_input=command_groups) + print("Execution results:") + print(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/gns3server/agent/gns3_copilot/utils/__init__.py b/gns3server/agent/gns3_copilot/utils/__init__.py new file mode 100644 index 000000000..ed79a457c --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/__init__.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3-Copilot Public Model Package + +This package provides reusable public models and utilities for GNS3 automation tasks. +It contains common functionality that can be shared across different tools and modules. + +Main modules: +- get_gns3_device_port: Device port information retrieval from GNS3 topology +- parse_tool_content: Tool execution result parsing and formatting utilities + +Author: Yue Guobin (岳国宾) +""" + +# Import main utility functions +from .error_handler import format_error_message +from .get_gns3_device_port import get_device_ports_from_topology +from .parse_tool_content import format_tool_response +from .parse_tool_content import normalize_tool_response +from .parse_tool_content import parse_tool_content + +# Dynamic version management +try: + from importlib.metadata import version + + __version__ = version("gns3-copilot") +except Exception: + __version__ = "unknown" + +__author__ = "Yue Guobin (岳国宾)" +__description__ = "AI-powered network automation assistant for GNS3" +__url__ = "https://github.com/yueguobin/gns3-copilot" + +# Export main utility functions +__all__ = [ + "format_error_message", + "get_device_ports_from_topology", + "parse_tool_content", + "format_tool_response", + "normalize_tool_response", +] diff --git a/gns3server/agent/gns3_copilot/utils/command_filter.py b/gns3server/agent/gns3_copilot/utils/command_filter.py new file mode 100644 index 000000000..deb5e7c5f --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/command_filter.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Command filter module for GNS3-Copilot. + +This module provides functionality to filter out dangerous or long-running +commands that may cause issues with tool execution timeouts or device console +availability. + +Forbidden commands are loaded from the external GNS3-Skills repository +(config/forbidden_commands.txt) via SkillsManager. +""" + +import logging + +from gns3server.agent.gns3_copilot.skills.registry import get_skills_manager + +logger = logging.getLogger(__name__) + +# Default forbidden commands (fallback if skills repository is not available) +DEFAULT_FORBIDDEN_COMMANDS = [ + "traceroute", + "tracepath", + "tracert", + "ping -f", + "debug", + "test", +] + +# Cache for forbidden commands +_forbidden_commands_cache: list[str] | None = None + + +def _load_forbidden_commands() -> list[str]: + """ + Load forbidden commands, preferring the skills repository. + + Tries to load from the external GNS3-Skills repository first. + Falls back to hardcoded defaults if the repo is unavailable. + + Returns: + List of forbidden command patterns. + """ + global _forbidden_commands_cache + + # Return cached value if available + if _forbidden_commands_cache is not None: + return _forbidden_commands_cache + + # Try to load from skills repository + manager = get_skills_manager() + if manager is not None: + commands = manager.load_forbidden_commands() + if commands: + logger.info( + "Loaded %d forbidden command patterns from skills repository", + len(commands), + ) + _forbidden_commands_cache = commands + return _forbidden_commands_cache + + # Fallback to defaults + logger.warning("Using default forbidden commands list") + _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() + return _forbidden_commands_cache + + +def reload_forbidden_commands() -> None: + """ + Reload the forbidden commands list from the skills repository. + + Directly loads and caches the commands from the repository. + Falls back to defaults if the repository is unavailable. + """ + global _forbidden_commands_cache + + from gns3server.agent.gns3_copilot.skills.registry import get_skills_manager + + manager = get_skills_manager() + if manager is not None: + commands = manager.load_forbidden_commands() + if commands: + logger.info( + "Loaded %d forbidden command patterns from skills repository", + len(commands), + ) + _forbidden_commands_cache = commands + return + + logger.warning("Using default forbidden commands list") + _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() + + +def get_forbidden_commands() -> list[str]: + """ + Get the current list of forbidden command patterns. + + Returns: + List of forbidden command patterns (lowercase). + """ + return _load_forbidden_commands() + + +def is_command_forbidden(command: str) -> bool: + """ + Check if a single command is forbidden. + + Args: + command: The command string to check. + + Returns: + True if the command matches a forbidden pattern, False otherwise. + """ + forbidden_commands = _load_forbidden_commands() + command_lower = command.strip().lower() + + for forbidden_pattern in forbidden_commands: + if command_lower.startswith(forbidden_pattern): + return True + + return False + + +def filter_forbidden_commands( + commands: list[str], +) -> tuple[list[str], dict[str, str]]: + """ + Filter out forbidden commands from a list of commands. + + Args: + commands: List of command strings to filter. + + Returns: + A tuple of (allowed_commands, blocked_commands_info): + - allowed_commands: List of commands that are not forbidden. + - blocked_commands_info: Dict mapping blocked commands to their reasons. + """ + allowed_commands: list[str] = [] + blocked_commands_info: dict[str, str] = {} + + for command in commands: + if is_command_forbidden(command): + # Find which pattern matched + forbidden_commands = _load_forbidden_commands() + command_lower = command.strip().lower() + matched_pattern = None + + for pattern in forbidden_commands: + if command_lower.startswith(pattern): + matched_pattern = pattern + break + + reason = ( + f"Command '{command}' is not allowed because it matches the " + f"forbidden pattern '{matched_pattern}'. " + f"This command may run longer than the tool timeout or leave " + f"the device console unavailable for subsequent commands." + ) + blocked_commands_info[command] = reason + else: + allowed_commands.append(command) + + if blocked_commands_info: + logger.info( + "Filtered %d command(s): %s", + len(blocked_commands_info), + list(blocked_commands_info.keys()), + ) + + return allowed_commands, blocked_commands_info diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md b/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md new file mode 100644 index 000000000..2eea98e5c --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/README.md @@ -0,0 +1,186 @@ +# Custom Netmiko Drivers + +Custom Netmiko drivers for GNS3-emulated network devices. + +## Overview + +This package contains Netmiko drivers optimized for GNS3 emulation environments where devices may have non-standard authentication or behavior patterns. + +## Directory Structure + +``` +custom_netmiko/ +├── __init__.py # Package initialization, auto-registers all drivers +├── huawei_ce.py # Huawei CloudEngine custom driver (GNS3HuaweiTelnetCE) +├── ruijie_telnet.py # Ruijie OS custom driver (RuijieTelnetEnhanced) +├── README.md # This file +├── scripts/ # Utility scripts +│ └── list_netmiko_telnet_devices.py +└── tests/ # Unit tests + ├── __init__.py + └── test_huawei_ce.py # Huawei CE driver tests +``` + +## Supported Drivers + +### Huawei (`huawei_ce.py`) + +**Driver Name:** `GNS3HuaweiTelnetCE` + +**Device Types:** +- `gns3_huawei_telnet_ce` - Primary type (GNS3 custom driver) +- `huawei_telnet` - Standard Netmiko type (for devices with authentication) + +**Features:** +- Skip authentication (for GNS3 devices without username/password) +- VRP prompt recognition (``, `[HUAWEI]`) +- Auto-commit before exit (prevents [Y/N/C] prompts) +- Proper output collection using `read_channel_timing()` + +**Limitations:** +- Does not support username/password authentication +- If your device requires authentication, use standard `huawei_telnet` driver + +## Usage + +### Direct Usage + +```python +from netmiko import ConnectHandler +from gns3server.agent.gns3_copilot.utils import custom_netmiko + +device = { + "device_type": "gns3_huawei_telnet_ce", + "host": "127.0.0.1", + "port": 5000, +} + +with ConnectHandler(**device) as conn: + output = conn.send_command("display version") + config = ["interface GE1/0/1", "description Test"] + output = conn.send_config_set(config) +``` + +### In GNS3 Copilot Tools + +The drivers are auto-registered when the tools are imported: + +```python +from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir + +tool = DisplayToolNornir() +result = tool._run(json.dumps({ + "device_names": ["huawei-sw1"], + "commands": ["display version"], + "project_id": "project-uuid" +})) +``` + +## Running Tests + +```bash +# Run all tests +source venv/bin/activate +python -m unittest discover -s gns3server/agent/gns3_copilot/utils/custom_netmiko/tests + +# Run Huawei CE tests only +python gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py +``` + +## Adding New Drivers + +### 1. Create Driver File + +Create a new file in `custom_netmiko/` (e.g., `cisco.py`): + +```python +# cisco.py +from netmiko.cisco.cisco_ios import CiscoIosTelnet + +class CustomCiscoDriver(CiscoIosTelnet): + """Custom Cisco driver for GNS3.""" + + def telnet_login(self, ...): + # Override login logic + pass + +def register_custom_device_type() -> None: + """Register this driver with Netmiko.""" + import importlib + sd = importlib.import_module("netmiko.ssh_dispatcher") + + sd.CLASS_MAPPER["cisco_custom"] = CustomCiscoDriver + sd.CLASS_MAPPER_BASE["cisco_custom"] = CustomCiscoDriver + + # Rebuild static lists + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + +# Auto-register on import +try: + register_custom_device_type() +except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Failed to register: {e}") +``` + +### 2. Update `__init__.py` + +Add import to `__init__.py`: + +```python +try: + from . import cisco # noqa: F401 +except Exception as e: + logger.warning(f"Failed to import Cisco driver: {e}", exc_info=True) + +__all__ = ["huawei_ce", "cisco"] +``` + +### 3. Create Tests + +Create `tests/test_cisco.py`: + +```python +import unittest +from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco + +class TestCustomCiscoDriver(unittest.TestCase): + def test_device_type_registered(self): + from netmiko.ssh_dispatcher import CLASS_MAPPER + self.assertIn("cisco_custom", CLASS_MAPPER) + +if __name__ == "__main__": + unittest.main() +``` + +## Troubleshooting + +### Driver Not Found Error + +``` +ValueError: Unsupported 'device_type' +``` + +**Solution:** Ensure the custom_netmiko package is imported before using the device: + +```python +from gns3server.agent.gns3_copilot.utils import custom_netmiko +# Now use the device +``` + +### Import Errors + +If you get import errors, check: +1. Virtual environment is activated +2. Python path includes project root +3. Dependencies are installed (`pip install netmiko nornir nornir-netmiko`) + +## Related Documentation + +- [Multi-Vendor Device Support](../../../docs/gns3-copilot/implemented/multi-vendor-device-support.md) +- [Netmiko Documentation](https://ktbyers.github.io/netmiko/) + +--- + +_Last updated: 2026-03-12_ diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py new file mode 100644 index 000000000..1aea7adf8 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/__init__.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Custom Netmiko drivers for GNS3-emulated network devices. + +This package contains custom Netmiko drivers optimized for GNS3 +emulation environments where devices may have non-standard +authentication or behavior patterns. + +Supported Drivers: +- huawei_ce: GNS3HuaweiTelnetCE for CloudEngine devices (no authentication) +- ruijie_telnet: RuijieTelnetEnhanced for Ruijie devices (interactive prompt handling) +- vpcs_telnet: VPCSTelnet for VPCS virtual PC simulator devices (no authentication) + +All drivers use 'gns3_' prefix to clearly distinguish them from Netmiko's +built-in drivers. + +Usage: + from gns3server.agent.gns3_copilot.utils import custom_netmiko + + # Auto-registers all drivers on import + from netmiko import ConnectHandler + + # Huawei device + device = { + "device_type": "gns3_huawei_telnet_ce", + "host": "127.0.0.1", + "port": 5000, + } + + with ConnectHandler(**device) as conn: + output = conn.send_command("display version") +""" + +import logging + +logger = logging.getLogger(__name__) + +# Import all custom drivers (auto-registers them with Netmiko) +try: + from . import huawei_ce # noqa: F401 +except Exception as e: + logger.warning(f"Failed to import Huawei CE driver: {e}", exc_info=True) + +try: + from .ruijie_telnet import RuijieTelnetEnhanced # noqa: F401 +except Exception as e: + logger.warning(f"Failed to import Ruijie driver: {e}", exc_info=True) + +try: + from . import vpcs_telnet # noqa: F401 +except Exception as e: + logger.warning(f"Failed to import VPCS driver: {e}", exc_info=True) + +__all__ = ["huawei_ce", "ruijie_telnet", "vpcs_telnet"] diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py new file mode 100644 index 000000000..99f2f4220 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/huawei_ce.py @@ -0,0 +1,457 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +# Mypy type checking is disabled for this module due to Netmiko library +# limitations. +# +# Reason: Netmiko does not provide type stubs (py.typed marker), which +# causes mypy to generate errors when importing and using Netmiko classes. +# The main issues are: +# +# 1. Missing library stubs for 'netmiko.huawei.huawei' module +# 2. Dynamic attribute assignments on netmiko.ssh_dispatcher module +# (platforms, platforms_base, telnet_platforms, etc.) that mypy cannot +# detect +# 3. Inheritance from HuaweiBase triggers 'import-untyped' errors +# +# Since Netmiko is a third-party library without type annotations, and this +# module is a runtime driver that extends Netmiko's functionality, type +# checking would require maintaining separate type stub files which is +# beyond the scope of this project. +# +# Alternative solutions considered: +# - Adding type stubs for Netmiko (too extensive, requires ongoing +# maintenance) +# - Using 'type: ignore' on specific lines (too many annotations needed) +# - Disabling only specific mypy errors (still noisy, doesn't address +# root cause) +# +# Solution: Disable mypy for this entire file. Runtime testing and +# integration tests ensure correctness. Netmiko's own test suite validates +# base functionality. +# +# mypy: ignore-errors + +""" +Custom Netmiko device driver for Huawei devices in GNS3 emulation environment. + +This module provides a custom device type 'gns3_huawei_telnet_ce' for Huawei +network devices that connect via console without requiring authentication +(username/password). + +This is specifically designed for GNS3 emulated Huawei devices +(e.g., CloudEngine series) where the console connection directly enters the +system view without login prompts. + +Key Features: +- Inherits from HuaweiBase for proper VRP command handling +- Skips authentication (no username/password required) +- Supports Huawei-specific config mode (system-view) +- Handles Huawei prompt patterns (<>, [], >) +""" + +import importlib +import logging +import re +import time + +from netmiko.huawei.huawei import HuaweiBase + +logger = logging.getLogger(__name__) + + +class GNS3HuaweiTelnetCE(HuaweiBase): + """ + Custom Huawei device driver for GNS3 emulation. + + Inherits from HuaweiBase to leverage existing VRP-specific functionality: + - system-view configuration mode + - Huawei prompt patterns + - Config mode detection and exit + + This driver overrides telnet_login to handle GNS3 devices that + don't require authentication. + """ + + def __init__( + self, + *args, + **kwargs, + ) -> None: + """Initialize GNS3HuaweiTelnetCE connection.""" + # Set default device type for proper initialization + # The '_telnet' suffix in device_type tells Netmiko to use + # Telnet protocol + kwargs.setdefault("device_type", "huawei_telnet") + + # Huawei prompt patterns (inherited from HuaweiBase) + # User view: + # System view: [HUAWEI] + # Interface view: [HUAWEI-GigabitEthernet0/0/1] + + super().__init__(*args, **kwargs) + + def telnet_login( + self, + pri_prompt_terminator: str = r"<\S+>|>\s*$", + alt_prompt_terminator: str = r"\[\S+\]", + username_pattern: str = r"(?:user:|username|login|user name)", + pwd_pattern: str = r"assword", + delay_factor: float = 1.0, + max_loops: int = 10, + ) -> str: + """ + Telnet login for GNS3 Huawei devices (no authentication). + + Simplified login logic for devices that connect directly to + command line without username/password prompts. + + Strategy: + 1. Clear any existing buffer data + 2. Send carriage returns to trigger prompt + 3. Wait for Huawei prompt pattern + 4. Return once prompt is detected + + Args: + pri_prompt_terminator: Primary prompt pattern (e.g., ) + alt_prompt_terminator: Alternate prompt pattern (e.g., [HUAWEI]) + username_pattern: Not used (kept for signature compatibility) + pwd_pattern: Not used (kept for signature compatibility) + delay_factor: Delay factor for timing + max_loops: Maximum wait loops + + Returns: + Output from the connection process + """ + delay_factor = self.select_delay_factor(delay_factor) + output = "" + return_msg = "" + + # Step 1: Clear buffer - read any existing data to avoid interference + try: + initial_data = self.read_channel() + if initial_data: + return_msg += initial_data + except Exception: + # Ignore errors during initial read + pass + + # Step 2: Send carriage returns and wait for prompt + for i in range(max_loops): + try: + # Send return to trigger prompt + self.write_channel(self.RETURN) + time.sleep(0.5 * delay_factor) + + # Read response + new_output = self.read_channel() + output = new_output + return_msg += new_output + + # Check for Huawei prompt patterns + # User view: , System view: [HUAWEI], or just > + if re.search(pri_prompt_terminator, output, flags=re.M): + return return_msg + if re.search(alt_prompt_terminator, output, flags=re.M): + return return_msg + + except EOFError: + self.remote_conn.close() + msg = f"Connection failed (EOF): {self.host}" + raise self.connection_error(msg) from None + except Exception: + # Continue trying on other exceptions + pass + + # Step 3: Final attempt - return what we have + # Even if we couldn't detect the prompt clearly, the connection + # might still be usable + return return_msg + + def session_preparation(self) -> None: + """ + Prepare the session after connection is established. + + Inherited from HuaweiBase, but ensures proper initialization + for GNS3 emulation environment. + """ + # Wait for prompt to stabilize + time.sleep(0.5 * self.global_delay_factor) + + # Disable paging using Huawei-specific command + try: + self.disable_paging(command="screen-length 0 temporary") + except Exception: + # If disable_paging fails, try the parent implementation + super().disable_paging() + + # Ensure we're in a clean state + try: + if hasattr(self, 'base_prompt') and self.base_prompt: + self._test_channel_read(pattern=self.base_prompt) + else: + # If base_prompt is not set yet, just read to clear buffer + self.read_channel() + except Exception: + pass + + def disable_paging( + self, + command: str = "screen-length 0 temporary", + **kwargs, + ) -> str: + """ + Disable paging for Huawei devices. + + Uses Huawei-specific command 'screen-length 0 temporary' + which disables paging for the current session only. + + Args: + command: Command to disable paging + + Returns: + Output from the disable paging command + """ + return super().disable_paging(command=command, **kwargs) + + def send_config_set( + self, + config_commands: str | list[str], + **kwargs, + ) -> str: + """ + Send configuration commands to Huawei device. + + Overrides the parent method to handle Huawei-specific behavior: + - Uses Huawei-specific prompts (<...> for user view, [...] for + system view) + - Handles the 'return' confirmation prompt + - Uses read_channel_timing for proper output collection + + Args: + config_commands: Configuration commands to send + **kwargs: Additional arguments (exit_config_mode, read_timeout, + etc.) + + Returns: + Output from configuration commands + """ + # Convert string to list + if isinstance(config_commands, str): + config_commands = [config_commands] + + # Get parameters with Huawei-specific defaults + exit_config_mode = kwargs.get("exit_config_mode", True) + # Longer timeout for GNS3 emulation + read_timeout = kwargs.get("read_timeout", 30) + delay_factor = self.global_delay_factor + strip_prompt = kwargs.get("strip_prompt", False) + strip_command = kwargs.get("strip_command", False) + config_mode_command = kwargs.get("config_command", "system-view") + + output = "" + + # Enter config mode if needed + if kwargs.get("enter_config_mode", True): + output += self.config_mode(config_command=config_mode_command) + + # Send all configuration commands + # Send all commands, then read all output at once + for cmd in config_commands: + self.write_channel(f"{cmd}{self.RETURN}") + # Small delay between commands + time.sleep(delay_factor * 0.05) + + # Use read_channel_timing to collect all output + # This method keeps reading until there is no new data for + # 'last_read' seconds. This is the proper Netmiko way to handle + # command output + output += self.read_channel_timing( + read_timeout=read_timeout, last_read=2.0 + ) + + # Exit config mode if requested + if exit_config_mode: + # For Huawei devices, commit configuration before exiting + # This avoids the "Uncommitted configurations" [Y/N/C] prompt + try: + # Send commit command (in system view [HUAWEI]) + self.write_channel(f"commit{self.RETURN}") + time.sleep(0.5 * self.global_delay_factor) + commit_output = self.read_channel() + output += commit_output + except Exception: + # If commit fails, continue with exit + # (might not support commit) + pass + + # Now exit config mode + output += self.exit_config_mode() + + if strip_prompt: + output = self.strip_prompt(output) + + if strip_command: + output = self.strip_command(config_commands, output) + + return output + + def exit_config_mode( + self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$" + ) -> str: + r""" + Exit configuration mode for Huawei devices. + + Huawei devices display a confirmation prompt when using 'return': + Return to user view? [y/n]: + + This method automatically answers 'y' to the prompt. + + Args: + exit_config: Command to exit config mode (default: "return") + pattern: Pattern to detect user view prompt + (default: r"<\S+>|>\s*$") + + Returns: + Output from exiting config mode + """ + # Check if we're currently in config mode + if not self.check_config_mode(): + return "" + + output = "" + # Send the exit command (write_channel returns None, don't concatenate) + self.write_channel(f"{exit_config}{self.RETURN}") + time.sleep(0.5 * self.global_delay_factor) + + # Look for the confirmation prompt + # Huawei prompt: "Return to user view? [y/n]:" + prompt_pattern = r"\[y/n\]" + max_loops = 20 # More loops for slower devices + + for _ in range(max_loops): + new_output = self.read_channel() + output += new_output + + # If we see the confirmation prompt, send 'y' + if re.search(prompt_pattern, new_output): + self.write_channel(f"y{self.RETURN}") # Returns None + time.sleep(0.5 * self.global_delay_factor) + # Clear the confirmation response + new_output = self.read_channel() + output += new_output + + # Check if we've exited to user view + if re.search(pattern, new_output): + return output + + # Final read to get remaining output + new_output = self.read_channel() + output += new_output + + return output + + +# Register the custom device type with Netmiko +_registered = False # Flag to prevent duplicate registration + +def register_custom_device_type() -> None: + """ + Register the custom GNS3HuaweiTelnetCE device type with Netmiko. + + This function adds 'gns3_huawei_telnet_ce' to both Netmiko's CLASS_MAPPER + and CLASS_MAPPER_BASE so it can be used like any other built-in device + type. + + Additionally, it updates the static 'platforms' and 'telnet_platforms' + lists which are used by ConnectHandler for device type validation. + + IMPORTANT: This function should be called BEFORE initializing Nornir + or running any Netmiko tasks. Call it explicitly at the appropriate + time. + + Note: This function is idempotent - multiple calls will only register once. + + Returns: + None + """ + global _registered + + # Prevent duplicate registration + if _registered: + logger.debug("Huawei CE device type already registered, skipping") + return + + # Use importlib to avoid namespace conflicts + # Import the module using importlib to ensure we get the module, + # not a function + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Register the device type in both mappers + # CLASS_MAPPER_BASE is used for base class definitions + sd.CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE + + # CLASS_MAPPER is used by ConnectHandler for device type validation + sd.CLASS_MAPPER["gns3_huawei_telnet_ce"] = GNS3HuaweiTelnetCE + + # CRITICAL: Update the static platforms lists + # These lists are computed at module import time and won't + # automatically update when CLASS_MAPPER is modified. + # We need to manually rebuild them. + + # Recalculate platforms list + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + + # Recalculate platforms_base list + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + + # Recalculate telnet_platforms list + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + + # Rebuild the platform strings used in error messages + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) + + # Mark as registered + _registered = True + + logger.info("Successfully registered Huawei CE device type with Netmiko") + + +# Auto-register on import +# This ensures the device type is available when the module is imported +# NOTE: For Nornir scenarios, you may need to call this explicitly +# before InitNornir to ensure proper timing +try: + register_custom_device_type() +except Exception as e: + # Log but don't fail on import + logger = logging.getLogger(__name__) + logger.warning( + f"Failed to register custom device type: {e}", + exc_info=True + ) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py new file mode 100644 index 000000000..108eb02b9 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py @@ -0,0 +1,301 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +# mypy: ignore-errors + +""" +Custom Netmiko device driver for Ruijie (锐捷) devices in GNS3 emulation. + +This module provides enhanced handling for Ruijie network devices, +specifically addressing interactive prompts during configuration. + +Key Features: +- Inherits from RuijieOSBase (Netmiko's native Ruijie support) +- Handles interactive prompts (e.g., OSPF router-id [yes/no] confirmation) +- Maintains compatibility with Netmiko's Ruijie implementation + +Device Type: gns3_ruijie_telnet +""" + +import importlib +import logging +import re +import time + +from netmiko.ruijie.ruijie_os import RuijieOSBase + +logger = logging.getLogger(__name__) + + +class RuijieTelnetEnhanced(RuijieOSBase): + """ + Enhanced Ruijie device driver with interactive prompt handling. + + Inherits from RuijieOSBase to maintain native Netmiko compatibility, + and adds automatic handling for interactive configuration prompts. + """ + + # Interactive command patterns that trigger [yes/no] prompts + # These are commands that commonly require confirmation + INTERACTIVE_PATTERNS = [ + re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id + re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config + re.compile(r'^delete\s+', re.IGNORECASE), # delete files + re.compile(r'^format\s+', re.IGNORECASE), # format filesystem + re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot + re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image + ] + + def __init__( + self, + *args, + **kwargs, + ) -> None: + """Initialize RuijieTelnetEnhanced connection.""" + # Set default_enter for telnet (like Netmiko's RuijieOSTelnet) + default_enter = kwargs.get("default_enter") + if default_enter is None: + kwargs["default_enter"] = "\r\n" + super().__init__(*args, **kwargs) + + def _preprocess_interactive_commands( + self, config_commands: list[str] + ) -> list[str]: + """ + Preprocess commands to insert 'yes' after interactive commands. + + Detects commands that trigger [yes/no] prompts and automatically + inserts 'yes' response after them. + + Args: + config_commands: List of configuration commands + + Returns: + List of commands with 'yes' inserted after interactive commands + """ + processed = [] + + for cmd in config_commands: + # Add the original command + processed.append(cmd) + + # Check if this command matches any interactive pattern + for pattern in self.INTERACTIVE_PATTERNS: + if pattern.match(cmd.strip()): + logging.info( + "Ruijie device: Detected interactive command '%s', " + "inserting 'yes'", + cmd.strip(), + ) + # Insert 'yes' after this command + processed.append("yes") + break + + return processed + + def send_config_set( + self, + config_commands: str | list[str], + **kwargs, + ) -> str: + """ + Send configuration commands with interactive prompt handling. + + Hybrid Strategy: + 1. Preprocess commands to insert 'yes' after known interactive commands + 2. Try batch send (fast) + 3. If batch fails, fallback to one-by-one send with real-time detection + + Args: + config_commands: Configuration commands to send + **kwargs: Additional arguments (exit_config_mode, read_timeout, + etc.) + + Returns: + Output from configuration commands + """ + # Convert string to list + if isinstance(config_commands, str): + config_commands = [config_commands] + + # Get parameters with defaults + exit_config_mode = kwargs.get("exit_config_mode", True) + enter_config_mode = kwargs.get("enter_config_mode", True) + read_timeout = kwargs.get("read_timeout", 60) + delay_factor = self.global_delay_factor + + output = "" + + # Enter config mode if needed + if enter_config_mode: + output += self.config_mode() + + # Preprocess: Insert 'yes' after known interactive commands + processed_commands = self._preprocess_interactive_commands( + config_commands + ) + + # Try batch send first (fast path) + try: + output += self._send_config_batch( + processed_commands, read_timeout, delay_factor + ) + except Exception as batch_error: + logging.warning( + "Ruijie device: Batch send failed, " + "falling back to one-by-one: %s", + batch_error, + ) + # Fallback to one-by-one send with real-time detection + output += self._send_config_one_by_one( + config_commands, read_timeout, delay_factor + ) + + # Exit config mode if requested + if exit_config_mode: + output += self.exit_config_mode() + + return output + + def _send_config_batch( + self, commands: list[str], read_timeout: int, delay_factor: float + ) -> str: + """ + Send configuration commands in batch (fast). + + This works when all interactive prompts have been pre-processed + with 'yes' responses inserted. + """ + output = "" + + # Batch send: Write all commands rapidly + for cmd in commands: + self.write_channel(f"{cmd}{self.RETURN}") + time.sleep(0.01) + + # Read all output at once + # Use same default as Netmiko (2.0 seconds) to ensure complete output + output += self.read_channel_timing( + read_timeout=read_timeout, last_read=2.0 + ) + + return output + + def _send_config_one_by_one( + self, commands: list[str], read_timeout: int, delay_factor: float + ) -> str: + """ + Send commands one-by-one with real-time prompt detection. + + This is the fallback method when batch send fails. + """ + output = "" + interactive_patterns = [ + r"\[yes/no\]", + r"\[y/n\]", + r"\[Y/N\]", + ] + + # Send commands ONE BY ONE to detect prompts after each command + for cmd in commands: + # Write the command + self.write_channel(f"{cmd}{self.RETURN}") + time.sleep(0.05) + + # Read output after this command + new_output = self.read_channel_timing( + read_timeout=10, last_read=0.5 + ) + output += new_output + + # Check if interactive prompt appeared after this command + for pattern in interactive_patterns: + if re.search(pattern, new_output, re.IGNORECASE): + logging.info( + "Ruijie device: Detected interactive prompt " + "after '%s', sending 'yes'", + cmd, + ) + # Send 'yes' to confirm + self.write_channel(f"yes{self.RETURN}") + time.sleep(0.3) + # Read the confirmation response + output += self.read_channel_timing( + read_timeout=30, last_read=0.5 + ) + break + + return output + + +# Register the custom device type with Netmiko +_registered = False # Flag to prevent duplicate registration + +def register_custom_device_type() -> None: + """ + Register the custom RuijieTelnetEnhanced device type with Netmiko. + + This function adds 'gns3_ruijie_telnet' to Netmiko's CLASS_MAPPER + and updates the platforms lists. + + Note: This function is idempotent - multiple calls will only register once. + """ + global _registered + + # Prevent duplicate registration + if _registered: + logger.debug("Ruijie Telnet device type already registered, skipping") + return + + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Register in both mappers + sd.CLASS_MAPPER_BASE["gns3_ruijie_telnet"] = RuijieTelnetEnhanced + sd.CLASS_MAPPER["gns3_ruijie_telnet"] = RuijieTelnetEnhanced + + # Update platforms lists + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) + + # Mark as registered + _registered = True + + logger.info("Successfully registered Ruijie Telnet device type with Netmiko") + + +# Auto-register on import +try: + register_custom_device_type() +except Exception as e: + logger = logging.getLogger(__name__) + logger.warning( + f"Failed to register Ruijie device type: {e}", + exc_info=True + ) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py new file mode 100644 index 000000000..d9edd8c34 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/scripts/list_netmiko_telnet_devices.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Script to list all Netmiko supported devices (SSH and Telnet) +# +# This script queries the Netmiko library to extract all device types +# that support SSH and Telnet connections, grouped by vendor/brand. +# Includes custom device drivers from the GNS3-Copilot project. +# Outputs a Markdown document with the complete device list. +# +# Usage: python list_netmiko_devices.py +# Output: netmiko_devices.md (in current directory) +# + +""" +Netmiko Device List Generator + +This script extracts and displays all SSH and Telnet-supported device +types from the Netmiko library, organized by vendor/brand. + +Features: +- Automatically detects Netmiko version +- Loads custom GNS3-Copilot device drivers +- Extracts all SSH and Telnet device types from CLASS_MAPPER +- Groups devices by vendor/brand +- Generates Markdown documentation +- Provides statistics and summary +""" + +import os +import sys +from typing import Dict, List, Tuple +from datetime import datetime + + +def get_netmiko_version() -> str: + """Get Netmiko library version.""" + try: + import netmiko + return netmiko.__version__ + except Exception: + return "Unknown" + + +def register_custom_drivers() -> set: + """ + Register custom GNS3-Copilot device drivers. + + This function imports and executes the registration functions for + custom device drivers, adding them to Netmiko's CLASS_MAPPER. + + Returns: + Set of custom device type names that were registered + """ + # Get the directory where this script is located + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # Find the custom drivers directory + custom_drivers_dir = os.path.dirname(script_dir) + + # Add to sys.path if not already there + if custom_drivers_dir not in sys.path: + sys.path.insert(0, custom_drivers_dir) + + custom_devices = set() + + try: + # Import and register Huawei CE driver + from gns3server.agent.gns3_copilot.utils.custom_netmiko \ + import huawei_ce + + # Store custom device types before registration + custom_devices.add("gns3_huawei_telnet_ce") + + # The driver auto-registers on import, but we can call it + # explicitly + if hasattr(huawei_ce, 'register_custom_device_type'): + huawei_ce.register_custom_device_type() + except ImportError: + # Silently skip if custom drivers are not available + pass + except Exception as e: + # Log but don't fail + print( + f"Warning: Failed to register Huawei CE driver: {e}", + file=sys.stderr + ) + + try: + # Import and register Ruijie Telnet driver + from gns3server.agent.gns3_copilot.utils.custom_netmiko \ + import ruijie_telnet + + # Store custom device types before registration + custom_devices.add("gns3_ruijie_telnet") + + # The driver auto-registers on import, but we can call it + # explicitly + if hasattr(ruijie_telnet, 'register_custom_device_type'): + ruijie_telnet.register_custom_device_type() + except ImportError: + # Silently skip if custom drivers are not available + pass + except Exception as e: + # Log but don't fail + print( + f"Warning: Failed to register Ruijie driver: {e}", + file=sys.stderr + ) + + return custom_devices + + +def extract_brand_name(device_type: str) -> str: + """ + Extract brand name from device type. + + Args: + device_type: Device type string (e.g., 'cisco_ios_telnet') + + Returns: + Brand name in a standardized format + """ + # Remove common suffixes + name = device_type.replace('_telnet', '').replace('_ssh', '') + name = name.replace('_serial', '') + + # Map to standard brand names + brand_mapping = { + 'cisco': 'Cisco', + 'huawei': 'Huawei', + 'juniper': 'Juniper', + 'arista': 'Arista', + 'hp': 'HP', + 'aruba': 'Aruba', + 'dell': 'Dell', + 'brocade': 'Brocade', + 'extreme': 'Extreme', + 'ruckus': 'Ruckus', + 'ruijie': 'Ruijie (锐捷)', + 'zte': 'ZTE (中兴)', + 'maipu': 'Maipu (迈普)', + 'h3c': 'H3C (华三)', + 'nokia': 'Nokia', + 'paloalto': 'Palo Alto', + 'f5': 'F5', + 'checkpoint': 'Check Point', + 'generic': 'Generic', + 'huaweiyt': 'HuaweiYT', + 'vsz': 'Ruckus', + } + + # Check for exact matches first + for key, value in brand_mapping.items(): + if name == key: + return value + + # Check for partial matches + for key, value in brand_mapping.items(): + if key in name: + return value + + # Return capitalized version of the first part + return name.split('_')[0].capitalize() + + +def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]: + """ + Group device types by brand. + + Args: + device_types: List of device type strings + + Returns: + Dictionary mapping brand names to device type lists + """ + brands = {} + + for device_type in device_types: + brand = extract_brand_name(device_type) + + if brand not in brands: + brands[brand] = [] + + brands[brand].append(device_type) + + return brands + + +def get_devices_by_protocol() -> Tuple[List[str], List[str]]: + """ + Get all SSH and Telnet device types from Netmiko. + + Returns: + Tuple of (ssh_devices, telnet_devices) as sorted lists + """ + try: + from netmiko.ssh_dispatcher import CLASS_MAPPER + + ssh_devices = [] + telnet_devices = [] + both_protocols = [] + + for device_type in CLASS_MAPPER.keys(): + device_lower = device_type.lower() + has_telnet = 'telnet' in device_lower + has_ssh = 'ssh' in device_lower + + if has_telnet and has_ssh: + both_protocols.append(device_type) + elif has_telnet: + telnet_devices.append(device_type) + elif has_ssh: + ssh_devices.append(device_type) + + # Devices with both protocols go to both lists + ssh_devices.extend(both_protocols) + telnet_devices.extend(both_protocols) + + return sorted(ssh_devices), sorted(telnet_devices) + + except ImportError as e: + print(f"Error: Failed to import Netmiko: {e}", file=sys.stderr) + sys.exit(1) + + +def get_total_device_count() -> int: + """Get total number of device types in Netmiko.""" + try: + from netmiko.ssh_dispatcher import CLASS_MAPPER + return len(CLASS_MAPPER) + except ImportError: + return 0 + + +def generate_markdown( + version: str, + ssh_devices: List[str], + telnet_devices: List[str], + total_count: int, + custom_devices: set +) -> str: + """ + Generate Markdown documentation with table format. + + Args: + version: Netmiko version string + ssh_devices: List of SSH device types + telnet_devices: List of Telnet device types + total_count: Total number of device types + custom_devices: Set of custom device type names + + Returns: + Markdown formatted string + """ + md_lines = [] + + # Header + md_lines.append("# Netmiko Supported Devices") + md_lines.append("") + md_lines.append(f"**Netmiko Version:** {version}") + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + md_lines.append(f"**Generated:** {timestamp}") + md_lines.append("") + md_lines.append("---") + md_lines.append("") + + # Summary + md_lines.append("## Summary") + md_lines.append("") + md_lines.append(f"- **Total Device Types:** {total_count}") + md_lines.append(f"- **SSH Devices:** {len(ssh_devices)}") + md_lines.append(f"- **Telnet Devices:** {len(telnet_devices)}") + md_lines.append(f"- **Custom Devices:** {len(custom_devices)}") + md_lines.append("") + + # SSH Devices Section + md_lines.append("## SSH Supported Devices") + md_lines.append("") + md_lines.append("| Platform | Device Type | Source |") + md_lines.append("|----------|-------------|--------|") + + ssh_brands = group_devices_by_brand(ssh_devices) + for brand in sorted(ssh_brands.keys()): + devices = sorted(ssh_brands[brand]) + for device in devices: + if device in custom_devices: + source = "Custom ✨" + else: + source = "Netmiko" + md_lines.append(f"| {brand} | `{device}` | {source} |") + md_lines.append("") + + # Telnet Devices Section + md_lines.append("## Telnet Supported Devices") + md_lines.append("") + md_lines.append("| Platform | Device Type | Source |") + md_lines.append("|----------|-------------|--------|") + + telnet_brands = group_devices_by_brand(telnet_devices) + for brand in sorted(telnet_brands.keys()): + devices = sorted(telnet_brands[brand]) + for device in devices: + if device in custom_devices: + source = "Custom ✨" + else: + source = "Netmiko" + md_lines.append(f"| {brand} | `{device}` | {source} |") + md_lines.append("") + + # Footer + md_lines.append("---") + md_lines.append("") + md_lines.append("*This document was generated automatically by the " + "Netmiko device list script.*") + + return "\n".join(md_lines) + + +def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None: + """ + Save Markdown content to file in docs/gns3-copilot directory. + + Args: + content: Markdown content to save + filename: Output filename (default: netmiko_devices.md) + """ + # Get the project root directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + # Go up from: .../gns3server/agent/gns3_copilot/utils/ + # custom_netmiko/scripts to project root + # That's 6 levels up + project_root = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(script_dir)))) + ) + ) + + # Target directory: docs/gns3-copilot + target_dir = os.path.join(project_root, "docs", "gns3-copilot") + + # Create directory if it doesn't exist + os.makedirs(target_dir, exist_ok=True) + + # Full file path + output_path = os.path.join(target_dir, filename) + + try: + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + print(f"Markdown document saved to: {output_path}") + except IOError as e: + print(f"Error: Failed to save Markdown file: {e}", + file=sys.stderr) + sys.exit(1) + + +def print_summary(version: str, ssh_count: int, telnet_count: int, + total_count: int, output_file: str) -> None: + """Print summary to console.""" + print("=" * 80) + print("Netmiko Device List Generator") + print(f"Netmiko Version: {version}") + print("=" * 80) + print() + print(f"Total SSH devices: {ssh_count}") + print(f"Total Telnet devices: {telnet_count}") + print(f"Total device types: {total_count}") + print() + print(f"Output file: {output_file}") + print("=" * 80) + + +def main() -> int: + """ + Main function to execute the script. + + Returns: + Exit code (0 for success, 1 for error) + """ + # Register custom GNS3-Copilot drivers + custom_devices = register_custom_drivers() + + # Get Netmiko version + version = get_netmiko_version() + + # Get SSH and Telnet devices + ssh_devices, telnet_devices = get_devices_by_protocol() + + if not ssh_devices and not telnet_devices: + print("Error: No devices found!", file=sys.stderr) + return 1 + + # Get total device count + total_count = get_total_device_count() + + # Generate Markdown + markdown_content = generate_markdown( + version=version, + ssh_devices=ssh_devices, + telnet_devices=telnet_devices, + total_count=total_count, + custom_devices=custom_devices + ) + + # Save to file + output_file = "netmiko_devices.md" + save_markdown(markdown_content, output_file) + + # Print summary + print_summary( + version=version, + ssh_count=len(ssh_devices), + telnet_count=len(telnet_devices), + total_count=total_count, + output_file=output_file + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/__init__.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/__init__.py new file mode 100644 index 000000000..c7e50c78f --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/__init__.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Unit tests for custom Netmiko drivers. +""" diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py new file mode 100644 index 000000000..db18bada4 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_huawei_ce.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Unit test script for custom Netmiko GNS3HuaweiTelnetCE driver +# + +""" +Unit test script for GNS3HuaweiTelnetCE custom device driver. + +This script tests: +1. Device type registration +2. Inheritance from HuaweiBase +3. VRP-specific command handling +4. Telnet login logic (mocked) + +Run with: python test_huawei_ce.py +""" + +import sys +import unittest +from unittest.mock import Mock, patch +import os + +# Add project root to path using relative path +test_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname( + os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(test_dir))))) +sys.path.insert(0, project_root) + + +class TestHuaweiTelnetCEDriver(unittest.TestCase): + """Test suite for GNS3HuaweiTelnetCE custom driver.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures - import and register custom driver.""" + # Import the custom driver module (triggers registration) + from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( + huawei_ce, + ) + + cls.huawei_ce = huawei_ce + cls.HuaweiTelnetCE = huawei_ce.GNS3HuaweiTelnetCE + + def test_device_type_registered(self): + """Test that gns3_huawei_telnet_ce is registered in Netmiko.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE + + # Check CLASS_MAPPER + self.assertIn("gns3_huawei_telnet_ce", CLASS_MAPPER) + registered_class = CLASS_MAPPER["gns3_huawei_telnet_ce"] + # Compare class name since class may be imported via different paths + self.assertEqual(registered_class.__name__, self.HuaweiTelnetCE.__name__) + + # Check CLASS_MAPPER_BASE + self.assertIn("gns3_huawei_telnet_ce", CLASS_MAPPER_BASE) + registered_class_base = CLASS_MAPPER_BASE["gns3_huawei_telnet_ce"] + self.assertEqual(registered_class_base.__name__, self.HuaweiTelnetCE.__name__) + + def test_inheritance_from_huawei_base(self): + """Test that GNS3HuaweiTelnetCE inherits from HuaweiBase.""" + from netmiko.huawei.huawei import HuaweiBase + + # Verify inheritance + self.assertIsInstance(self.HuaweiTelnetCE, type) + # Check if GNS3HuaweiTelnetCE is a subclass of HuaweiBase + self.assertTrue(issubclass(self.HuaweiTelnetCE, HuaweiBase)) + + def test_huawei_base_methods_available(self): + """Test that VRP-specific methods are available.""" + # These methods should be inherited from HuaweiBase + vrp_methods = [ + "config_mode", + "check_config_mode", + "exit_config_mode", + "send_config_set", + "send_command", + "disable_paging", + ] + + for method_name in vrp_methods: + self.assertTrue( + hasattr(self.HuaweiTelnetCE, method_name), + f"Method {method_name} not found in GNS3HuaweiTelnetCE", + ) + + def test_telnet_login_method_exists(self): + """Test that telnet_login method is overridden.""" + # Should have overridden telnet_login + self.assertTrue(hasattr(self.HuaweiTelnetCE, "telnet_login")) + + # Check if method is in GNS3HuaweiTelnetCE's __dict__ + # (means it's defined there, not inherited) + self.assertIn( + "telnet_login", + self.HuaweiTelnetCE.__dict__, + "telnet_login should be defined in GNS3HuaweiTelnetCE", + ) + + def test_initialization_parameters(self): + """Test that initialization sets correct parameters.""" + # Create a mock instance (without actual connection) + with patch.object( + self.HuaweiTelnetCE, "__init__", + lambda self, *args, **kwargs: None + ): + instance = self.HuaweiTelnetCE.__new__( + self.HuaweiTelnetCE + ) + + # Mock the necessary attributes + instance.protocol = "telnet" + instance.device_type = "huawei_telnet" + + # Verify protocol is set to telnet + self.assertEqual(instance.protocol, "telnet") + self.assertEqual(instance.device_type, "huawei_telnet") + + def test_connect_handler_accepts_device_type(self): + """Test that ConnectHandler accepts gns3_huawei_telnet_ce.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER + + # Get platforms list + platforms = list(CLASS_MAPPER.keys()) + + # Verify gns3_huawei_telnet_ce is in platforms + self.assertIn("gns3_huawei_telnet_ce", platforms) + + # Verify it's in telnet platforms + telnet_platforms = [x for x in platforms if "telnet" in x] + self.assertIn("gns3_huawei_telnet_ce", telnet_platforms) + + def test_prompt_pattern_constants(self): + """Test that Huawei prompt patterns are correctly defined.""" + import inspect + + # Get the telnet_login method signature + sig = inspect.signature(self.HuaweiTelnetCE.telnet_login) + + # Check default prompt patterns + pri_prompt = sig.parameters["pri_prompt_terminator"].default + alt_prompt = sig.parameters["alt_prompt_terminator"].default + + # Should match Huawei prompt patterns + self.assertIn("<", pri_prompt) + self.assertIn("[", alt_prompt) + + def test_disable_paging_command(self): + """Test that disable_paging uses correct Huawei command.""" + import inspect + + sig = inspect.signature(self.HuaweiTelnetCE.disable_paging) + command_default = sig.parameters["command"].default + + # Should use Huawei-specific command + self.assertEqual(command_default, "screen-length 0 temporary") + + +class TestHuaweiTelnetCEIntegration(unittest.TestCase): + """Integration tests for GNS3HuaweiTelnetCE driver.""" + + def test_mock_telnet_connection(self): + """Test telnet_login logic with mocked connection.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.huawei_ce import ( # noqa: E501 + GNS3HuaweiTelnetCE, + ) + + # Create a mock instance + instance = GNS3HuaweiTelnetCE.__new__( + GNS3HuaweiTelnetCE + ) + + # Mock the necessary attributes and methods + instance.host = "127.0.0.1" + instance.RETURN = "\r\n" + instance.global_delay_factor = 1.0 + instance.remote_conn = Mock() + + # Mock select_delay_factor + instance.select_delay_factor = Mock(return_value=1.0) + + # Mock read_channel and write_channel + test_outputs = ["", ""] + instance.read_channel = Mock(side_effect=test_outputs) + instance.write_channel = Mock() + + # Call telnet_login + result = instance.telnet_login() + + # Verify behavior + self.assertIn("", result) + instance.write_channel.assert_called() + self.assertEqual(instance.read_channel.call_count, 2) + + +def run_tests(): + """Run all tests and print results.""" + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add test cases + suite.addTests( + loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver) + ) + suite.addTests( + loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration) + ) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Print summary + print("\n" + "=" * 100) + print("Test Summary:") + print(f" Run: {result.testsRun}") + success_count = ( + result.testsRun - len(result.failures) - len(result.errors) + ) + print(f" Success: {success_count}") + print(f" Failed: {len(result.failures)}") + print(f" Errors: {len(result.errors)}") + print("=" * 100) + + return result.wasSuccessful() + + +if __name__ == "__main__": + success = run_tests() + sys.exit(0 if success else 1) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py new file mode 100644 index 000000000..b913f83ee --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_ruijie_telnet.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Unit test script for custom Netmiko RuijieTelnetEnhanced driver +# + +""" +Unit test script for RuijieTelnetEnhanced custom device driver. + +This script tests: +1. Device type registration +2. Inheritance from RuijieOSBase +3. Interactive command preprocessing +4. Batch send and fallback logic (mocked) + +Run with: python test_ruijie_telnet.py +""" + +import sys +import unittest +from unittest.mock import Mock, patch +import os + +# Add project root to path using relative path +test_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname( + os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(test_dir))))) +sys.path.insert(0, project_root) + + +class TestRuijieTelnetEnhancedDriver(unittest.TestCase): + """Test suite for RuijieTelnetEnhanced custom driver.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures - import and register custom driver.""" + # Import the custom driver module (triggers registration) + from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( + ruijie_telnet, + ) + + cls.ruijie_telnet = ruijie_telnet + cls.RuijieTelnetEnhanced = ruijie_telnet.RuijieTelnetEnhanced + + def test_device_type_registered(self): + """Test that gns3_ruijie_telnet is registered in Netmiko.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE + + # Check CLASS_MAPPER + self.assertIn("gns3_ruijie_telnet", CLASS_MAPPER) + registered_class = CLASS_MAPPER["gns3_ruijie_telnet"] + # Compare class name since class may be imported via different paths + self.assertEqual(registered_class.__name__, self.RuijieTelnetEnhanced.__name__) + + # Check CLASS_MAPPER_BASE + self.assertIn("gns3_ruijie_telnet", CLASS_MAPPER_BASE) + registered_class_base = CLASS_MAPPER_BASE["gns3_ruijie_telnet"] + self.assertEqual(registered_class_base.__name__, self.RuijieTelnetEnhanced.__name__) + + def test_inheritance_from_ruijie_os_base(self): + """Test that RuijieTelnetEnhanced inherits from RuijieOSBase.""" + from netmiko.ruijie.ruijie_os import RuijieOSBase + + # Verify inheritance + self.assertIsInstance(self.RuijieTelnetEnhanced, type) + # Check if RuijieTelnetEnhanced is a subclass of RuijieOSBase + self.assertTrue(issubclass(self.RuijieTelnetEnhanced, RuijieOSBase)) + + def test_ruijie_base_methods_available(self): + """Test that Ruijie-specific methods are available.""" + # These methods should be inherited from RuijieOSBase + ruijie_methods = [ + "config_mode", + "check_config_mode", + "exit_config_mode", + "send_config_set", + "send_command", + "disable_paging", + ] + + for method_name in ruijie_methods: + self.assertTrue( + hasattr(self.RuijieTelnetEnhanced, method_name), + f"Method {method_name} not found in RuijieTelnetEnhanced", + ) + + def test_interactive_patterns_defined(self): + """Test that INTERACTIVE_PATTERNS is correctly defined.""" + import re + + # Should have INTERACTIVE_PATTERNS as a class attribute + self.assertTrue( + hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS") + ) + + # Check it's a list + patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS + self.assertIsInstance(patterns, list) + + # Check it contains expected patterns (compiled regex) + self.assertGreater(len(patterns), 0) + for pattern in patterns: + self.assertIsInstance(pattern, re.Pattern) + + def test_preprocess_interactive_commands(self): + """Test that interactive commands are preprocessed correctly.""" + # Create a mock instance (without actual connection) + with patch.object( + self.RuijieTelnetEnhanced, "__init__", + lambda self, *args, **kwargs: None + ): + instance = self.RuijieTelnetEnhanced.__new__( + self.RuijieTelnetEnhanced + ) + + # Set INTERACTIVE_PATTERNS from class + instance.INTERACTIVE_PATTERNS = ( + self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS + ) + + # Test commands with router-id (should trigger 'yes') + commands = [ + "router ospf 1", + "router-id 1.1.1.1", + "network 10.0.0.0 0.0.0.255 area 0", + ] + + processed = instance._preprocess_interactive_commands( + commands + ) + + # Should have inserted 'yes' after router-id command + self.assertEqual(len(processed), 4) + self.assertEqual(processed[0], "router ospf 1") + self.assertEqual(processed[1], "router-id 1.1.1.1") + self.assertEqual(processed[2], "yes") + self.assertEqual( + processed[3], "network 10.0.0.0 0.0.0.255 area 0" + ) + + def test_preprocess_non_interactive_commands(self): + """Test that non-interactive commands are not modified.""" + # Create a mock instance + with patch.object( + self.RuijieTelnetEnhanced, "__init__", + lambda self, *args, **kwargs: None + ): + instance = self.RuijieTelnetEnhanced.__new__( + self.RuijieTelnetEnhanced + ) + + # Set INTERACTIVE_PATTERNS from class + instance.INTERACTIVE_PATTERNS = ( + self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS + ) + + # Test commands without interactive prompts + commands = [ + "interface GigabitEthernet 0/1", + "description Test Interface", + "ip address 192.168.1.1 255.255.255.0", + ] + + processed = instance._preprocess_interactive_commands(commands) + + # Should not have inserted any 'yes' commands + self.assertEqual(len(processed), 3) + self.assertEqual(processed, commands) + + def test_connect_handler_accepts_device_type(self): + """Test that ConnectHandler accepts gns3_ruijie_telnet.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER + + # Get platforms list + platforms = list(CLASS_MAPPER.keys()) + + # Verify gns3_ruijie_telnet is in platforms + self.assertIn("gns3_ruijie_telnet", platforms) + + # Verify it's in telnet platforms + telnet_platforms = [x for x in platforms if "telnet" in x] + self.assertIn("gns3_ruijie_telnet", telnet_platforms) + + def test_default_enter_parameter(self): + """Test that default_enter is set to \\r\\n for telnet.""" + import inspect + + # Check that default_enter is handled in __init__ + # (The actual logic is in the __init__ method body) + init_source = inspect.getsource( + self.RuijieTelnetEnhanced.__init__ + ) + self.assertIn("default_enter", init_source) + self.assertIn("\\r\\n", init_source) + + +class TestRuijieTelnetEnhancedIntegration(unittest.TestCase): + """Integration tests for RuijieTelnetEnhanced driver.""" + + def test_send_config_set_calls_preprocess(self): + """Test that send_config_set calls preprocessing.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501 + RuijieTelnetEnhanced, + ) + + # Create a mock instance + instance = RuijieTelnetEnhanced.__new__( + RuijieTelnetEnhanced + ) + + # Mock the necessary attributes and methods + instance.RETURN = "\r\n" + instance.global_delay_factor = 1.0 + + # Mock config_mode and exit_config_mode + instance.config_mode = Mock(return_value="") + instance.exit_config_mode = Mock(return_value="") + instance._send_config_batch = Mock(return_value="Output") + + # Test commands + commands = ["interface GigabitEthernet 0/1", "description Test"] + + # Call send_config_set + instance.send_config_set( + commands, enter_config_mode=False, exit_config_mode=False + ) + + # Verify _send_config_batch was called + instance._send_config_batch.assert_called_once() + + def test_multiple_interactive_patterns(self): + """Test that multiple interactive commands are detected.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.ruijie_telnet import ( # noqa: E501 + RuijieTelnetEnhanced, + ) + + # Create a mock instance + instance = RuijieTelnetEnhanced.__new__( + RuijieTelnetEnhanced + ) + instance.INTERACTIVE_PATTERNS = ( + RuijieTelnetEnhanced.INTERACTIVE_PATTERNS + ) + + # Test commands with multiple interactive prompts + commands = [ + "router-id 1.1.1.1", # Should trigger 'yes' + "erase startup-config", # Should trigger 'yes' + "interface GigabitEthernet 0/1", # Should NOT trigger + ] + + processed = instance._preprocess_interactive_commands(commands) + + # Should have inserted 'yes' after first two commands + # processed[0] = "router-id 1.1.1.1", processed[1] = "yes" + # processed[2] = "erase startup-config", processed[3] = "yes" + # processed[4] = "interface GigabitEthernet 0/1" + self.assertEqual(len(processed), 5) + self.assertEqual(processed[0], "router-id 1.1.1.1") + self.assertEqual(processed[1], "yes") + self.assertEqual(processed[2], "erase startup-config") + self.assertEqual(processed[3], "yes") + self.assertEqual(processed[4], "interface GigabitEthernet 0/1") + + +def run_tests(): + """Run all tests and print results.""" + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add test cases + suite.addTests( + loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver) + ) + suite.addTests( + loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration) + ) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Print summary + print("\n" + "=" * 100) + print("Test Summary:") + print(f" Run: {result.testsRun}") + success_count = ( + result.testsRun - len(result.failures) - len(result.errors) + ) + print(f" Success: {success_count}") + print(f" Failed: {len(result.failures)}") + print(f" Errors: {len(result.errors)}") + print("=" * 100) + + return result.wasSuccessful() + + +if __name__ == "__main__": + success = run_tests() + sys.exit(0 if success else 1) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py new file mode 100644 index 000000000..95fa06b8c --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/tests/test_vpcs_telnet.py @@ -0,0 +1,492 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Unit test script for custom Netmiko VPCSTelnet driver +# + +""" +Unit test script for VPCSTelnet custom device driver. + +This script tests: +1. Device type registration +2. Inheritance from BaseConnection +3. VPCS-specific methods (telnet_login, session_preparation, send_command) +4. Config mode methods (check_config_mode, config_mode, exit_config_mode) +5. disable_paging method +6. Default parameters (default_enter, global_delay_factor, fast_cli) + +Run with: python test_vpcs_telnet.py +""" + +import sys +import unittest +from unittest.mock import Mock, patch, MagicMock +import os + +# Add project root to path using relative path +test_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname( + os.path.dirname(os.path.dirname( + os.path.dirname(os.path.dirname(test_dir))))) +sys.path.insert(0, project_root) + + +class TestVPCSTelnetDriver(unittest.TestCase): + """Test suite for VPCSTelnet custom driver.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures - import and register custom driver.""" + # Import the custom driver module (triggers registration) + from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( + vpcs_telnet, + ) + + cls.vpcs_telnet = vpcs_telnet + cls.VPCSTelnet = vpcs_telnet.VPCSTelnet + + def test_device_type_registered(self): + """Test that gns3_vpcs_telnet is registered in Netmiko.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER, CLASS_MAPPER_BASE + + # Check CLASS_MAPPER + self.assertIn("gns3_vpcs_telnet", CLASS_MAPPER) + registered_class = CLASS_MAPPER["gns3_vpcs_telnet"] + # Compare class name since class may be imported via different paths + self.assertEqual(registered_class.__name__, self.VPCSTelnet.__name__) + + # Check CLASS_MAPPER_BASE + self.assertIn("gns3_vpcs_telnet", CLASS_MAPPER_BASE) + registered_class_base = CLASS_MAPPER_BASE["gns3_vpcs_telnet"] + self.assertEqual(registered_class_base.__name__, self.VPCSTelnet.__name__) + + def test_inheritance_from_base_connection(self): + """Test that VPCSTelnet inherits from BaseConnection.""" + from netmiko.base_connection import BaseConnection + + # Verify inheritance + self.assertIsInstance(self.VPCSTelnet, type) + # Check if VPCSTelnet is a subclass of BaseConnection + self.assertTrue(issubclass(self.VPCSTelnet, BaseConnection)) + + def test_vpcs_methods_available(self): + """Test that VPCS-specific methods are available.""" + # These methods should be available in VPCSTelnet + vpcs_methods = [ + "telnet_login", + "session_preparation", + "send_command", + "send_command_timing", + "check_config_mode", + "config_mode", + "exit_config_mode", + "disable_paging", + ] + + for method_name in vpcs_methods: + self.assertTrue( + hasattr(self.VPCSTelnet, method_name), + f"Method {method_name} not found in VPCSTelnet", + ) + + def test_connect_handler_accepts_device_type(self): + """Test that ConnectHandler accepts gns3_vpcs_telnet.""" + from netmiko.ssh_dispatcher import CLASS_MAPPER + + # Get platforms list + platforms = list(CLASS_MAPPER.keys()) + + # Verify gns3_vpcs_telnet is in platforms + self.assertIn("gns3_vpcs_telnet", platforms) + + # Verify it's in telnet platforms + telnet_platforms = [x for x in platforms if "telnet" in x] + self.assertIn("gns3_vpcs_telnet", telnet_platforms) + + def test_telnet_platforms_list(self): + """Test that gns3_vpcs_telnet is in telnet_platforms list.""" + from netmiko.ssh_dispatcher import telnet_platforms + + self.assertIn("gns3_vpcs_telnet", telnet_platforms) + + +class TestVPCSTelnetInit(unittest.TestCase): + """Test VPCSTelnet initialization and default parameters.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 + VPCSTelnet, + ) + cls.VPCSTelnet = VPCSTelnet + + def test_default_enter_parameter(self): + """Test that default_enter is set to \\r\\n for VPCS.""" + import inspect + + # Check that default_enter is handled in __init__ + init_source = inspect.getsource(self.VPCSTelnet.__init__) + self.assertIn("default_enter", init_source) + self.assertIn("\\r\\n", init_source) + + def test_default_parameters_when_none(self): + """Test default parameters when not provided.""" + # Mock the BaseConnection.__init__ to avoid actual connection + # But also set device_type since VPCSTelnet.__init__ sets it + def mock_init(self, *args, **kwargs): + # Simulate what VPCSTelnet.__init__ does + kwargs.setdefault("device_type", "gns3_vpcs_telnet") + self.device_type = kwargs.get("device_type") + + with patch.object( + self.VPCSTelnet, "__init__", mock_init + ): + instance = self.VPCSTelnet(host="127.0.0.1") + self.assertEqual(instance.device_type, "gns3_vpcs_telnet") + + def test_device_type_set(self): + """Test that device_type is set to gns3_vpcs_telnet.""" + # Mock the __init__ to set device_type + def mock_init(self, *args, **kwargs): + # Simulate what VPCSTelnet.__init__ does + kwargs.setdefault("device_type", "gns3_vpcs_telnet") + self.device_type = kwargs.get("device_type") + + with patch.object( + self.VPCSTelnet, "__init__", mock_init + ): + instance = self.VPCSTelnet(host="127.0.0.1") + self.assertEqual(instance.device_type, "gns3_vpcs_telnet") + + +class TestVPCSTelnetMethods(unittest.TestCase): + """Test VPCSTelnet method implementations.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 + VPCSTelnet, + ) + cls.VPCSTelnet = VPCSTelnet + + def test_check_config_mode_always_false(self): + """Test that check_config_mode always returns False.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + result = instance.check_config_mode() + self.assertFalse(result) + + def test_config_mode_returns_empty(self): + """Test that config_mode returns empty string.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + result = instance.config_mode() + self.assertEqual(result, "") + + def test_exit_config_mode_returns_empty(self): + """Test that exit_config_mode returns empty string.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + result = instance.exit_config_mode() + self.assertEqual(result, "") + + def test_disable_paging_returns_empty(self): + """Test that disable_paging returns empty string.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + result = instance.disable_paging() + self.assertEqual(result, "") + + def test_session_preparation_logs_debug(self): + """Test that session_preparation completes without errors.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + # Should not raise any exception + instance.session_preparation() + + +class TestVPCSTelnetSendCommand(unittest.TestCase): + """Test VPCSTelnet send_command method.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 + VPCSTelnet, + ) + cls.VPCSTelnet = VPCSTelnet + + def test_send_command_writes_bytes(self): + """Test that send_command writes command as bytes.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + + # Mock the necessary attributes + instance.remote_conn = MagicMock() + instance.remote_conn.write = MagicMock() + + # Test command encoding + command_string = "ip 192.168.1.1 255.255.255.0" + expected_bytes = command_string.encode("ascii") + b"\n" + + # Call send_command (will fail at read_until_pattern, but we can + # check the write call) + try: + instance.send_command(command_string) + except Exception: + pass + + # Verify write was called with correct bytes + instance.remote_conn.write.assert_called_once_with(expected_bytes) + + def test_send_command_default_expect_string(self): + """Test that send_command uses VPCS prompt as default expect_string.""" + import inspect + + # Check that expect_string defaults to r"PC\d+>" + source = inspect.getsource(self.VPCSTelnet.send_command) + self.assertIn("expect_string = r\"PC\\d+>\"", source) + + def test_send_command_timing_calls_send_command(self): + """Test that send_command_timing delegates to send_command.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + + # Mock send_command + instance.send_command = Mock(return_value="test output") + + # Call send_command_timing + result = instance.send_command_timing( + "ip 192.168.1.1 255.255.255.0", + strip_prompt=True, + strip_command=True, + ) + + # Verify send_command was called + instance.send_command.assert_called_once_with( + command_string="ip 192.168.1.1 255.255.255.0", + strip_prompt=True, + strip_command=True, + ) + self.assertEqual(result, "test output") + + +class TestVPCSTelnetTelnetLogin(unittest.TestCase): + """Test VPCSTelnet telnet_login method.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 + VPCSTelnet, + ) + cls.VPCSTelnet = VPCSTelnet + + def test_telnet_login_default_prompt_pattern(self): + """Test that telnet_login has correct default prompt pattern.""" + import inspect + + # Check default prompt terminator + source = inspect.getsource(self.VPCSTelnet.telnet_login) + self.assertIn('pri_prompt_terminator: str = r"PC\\d+>"', source) + + def test_telnet_login_sends_newlines(self): + """Test that telnet_login sends 4 newlines.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + + # Mock necessary attributes + instance.remote_conn = MagicMock() + instance.remote_conn.write = MagicMock() + instance.read_channel = MagicMock(return_value="PC1>") + instance.select_delay_factor = Mock(return_value=1.0) + instance.read_until_pattern = MagicMock(return_value="PC1>") + + # Call telnet_login + instance.telnet_login() + + # Verify write was called 4 times (4 newlines) + self.assertEqual(instance.remote_conn.write.call_count, 4) + + # Verify each call was with b"\n" + for call in instance.remote_conn.write.call_args_list: + self.assertEqual(call[0][0], b"\n") + + def test_telnet_login_waits_for_prompt(self): + """Test that telnet_login waits for VPCS prompt.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + + # Mock necessary attributes + instance.remote_conn = MagicMock() + instance.remote_conn.write = MagicMock() + instance.read_channel = MagicMock(return_value="PC1>") + instance.select_delay_factor = Mock(return_value=1.0) + instance.read_until_pattern = MagicMock(return_value="PC1>") + + # Call telnet_login + result = instance.telnet_login() + + # Verify read_until_pattern was called + instance.read_until_pattern.assert_called_once() + + +class TestVPCSTelnetRegistration(unittest.TestCase): + """Test VPCSTelnet registration function.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko import ( + vpcs_telnet, + ) + cls.vpcs_telnet = vpcs_telnet + + def test_register_function_exists(self): + """Test that register_custom_device_type function exists.""" + self.assertTrue( + hasattr(self.vpcs_telnet, "register_custom_device_type") + ) + + def test_register_function_is_callable(self): + """Test that register_custom_device_type is callable.""" + self.assertTrue( + callable(self.vpcs_telnet.register_custom_device_type) + ) + + +class TestVPCSTelnetAnsiStripping(unittest.TestCase): + """Test VPCSTelnet ANSI escape code stripping.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501 + VPCSTelnet, + ) + cls.VPCSTelnet = VPCSTelnet + + def test_strip_ansi_codes_method_exists(self): + """Test that _strip_ansi_codes method exists.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + self.assertTrue(hasattr(instance, "_strip_ansi_codes")) + self.assertTrue(callable(instance._strip_ansi_codes)) + + def test_strip_bold_codes(self): + """Test stripping bold ANSI codes.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + text = "\x1b[1mip\x1b[0m \x1b[4mARG\x1b[0m" + result = instance._strip_ansi_codes(text) + self.assertEqual(result, "ip ARG") + + def test_strip_underline_codes(self): + """Test stripping underline ANSI codes.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + text = "\x1b[4maddress\x1b[0m [\x1b[4mmask\x1b[0m]" + result = instance._strip_ansi_codes(text) + self.assertEqual(result, "address [mask]") + + def test_strip_reset_codes(self): + """Test stripping reset ANSI codes.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + text = "\x1b[1mNAME\x1b[0m : PC2[1]" + result = instance._strip_ansi_codes(text) + # Note: [1] at the end is not a valid ANSI code, so it should remain + self.assertEqual(result, "NAME : PC2[1]") + + def test_strip_color_codes(self): + """Test stripping color ANSI codes.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + text = "\x1b[31mRed\x1b[32mGreen\x1b[0mNormal" + result = instance._strip_ansi_codes(text) + self.assertEqual(result, "RedGreenNormal") + + def test_strip_complex_vpcs_output(self): + """Test stripping ANSI codes from real VPCS output.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + # Sample VPCS output with ANSI codes (with proper ESC characters) + text = """\x1b[1mip\x1b[0m \x1b[4mARG\x1b[0m ... [[\x1b[4mOPTION\x1b[0m] + Configure the current VPC's IP settings + \x1b[1mNAME\x1b[0m : PC2[1] +IP/MASK : 192.168.1.20/24""" + result = instance._strip_ansi_codes(text) + # Verify ANSI codes are removed + self.assertNotIn("\x1b[1m", result) + self.assertNotIn("\x1b[0m", result) + self.assertNotIn("\x1b[4m", result) + self.assertIn("ip ARG", result) + self.assertIn("NAME", result) + self.assertIn("PC2[1]", result) # [1] is not a valid ANSI escape + + def test_strip_empty_string(self): + """Test stripping ANSI codes from empty string.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + result = instance._strip_ansi_codes("") + self.assertEqual(result, "") + + def test_strip_no_ansi_codes(self): + """Test that text without ANSI codes is unchanged.""" + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + text = "NAME : PC2\nIP/MASK : 192.168.1.20/24" + result = instance._strip_ansi_codes(text) + self.assertEqual(result, text) + + def test_send_command_calls_strip_ansi(self): + """Test that send_command calls _strip_ansi_codes.""" + from unittest.mock import patch + + instance = self.VPCSTelnet.__new__(self.VPCSTelnet) + + # Mock necessary attributes + instance.remote_conn = MagicMock() + instance.remote_conn.write = MagicMock() + instance.read_until_pattern = MagicMock( + return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>" + ) + + # Mock _strip_ansi_codes to verify it's called + instance._strip_ansi_codes = Mock( + return_value="NAME : PC2\nPC2>" + ) + + # Call send_command + instance.send_command("show ip") + + # Verify _strip_ansi_codes was called + instance._strip_ansi_codes.assert_called_once() + call_arg = instance._strip_ansi_codes.call_args[0][0] + self.assertIn("\x1b[1m", call_arg) # Verify it received raw output + + +def run_tests(): + """Run all tests and print results.""" + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add test cases + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetDriver)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetInit)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetMethods)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetSendCommand)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetTelnetLogin)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetRegistration)) + suite.addTests(loader.loadTestsFromTestCase(TestVPCSTelnetAnsiStripping)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Print summary + print("\n" + "=" * 100) + print("Test Summary:") + print(f" Run: {result.testsRun}") + success_count = ( + result.testsRun - len(result.failures) - len(result.errors) + ) + print(f" Success: {success_count}") + print(f" Failed: {len(result.failures)}") + print(f" Errors: {len(result.errors)}") + print("=" * 100) + + return result.wasSuccessful() + + +if __name__ == "__main__": + success = run_tests() + sys.exit(0 if success else 1) diff --git a/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py new file mode 100644 index 000000000..348e123e1 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/custom_netmiko/vpcs_telnet.py @@ -0,0 +1,438 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +# mypy: ignore-errors + +""" +Custom Netmiko device driver for VPCS (Virtual PC Simulator) in GNS3. + +VPCS is a lightweight virtual PC simulator used in GNS3 lab environments +for testing network connectivity and basic IP configuration. + +Key Features: +- No authentication required (direct console access) +- Simple command-line interface (PC1>, PC2>, etc.) +- Supports basic PC commands (ip, ping, arp, version, etc.) +- Telnet-based connection + +Device Type: gns3_vpcs_telnet +""" + +import importlib +import logging +import re +import time + +from netmiko.base_connection import BaseConnection + + +logger = logging.getLogger(__name__) + +# ANSI escape code pattern for stripping terminal formatting codes +# Matches sequences like: [1m (bold), [0m (reset), [4m (underline), etc. +ANSI_ESCAPE_PATTERN = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +class VPCSTelnet(BaseConnection): + """ + Custom VPCS device driver for GNS3 emulation. + + VPCS devices are lightweight virtual PCs that simulate basic network + functionality for lab testing. This driver handles the unique + characteristics of VPCS: + - No authentication (no username/password) + - Simple prompt pattern (PC1>, PC2>, etc.) + - No configuration modes + """ + + def __init__( + self, + *args, + **kwargs, + ) -> None: + """Initialize VPCS Telnet connection.""" + # Set device type to identify this as a VPCS device + kwargs.setdefault("device_type", "gns3_vpcs_telnet") + + # VPCS uses carriage return + line feed for command termination + # This is critical for VPCS devices to respond to commands + default_enter = kwargs.get("default_enter") + if default_enter is None: + kwargs["default_enter"] = "\r\n" + + kwargs.setdefault("global_delay_factor", 1.0) + kwargs.setdefault("fast_cli", False) + + super().__init__(*args, **kwargs) + + def telnet_login( + self, + pri_prompt_terminator: str = r"PC\d+>", + alt_prompt_terminator: str = r"", + username_pattern: str = r"(?:user:|username|login|user name)", + pwd_pattern: str = r"assword", + delay_factor: float = 1.0, + max_loops: int = 20, + ) -> str: + """ + Telnet login for VPCS devices (no authentication). + + VPCS devices connect directly to command line without username/password. + This method initializes the connection and waits for the VPCS prompt. + + Strategy (matching telnetlib3 implementation): + 1. Send 4 newlines with 0.5s delay between each + 2. Wait for VPCS prompt pattern (PC1>, PC2>, etc.) + 3. Return once prompt is detected + + Args: + pri_prompt_terminator: Primary prompt pattern (default: r"PC\\d+>") + alt_prompt_terminator: Not used for VPCS + username_pattern: Not used (kept for signature compatibility) + pwd_pattern: Not used (kept for signature compatibility) + delay_factor: Delay factor for timing + max_loops: Maximum wait loops + + Returns: + Output from the connection process + """ + delay_factor = self.select_delay_factor(delay_factor) + return_msg = "" + + # Step 1: Clear buffer - read any existing data + try: + initial_data = self.read_channel() + if initial_data: + return_msg += initial_data + except Exception: + # Ignore errors during initial read + pass + + # Step 2: Send 4 newlines with delays (matching telnetlib3 exactly) + # tn.write(b"\n"); sleep(0.5) - repeated 4 times + for i in range(4): + try: + # Send newline directly as bytes + self.remote_conn.write(b"\n") + time.sleep(0.5 * delay_factor) + + # Read response + new_output = self.read_channel() + if new_output: + return_msg += new_output + + except Exception as e: + logger.debug("Error during VPCS connection initialization: %s", e) + + # Step 3: Wait for VPCS prompt pattern + try: + # Read until we see the prompt + output = self.read_until_pattern( + pattern=pri_prompt_terminator, + read_timeout=10 + ) + return_msg += output + + if re.search(pri_prompt_terminator, return_msg, flags=re.M): + logger.info("VPCS prompt detected") + return return_msg + + except Exception as e: + logger.debug("Error waiting for VPCS prompt: %s", e) + + # Step 4: Return what we have (connection might still work) + logger.debug("VPCS prompt not clearly detected, returning current output") + return return_msg + + def session_preparation(self) -> None: + """ + Prepare the session after connection is established. + + VPCS doesn't require special session preparation. + The telnet_login method already handles initialization with 4 newlines. + """ + # No additional preparation needed + logger.debug("VPCS session preparation completed") + + def send_command( + self, + command_string: str, + expect_string: str | None = None, + read_timeout: float | None = None, + delay_factor: float | None = None, + max_loops: int | None = None, + strip_prompt: bool = False, + strip_command: bool = False, + normalize: bool = False, + use_textfsm: bool = False, + ) -> str: + """ + Send a command to VPCS and return the output. + + This method exactly matches the telnetlib3 implementation: + 1. Encode command as ASCII and append newline + 2. Write command+newline as single byte sequence + 3. Sleep for 5 seconds + 4. Wait for prompt pattern and capture output + 5. Return captured output + + Args: + command_string: The command to send + expect_string: Pattern to expect (default: VPCS prompt r"PC\\d+>") + read_timeout: Timeout for reading output + delay_factor: Not used (kept for signature compatibility) + max_loops: Not used (kept for signature compatibility) + strip_prompt: Remove prompt from output (default: False for debugging) + strip_command: Remove command from output (default: False for debugging) + normalize: Not used for VPCS + use_textfsm: Not used for VPCS + + Returns: + Command output from VPCS + """ + # Use VPCS prompt as default expect_string + if expect_string is None: + expect_string = r"PC\d+>" + + # CRITICAL: Match telnetlib3 behavior exactly + # tn.write(command.encode(encoding="ascii") + b"\n") + cmd_bytes = command_string.encode(encoding="ascii") + b"\n" + self.remote_conn.write(cmd_bytes) + + logger.debug("VPCS: Sent command '%s' (%d bytes)", command_string, len(cmd_bytes)) + + # Sleep for 5 seconds (matching telnetlib3: sleep(5)) + time.sleep(5) + + # Wait for prompt pattern and capture output + # This matches telnetlib3: tn.expect([rb"PC\d+>"]) + tn.read_very_eager() + # read_until_pattern returns the output including the matched pattern + try: + output = self.read_until_pattern(pattern=expect_string, read_timeout=10) + logger.debug("VPCS: read_until_pattern returned %d chars", len(output)) + except Exception as e: + # If pattern not found, try to read whatever is available + logger.debug("VPCS: Pattern not found, reading available output: %s", e) + output = self.read_channel() + logger.debug("VPCS: read_channel returned %d chars", len(output) if output else 0) + + if isinstance(output, bytes): + output = output.decode("utf-8", errors="replace") + + logger.debug("VPCS: Raw output before strip: '%s'", output) + + # Strip ANSI escape codes from VPCS output + # VPCS uses terminal formatting codes (bold, underline, etc.) + # that need to be removed for clean output parsing + output = self._strip_ansi_codes(output) + + # Strip command and prompt if requested (currently disabled for debugging) + if strip_command: + output = self.strip_command_packets(command_string, output) + if strip_prompt: + output = self.strip_prompt(output) + + logger.debug("VPCS: Final output after strip: '%s'", output) + + return output + + def send_command_timing( + self, + command_string: str, + delay_factor: float | None = None, + max_loops: int | None = None, + strip_prompt: bool = True, + strip_command: bool = True, + normalize: bool = False, + ) -> str: + """ + Send command using timing-based delay. + + For VPCS, timing-based and pattern-based methods are identical, + both use the telnetlib3 implementation pattern with fixed 5s delay. + + Args: + command_string: The command to send + delay_factor: Not used (fixed 5s delay for VPCS) + max_loops: Not used (kept for signature compatibility) + strip_prompt: Remove prompt from output + strip_command: Remove command from output + normalize: Not used for VPCS + + Returns: + Command output from VPCS + """ + # For VPCS, timing and pattern-based methods are identical + return self.send_command( + command_string=command_string, + strip_prompt=strip_prompt, + strip_command=strip_command, + ) + + def check_config_mode( + self, check_string: str = "", pattern: str = "", force_regex: bool = False + ) -> bool: + """ + VPCS has no configuration mode. + + This method always returns False for VPCS devices. + + Returns: + False (VPCS has no config mode) + """ + return False + + def _strip_ansi_codes(self, text: str) -> str: + """ + Strip ANSI escape codes from VPCS output. + + VPCS uses terminal formatting codes (bold, underline, colors, etc.) + that need to be removed for clean output parsing. This method removes + all ANSI escape sequences from the given text. + + Examples of codes removed: + - [1m - Bold text + - [0m - Reset formatting + - [4m - Underline text + - [30-47m - Color codes + + Args: + text: Text potentially containing ANSI escape codes + + Returns: + Text with all ANSI escape codes removed + """ + return ANSI_ESCAPE_PATTERN.sub("", text) + + def config_mode( + self, config_command: str = "", pattern: str = "", re_flags: int = 0 + ) -> str: + """ + VPCS has no configuration mode. + + This method returns an empty string for VPCS devices. + + Returns: + Empty string (no config mode to enter) + """ + return "" + + def exit_config_mode(self, exit_config: str = "", pattern: str = "") -> str: + """ + VPCS has no configuration mode. + + This method returns an empty string for VPCS devices. + + Returns: + Empty string (no config mode to exit) + """ + return "" + + def disable_paging( + self, + command: str = "", + delay_factor: float | None = None, + **kwargs, + ) -> str: + """ + VPCS doesn't use paging. + + This method returns an empty string for VPCS devices. + + Returns: + Empty string (no paging to disable) + """ + return "" + + +# Register the custom device type with Netmiko +_registered = False # Flag to prevent duplicate registration + +def register_custom_device_type() -> None: + """ + Register the custom VPCS Telnet device type with Netmiko. + + This function adds 'gns3_vpcs_telnet' to Netmiko's CLASS_MAPPER + and updates the platforms lists. + + IMPORTANT: This function should be called BEFORE using the VPCS device + type with Netmiko. + + Note: This function is idempotent - multiple calls will only register once. + """ + global _registered + + # Prevent duplicate registration + if _registered: + logger.debug("VPCS Telnet device type already registered, skipping") + return + + # Use importlib to avoid namespace conflicts + sd = importlib.import_module("netmiko.ssh_dispatcher") + + # Register the device type in both mappers + # CLASS_MAPPER_BASE is used for base class definitions + sd.CLASS_MAPPER_BASE["gns3_vpcs_telnet"] = VPCSTelnet + + # CLASS_MAPPER is used by ConnectHandler for device type validation + sd.CLASS_MAPPER["gns3_vpcs_telnet"] = VPCSTelnet + + # CRITICAL: Update the static platforms lists + # These lists are computed at module import time and won't + # automatically update when CLASS_MAPPER is modified. + # We need to manually rebuild them. + + # Recalculate platforms list + sd.platforms = list(sd.CLASS_MAPPER.keys()) + sd.platforms.sort() + + # Recalculate platforms_base list + sd.platforms_base = list(sd.CLASS_MAPPER_BASE.keys()) + sd.platforms_base.sort() + + # Recalculate telnet_platforms list + sd.telnet_platforms = [x for x in sd.platforms if "telnet" in x] + + # Rebuild the platform strings used in error messages + sd.platforms_str = "\n" + "\n".join(sd.platforms_base) + sd.telnet_platforms_str = "\n" + "\n".join(sd.telnet_platforms) + + # Mark as registered + _registered = True + + logger.info("Successfully registered VPCS Telnet device type with Netmiko") + + +# Auto-register on import +# This ensures the device type is available when the module is imported +try: + register_custom_device_type() +except Exception as e: + # Log but don't fail on import + logger.warning( + "Failed to register VPCS device type: %s", + e, + exc_info=True + ) diff --git a/gns3server/agent/gns3_copilot/utils/error_handler.py b/gns3server/agent/gns3_copilot/utils/error_handler.py new file mode 100644 index 000000000..a919d0a41 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/error_handler.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Error Handler for GNS3-Copilot Agent + +This module provides utilities for formatting and handling errors from +the LLM API client layer (LangChain), particularly HTTP errors that +may return HTML or other unhelpful content. +""" + +import logging +import re + +logger = logging.getLogger(__name__) + +# Patterns to detect HTML in error messages +HTML_PATTERNS = [ + r"", + r" str: + """ + Format an error message for user display, cleaning up HTML and + extracting meaningful error information. + + Args: + error: The exception to format + + Returns: + A formatted, user-friendly error message + """ + error_str = str(error) + + # Check if error message contains HTML + if _contains_html(error_str): + logger.warning( + "Detected HTML in error message, likely due to incorrect API " + "configuration or base URL" + ) + return ( + "API request failed. The error response indicates an issue with " + "your API configuration. Please check:\n" + "1. Base URL is correct (e.g., https://api.openai.com/v1 for OpenAI)\n" + "2. API key is valid\n" + "3. Provider is correctly configured" + ) + + # For other errors, return the original message + return error_str + + +def _contains_html(text: str) -> bool: + """Check if text contains HTML tags.""" + text_lower = text.lower() + for pattern in HTML_PATTERNS: + if re.search(pattern, text_lower, re.IGNORECASE): + return True + return False diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py new file mode 100644 index 000000000..3ac1bc992 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +Public module for getting device port information from GNS3 topology +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def get_device_ports_from_topology( + device_names: list[str], + project_id: str | None = None, +) -> dict[str, dict[str, Any]]: + """ + Get device connection information from GNS3 topology + + Args: + device_names: List of device names to look up + project_id: UUID of the specific GNS3 project to retrieve topology from + + Returns: + Dictionary mapping device names to their connection data: + { + "device_name": { + "port": console_port, + "platform": "huawei", # Extracted from tags + "groups": ["network_devices"], # For inheriting shared settings + "connection_options": { + "netmiko": { + "extras": {"device_type": "huawei_telnet"} # Extracted from tags + } + } + } + } + Devices that don't exist or missing console_port will not be included + """ + # Log received parameters + logger.info( + "Called with device_names=%s, project_id=%s", device_names, project_id + ) + + try: + # Lazy import to avoid circular dependency + from gns3server.agent.gns3_copilot.gns3_client import GNS3TopologyTool + + # Get topology information + topo = GNS3TopologyTool() + topology = topo._run(project_id=project_id) + + # Dynamically build hosts_data from topology + hosts_data: dict[str, dict[str, Any]] = {} + + if not topology: + logger.warning("Unable to get topology information") + return hosts_data + + for device_name in device_names: + # Check if device exists in topology + if device_name not in topology.get("nodes", {}): + logger.warning( + "Device '%s' not found in topology", device_name + ) + continue + + node_info = topology["nodes"][device_name] + if "console_port" not in node_info: + logger.warning("Device '%s' missing console_port", device_name) + continue + + # Extract device_type and platform from tags + device_type = None + platform = None + tags = node_info.get("tags", []) + + for tag in tags: + if tag.startswith("device_type:"): + device_type = tag.split(":", 1)[1].strip() + elif tag.startswith("platform:"): + platform = tag.split(":", 1)[1].strip() + + # Return error if device_type not found in tags + # Using a default would cause command execution errors + if device_type is None: + tested_device_types = ( + "cisco_ios_telnet (Netmiko built-in), gns3_huawei_telnet_ce (custom Huawei), " + "gns3_ruijie_telnet (custom Ruijie)" + ) + error_msg = ( + f"Device '{device_name}': device_type tag not found. " + f"Please add 'device_type:' tag to this device in GNS3. " + f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:'. " + f"Tested types: {tested_device_types}. " + f"Current tags: {tags}" + ) + logger.error(error_msg) + hosts_data[device_name] = { + "error": error_msg + } + continue + + logger.debug( + "Device '%s': extracted device_type=%s from tags", + device_name, + device_type, + ) + + if platform is None: + platform = "cisco_ios" + logger.debug( + "Device '%s': platform not found in tags, using default: cisco_ios", + device_name, + ) + else: + logger.debug( + "Device '%s': extracted platform=%s from tags", + device_name, + platform, + ) + + # Add device to hosts_data with connection_options at host level + # This is the Nornir best practice - each host has its own + # connection configuration (device_type), while sharing common + # settings (hostname, timeout) via group inheritance. + hosts_data[device_name] = { + "port": node_info["console_port"], + "platform": platform, + "groups": ["network_devices"], # For inheriting hostname, timeout, etc. + "connection_options": { + "netmiko": { + "extras": {"device_type": device_type} + } + }, + } + + logger.info("Returning %d device port mappings", len(hosts_data)) + + return hosts_data + + except Exception as e: + logger.error("Error getting device port information: %s", e) + return {} diff --git a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py new file mode 100644 index 000000000..8dc21a516 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py @@ -0,0 +1,475 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +Drawing utility functions for GNS3 area annotations. + +Calculates drawing parameters and generates SVG content for network area annotations. +Supports ellipse and rectangle shapes for two-node annotations. +""" + +import math +from typing import Any +from typing import Literal + +# Default parameters +DEFAULT_DEVICE_WIDTH = 50 +DEFAULT_DEVICE_HEIGHT = 50 +DEFAULT_FONT_SIZE = 14 +DEFAULT_SHAPE_Z = 0 +DEFAULT_TEXT_Z = 1 + +# Z-order thresholds (pixels) +Z_ORDER_LARGE_AREA_THRESHOLD = 500 # z=1 for large areas +Z_ORDER_MEDIUM_AREA_THRESHOLD = 300 # z=2 for medium areas + +# Color schemes (business professional style) +# Text uses 'stroke', shape uses 'fill' with 'fill-opacity' +COLOR_SCHEMES = { + # Core/Backbone - biz_blue + "CORE_BACKBONE": { + "stroke": "#FFFFFF", # White (for text on dark background) + "fill": "#2980B9", # Blue background + "fill_opacity": 0.8, # Transparency level + }, + # Normal Areas - biz_blue_light + "NORMAL_AREA": { + "stroke": "#000000", # Black (for text on light background) + "fill": "#5AA9DD", # Light blue background + "fill_opacity": 0.8, # Transparency level + }, + # Logical Isolation - biz_purple + "ISOLATION": { + "stroke": "#FFFFFF", # White (for text on dark background) + "fill": "#9B59B6", # Purple background + "fill_opacity": 0.8, # Transparency level + }, + # Management/Infrastructure - biz_orange + "MANAGEMENT_INFRA": { + "stroke": "#FFFFFF", # White (for text on medium-dark background) + "fill": "#E67E22", # Orange background + "fill_opacity": 0.8, # Transparency level + }, + # Redundancy/High Availability - biz_orange_bright + "HIGH_AVAILABILITY": { + "stroke": "#000000", # Black (for text on bright background) + "fill": "#F39C12", # Bright orange background + "fill_opacity": 0.8, # Transparency level + }, + # External/Boundary - biz_red_bright + "EXTERNAL": { + "stroke": "#FFFFFF", # White (for text on dark background) + "fill": "#E74C3C", # Bright red background + "fill_opacity": 0.8, # Transparency level + }, + # Security/Trusted - biz_green_bright + "SECURITY_TRUSTED": { + "stroke": "#FFFFFF", # White (for text on medium-dark background) + "fill": "#2ECC71", # Bright green background + "fill_opacity": 0.8, # Transparency level + }, + # Cloud/Tunnel - biz_cyan_dark + "CLOUD_TUNNEL": { + "stroke": "#FFFFFF", # White (for text on dark background) + "fill": "#00CED1", # Dark cyan background + "fill_opacity": 0.8, # Transparency level + }, + # Default - biz_gray + "DEFAULT": { + "stroke": "#FFFFFF", # White (for text on medium-dark background) + "fill": "#A5CC78", # Gray background + "fill_opacity": 0.8, # Transparency level + }, +} + + +def calculate_two_node_shape( + node1: dict, + node2: dict, + area_name: str, + shape_type: Literal["ellipse", "rectangle"] = "ellipse", + text_offset_ratio: float = 0.0, +) -> dict[str, Any]: + """ + Calculate shape annotation parameters for two nodes. + + Args: + node1: First node with 'x', 'y', 'height', 'width' (top-left) + node2: Second node with 'x', 'y', 'height', 'width' (top-left) + area_name: Name of area (e.g., "Area 0", "AS 100") + shape_type: "ellipse" or "rectangle" (default: "ellipse") + text_offset_ratio: Ratio to offset text (0=center, positive=offset) + + Returns: + Dict with shape, text SVG params, and metadata + """ + node1_width = node1.get("width", DEFAULT_DEVICE_WIDTH) + node1_height = node1.get("height", DEFAULT_DEVICE_HEIGHT) + node2_width = node2.get("width", DEFAULT_DEVICE_WIDTH) + node2_height = node2.get("height", DEFAULT_DEVICE_HEIGHT) + + node1_center_x = node1["x"] + (node1_width / 2) + node1_center_y = node1["y"] + (node1_height / 2) + node2_center_x = node2["x"] + (node2_width / 2) + node2_center_y = node2["y"] + (node2_height / 2) + + distance = math.sqrt( + (node2_center_x - node1_center_x) ** 2 + + (node2_center_y - node1_center_y) ** 2 + ) + + angle_rad = math.atan2( + node2_center_y - node1_center_y, node2_center_x - node1_center_x + ) + angle_deg = round(math.degrees(angle_rad)) + angle_rad = math.radians(angle_deg) + + center_x = (node1_center_x + node2_center_x) / 2 + center_y = (node1_center_y + node2_center_y) / 2 + + color_scheme = _get_color_scheme(area_name) + text_svg = generate_text_svg(area_name, color_scheme) + + text_svg_width = len(area_name) * 8 + 20 + text_svg_height = DEFAULT_FONT_SIZE + 16 + + if shape_type == "ellipse": + rx = distance / 2 + ry = math.sqrt((node1_width / 2) ** 2 + (node1_height / 2) ** 2) + + shape_width = rx * 2 + shape_height = ry * 2 + + svg_x = center_x - ( + rx * math.cos(angle_rad) - ry * math.sin(angle_rad) + ) + svg_y = center_y - ( + rx * math.sin(angle_rad) + ry * math.cos(angle_rad) + ) + + shape_svg = generate_ellipse_svg( + int(rx), int(ry), color_scheme, int(shape_width), int(shape_height) + ) + + offset_distance = ry * text_offset_ratio + + metadata = { + "center_x": center_x, + "center_y": center_y, + "distance": distance, + "shape_width": shape_width, + "shape_height": shape_height, + "angle_deg": angle_deg, + "rx": rx, + "ry": ry, + } + + else: # rectangle + shape_width = distance + shape_height = max( + node1_width, node1_height, node2_width, node2_height + ) + + svg_x = center_x - ( + (shape_width / 2) * math.cos(angle_rad) + - (shape_height / 2) * math.sin(angle_rad) + ) + svg_y = center_y - ( + (shape_width / 2) * math.sin(angle_rad) + + (shape_height / 2) * math.cos(angle_rad) + ) + + shape_svg = generate_rectangle_svg( + int(shape_width), int(shape_height), color_scheme + ) + + offset_distance = (shape_height / 2) * text_offset_ratio + + metadata = { + "center_x": center_x, + "center_y": center_y, + "distance": distance, + "shape_width": shape_width, + "shape_height": shape_height, + "angle_deg": angle_deg, + "rx": None, + "ry": None, + } + + if text_offset_ratio != 0: + perpendicular_x = -math.sin(angle_rad) + perpendicular_y = math.cos(angle_rad) + + text_offset_x = perpendicular_x * offset_distance + text_offset_y = perpendicular_y * offset_distance + + text_x = int(center_x + text_offset_x - text_svg_width / 2) + text_y = int(center_y + text_offset_y - text_svg_height / 2) + else: + text_x = int(center_x - text_svg_width / 2) + text_y = int(center_y - text_svg_height / 2) + + return { + "shape": { + "svg": shape_svg, + "x": int(svg_x), + "y": int(svg_y), + "z": DEFAULT_SHAPE_Z, + "rotation": int(angle_deg), + "type": shape_type, + }, + "text": { + "svg": text_svg, + "x": text_x, + "y": text_y, + "z": DEFAULT_TEXT_Z, + "rotation": 0, + }, + "metadata": metadata, + } + + +def generate_ellipse_svg( + rx: int, + ry: int, + color_scheme: dict[str, Any], + svg_width: int, + svg_height: int, +) -> str: + """Generate SVG for ellipse.""" + return f"""""" # noqa: E501 + + +def generate_rectangle_svg( + width: int, + height: int, + color_scheme: dict[str, Any], +) -> str: + """Generate SVG for rectangle.""" + return f"""""" # noqa: E501 + + +def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str: + """Generate SVG for text label.""" + text_width = len(text) * 8 + 20 + text_height = DEFAULT_FONT_SIZE + 16 + + return f"""{text}""" # noqa: E501 + + +def _hsv_to_hex(h: int, s: int, v: int) -> str: + """Convert HSV to HEX color.""" + h_norm = (h % 360) / 360 + s_norm = s / 100 + v_norm = v / 100 + + c: float = v_norm * s_norm + x: float = c * (1 - abs((h_norm / 60) % 2 - 1)) + m: float = v_norm - c + + r: float + g: float + b: float + + if 0 <= h_norm < 60: + r, g, b = c, x, 0.0 + elif 60 <= h_norm < 120: + r, g, b = x, c, 0.0 + elif 120 <= h_norm < 180: + r, g, b = 0.0, c, x + elif 180 <= h_norm < 240: + r, g, b = 0.0, x, c + elif 240 <= h_norm < 300: + r, g, b = x, 0.0, c + else: + r, g, b = c, 0.0, x + + r = int((r + m) * 255) + g = int((g + m) * 255) + b = int((b + m) * 255) + + return f"#{r:02x}{g:02x}{b:02x}" + + +def calculate_z_order(area_size: float) -> int: + """ + Calculate z-order based on area size for proper layering. + + Args: + area_size: Size of area (width for rectangles, 2*rx for ellipses) + + Returns: + Z-order: 1=back (large), 2=middle, 3=front (small) + """ + if area_size >= Z_ORDER_LARGE_AREA_THRESHOLD: + return 1 + elif area_size >= Z_ORDER_MEDIUM_AREA_THRESHOLD: + return 2 + else: + return 3 + + +def _get_color_scheme(area_name: str) -> dict[str, Any]: + """ + Get color scheme based on area name using keyword inference. + + Maps labels to semantic groups for professional business styling. + """ + if area_name in COLOR_SCHEMES: + return COLOR_SCHEMES[area_name] + + label = area_name.upper() + + # 1. Routing Domain Core/Backbone + if "AREA 0" in label or "BACKBONE" in label or "CORE" in label: + return COLOR_SCHEMES["CORE_BACKBONE"] + + if "BGP" in label or "AS " in label: + return COLOR_SCHEMES["CORE_BACKBONE"] + + # 2. Routing Domain Normal Areas + if "AREA " in label or "LEVEL" in label: + return COLOR_SCHEMES["NORMAL_AREA"] + + # 3. Logical Isolation + if ( + "VRF" in label + or "VLAN" in label + or "MSTP" in label + or "VXLAN" in label + or "MPLS" in label + ): + return COLOR_SCHEMES["ISOLATION"] + + # 4. High Availability + if ( + "VRRP" in label + or "HSRP" in label + or "HA" in label + or "STACK" in label + or "M-LAG" in label + ): + return COLOR_SCHEMES["HIGH_AVAILABILITY"] + + # 5. External/Internet + if ( + "INET" in label + or "OUT" in label + or "EXTERNAL" in label + or "INTERNET" in label + or "DMZ" in label + ): + return COLOR_SCHEMES["EXTERNAL"] + + # 6. Management + if "MGMT" in label or "OOB" in label or "MANAGEMENT" in label: + return COLOR_SCHEMES["MANAGEMENT_INFRA"] + + # 7. Security/Trusted + if "SECURITY" in label or "TRUSTED" in label or "VPN" in label: + return COLOR_SCHEMES["SECURITY_TRUSTED"] + + # 8. Cloud/Tunnel + if ( + "TUNNEL" in label + or "CLOUD" in label + or "GRE" in label + or "IPSEC" in label + ): + return COLOR_SCHEMES["CLOUD_TUNNEL"] + + # Legacy keyword matching for backward compatibility + label_lower = area_name.lower() + protocol_keywords = [ + ("ospf", "NORMAL_AREA"), + ("is-is", "NORMAL_AREA"), + ("rip", "NORMAL_AREA"), + ("eigrp", "NORMAL_AREA"), + ("bgp", "CORE_BACKBONE"), + ("vxlan", "ISOLATION"), + ("mpls", "ISOLATION"), + ("vrf", "ISOLATION"), + ("vlan", "ISOLATION"), + ("vrrp", "HIGH_AVAILABILITY"), + ("hsrp", "HIGH_AVAILABILITY"), + ("gre", "CLOUD_TUNNEL"), + ("ipsec", "SECURITY_TRUSTED"), + ] + + for keyword, scheme_key in protocol_keywords: + if keyword in label_lower: + return COLOR_SCHEMES[scheme_key] + + return COLOR_SCHEMES["DEFAULT"] + + +def calculate_two_node_ellipse( + node1: dict, + node2: dict, + area_name: str, + text_offset_ratio: float = 0.0, +) -> dict[str, Any]: + """ + Calculate ellipse annotation parameters for two nodes. + + Wrapper around calculate_two_node_shape with shape_type="ellipse". + """ + result = calculate_two_node_shape( + node1, node2, area_name, "ellipse", text_offset_ratio + ) + return { + "ellipse": result["shape"], + "text": result["text"], + "metadata": result["metadata"], + } + + +def calculate_two_node_rectangle( + node1: dict, + node2: dict, + area_name: str, + text_offset_ratio: float = 0.0, +) -> dict[str, Any]: + """ + Calculate rectangle annotation parameters for two nodes. + + Wrapper around calculate_two_node_shape with shape_type="rectangle". + """ + result = calculate_two_node_shape( + node1, node2, area_name, "rectangle", text_offset_ratio + ) + metadata = result["metadata"] + return { + "rectangle": result["shape"], + "text": result["text"], + "metadata": { + "center_x": metadata["center_x"], + "center_y": metadata["center_y"], + "distance": metadata["distance"], + "rect_width": metadata["shape_width"], + "rect_height": metadata["shape_height"], + "angle_deg": metadata["angle_deg"], + }, + } diff --git a/gns3server/agent/gns3_copilot/utils/llm_config_helper.py b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py new file mode 100644 index 000000000..568a062cb --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/llm_config_helper.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +LLM Model Configuration Helper for GNS3 Copilot + +This module provides utility functions to retrieve LLM model configurations +with decrypted API keys by directly accessing the database. + +Usage: + from gns3server.agent.gns3_copilot.utils.llm_config_helper import ( + get_user_llm_config_with_app, + ) + + # Get user's default LLM config (with API key) + config = await get_user_llm_config_with_app(user_id, app) + if config: + provider = config['provider'] + api_key = config['api_key'] + model = config['model'] +""" + +import logging +from typing import Any +from typing import Dict +from typing import Optional +from uuid import UUID + +from fastapi import FastAPI + +logger = logging.getLogger(__name__) + + +async def get_user_llm_config_with_app( + user_id: UUID, app: FastAPI +) -> Optional[Dict[str, Any]]: + """ + Get user's default LLM model configuration with decrypted API key. + + This function directly accesses the database through the app reference, + bypassing API security restrictions to get the complete configuration including + decrypted API keys, even for inherited group configurations. + + Args: + user_id: User UUID + app: FastAPI application instance + + Returns: + Configuration dict with provider, api_key, model, etc., or None if not found + + Example: + config = await get_user_llm_config_with_app(user_id, app) + if config: + print(f"Provider: {config['provider']}") + print(f"Model: {config['model']}") + print(f"API Key: {config['api_key']}") + print(f"Source: {config['source']}") + """ + from gns3server.db.tasks import get_user_llm_config_full + + try: + user_id_str = str(user_id) + config = await get_user_llm_config_full(user_id_str, app) + + if config: + logger.info( + f"Successfully retrieved LLM config for user {user_id}: " + f"provider={config.get('provider')}, model={config.get('model')}" + ) + else: + logger.warning(f"No LLM configuration found for user {user_id}") + + return config + + except Exception as e: + logger.error( + f"Failed to retrieve LLM config for user {user_id}: {e}", + exc_info=True, + ) + return None diff --git a/gns3server/agent/gns3_copilot/utils/message_converters.py b/gns3server/agent/gns3_copilot/utils/message_converters.py new file mode 100644 index 000000000..8bf18f668 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/message_converters.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" + +Message format converters for OpenAI-compatible message format. +Converts between LangChain messages and OpenAI-compatible format. +""" + +import json +import uuid +from typing import Any +from typing import Dict + +from langchain_core.messages import AIMessage +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage +from langchain_core.messages import ToolMessage + + +def _ensure_string(content: Any) -> str: + """Ensure content is a string, converting dicts/lists to JSON if needed.""" + if isinstance(content, str): + return content + elif isinstance(content, (dict, list)): + return json.dumps(content, ensure_ascii=False, indent=2) + else: + return str(content) + + +def convert_langchain_to_openai(lc_message) -> Dict[str, Any]: + """ + Convert LangChain message to OpenAI-compatible format. + + Args: + lc_message: LangChain message (HumanMessage, AIMessage, SystemMessage, + ToolMessage) + + Returns: + Dictionary in OpenAI-compatible format + """ + # Generate message ID + msg_id = getattr(lc_message, "id", None) + if msg_id is None: + msg_id = str(uuid.uuid4()) + + # Get metadata from message (including created_at) + metadata = getattr(lc_message, "metadata", None) or {} + if not isinstance(metadata, dict): + metadata = {} + + # Base message structure (no top-level created_at, only metadata) + base_msg = {"id": msg_id, "metadata": metadata} + + # Convert based on message type + if isinstance(lc_message, HumanMessage): + return {**base_msg, "role": "user", "content": lc_message.content} + + elif isinstance(lc_message, AIMessage): + msg = {**base_msg, "role": "assistant", "content": lc_message.content} + + # Handle tool calls - convert to OpenAI format + if hasattr(lc_message, "tool_calls") and lc_message.tool_calls: + tool_calls = [] + for tc in lc_message.tool_calls: + # Convert to dict if it's an object + tc_dict = tc if isinstance(tc, dict) else tc.model_dump() + tool_calls.append( + { + "id": tc_dict.get("id", str(uuid.uuid4())), + "type": "function", + "function": { + "name": tc_dict.get("name", ""), + "arguments": tc_dict.get("args", {}), + }, + } + ) + msg["tool_calls"] = tool_calls + + return msg + + elif isinstance(lc_message, ToolMessage): + return { + **base_msg, + "role": "tool", + "content": _ensure_string(lc_message.content), + "name": getattr(lc_message, "name", ""), + "tool_call_id": getattr(lc_message, "tool_call_id", ""), + } + + elif isinstance(lc_message, SystemMessage): + return {**base_msg, "role": "system", "content": lc_message.content} + + else: + # Fallback for unknown message types + return {**base_msg, "role": "unknown", "content": str(lc_message)} + + +def convert_openai_to_langchain(msg: Dict[str, Any]): + """ + Convert OpenAI-compatible format to LangChain message. + + Args: + msg: Dictionary in OpenAI-compatible format + + Returns: + LangChain message + """ + role = msg.get("role", "user") + content = msg.get("content", "") + + if role == "user": + return HumanMessage(content=content, id=msg.get("id")) + + elif role == "assistant": + ai_msg = AIMessage(content=content, id=msg.get("id")) + + # Restore tool calls if present + if "tool_calls" in msg and msg["tool_calls"]: + tool_calls = [] + for tc in msg["tool_calls"]: + tool_calls.append( + { + "id": tc.get("id", str(uuid.uuid4())), + "name": tc.get("function", {}).get("name", ""), + "args": tc.get("function", {}).get("arguments", {}), + } + ) + ai_msg.tool_calls = tool_calls + + return ai_msg + + elif role == "tool": + return ToolMessage( + content=content, + name=msg.get("name", ""), + tool_call_id=msg.get("tool_call_id", ""), + ) + + elif role == "system": + return SystemMessage(content=content) + + else: + # Fallback to HumanMessage for unknown roles + return HumanMessage(content=content) + + +def convert_stream_event_to_openai(event: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert LangGraph streaming event to OpenAI-compatible format. + + Args: + event: LangGraph streaming event + + Returns: + Dictionary in OpenAI-compatible streaming response format + """ + event_type = event.get("event", "") + + if event_type == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk", {}) + content = getattr(chunk, "content", "") + + if content: + return { + "type": "content", + "content": content, + "message_id": event.get("metadata", {}).get("msg_id"), + } + + # Check for tool call chunks + if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: + for tc_chunk in chunk.tool_call_chunks: + tc_id = getattr(tc_chunk, "id", None) + tc_name = getattr(tc_chunk, "name", None) + tc_args = getattr(tc_chunk, "args", None) + + if tc_id: + return { + "type": "tool_call", + "tool_call": { + "id": tc_id, + "type": "function", + "function": { + "name": tc_name or "", + "arguments": tc_args or "", + }, + }, + } + + elif event_type == "on_tool_start": + return { + "type": "tool_start", + "tool_name": event.get("name", ""), + "metadata": event.get("metadata", {}), + } + + elif event_type == "on_tool_end": + tool_output = event.get("data", {}).get("output", "") + # Convert dict or list output to JSON string for serialization + if isinstance(tool_output, (dict, list)): + tool_output = json.dumps(tool_output, ensure_ascii=False, indent=2) + + return { + "type": "tool_end", + "tool_output": tool_output, + "tool_name": event.get("name", ""), + "metadata": event.get("metadata", {}), + } + + # Default empty response + return {"type": "unknown"} diff --git a/gns3server/agent/gns3_copilot/utils/parse_tool_content.py b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py new file mode 100644 index 000000000..92e60f216 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/parse_tool_content.py @@ -0,0 +1,573 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" +Public module for parsing tool execution results + +This module is specifically designed to parse and format results returned by tools_v2 +after tool execution, supporting multiple formats and providing unified error handling. +Mainly used for result display in UI interfaces. + +Supported formats: +- JSON strings +- Python literal strings +- Dictionary objects +- List objects (JSON arrays) +- Primitive types (int, float, bool, str) +- Error message strings +- Plain text output + +Standard Tool Response Format: + All tools should follow this standardized format for consistency: + + { + "success": bool, # Whether the overall operation succeeded + "total": int, # Total number of items processed + "successful": int, # Number of successful operations + "failed": int, # Number of failed operations + "data": list[dict], # Detailed results (one entry per item) + "error": str, # Global error message (if operation failed entirely) + "metadata": dict # Optional metadata (timestamp, execution_time, etc.) + } + + Single item format (for data array items): + { + "id": str, # Device/node/link ID + "name": str, # Human-readable name + "status": "success" | "failed", # Item status + "result": str, # Success result or output + "error": str # Error message (if failed) + } + +Author: Yue Guobin (岳国宾) +""" + +import ast +import json +import logging +from datetime import datetime +from typing import Any + +logger = logging.getLogger(__name__) + + +def parse_tool_content( + content: str | dict | list | int | float | bool | None, + fallback_to_raw: bool = True, + strict_mode: bool = False, +) -> dict[str, Any] | list[Any] | Any: + """ + Parse tool execution results into structured data, specifically for UI display. + + This function can handle various input types including strings, dictionaries, lists, + and primitive types. It ensures the returned data can be properly serialized + by json.dumps. + + Args: + content: Content returned by tools (can be str, dict, list, int, float, bool, + or None) + fallback_to_raw: Whether to return raw content when parsing fails, default True + strict_mode: Strict mode, raises exceptions when parsing fails, default False + + Returns: + Union[Dict[str, Any], List[Any], Any]: Parsed data that can be serialized by + json.dumps: + - Successfully parsed JSON/Python literal data + - Original dict/list objects (passed through) + - Primitive types (int, float, bool, str) + - {"raw": content} when unable to parse but fallback_to_raw=True + - {"error": "error_message"} when parsing fails and fallback_to_raw=False + - {} for None input + + Raises: + ValueError: When strict_mode=True and parsing fails + TypeError: When content type is unsupported and strict_mode=True + + Examples: + >>> parse_tool_content('{"status": "success", "data": [1, 2, 3]}') + {'status': 'success', 'data': [1, 2, 3]} + + >>> parse_tool_content({"status": "success"}) + {'status': 'success'} + + >>> parse_tool_content([1, 2, 3]) + [1, 2, 3] + + >>> parse_tool_content(42) + 42 + + >>> parse_tool_content("{'name': 'PC1', 'status': 'ok'}") + {'name': 'PC1', 'status': 'ok'} + + >>> parse_tool_content("Invalid JSON input: ...") + {'raw': 'Invalid JSON input: ...'} + + >>> parse_tool_content("{}") + {} + + >>> parse_tool_content(None) + {} + """ + # Log received input parameters + logger.info( + "Received parameters: fallback_to_raw=%s, strict_mode=%s, content=%s", + fallback_to_raw, + strict_mode, + content, + ) + + # Handle None input + if content is None: + result: dict[str, Any] = {} + logger.info("Content is None, returning: %s", result) + return result + + # Handle dictionary objects (already parsed) + if isinstance(content, dict): + logger.info("Content is already a dictionary, returning: %s", content) + return content + + # Handle list objects (JSON arrays) + if isinstance(content, list): + logger.info("Content is already a list, returning: %s", content) + return content + + # Handle primitive types that are JSON serializable + if isinstance(content, (str, int, float, bool)): + # For strings, we need to try parsing them + if isinstance(content, str): + # Empty string handling + if not content.strip(): + result = {} + logger.info( + "Content is empty or whitespace, returning: %s", result + ) + return result + + s = content.strip() + + # Handle empty dictionary case + if s == "{}": + result = {} + logger.info( + "Content is empty dictionary, returning: %s", result + ) + return result + + # Try to parse as Python literal + # (higher priority as many tools return Python format strings) + try: + result = ast.literal_eval(s) + logger.info( + "Successfully parsed as Python literal, returning: %s", + result, + ) + return result + except (ValueError, SyntaxError): + pass + + # Try to parse as JSON + try: + result = json.loads(s) + logger.info( + "Successfully parsed as JSON, returning: %s", result + ) + return result + except json.JSONDecodeError: + pass + + # Handle parsing failure for strings + error_msg = "Unable to parse content as JSON or Python literal" + logger.warning("%s: %s", error_msg, s) + + if strict_mode: + raise ValueError("%s. Content: %s", error_msg, s) + + if fallback_to_raw: + result = {"raw": s} + logger.info("Returning raw content as fallback: %s", result) + return result + result = {"error": error_msg} + logger.info("Returning error: %s", result) + return result + # For non-string primitives (int, float, bool), return as-is + logger.info( + "Content is a primitive type %s, returning: %s", + type(content).__name__, + content, + ) + return content + + # Handle unsupported types + error_msg = ( # type: ignore[unreachable] + "Content must be str, dict, list, int, float, bool, or None, got " + f"{type(content).__name__}" + ) + logger.error(error_msg) + + if strict_mode: + raise TypeError(error_msg) + + if fallback_to_raw: + result = {"raw": str(content)} + logger.info("Returning raw content as fallback: %s", result) + return result + result = {"error": error_msg} + logger.info("Returning error: %s", result) + return result + + +def format_tool_response( + content: str | dict | list | int | float | bool | None, indent: int = 2 +) -> str: + """ + Format tool response as a beautiful JSON string for UI display. + + This function ensures that the output is always a valid JSON string that can be + properly displayed in UI interfaces. + + Args: + content: Content returned by tools (can be str, dict, list, int, float, bool, + or None) + indent: JSON indentation spaces, default 2 + + Returns: + str: Formatted JSON string, always valid JSON + """ + logger.info("format_tool_response received input content: %s", content) + logger.info("format_tool_response parameter indent: %s", indent) + + try: + parsed = parse_tool_content( + content, fallback_to_raw=True, strict_mode=False + ) + # Ensure the result can be serialized to JSON + result = json.dumps(parsed, ensure_ascii=False, indent=indent) + logger.info("format_tool_response returning: %s", result) + return result + except (TypeError, ValueError) as e: + # If the parsed result cannot be serialized, convert to string and wrap + logger.error("Cannot serialize parsed result to JSON: %s", e) + try: + result = json.dumps( + {"raw": str(content)}, ensure_ascii=False, indent=indent + ) + logger.info("format_tool_response returning fallback: %s", result) + return result + except Exception: + # Last resort: return a simple error message + result = json.dumps( + {"error": "Unable to format response"}, + ensure_ascii=False, + indent=indent, + ) + logger.info("format_tool_response returning error: %s", result) + return result + except Exception as e: + logger.error("Error formatting tool response: %s", e) + result = json.dumps( + {"error": str(e)}, ensure_ascii=False, indent=indent + ) + logger.info("format_tool_response returning error: %s", result) + return result + + +def normalize_tool_response( + response: dict | list | str, tool_name: str = "unknown" +) -> dict: + """ + Normalize tool response to standard format for consistent frontend display. + + This function converts various tool response formats into a standardized structure + that frontend code can rely on. It handles both legacy formats and new formats, + ensuring backward compatibility. + + Args: + response: Raw tool response (dict, list, or string) + tool_name: Name of the tool (for error messages) + + Returns: + dict: Normalized response in standard format: + { + "success": bool, + "total": int, + "successful": int, + "failed": int, + "data": list[dict], + "error": str (optional), + "metadata": dict + } + + Examples: + >>> normalize_tool_response({"status": "success", "output": "OK"}) + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, + 'data': [{'status': 'success', 'result': 'OK'}], 'metadata': {}} + + >>> normalize_tool_response([{"device_name": "R1", "status": "success"}]) + {'success': True, 'total': 1, 'successful': 1, 'failed': 0, + 'data': [...], 'metadata': {}} + """ + metadata = { + "tool_name": tool_name, + "normalized_at": datetime.utcnow().isoformat(), + } + + # Handle error responses + if ( + isinstance(response, dict) + and "error" in response + and len(response) == 1 + ): + return { + "success": False, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "error": str(response["error"]), + "metadata": metadata, + } + + # Handle empty responses + if not response: + return { + "success": True, + "total": 0, + "successful": 0, + "failed": 0, + "data": [], + "metadata": metadata, + } + + # Handle list responses (most tools return list of device results) + if isinstance(response, list): + successful = sum( + 1 + for item in response + if isinstance(item, dict) and item.get("status") == "success" + ) + failed = len(response) - successful + + normalized_data = [] + for item in response: + if isinstance(item, dict): + normalized_item = { + "id": item.get("device_id") + or item.get("node_id") + or item.get("id") + or "", + "name": item.get("device_name") or item.get("name") or "", + "status": item.get("status", "unknown"), + } + if normalized_item["status"] == "success": + normalized_item["result"] = ( + item.get("output") or item.get("result") or "" + ) + else: + normalized_item["error"] = ( + item.get("error") + or item.get("output") + or "Unknown error" + ) + normalized_data.append(normalized_item) + else: + # Non-dict items in list + normalized_data.append( + { + "id": "", + "name": "", + "status": "unknown", + "result": str(item), + } + ) + + return { + "success": failed == 0, + "total": len(response), + "successful": successful, + "failed": failed, + "data": normalized_data, + "metadata": metadata, + } + + # Handle dict responses (some tools return summary + results) + if isinstance(response, dict): + # Check if already in standard format + if "success" in response and "data" in response: + return { + "success": response.get("success", True), + "total": response.get("total", len(response.get("data", []))), + "successful": response.get("successful", 0), + "failed": response.get("failed", 0), + "data": response.get("data", []), + "error": response.get("error"), + "metadata": {**metadata, **response.get("metadata", {})}, + } + + # Legacy format: extract common fields + total = ( + response.get("total_nodes") + or response.get("total") + or response.get("count", 1) + ) + successful = ( + response.get("successful_nodes") or response.get("successful") or 0 + ) + failed = response.get("failed_nodes") or response.get("failed") or 0 + + # Extract data from various possible locations + data = [] + if "nodes" in response: + data = response["nodes"] + elif "results" in response: + data = response["results"] + elif "data" in response: + data = response["data"] + elif "output" in response: + # Single device response + data = [ + { + "name": response.get("device_name", ""), + "status": response.get("status", "success"), + "result": response["output"], + } + ] + + # If no data found but have status, create single item + if not data and "status" in response: + data = [ + { + "name": response.get("device_name") + or response.get("name") + or "", + "status": response["status"], + "result": response.get("output") + or response.get("result") + or "", + "error": response.get("error") or "", + } + ] + + # Recursively normalize data items + if data and isinstance(data, list): + return normalize_tool_response(data, tool_name) + else: + # No data array, return empty but preserve counts + return { + "success": failed == 0, + "total": total, + "successful": successful, + "failed": failed, + "data": [], + "metadata": metadata, + } + + # Handle string responses (parse first) + if isinstance(response, str): + parsed = parse_tool_content(response, fallback_to_raw=True) + return normalize_tool_response(parsed, tool_name) + + # Fallback for unknown types + return { + "success": True, + "total": 1, + "successful": 1, + "failed": 0, + "data": [ + { + "id": "", + "name": "", + "status": "unknown", + "result": str(response), + } + ], + "metadata": metadata, + } + + +# Test function to verify the implementation +def _test_parse_tool_content() -> None: + """Test function to verify parse_tool_content works correctly with all input + types + """ + test_cases: list[tuple[Any, Any]] = [ + # String inputs + ( + '{"status": "success", "data": [1, 2, 3]}', + {"status": "success", "data": [1, 2, 3]}, + ), + ("{'name': 'PC1', 'status': 'ok'}", {"name": "PC1", "status": "ok"}), + ("[1, 2, 3]", [1, 2, 3]), + ('"hello"', "hello"), + ("42", 42), + ("true", True), + ("3.14", 3.14), + ("{}", {}), + (" {} ", {}), + ("", {}), + (" ", {}), + ("Invalid JSON input", {"raw": "Invalid JSON input"}), + # Direct object inputs + ({"status": "success"}, {"status": "success"}), + ([1, 2, 3], [1, 2, 3]), + ("hello", "hello"), + (42, 42), + (True, True), + (3.14, 3.14), + (None, {}), + ] + + print("Testing parse_tool_content function:") + for i, (input_data, expected) in enumerate(test_cases): + result = parse_tool_content(input_data) + status = "✓" if result == expected else "✗" + print(f"Test {i + 1}: {status} Input: {repr(input_data)} -> {result}") + + print("\nTesting format_tool_response function:") + format_tests = [ + '{"status": "success"}', + "{}", + None, + "Invalid input", + {"direct": "dict"}, + [1, 2, 3], + 42, + True, + ] + + for i, input_data in enumerate(format_tests): + result = format_tool_response(input_data) + # Verify it's valid JSON + try: + json.loads(result) + valid = "✓" + except Exception: + valid = "✗" + print( + f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}" + ) + + +if __name__ == "__main__": + _test_parse_tool_content() diff --git a/gns3server/agent/gns3_copilot/utils/tool_call_stream.py b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py new file mode 100644 index 000000000..8cd89a93e --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/tool_call_stream.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Tool call streaming accumulator for handling progressive tool call arguments +Maintains state for streaming tool call chunks +Based on FlowNet-Lab implementation +""" + +from typing import Any, Dict, List, Optional + + +class ToolCallStreamAccumulator: + """ + Accumulates tool call information from streaming events + Handles the progressive build-up of tool call arguments + + This class processes LangGraph streaming events and accumulates + tool call arguments that come in chunks, emitting progressive + tool_call events to the frontend. + """ + + def __init__(self) -> None: + # Current active tool call being accumulated + # Format: {"id": str, "name": str, "args_string": str} + self._current_tool_call: Optional[Dict[str, str]] = None + + def process_event(self, event: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Process a streaming event and return one or more response chunks + + Args: + event: LangGraph streaming event (on_chat_model_stream) + + Returns: + List of response chunks to send to frontend + + Processing phases: + 1. Initialize: Extract tool ID and name from tool_calls + 2. Accumulate: Concatenate argument strings from tool_call_chunks + 3. Complete: Mark as complete when finish_reason is "tool_calls" or "stop" + """ + event_type = event.get("event", "") + chunks = [] + + if event_type == "on_chat_model_stream": + chunk = event.get("data", {}).get("chunk", {}) + + # ========== Phase 1: Initialize tool call from tool_calls ========== + # Get metadata (ID and name) from tool_calls + if hasattr(chunk, "tool_calls") and chunk.tool_calls: + for tool_call in chunk.tool_calls: + if isinstance(tool_call, dict): + tc_id = tool_call.get("id") + tc_name = tool_call.get("name", "") + else: + tc_id = getattr(tool_call, "id", None) + tc_name = getattr(tool_call, "name", "") + + # Only when ID is not empty, consider it as the start of a new + # tool call + if tc_id: + # Initialize current tool state (this is the only time to + # get ID). Note: only one tool can be called at a time + self._current_tool_call = { + "id": tc_id, + "name": tc_name if tc_name else "UNKNOWN_TOOL", + "args_string": "", + } + + # Send initial tool_call event with empty args + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tc_id, + "type": "function", + "function": { + "name": self._current_tool_call[ + "name" + ], + "arguments": "", + }, + }, + } + ) + + # ========== Phase 2: Concatenate parameter strings from + # tool_call_chunks ========== + # Concatenate parameter strings from tool_call_chunk + if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: + if self._current_tool_call: + tool_data = self._current_tool_call + for tc_chunk in chunk.tool_call_chunks: + # Default to "" instead of None + if isinstance(tc_chunk, dict): + args_chunk = tc_chunk.get("args", "") + else: + args_chunk = getattr(tc_chunk, "args", "") + + # Core: string concatenation + if isinstance(args_chunk, str): + tool_data[ + "args_string" + ] += args_chunk + + # Send updated tool_call event with accumulated args + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tool_data["id"], + "type": "function", + "function": { + "name": tool_data["name"], + "arguments": tool_data[ + "args_string" + ], + }, + }, + } + ) + + # ========== Phase 3: Determine if tool_calls_chunks output is + # complete ========== + # Check finish_reason == "tool_calls" or "STOP" + response_metadata = getattr(chunk, "response_metadata", {}) + finish_reason = ( + response_metadata.get("finish_reason") + if isinstance(response_metadata, dict) + else None + ) + + if (finish_reason == "tool_calls") or ( + finish_reason == "stop" and self._current_tool_call is not None + ): + if self._current_tool_call: + tool_data = self._current_tool_call + + # Send final complete tool_call event + chunks.append( + { + "type": "tool_call", + "tool_call": { + "id": tool_data["id"], + "type": "function", + "function": { + "name": tool_data["name"], + "arguments": tool_data["args_string"], + "complete": True, # Mark as complete + }, + }, + } + ) + + # Clear the current tool call state + self._current_tool_call = None + + # Also handle regular content (when not in tool call mode) + content = getattr(chunk, "content", "") + if content and not self._current_tool_call: + chunks.append( + { + "type": "content", + "content": content, + "message_id": event.get("metadata", {}).get("msg_id"), + } + ) + + return chunks + + def reset(self) -> None: + """Reset the accumulator state""" + self._current_tool_call = None diff --git a/gns3server/agent/web_wireshark/WEB_WIRESHARK.md b/gns3server/agent/web_wireshark/WEB_WIRESHARK.md new file mode 100644 index 000000000..8f2a96133 --- /dev/null +++ b/gns3server/agent/web_wireshark/WEB_WIRESHARK.md @@ -0,0 +1,466 @@ +> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. + + +# GNS3 Web Wireshark Container Management Guide + +This document describes how to manage GNS3 Web Wireshark containers using the management script or Docker commands. + +## Installation + +Before using Web Wireshark, you need to set up the Docker image: + +```bash +pip install . && gns3server-web-wireshark-setup +``` + +This will: +1. First try to pull the `gns3/web-wireshark:latest` image from Docker Hub +2. If pull fails, build the image locally using the Dockerfile + +The setup command shows the output from `docker pull` or `docker build` directly, so you can see the progress. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────┐ +│ Host Machine │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Docker Network: gns3-wireshark │ │ +│ │ (Bridge network for container-host │ │ +│ │ communication) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────┐ │ │ +│ │ │ Container: gns3-PROJECT-ID │ │ │ +│ │ │ │ │ │ +│ │ │ Link 1: Display :10001, Port 10001 │ │ │ +│ │ │ Link 2: Display :10002, Port 10002 │ │ │ +│ │ │ Link 3: Display :10003, Port 10003 │ │ │ +│ │ │ ... │ │ │ +│ │ └──────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Quick Start (Using Management Script) + +### Prerequisites +- Docker is running +- Virtual environment is activated: `source venv/bin/activate` + +### Start Session + +```bash +# Start session (using all defaults) +python3 gns3server/agent/web_wireshark/manage_wireshark.py \ + --verbose start \ + --project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \ + --link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \ + --jwt-token "YOUR_JWT_TOKEN" + +# Use custom image +python3 gns3server/agent/web_wireshark/manage_wireshark.py \ + --verbose start \ + --project-id "5af0fe00-f39d-4985-8669-7e8c512d729c" \ + --link-id "f233f27f-7432-49c3-9aa2-50e326a10eec" \ + --jwt-token "YOUR_JWT_TOKEN" \ + --image "gns3/web-wireshark:test" +``` + +### Access Web Interface + +After starting, access Wireshark at: +``` +ws://: +``` + +### 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//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 diff --git a/gns3server/agent/web_wireshark/docker/Dockerfile b/gns3server/agent/web_wireshark/docker/Dockerfile new file mode 100644 index 000000000..3f3f39a2e --- /dev/null +++ b/gns3server/agent/web_wireshark/docker/Dockerfile @@ -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 " +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"] diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py new file mode 100644 index 000000000..ca9e86360 --- /dev/null +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2026 YueGuobin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Web Wireshark - Docker HTTP API client + +This module handles Docker API operations via aiohttp Unix socket. +""" + +import asyncio +import logging + +import aiohttp + +from gns3server.utils import parse_version + +logger = logging.getLogger(__name__) + +# Docker API configuration +DOCKER_SOCKET = "/var/run/docker.sock" +DOCKER_MINIMUM_API_VERSION = "1.40" +DOCKER_PREFERRED_API_VERSION = "1.44" + + +class DockerHTTPClient: + """Docker HTTP API client using aiohttp with Unix socket connection.""" + + # API request timeout (seconds) + REQUEST_TIMEOUT = 10 + + def __init__(self): + self._connector = None + self._session = None + self._connected = False + # Start with preferred API version (1.44) to support newer Docker + # Will fallback to minimum version (1.40) if server doesn't support it + self._api_version = DOCKER_PREFERRED_API_VERSION + + async def _get_connector(self): + """Get or create Unix socket connector.""" + if self._connector is None or self._connector.closed: + try: + self._connector = aiohttp.connector.UnixConnector(DOCKER_SOCKET, limit=None) + except (aiohttp.ClientError, FileNotFoundError): + raise RuntimeError(f"Can't connect to Docker daemon at {DOCKER_SOCKET}") + return self._connector + + async def _get_session(self): + """Get or create aiohttp session.""" + if self._session is None or self._session.closed: + connector = await self._get_connector() + self._session = aiohttp.ClientSession(connector=connector) + return self._session + + async def close(self): + """Close connections.""" + if self._session and not self._session.closed: + await self._session.close() + if self._connector and not self._connector.closed: + await self._connector.close() + self._connected = False + + async def _check_connection(self): + """Check Docker connection and detect API version.""" + if not self._connected: + try: + # Get Docker version info + docker_info = await self._request("GET", "version", check_connection=False) + self._connected = True + + # Parse API version + api_version = parse_version(docker_info['ApiVersion']) + docker_version = docker_info["Version"] + + logger.info(f"Connected to Docker {docker_version}, API {api_version}") + + # Check minimum version requirement + if api_version < parse_version(DOCKER_MINIMUM_API_VERSION): + raise RuntimeError( + f"Docker version is {docker_version}. " + f"GNS3 requires a minimum API version of {DOCKER_MINIMUM_API_VERSION}" + ) + + # Use preferred API version if supported + preferred_api_version = parse_version(DOCKER_PREFERRED_API_VERSION) + if api_version >= preferred_api_version: + self._api_version = DOCKER_PREFERRED_API_VERSION + logger.info(f"Using Docker API version {self._api_version}") + else: + # Use Docker daemon's actual API version + self._api_version = docker_info['ApiVersion'] + logger.info(f"Using Docker API version {self._api_version} (daemon native)") + + except (aiohttp.ClientError, FileNotFoundError) as e: + self._connected = False + raise RuntimeError(f"Can't connect to Docker daemon: {e}") from e + except KeyError as e: + raise RuntimeError(f"Unexpected Docker API response: missing {e}") from e + + async def _request(self, method: str, endpoint: str, **kwargs): + """ + Send request to Docker API. + + Args: + method: HTTP method + endpoint: API endpoint (without version prefix) + **kwargs: Other parameters for aiohttp.request + + Returns: + Response JSON data + """ + # Check connection and version on first request + check_connection = kwargs.pop('check_connection', True) + if check_connection and not self._connected: + await self._check_connection() + + session = await self._get_session() + url = f"http://docker/v{self._api_version}/{endpoint}" + + try: + async with asyncio.timeout(self.REQUEST_TIMEOUT): + async with session.request(method, url, **kwargs) as response: + if response.status >= 300: + error_text = await response.text() + raise RuntimeError(f"Docker API error {response.status}: {error_text}") + if response.status == 204: # No Content + return None + return await response.json() + except asyncio.TimeoutError: + raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for {endpoint}") + except aiohttp.ClientError as e: + raise RuntimeError(f"Docker connection error: {e}") from e + except RuntimeError as e: + # Retry with lower API version if Docker daemon doesn't support current version + error_msg = str(e) + if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION: + logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}") + self._api_version = DOCKER_MINIMUM_API_VERSION + return await self._request(method, endpoint, check_connection=False, **kwargs) + raise + + async def create_network(self, name: str, driver: str = "bridge", subnet: str = None): + """Create Docker network.""" + data = { + "Name": name, + "Driver": driver + } + if subnet: + data["IPAM"] = { + "Config": [{"Subnet": subnet}] + } + await self._request("POST", "networks/create", json=data) + + async def get_network(self, name: str): + """Get network info.""" + try: + return await self._request("GET", f"networks/{name}") + except RuntimeError as e: + if "404" in str(e): + return None + raise + + async def create_container(self, name: str, image: str, **kwargs): + """Create container.""" + data = { + "Image": image, + "name": name, + "HostConfig": {}, + "NetworkingConfig": {} + } + + # Handle network config + if "network" in kwargs: + network_name = kwargs.pop("network") + data["NetworkingConfig"]["EndpointsConfig"] = { + network_name: {} + } + + # Handle environment variables + if "environment" in kwargs: + data["Env"] = [f"{k}={v}" for k, v in kwargs.pop("environment").items()] + + # Handle resource limits + if "host_config" in kwargs: + data["HostConfig"].update(kwargs.pop("host_config")) + else: + # Legacy parameter format + host_config = {} + if "mem_limit" in kwargs: + host_config["Memory"] = kwargs.pop("mem_limit") + if "cpu_quota" in kwargs: + host_config["NanoCpus"] = kwargs.pop("cpu_quota") + if "pids_limit" in kwargs: + host_config["PidsLimit"] = kwargs.pop("pids_limit") + if "restart_policy" in kwargs: + host_config["RestartPolicy"] = kwargs.pop("restart_policy") + data["HostConfig"].update(host_config) + + # Handle health check + if "health_config" in kwargs: + data["HealthCheck"] = kwargs.pop("health_config") + + # Create container + result = await self._request("POST", "containers/create", params={"name": name}, json=data) + return result["Id"] + + async def start_container(self, container_id: str): + """Start container.""" + await self._request("POST", f"containers/{container_id}/start") + + async def get_container(self, name: str): + """Get container info.""" + try: + return await self._request("GET", f"containers/{name}/json") + except RuntimeError as e: + if "404" in str(e): + return None + raise + + async def stop_container(self, container_id: str, timeout: int = 0): + """Stop container immediately (force kill, no graceful shutdown).""" + await self._request("POST", f"containers/{container_id}/stop", params={"t": timeout}) + + async def remove_container(self, container_id: str, force: bool = False): + """Remove container.""" + params = {"force": "true"} if force else {} + await self._request("DELETE", f"containers/{container_id}", params=params) + + async def list_processes(self, container_name: str) -> list: + """Get process list from container. + + Args: + container_name: Container name (e.g., "gns3-wireshark-xxx") + + Returns: + List of process dicts with keys: PID, USER, COMMAND, etc. + """ + if not self._connected: + await self._check_connection() + + session = await self._get_session() + url = f"http://docker/v{self._api_version}/containers/{container_name}/top?ps_args=aux" + + try: + async with asyncio.timeout(self.REQUEST_TIMEOUT): + async with session.get(url) as response: + if response.status >= 300: + error_text = await response.text() + raise RuntimeError(f"Docker API error {response.status}: {error_text}") + result = await response.json() + except asyncio.TimeoutError: + raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for containers/{container_name}/top") + except aiohttp.ClientError as e: + raise RuntimeError(f"Docker connection error: {e}") from e + + # Docker API returns JSON format: + # {"Processes": [["yueguob+", "259847", ...], [...]], "Titles": ["USER", "PID", "%CPU", ...]} + # Headers are in "Titles" key, data rows are in "Processes" + + headers = result.get("Titles", []) + processes_data = result.get("Processes", []) + if not headers or not processes_data: + return [] + + processes = [] + for values in processes_data: + if not values or len(values) < len(headers): + continue + + proc = {} + for i, header in enumerate(headers): + if i < len(values): + proc[header] = values[i] + + processes.append(proc) + + return processes diff --git a/gns3server/agent/web_wireshark/manage_wireshark.py b/gns3server/agent/web_wireshark/manage_wireshark.py new file mode 100755 index 000000000..905e1c2da --- /dev/null +++ b/gns3server/agent/web_wireshark/manage_wireshark.py @@ -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 . + +""" +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 \\ + --link-id \\ + --jwt-token + + # Stop a Web Wireshark session + python manage_wireshark.py stop \\ + --project-id \\ + --link-id + + # Restart a Web Wireshark session + python manage_wireshark.py restart \\ + --project-id \\ + --link-id \\ + --jwt-token + + # Stop all sessions for a project + python manage_wireshark.py stop-all --project-id + + # Delete project container + python manage_wireshark.py delete --project-id + +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 --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) diff --git a/gns3server/agent/web_wireshark/manager.py b/gns3server/agent/web_wireshark/manager.py new file mode 100644 index 000000000..19d23a5ab --- /dev/null +++ b/gns3server/agent/web_wireshark/manager.py @@ -0,0 +1,1087 @@ +# 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 . + +""" +Web Wireshark - Session Manager + +This module handles xpra session management for Web Wireshark containers. +""" + +import asyncio +import logging +import re +import socket +from typing import Optional +from urllib.parse import urlparse + +from gns3server.config import Config +from gns3server.utils.port_allocator import link_id_to_display, link_id_to_port +from .docker_client import DockerHTTPClient + +logger = logging.getLogger(__name__) + +# Default GNS3 server address +DEFAULT_GNS3_URL = "http://127.0.0.1:3080" + + +class WebWiresharkManager: + """Manages Web Wireshark sessions and containers.""" + + # Container command execution timeout (seconds) + CONTAINER_EXEC_TIMEOUT = 10 + + def __init__(self): + self.docker = DockerHTTPClient() + self.network_name = "gns3-wireshark" + + async def close(self): + """Close Docker connection.""" + await self.docker.close() + + async def _is_container_healthy(self, container_id: str) -> bool: + """Check if container is responsive. + + Args: + container_id: Container ID + + Returns: + True if healthy, False otherwise + """ + try: + proc = await asyncio.create_subprocess_exec( + "docker", "exec", container_id, + "bash", "-c", "echo 'ping'", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await asyncio.wait_for( + proc.communicate(), + timeout=self.CONTAINER_EXEC_TIMEOUT + ) + return proc.returncode == 0 and b"ping" in stdout + except asyncio.TimeoutError: + logger.warning(f"Container {container_id[:12]} health check timeout") + return False + except FileNotFoundError: + logger.warning(f"Container {container_id[:12]} docker command not found") + return False + except Exception as e: + logger.warning(f"Container {container_id[:12]} health check failed: {e}") + return False + + async def _exec_in_container(self, container_id: str, command: str, timeout: int = None) -> tuple: + """Execute command in container with timeout. + + Args: + container_id: Container ID + command: Command to execute + timeout: Timeout in seconds (default: CONTAINER_EXEC_TIMEOUT) + + Returns: + (returncode, stdout, stderr) + """ + if timeout is None: + timeout = self.CONTAINER_EXEC_TIMEOUT + + try: + proc = await asyncio.create_subprocess_exec( + "docker", "exec", container_id, + "bash", "-c", command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=timeout + ) + return (proc.returncode, stdout.decode().strip(), stderr.decode().strip()) + except asyncio.TimeoutError: + logger.error(f"Command timeout after {timeout}s: {command}") + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + proc.kill() + return (-1, "", f"timeout after {timeout}s") + except Exception as e: + logger.error(f"Command execution failed: {e}") + return (-1, "", str(e)) + + async def _kill_process_tree(self, container_id: str, pattern: str) -> None: + """Kill all processes matching pattern inside the container. + + Uses pgrep inside the container to get container-local PIDs, then kills them. + This avoids the issue where Docker API returns host PIDs instead of container PIDs. + + Args: + container_id: Container ID + pattern: Process pattern to match (regex applied to COMMAND field) + """ + try: + # Get container-local PIDs using pgrep inside the container + pgrep_cmd = f"sh -c 'pgrep -f \"{pattern}\" || true'" + returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd) + pids_str = stdout.strip() + + if not pids_str: + logger.debug(f"No processes found matching pattern '{pattern}'") + return + + pids = pids_str.replace('\n', ' ') + logger.info(f"Found matching processes for '{pattern}': PIDs={pids}") + + # Kill the processes using their container-local PIDs + kill_cmd = f"kill -9 {pids} 2>/dev/null || true" + returncode, stdout, stderr = await self._exec_in_container(container_id, kill_cmd) + logger.debug(f"Kill result: returncode={returncode}") + + except Exception as e: + logger.error(f"Error killing processes matching '{pattern}': {e}") + + async def _check_residuals_exist(self, container_id: str, display: int) -> tuple[bool, bool]: + """Check if there are residual processes or files for a display. + + Uses host perspective for fast checking (~20ms vs ~850ms for docker exec). + Returns two booleans: (has_process_residuals, has_socket_residuals). + This allows selective cleanup when only one type needs attention. + + Args: + container_id: Container ID + display: Display number (e.g., 10210) + + Returns: + Tuple of (has_process_residuals, has_socket_residuals) + """ + try: + # Get container init PID from host perspective + proc = await asyncio.create_subprocess_exec( + "docker", "inspect", container_id, + "--format", "{{.State.Pid}}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await proc.communicate() + container_init_pid = stdout.decode().strip() + + if not container_init_pid: + # Can't determine, assume cleanup needed + return (True, True) + + has_process_residuals = False + has_socket_residuals = False + + # Check for residual processes from host perspective + proc = await asyncio.create_subprocess_exec( + "ps", "-eo", "pid,ppid,args", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await proc.communicate() + + # Build parent->children mapping + children_map = {} + for line in stdout.decode().strip().split('\n'): + parts = line.split(None, 2) + if len(parts) < 3: + continue + try: + pid, ppid = int(parts[0]), int(parts[1]) + children_map.setdefault(ppid, []).append(pid) + except ValueError: + continue + + # Recursively find all descendant PIDs of container init + descendant_pids = set() + + def find_descendants(ppid): + children = children_map.get(ppid, []) + for child_pid in children: + descendant_pids.add(child_pid) + find_descendants(child_pid) + + find_descendants(int(container_init_pid)) + + # Check if any descendant processes match display patterns + import re + patterns = [ + f'xpra.*:{display}', + f'Xvfb.*:{display}', + f'wireshark.*:{display}', + f'pulseaudio.*display=:{display}' + ] + + for line in stdout.decode().strip().split('\n'): + parts = line.split(None, 2) + if len(parts) < 3: + continue + try: + pid, ppid = int(parts[0]), int(parts[1]) + command = parts[2] + + if pid not in descendant_pids: + continue + + for pattern in patterns: + if re.search(pattern, command): + logger.debug(f"Found residual process: PID={pid}, COMMAND={command[:80]}") + has_process_residuals = True + break + except ValueError: + continue + + # Check for X lock files from host perspective + # /proc//root points to container filesystem + lock_paths = [ + f"/proc/{container_init_pid}/root/tmp/.X{display}-lock", + f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}" + ] + for lock_path in lock_paths: + proc = await asyncio.create_subprocess_exec( + "test", "-e", lock_path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + await proc.communicate() + if proc.returncode == 0: + logger.debug(f"Found residual lock file: {lock_path}") + has_socket_residuals = True + + # Check for xpra socket files + socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket" + proc = await asyncio.create_subprocess_exec( + "test", "-e", socket_path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + await proc.communicate() + if proc.returncode == 0: + logger.debug(f"Found residual socket: {socket_path}") + has_socket_residuals = True + + return (has_process_residuals, has_socket_residuals) + + except Exception as e: + logger.warning(f"Error checking residuals (assuming cleanup needed): {e}") + return (True, True) + + async def _kill_process_tree_batch(self, container_id: str, patterns: list) -> None: + """Kill all processes matching multiple patterns inside the container. + + Uses host perspective to find and kill container processes much faster + than docker exec (37x faster: ~23ms vs ~850ms). + + This method finds ALL processes in the container (including grandchildren) + by walking the process tree from container init, not just direct children. + + Args: + container_id: Container ID + patterns: List of process patterns to match (regex applied to COMMAND field) + """ + if not patterns: + return + + try: + # Get container init PID from host perspective + proc = await asyncio.create_subprocess_exec( + "docker", "inspect", container_id, + "--format", "{{.State.Pid}}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await proc.communicate() + container_init_pid = stdout.decode().strip() + + if not container_init_pid: + logger.warning("Could not get container init PID, falling back to docker exec") + await self._kill_process_tree_batch_via_exec(container_id, patterns) + return + + # List all processes from host perspective with PID, PPID, and command + # This is faster than docker exec and allows us to walk the process tree + proc = await asyncio.create_subprocess_exec( + "ps", "-eo", "pid,ppid,args", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await proc.communicate() + + if not stdout.strip(): + logger.debug("No processes found from host perspective") + return + + # Build parent->children mapping and collect process info + children_map = {} # ppid -> [pid] + process_info = {} # pid -> (ppid, command) + + for line in stdout.decode().strip().split('\n'): + parts = line.split(None, 2) + if len(parts) < 3: + continue + try: + pid, ppid = int(parts[0]), int(parts[1]) + command = parts[2] + + children_map.setdefault(ppid, []).append(pid) + process_info[pid] = (ppid, command) + except ValueError: + continue + + # Recursively find all descendant processes of container init + descendant_pids = set() + + def find_descendants(ppid): + """Recursively find all descendant PIDs.""" + children = children_map.get(ppid, []) + for child_pid in children: + descendant_pids.add(child_pid) + find_descendants(child_pid) # Recursively find grandchildren + + find_descendants(int(container_init_pid)) + + # Filter descendant processes by pattern and extract PIDs + matching_pids = [] + for pid in descendant_pids: + if pid not in process_info: + continue + _, command = process_info[pid] + + # Check if command matches any pattern + for pattern in patterns: + if re.search(pattern, command): + matching_pids.append(str(pid)) + logger.debug(f"Found process: PID={pid}, COMMAND={command[:100]}") + break + + if matching_pids: + logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}") + # Kill all matching PIDs from host perspective + proc = await asyncio.create_subprocess_exec( + "kill", "-9", *matching_pids, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + await proc.communicate() + logger.debug("Kill completed from host perspective") + else: + logger.debug(f"No processes found matching patterns: {patterns}") + + except Exception as e: + logger.warning(f"Error using host perspective for killing (fallback to docker exec): {e}") + # Fallback to docker exec method + await self._kill_process_tree_batch_via_exec(container_id, patterns) + + async def _kill_process_tree_batch_via_exec(self, container_id: str, patterns: list) -> None: + """Kill processes using docker exec (fallback method). + + This is slower (~850ms) but more reliable in some edge cases. + + Args: + container_id: Container ID + patterns: List of process patterns to match + """ + try: + # Combine all patterns into a single regex using OR operator + combined_pattern = '|'.join(f'({pattern})' for pattern in patterns) + + # Single pgrep to find all matching processes + pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi' + + returncode, stdout, stderr = await self._exec_in_container( + container_id, + pgrep_cmd, + timeout=5 + ) + + if "Found processes:" in stdout: + logger.info(stdout.strip()) + + except Exception as e: + logger.error(f"Error in batch kill via docker exec: {e}") + + async def _cleanup_x_lock(self, container_id: str, display: int) -> None: + """Remove X lock file and xpra socket files for a display. + + Args: + container_id: Container ID + display: Display number (e.g., 10210) + """ + # Clean up X lock files and xpra socket files in a single docker exec + # This reduces docker exec calls from 2 to 1, improving stop performance + cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} " + f"/run/user/1000/xpra/{display}/socket " + f"/run/user/1000/xpra/*-{display} " + f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") + returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) + logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}") + + async def _cleanup_socket_files(self, container_id: str, display: int) -> None: + """Remove only xpra socket files for a display (faster, single docker exec). + + Args: + container_id: Container ID + display: Display number (e.g., 10210) + """ + cmd = (f"rm -f /run/user/1000/xpra/{display}/socket " + f"/run/user/1000/xpra/*-{display} " + f"/home/gns3/.xpra/*-{display} 2>/dev/null || true") + returncode, stdout, stderr = await self._exec_in_container(container_id, cmd) + logger.debug(f"Cleanup socket files: returncode={returncode}") + + @staticmethod + def _parse_memory(memory_str: str) -> int: + """Parse memory string to bytes. + + Args: + memory_str: Memory string (e.g., "2g", "512m") + + Returns: + Memory in bytes + """ + memory_str = memory_str.lower().strip() + if not memory_str: + return 0 + + # Extract number and unit + match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str) + if not match: + raise ValueError(f"Invalid memory format: {memory_str}") + + value = float(match.group(1)) + unit = match.group(2) or 'b' + + # Convert to bytes + multipliers = { + 'b': 1, + 'k': 1024, + 'm': 1024 * 1024, + 'g': 1024 * 1024 * 1024 + } + + unit = unit[0] # Take first character + return int(value * multipliers.get(unit, 1)) + + def _get_gns3_url_from_config(self) -> Optional[str]: + """Get GNS3 server URL from config file.""" + try: + server_config = Config.instance().settings.Server + url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}" + logger.info(f"Got GNS3 URL from Config: {url}") + return url + except Exception as e: + logger.debug(f"Cannot get URL from Config: {e}") + return None + + async def _get_container_gateway_ip(self, container_id: str = None) -> Optional[str]: + """Get the Docker bridge gateway IP for container to access host. + + Args: + container_id: Container ID (optional, only used for fallback methods) + + Returns: + Gateway IP (e.g., 172.31.0.1) or None if not detectable + """ + # Method 1: Get from Docker Network API (fastest, no container access needed) + try: + network = await self.docker.get_network(self.network_name) + if network and "IPAM" in network: + for config in network["IPAM"].get("Config", []): + if "Gateway" in config: + gateway_ip = config["Gateway"] + logger.info(f"Got gateway IP from Docker network API: {gateway_ip}") + return gateway_ip + except Exception as e: + logger.debug(f"Cannot get gateway from Docker network API: {e}") + + # Fallback: Read from /proc/net/route inside container (slower) + if container_id: + try: + returncode, stdout, stderr = await self._exec_in_container( + container_id, + "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1" + ) + logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'") + if returncode == 0 and stdout.strip(): + gateway_hex = stdout.strip() + # Convert hex to dotted decimal + gateway_ip = socket.inet_ntoa(bytes.fromhex(gateway_hex)[::-1]) + logger.info(f"Detected container gateway IP from route: {gateway_ip}") + return gateway_ip + except Exception as e: + logger.debug(f"Cannot get gateway from /proc/net/route: {e}") + + return None + + async def get_container_ip(self, container_name: str, container_id: str = None) -> Optional[str]: + """Get the container IP address in the wireshark network. + + Args: + container_name: Container name (e.g., "gns3-wireshark-project_id") + container_id: Container ID (optional, only used for fallback method) + + Returns: + Container IP address (e.g., 172.31.0.2) or None if not found + """ + # Method 1: Get from Docker Container API (fastest) + try: + container = await self.docker.get_container(container_name) + if container: + networks = container.get("NetworkSettings", {}).get("Networks", {}) + # Find gns3-wireshark network + for network_name, network_config in networks.items(): + if "wireshark" in network_name.lower(): + container_ip = network_config.get("IPAddress") + if container_ip: + logger.info(f"Got container IP from Docker API: {container_ip}") + return container_ip + except Exception as e: + logger.debug(f"Cannot get container IP from Docker API: {e}") + + # Fallback: Execute command inside container to get IP (slower) + if container_id: + try: + # Use 'hostname -I' to get all IP addresses and take the first one + # This works on most Linux systems and is more portable than 'ip' command + returncode, stdout, stderr = await self._exec_in_container( + container_id, + "hostname -I 2>/dev/null | awk '{print $1}'" + ) + if returncode == 0 and stdout.strip(): + container_ip = stdout.strip() + logger.info(f"Got container IP from container command: {container_ip}") + return container_ip + except Exception as e: + logger.debug(f"Cannot get container IP from container command: {e}") + + logger.warning(f"Failed to get container IP for {container_name}") + return None + + async def _fix_localhost_url(self, url: str, container_id: str) -> str: + """Fix URL with localhost/127.0.0.1 for container access. + + If the URL points to localhost, replace it with the container's + gateway IP so the container can reach the host. + + Args: + url: Original URL + container_id: Container ID + + Returns: + Fixed URL with accessible host IP + """ + try: + parsed = urlparse(url) + if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'): + gateway = await self._get_container_gateway_ip(container_id) + if gateway: + fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}" + if parsed.query: + fixed_url += f"?{parsed.query}" + logger.info(f"Fixed localhost URL for container: {url} -> {fixed_url}") + return fixed_url + else: + logger.warning(f"Cannot detect gateway IP, keeping original URL: {url}") + except Exception as e: + logger.warning(f"Error fixing localhost URL: {e}") + return url + + def detect_gns3_url(self) -> str: + """Detect GNS3 server URL from config or use default. + + Note: This returns the raw URL (may contain 127.0.0.1). + The URL will be fixed for container access later using _fix_localhost_url. + + Returns: + GNS3 server URL + """ + # Strategy 1: Get from Config + url = self._get_gns3_url_from_config() + if url: + return url + + # Strategy 2: Use default + logger.warning(f"Using fallback default URL: {DEFAULT_GNS3_URL}") + return DEFAULT_GNS3_URL + + async def ensure_network(self): + """Ensure Docker network exists.""" + network = await self.docker.get_network(self.network_name) + if network: + logger.info(f"Network {self.network_name} already exists") + else: + # Get subnet from config or use default + config = Config.instance() + subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22") + logger.info(f"Creating network {self.network_name} with subnet {subnet}") + await self.docker.create_network( + self.network_name, + driver="bridge", + subnet=subnet + ) + + async def get_or_create_container( + self, + project_id: str, + image: str = "gns3/web-wireshark:latest", + memory: str = "2g", + memory_swap: str = None, + cpus: float = 1.0, + pids_limit: int = 1000 + ) -> str: + """Get or create project's Web Wireshark container. + + Args: + project_id: Project ID + image: Docker image name + 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) + + Returns: + Container ID + """ + container_name = f"gns3-wireshark-{project_id}" + + logger.info(f"Checking container {container_name}") + container = await self.docker.get_container(container_name) + logger.info(f"Container found: {container is not None}") + + if container: + logger.info(f"Container state: {container['State']}") + if container["State"]["Running"]: + # Use Docker's built-in health check status instead of manual ping + health_status = container.get("Health", {}).get("Status", None) + + if health_status == "unhealthy": + # Only unhealthy containers need further verification + logger.warning(f"Container {container_name} reports unhealthy status") + if not await self._is_container_healthy(container["Id"]): + # Container is truly unresponsive, force remove + logger.warning(f"Container {container_name} is not responding, force removing...") + try: + await self.docker.remove_container(container["Id"], force=True) + logger.info(f"Container {container_name} force removed") + except Exception as e: + logger.warning(f"Failed to remove unhealthy container: {e}") + container = None # Trigger recreation + else: + # Health check passed despite unhealthy status, use it + logger.info(f"Container {container_name} is responsive despite unhealthy status") + return container["Id"] + else: + # healthy, starting, or no health check configured - trust Docker + logger.info(f"Container {container_name} is running (health: {health_status})") + return container["Id"] + else: + logger.info(f"Starting existing container {container_name}") + await self.docker.start_container(container["Id"]) + if await self._is_container_healthy(container["Id"]): + return container["Id"] + # Still unhealthy after start, remove and recreate + logger.error(f"Container {container_name} failed to become healthy, force removing...") + try: + await self.docker.remove_container(container["Id"], force=True) + except Exception: + pass + container = None + + # Need to create new container + if container is None: + logger.info(f"Creating new container {container_name} with image {image}") + logger.info(f"Resources: memory={memory}, cpus={cpus}, pids_limit={pids_limit}") + + # Calculate CPU quota (NanoCpus uses nanoseconds: 1 CPU = 1000000000) + cpu_quota = int(cpus * 1000000000) + + # Configure host settings + host_config = { + "Memory": self._parse_memory(memory), + "MemorySwap": self._parse_memory(memory_swap) if memory_swap else 0, + "NanoCpus": cpu_quota, + "PidsLimit": pids_limit, + "RestartPolicy": {"Name": "unless-stopped"}, + "Init": True, # Use init system (tini) as PID 1 to reap zombie processes + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "3" + } + } + } + + # Health check configuration + health_config = { + "Test": ["CMD-SHELL", "xpra list"], + "Interval": 30000000000, # 30 seconds (nanoseconds) + "Timeout": 10000000000, # 10 seconds + "Retries": 3 + } + + try: + container_id = await self.docker.create_container( + name=container_name, + image=image, + network=self.network_name, + host_config=host_config, + health_config=health_config, + environment={ + "XDG_RUNTIME_DIR": "/run/user/1000", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8" + } + ) + except RuntimeError as e: + error_msg = str(e) + if "404" in error_msg or "not found" in error_msg.lower(): + raise RuntimeError( + f"Docker image '{image}' not found. " + f"Please pull the image from Docker Hub: docker pull {image} " + f"Or build it locally using the Dockerfile in the GNS3 repository." + ) from e + raise + + # Start container + await self.docker.start_container(container_id) + logger.info(f"Container {container_name} created and started") + return container_id + + return container["Id"] + + async def start_wireshark_session( + self, + project_id: str, + link_id: str, + jwt_token: str, + capture_stream_url: Optional[str] = None, + image: str = "gns3/web-wireshark:latest", + memory: str = "2g", + memory_swap: Optional[str] = None, + cpus: float = 1.0, + pids_limit: int = 1000 + ) -> dict: + """Start Web Wireshark session. + + Args: + project_id: Project ID + link_id: Link ID + jwt_token: JWT authentication token + capture_stream_url: Capture stream URL (optional, auto-detected if not provided) + image: Docker image name + memory: Memory limit + memory_swap: Memory swap limit + cpus: CPU cores + pids_limit: Process limit + + Returns: + Session info dict + """ + logger.info(f"Starting Web Wireshark session for link {link_id}") + + # Ensure Docker network exists before creating container + await self.ensure_network() + + # Auto-detect capture_stream_url if not provided + if not capture_stream_url: + gns3_url = self.detect_gns3_url() + capture_stream_url = ( + f"{gns3_url}/v3/projects/{project_id}/links/" + f"{link_id}/capture/stream" + ) + logger.info(f"Auto-detected capture stream URL: {capture_stream_url}") + + container_id = await self.get_or_create_container( + project_id, + image, + memory, + memory_swap, + cpus, + pids_limit + ) + container_name = f"gns3-wireshark-{project_id}" + + # Fix localhost URL after we have container_id + capture_stream_url = await self._fix_localhost_url(capture_stream_url, container_id) + logger.info(f"Final capture stream URL: {capture_stream_url}") + + # Allocate display and port using deterministic hash + display = link_id_to_display(link_id) + port = link_id_to_port(link_id) + + # Check for residual processes/files from host perspective (fast ~20ms) + # Only execute docker exec cleanup (~850ms) if residuals are found + has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display) + + if has_process_residuals or has_socket_residuals: + logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...") + if has_process_residuals: + patterns = [ + f'xpra.*:{display}', + f'Xvfb.*:{display}', + f'wireshark.*:{display}', + f'pulseaudio.*display=:{display}' + ] + await self._kill_process_tree_batch(container_id, patterns) + if has_socket_residuals: + await self._cleanup_x_lock(container_id, display) + else: + logger.debug(f"No residual processes/files found on display :{display}") + + # Prepare xpra command + session_name = f"link-{link_id}" + logger.info(f"Starting xpra session {session_name} on display :{display}") + + xpra_cmd = [ + "XPRA_CLIENT_CAN_SHUTDOWN=false", + "xpra", "start", f":{display}", + '--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"', + "--html=off", + f"--bind-ws=0.0.0.0:{port}", + f"--session-name={session_name}", + "--daemon=yes", + "--dbus-launch=no", + "--resize-display=yes" + ] + + # Parallel execution: get container info + start xpra + container_info_task = asyncio.create_task( + self.docker.get_container(container_name) + ) + xpra_start_task = asyncio.create_task( + self._exec_in_container(container_id, " ".join(xpra_cmd)) + ) + + # Wait for both tasks to complete + container, (returncode, stdout, stderr) = await asyncio.gather( + container_info_task, + xpra_start_task + ) + + # Check xpra start result + if returncode != 0: + logger.error(f"xpra start failed (code {returncode}): {stdout} {stderr}") + raise RuntimeError(f"xpra start failed: {stdout} {stderr}") + + logger.info(f"xpra session {session_name} started successfully on display :{display}") + + # Extract container IP + networks = container["NetworkSettings"]["Networks"] + container_ip = networks.get(self.network_name, {}).get("IPAddress", "") + + if not container_ip: + logger.error(f"Container {container_name} has no IP in network {self.network_name}") + raise RuntimeError("Container has no IP address") + + # Start Wireshark in background (fire-and-forget, no waiting) + # Use -k for HTTPS to skip certificate validation (for self-signed certs) + curl_insecure_flag = "-k" if capture_stream_url.startswith("https://") else "" + wireshark_cmd = ( + f"curl {curl_insecure_flag} -N -H 'Authorization: Bearer {jwt_token}' " + f"'{capture_stream_url}' | " + f"wireshark -i - -k --fullscreen -display :{display} &" + ) + + logger.info(f"[Web Wireshark] Starting Wireshark with capture stream URL: {capture_stream_url}") + logger.info(f"[Web Wireshark] Display: :{display}, Container IP: {container_ip}, Port: {port}") + + # Execute Wireshark command without waiting for completion + await asyncio.create_subprocess_exec( + "docker", "exec", container_id, + "bash", "-c", wireshark_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + # Don't wait - Wireshark runs in background + logger.info(f"[Web Wireshark] Wireshark start command executed for link {link_id}") + + result = { + "link_id": link_id, + "display": display, + "port": port, + "ws_url": f"ws://{container_ip}:{port}", + "container_name": container_name, + "container_id": container_id, + "session_name": session_name, + "capture_stream_url": capture_stream_url + } + + logger.info(f"Web Wireshark session started successfully: {result['ws_url']}") + return result + + async def stop_wireshark_session(self, project_id: str, link_id: str): + """Stop Web Wireshark session. + + Args: + project_id: Project ID + link_id: Link ID + """ + logger.info(f"Stopping Web Wireshark session for link {link_id}") + try: + container_name = f"gns3-wireshark-{project_id}" + container = await self.docker.get_container(container_name) + + if not container: + logger.info(f"Container {container_name} not found, nothing to stop") + return + + # Get display number + display = link_id_to_display(link_id) + + # Kill all processes associated with this display using process tree kill + # This ensures child processes are properly terminated, not left as zombies + logger.info(f"Stopping all processes on display :{display}") + patterns = [ + f'xpra.*:{display}', + f'Xvfb.*:{display}', + f'Xvfb-for-Xpra-{display}', + f'wireshark.*:{display}', + f'pulseaudio.*display=:{display}' + ] + await self._kill_process_tree_batch(container["Id"], patterns) + await self._cleanup_x_lock(container["Id"], display) + + logger.info("Web Wireshark session stopped successfully") + + except Exception as e: + logger.error(f"Error stopping session: {e}") + + async def restart_wireshark_session( + self, + project_id: str, + link_id: str, + jwt_token: str, + capture_stream_url: str = None, + image: str = "gns3/web-wireshark:latest", + memory: str = "2g", + memory_swap: str = None, + cpus: float = 1.0, + pids_limit: int = 1000 + ): + """Restart Web Wireshark session. + + This simply calls start_wireshark_session since it already handles + cleanup of existing processes. + + Args: + project_id: Project ID + link_id: Link ID + jwt_token: JWT authentication token + capture_stream_url: Capture stream URL + image: Docker image name + memory: Memory limit + memory_swap: Memory swap limit + cpus: CPU cores + pids_limit: Process limit + + Returns: + Session info dict + """ + return await self.start_wireshark_session( + project_id=project_id, + link_id=link_id, + jwt_token=jwt_token, + capture_stream_url=capture_stream_url, + image=image, + memory=memory, + memory_swap=memory_swap, + cpus=cpus, + pids_limit=pids_limit + ) + + async def stop_all_sessions(self, project_id: str): + """Stop all Web Wireshark sessions for a project. + + Args: + project_id: Project ID + """ + logger.info(f"Stopping all Web Wireshark sessions for project {project_id}") + try: + container_name = f"gns3-wireshark-{project_id}" + container = await self.docker.get_container(container_name) + + if not container: + logger.info(f"Container {container_name} not found, nothing to stop") + return + + # Kill all wireshark, xpra and Xvfb processes for link sessions + # This ensures clean removal of all session processes + patterns = [ + "xpra.*--session-name=link-", + "Xvfb-for-Xpra-", + "wireshark.*display :", + "pulseaudio.*display :" + ] + await self._kill_process_tree_batch(container["Id"], patterns) + + logger.info(f"All Web Wireshark sessions stopped for project {project_id}") + + except Exception as e: + # Don't fail if container is already stopped or doesn't exist + logger.warning(f"Error stopping all sessions: {e}") + + async def stop_container(self, project_id: str): + """Stop Web Wireshark container. + + Args: + project_id: Project ID + """ + logger.info(f"Stopping Web Wireshark container for project {project_id}") + try: + container_name = f"gns3-wireshark-{project_id}" + container = await self.docker.get_container(container_name) + + if not container: + logger.info(f"Container {container_name} not found, nothing to stop") + return + + if not container["State"]["Running"]: + logger.info(f"Container {container_name} is not running") + return + + await self.docker.stop_container(container["Id"]) + logger.info(f"Container {container_name} stopped") + + except Exception as e: + logger.error(f"Error stopping container: {e}") + + async def delete_container(self, project_id: str): + """Delete Web Wireshark container. + + Args: + project_id: Project ID + """ + logger.info(f"Deleting Web Wireshark container for project {project_id}") + try: + container_name = f"gns3-wireshark-{project_id}" + container = await self.docker.get_container(container_name) + + if not container: + logger.info(f"Container {container_name} not found, nothing to delete") + return + + # Stop first if running + if container["State"]["Running"]: + await self.docker.stop_container(container["Id"]) + + # Remove container + await self.docker.remove_container(container["Id"], force=True) + logger.info(f"Container {container_name} deleted") + + except Exception as e: + logger.error(f"Error deleting container: {e}") diff --git a/gns3server/agent/web_wireshark/setup_wireshark_image.py b/gns3server/agent/web_wireshark/setup_wireshark_image.py new file mode 100644 index 000000000..65afc892d --- /dev/null +++ b/gns3server/agent/web_wireshark/setup_wireshark_image.py @@ -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 . + +""" +Setup Web Wireshark Docker image. + +This script pulls or builds the gns3/web-wireshark Docker image. +Run with: pip install gns3server && gns3server-web-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 .") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/gns3server/agent/web_wireshark/stats.py b/gns3server/agent/web_wireshark/stats.py new file mode 100644 index 000000000..e5611a997 --- /dev/null +++ b/gns3server/agent/web_wireshark/stats.py @@ -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 diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py index 1922e31a7..017853b85 100644 --- a/gns3server/api/routes/compute/__init__.py +++ b/gns3server/api/routes/compute/__init__.py @@ -59,7 +59,7 @@ log = logging.getLogger(__name__) compute_api = FastAPI( title="GNS3 compute API", description="This page describes the private compute API for GNS3. PLEASE DO NOT USE DIRECTLY!", - version="v3", + version="3.0.0", ) compute_api.state.controller_host = None diff --git a/gns3server/api/routes/compute/atm_switch_nodes.py b/gns3server/api/routes/compute/atm_switch_nodes.py index c50d745d1..2dbe10a1d 100644 --- a/gns3server/api/routes/compute/atm_switch_nodes.py +++ b/gns3server/api/routes/compute/atm_switch_nodes.py @@ -20,7 +20,7 @@ API routes for ATM switch nodes. import os -from fastapi import APIRouter, Depends, Body, Path, Response, status +from fastapi import APIRouter, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID @@ -121,20 +121,24 @@ async def delete_atm_switch_node(node: ATMSwitch = Depends(dep_node)) -> None: def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: """ Start an ATM switch node. - This endpoint results in no action since ATM switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Start is not supported for ATM switches" + ) @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT) def stop_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: """ Stop an ATM switch node. - This endpoint results in no action since ATM switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for ATM switches" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @@ -144,7 +148,10 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None: This endpoint results in no action since ATM switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for ATM switches" + ) @router.post( diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index c220ea1c2..8453ba0d8 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -20,7 +20,7 @@ API routes for cloud nodes. import os -from fastapi import APIRouter, Depends, Path, Response, status +from fastapi import APIRouter, Depends, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import Union @@ -120,10 +120,12 @@ async def start_cloud(node: Cloud = Depends(dep_node)) -> None: async def stop_cloud(node: Cloud = Depends(dep_node)) -> None: """ Stop a cloud node. - This endpoint results in no action since cloud nodes cannot be stopped. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for cloud nodes" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) @@ -133,7 +135,10 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None: This endpoint results in no action since cloud nodes cannot be suspended. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for cloud nodes" + ) @router.post( @@ -176,6 +181,7 @@ async def update_cloud_nio( """ nio = node.get_nio(port_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.update_nio(port_number, nio) diff --git a/gns3server/api/routes/compute/dependencies/authentication.py b/gns3server/api/routes/compute/dependencies/authentication.py index 377a89dc6..a5ad44b10 100644 --- a/gns3server/api/routes/compute/dependencies/authentication.py +++ b/gns3server/api/routes/compute/dependencies/authentication.py @@ -26,12 +26,29 @@ from gns3server.config import Config from typing import Optional, Union log = logging.getLogger(__name__) -security = HTTPBasic() +security = HTTPBasic(auto_error=False) def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None: + """ + Authenticate compute requests. + + Returns None if authentication is disabled or if authentication succeeds + Raises HTTPException if authentication is required but credentials are invalid + """ server_settings = Config.instance().settings.Server + + if not server_settings.enable_http_auth: + return None + + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid compute username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + username = secrets.compare_digest(credentials.username, server_settings.compute_username) password = secrets.compare_digest(credentials.password, server_settings.compute_password.get_secret_value()) if not (username and password): @@ -45,6 +62,12 @@ async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSock """ """ + server_settings = Config.instance().settings.Server + + if not server_settings.enable_http_auth: + await websocket.accept() + return websocket + await websocket.accept() # handle basic HTTP authentication @@ -68,7 +91,6 @@ async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSock if not separator: raise invalid_user_credentials_exc - server_settings = Config.instance().settings.Server username = secrets.compare_digest(username, server_settings.compute_username) password = secrets.compare_digest(password, server_settings.compute_password.get_secret_value()) if not (username and password): diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index bf1bb072c..1d9e98530 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -289,6 +289,7 @@ async def update_docker_node_nio( """ nio = node.get_nio(adapter_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.adapter_update_nio_binding(adapter_number, nio) @@ -383,6 +384,21 @@ async def console_ws( await node.start_websocket_console(websocket) +@router.websocket( + "/{node_id}/console/vnc" +) +async def vnc_console_ws( + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), + node: DockerVM = Depends(dep_node) +) -> None: + """ + VNC Console WebSocket. + """ + + if websocket: + await node.start_vnc_websocket_console(websocket) + + @router.post( "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 50ee7b45f..08eebb0d7 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -232,6 +232,7 @@ async def update_nio( """ nio = node.get_nio(adapter_number, port_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.slot_update_nio_binding(adapter_number, port_number, nio) diff --git a/gns3server/api/routes/compute/ethernet_hub_nodes.py b/gns3server/api/routes/compute/ethernet_hub_nodes.py index c3b3af6ca..22f7a3b6d 100644 --- a/gns3server/api/routes/compute/ethernet_hub_nodes.py +++ b/gns3server/api/routes/compute/ethernet_hub_nodes.py @@ -20,7 +20,7 @@ API routes for Ethernet hub nodes. import os -from fastapi import APIRouter, Depends, Body, Path, Response, status +from fastapi import APIRouter, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID @@ -123,27 +123,34 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: This endpoint results in no action since Ethernet hub nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Start is not supported for Ethernet hubs" + ) @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT) def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: """ Stop an Ethernet hub. - This endpoint results in no action since Ethernet hub nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for Ethernet hubs" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None: """ Suspend an Ethernet hub. - This endpoint results in no action since Ethernet hub nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for Ethernet hubs" + ) @router.post( diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index e672954bc..a8f755047 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -20,7 +20,7 @@ API routes for Ethernet switch nodes. import os -from fastapi import APIRouter, Depends, Body, Path, Response, status +from fastapi import APIRouter, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID @@ -124,30 +124,36 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ Start an Ethernet switch. - This endpoint results in no action since Ethernet switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Start is not supported for Ethernet switches" + ) @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT) def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ Stop an Ethernet switch. - This endpoint results in no action since Ethernet switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for Ethernet switches" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: """ Suspend an Ethernet switch. - This endpoint results in no action since Ethernet switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for Ethernet switches" + ) @router.post("/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT) @@ -157,7 +163,10 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: This endpoint results in no action since Ethernet switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Reload is not supported for Ethernet switches" + ) @router.post( diff --git a/gns3server/api/routes/compute/frame_relay_switch_nodes.py b/gns3server/api/routes/compute/frame_relay_switch_nodes.py index 32482621c..aa81896e3 100644 --- a/gns3server/api/routes/compute/frame_relay_switch_nodes.py +++ b/gns3server/api/routes/compute/frame_relay_switch_nodes.py @@ -20,7 +20,7 @@ API routes for Frame Relay switch nodes. import os -from fastapi import APIRouter, Depends, Body, Path, Response, status +from fastapi import APIRouter, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID @@ -124,30 +124,36 @@ async def delete_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None: """ Start a Frame Relay switch node. - This endpoint results in no action since Frame Relay switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Start is not supported for Frame Relay switches" + ) @router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT) def stop_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None: """ Stop a Frame Relay switch node. - This endpoint results in no action since Frame Relay switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for Frame Relay switches" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None: """ Suspend a Frame Relay switch node. - This endpoint results in no action since Frame Relay switch nodes are always on. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for Frame Relay switches" + ) @router.post( diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index f3b5b8fe8..5be4fdd23 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -20,7 +20,7 @@ API routes for IOU nodes. import os -from fastapi import APIRouter, WebSocket, Depends, Body, status +from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import Union @@ -186,17 +186,18 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None: @router.post( - "/{node_id}/stop", - status_code=status.HTTP_204_NO_CONTENT, + "/{node_id}/suspend", dependencies=[Depends(compute_authentication)] ) def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None: """ Suspend an IOU node. - Does nothing since IOU doesn't support being suspended. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for IOU nodes" + ) @router.post( @@ -250,6 +251,7 @@ async def update_iou_node_nio( """ nio = node.get_nio(adapter_number, port_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.adapter_update_nio_binding(adapter_number, port_number, nio) diff --git a/gns3server/api/routes/compute/nat_nodes.py b/gns3server/api/routes/compute/nat_nodes.py index 06788f6a8..d11b7975d 100644 --- a/gns3server/api/routes/compute/nat_nodes.py +++ b/gns3server/api/routes/compute/nat_nodes.py @@ -20,7 +20,7 @@ API routes for NAT nodes. import os -from fastapi import APIRouter, Depends, Path, Response, status +from fastapi import APIRouter, Depends, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import Union @@ -115,20 +115,24 @@ async def start_nat_node(node: Nat = Depends(dep_node)) -> None: async def stop_nat_node(node: Nat = Depends(dep_node)) -> None: """ Stop a NAT node. - This endpoint results in no action since cloud nodes cannot be stopped. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Stop is not supported for NAT nodes" + ) @router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT) async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None: """ Suspend a NAT node. - This endpoint results in no action since NAT nodes cannot be suspended. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for NAT nodes" + ) @router.post( @@ -171,6 +175,7 @@ async def update_nat_node_nio( """ nio = node.get_nio(port_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.update_nio(port_number, nio) diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index eca97c204..711e78416 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -192,6 +192,20 @@ async def get_compute_project_files(project: Project = Depends(dep_project)) -> return await project.list_files() +@router.get("/projects/{project_id}/nodes/{node_type}/{node_id}/files", response_model=List[schemas.NodeFile]) +async def get_compute_node_files( + node_type: str, + node_id: str, + project: Project = Depends(dep_project) +) -> List[schemas.NodeFile]: + """ + Return files belonging to a specific node with detailed metadata. + """ + + node_path = f"project-files/{node_type}/{node_id}" + return await project.list_node_files(node_path) + + @router.get("/projects/{project_id}/files/{file_path:path}") async def get_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse: """ diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 1689fabe9..623ae8dae 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -27,11 +27,15 @@ from typing import Union from uuid import UUID from gns3server import schemas +from gns3server.compute import qemu from gns3server.compute.qemu import Qemu from gns3server.compute.qemu.qemu_vm import QemuVM from .dependencies.authentication import compute_authentication, ws_compute_authentication +import logging +log = logging.getLogger(__name__) + responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}} router = APIRouter(responses=responses) @@ -74,6 +78,15 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s platform=node_data.pop("platform", None), ) + # update the disk image with the backing file if provided + # this is needed when duplicating a node that uses backed disk images + drives = ["a", "b", "c", "d"] + for disk_index, drive in enumerate(drives): + disk_image_backing_file = node_data.get(f"hd{drive}_disk_image_backing_file") + if disk_image_backing_file: + log.info(f"Updating disk image for drive {drive} with backing file {disk_image_backing_file}") + node_data[f"hd{drive}_disk_image"] = disk_image_backing_file + for name, value in node_data.items(): if hasattr(vm, name) and getattr(vm, name) != value: setattr(vm, name, value) @@ -304,10 +317,10 @@ async def update_qemu_node_nio( """ nio = node.get_nio(adapter_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters - if nio_data.suspend: - nio.suspend = nio_data.suspend + nio.suspend = nio_data.suspend await node.adapter_update_nio_binding(adapter_number, nio) return nio.asdict() @@ -402,6 +415,21 @@ async def console_ws( await node.start_websocket_console(websocket) +@router.websocket( + "/{node_id}/console/vnc" +) +async def vnc_console_ws( + websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), + node: QemuVM = Depends(dep_node) +) -> None: + """ + VNC Console WebSocket. + """ + + if websocket: + await node.start_vnc_websocket_console(websocket) + + @router.post( "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/api/routes/compute/virtualbox_nodes.py b/gns3server/api/routes/compute/virtualbox_nodes.py index f676025e6..957f1635f 100644 --- a/gns3server/api/routes/compute/virtualbox_nodes.py +++ b/gns3server/api/routes/compute/virtualbox_nodes.py @@ -268,10 +268,10 @@ async def update_virtualbox_node_nio( """ nio = node.get_nio(adapter_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters - if nio_data.suspend: - nio.suspend = nio_data.suspend + nio.suspend = nio_data.suspend await node.adapter_update_nio_binding(adapter_number, nio) return nio.asdict() diff --git a/gns3server/api/routes/compute/vmware_nodes.py b/gns3server/api/routes/compute/vmware_nodes.py index 30f79902c..aecce0289 100644 --- a/gns3server/api/routes/compute/vmware_nodes.py +++ b/gns3server/api/routes/compute/vmware_nodes.py @@ -233,6 +233,7 @@ async def update_vmware_node_nio( """ nio = node.get_nio(adapter_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.adapter_update_nio_binding(adapter_number, nio) diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index bd5181b67..c51f0254d 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -20,7 +20,7 @@ API routes for VPCS nodes. import os -from fastapi import APIRouter, WebSocket, Depends, Body, Path, status +from fastapi import APIRouter, WebSocket, Depends, Body, Path, status, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from typing import Union @@ -174,10 +174,12 @@ async def stop_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: async def suspend_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None: """ Suspend a VPCS node. - Does nothing, suspend is not supported by VPCS. """ - pass + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Suspend is not supported for VPCS nodes" + ) @router.post( @@ -235,6 +237,7 @@ async def update_vpcs_node_nio( """ nio = node.get_nio(port_number) + nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters await node.port_update_nio_binding(port_number, nio) diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index e5d095f82..f34d342c3 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -14,7 +14,33 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status + +# Check AI Copilot availability +from gns3server.agent import AI_COPILOT_AVAILABLE + +# Conditionally import AI-dependent routes +if AI_COPILOT_AVAILABLE: + from . import chat + from . import copilot + from . import llm_model_configs + _chat_router = chat.router + _copilot_router = copilot.router + _llm_router = llm_model_configs.router +else: + # Create stub routers that return 501 for all AI endpoints + _chat_router = APIRouter() + _copilot_router = APIRouter() + _llm_router = APIRouter() + + @_chat_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_copilot_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + async def ai_not_available(path: str = ""): + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-copilot]" + ) from . import controller from . import appliances @@ -145,3 +171,24 @@ router.include_router( prefix="/gns3vm", tags=["GNS3 VM"] ) + +router.include_router( + _llm_router, + prefix="/access", + dependencies=[Depends(get_current_active_user)], + tags=["LLM Model Configurations"] +) + +router.include_router( + _copilot_router, + prefix="/copilot", + dependencies=[Depends(get_current_active_user)], + tags=["GNS3 Copilot"] +) + +router.include_router( + _chat_router, + prefix="/copilot/projects/{project_id}/chat", + dependencies=[Depends(get_current_active_user)], + tags=["GNS3 Copilot"] +) diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py index ae33bd09e..f4b0e8897 100644 --- a/gns3server/api/routes/controller/acl.py +++ b/gns3server/api/routes/controller/acl.py @@ -115,19 +115,19 @@ async def endpoints( add_to_endpoints("/access/users", "All users", "user") users = await users_repo.get_users() for user in users: - add_to_endpoints(f"/users/{user.user_id}", f'User "{user.username}"', "user") + add_to_endpoints(f"/access/users/{user.user_id}", f'User "{user.username}"', "user") # groups add_to_endpoints("/access/groups", "All groups", "group") groups = await users_repo.get_user_groups() for group in groups: - add_to_endpoints(f"/groups/{group.user_group_id}", f'Group "{group.name}"', "group") + add_to_endpoints(f"/access/groups/{group.user_group_id}", f'Group "{group.name}"', "group") # roles add_to_endpoints("/access/roles", "All roles", "role") roles = await rbac_repo.get_roles() for role in roles: - add_to_endpoints(f"/roles/{role.role_id}", f'Role "{role.name}"', "role") + add_to_endpoints(f"/access/roles/{role.role_id}", f'Role "{role.name}"', "role") # images add_to_endpoints("/images", "All images", "image") diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index 93db5b204..1ca32d8e9 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -21,7 +21,7 @@ API routes for appliances. import logging from fastapi import APIRouter, Depends, status -from typing import Optional, List +from typing import Optional, List, Union from uuid import UUID from gns3server import schemas @@ -94,7 +94,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))] ) -def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersion) -> dict: +def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]) -> dict: """ Add a version to an appliance. diff --git a/gns3server/api/routes/controller/chat.py b/gns3server/api/routes/controller/chat.py new file mode 100644 index 000000000..61f56e0d7 --- /dev/null +++ b/gns3server/api/routes/controller/chat.py @@ -0,0 +1,576 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +API routes for GNS3 Copilot Chat integration. + +Nested under projects: /v3/projects/{project_id}/chat/... +""" + +import json +import logging +import uuid +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import StreamingResponse +from uuid import UUID + +from gns3server import schemas +from gns3server.controller import Controller +from gns3server.controller.project import Project +from gns3server.controller.controller_error import ControllerNotFoundError +from gns3server.agent.gns3_copilot.project_agent_manager import get_project_agent_manager +from gns3server.db.tasks import get_user_llm_config_full + +from .dependencies.authentication import get_current_active_user + +log = logging.getLogger(__name__) + +responses = {404: {"model": schemas.ErrorMessage, "description": "Resource not found"}} + +router = APIRouter(responses=responses) + + +def dep_project(project_id: UUID) -> Project: + """ + Dependency to retrieve a project. + + Args: + project_id: GNS3 project ID + + Returns: + Project instance + + Raises: + ControllerNotFoundError: If project not found + """ + controller = Controller.instance() + project = controller.get_project(str(project_id)) + if not project: + raise ControllerNotFoundError(f"Project '{project_id}' not found") + return project + + +@router.post( + "/stream", + response_model=None, + summary="Stream chat responses from GNS3 Copilot", + description="Send a message to GNS3 Copilot and stream the response via Server-Sent Events (SSE)." +) +async def stream_chat( + request: schemas.ChatRequest, + http_request: Request, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> StreamingResponse: + """ + Stream chat endpoint for GNS3 Copilot. + + This endpoint uses Server-Sent Events (SSE) to stream responses from + the AI agent. Each message is a JSON object with a `type` field indicating + the message kind (content, tool_call, tool_start, tool_end, error, done, heartbeat). + + The project must be opened to use chat functionality. + """ + + # Get user authentication info + user_id = str(current_user.user_id) + + # Check if project is opened + if project.status != "opened": + log.warning( + "Chat rejected: project not opened. user_id=%s, project_id=%s, status=%s", + user_id, + project.id, + project.status + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to use chat. Current status: {project.status}" + ) + + log.info( + "Chat request started: user_id=%s, project_id=%s, project_name=%s, session_id=%s", + user_id, + project.id, + project.name, + request.session_id or "(new)", + ) + + # Get JWT token from Authorization header + auth_header = http_request.headers.get("Authorization", "") + jwt_token = auth_header.replace("Bearer ", "") if auth_header else None + + # Get FastAPI app reference (for database access) + app = http_request.app + + # Get user's LLM config (with decrypted API key) + llm_config = await get_user_llm_config_full(user_id, app) + if not llm_config: + log.warning("LLM config not found for user: %s", user_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LLM configuration not found. Please configure your LLM settings first." + ) + + log.debug( + "LLM config loaded: user_id=%s, provider=%s, model=%s", + user_id, + llm_config.get("provider"), + llm_config.get("model"), + ) + + # TODO: Support runtime temperature override from request.temperature + # Currently, temperature is loaded from the user's LLM config in the database. + # To enable runtime override, uncomment the following: + # if request.temperature is not None: + # llm_config["temperature"] = str(request.temperature) + + # Get or create AgentService for this project + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + log.debug("AgentService obtained for project: %s", project.id) + + # Generate session_id if not provided + session_id = request.session_id or str(uuid.uuid4()) + if not request.session_id: + log.debug("New session created: %s", session_id) + + async def generate(): + """Generator for SSE streaming.""" + try: + log.debug("Starting stream: session_id=%s", session_id) + async for chunk in agent_service.stream_chat( + message=request.message, + session_id=session_id, + project_id=str(project.id), + user_id=user_id, + jwt_token=jwt_token, + mode=request.mode, + llm_config=llm_config + ): + try: + # Validate and serialize chunk + validated = schemas.ChatResponse(**chunk) + yield f"data: {json.dumps(validated.model_dump(exclude_none=True), ensure_ascii=False)}\n\n" + except Exception as e: + log.warning("Error serializing chunk: %s", e) + # Skip invalid chunks but continue streaming + continue + + # Final done message + log.debug("Stream completed: session_id=%s", session_id) + yield f"data: {json.dumps({'type': 'done', 'session_id': session_id})}\n\n" + + except Exception as e: + log.error("Error in stream_chat: %s", e, exc_info=True) + yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + } + ) + + +@router.get( + "/sessions", + response_model=List[schemas.ChatSession], + summary="List chat sessions", + description="List all chat sessions for a project, optionally filtered by copilot_mode." +) +async def list_sessions( + project: Project = Depends(dep_project), + copilot_mode: Optional[str] = None, + current_user: schemas.User = Depends(get_current_active_user), +) -> list[schemas.ChatSession]: + """ + List chat sessions for a project. + + Query Parameters: + - copilot_mode: Optional filter by copilot mode (e.g., "troubleshooting_injection", "teaching_assistant", "lab_automation_assistant") + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to access chat sessions. 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) + + # List sessions with optional copilot_mode filter + sessions = await agent_service.list_sessions( + user_id=str(current_user.user_id), + copilot_mode=copilot_mode + ) + + # Convert to schemas + return [schemas.ChatSession(**s) for s in sessions] + + +@router.get( + "/sessions/{session_id}/history", + response_model=schemas.ConversationHistory, + summary="Get conversation history", + description="Retrieve the conversation history for a specific session/thread." +) +async def get_history( + session_id: str, + project: Project = Depends(dep_project), + limit: int = 100, + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ConversationHistory: + """ + Get conversation history for a session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to access chat history. 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) + + # Get history + history = await agent_service.get_history(session_id, limit) + + return schemas.ConversationHistory(**history) + + +@router.delete( + "/sessions/{session_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a chat session", + description="Delete a specific chat session and its checkpoints." +) +async def delete_session( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +): + """ + Delete a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to delete chat sessions. 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) + + # Delete session + deleted = await agent_service.delete_session(session_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + +@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, + summary="Rename a chat session", + description="Rename a specific chat session." +) +async def rename_session( + session_id: str, + request: schemas.RenameSession, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ChatSession: + """ + Rename a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to rename chat sessions. 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) + + # Rename session + session = await agent_service.rename_session(session_id, request.title) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + return schemas.ChatSession(**session) + + +@router.put( + "/sessions/{session_id}/pin", + response_model=schemas.ChatSession, + summary="Pin a chat session", + description="Pin a chat session to the top of the list." +) +async def pin_session( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ChatSession: + """ + Pin a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to pin chat sessions. 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) + + # Pin session + session = await agent_service.pin_session(session_id, pinned=True) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + return schemas.ChatSession(**session) + + +@router.delete( + "/sessions/{session_id}/pin", + response_model=schemas.ChatSession, + summary="Unpin a chat session", + description="Unpin a chat session from the top of the list." +) +async def unpin_session( + session_id: str, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> schemas.ChatSession: + """ + Unpin a chat session. + """ + + # Check if project is opened + if project.status != "opened": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to unpin chat sessions. 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) + + # Unpin session + session = await agent_service.pin_session(session_id, pinned=False) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session '{session_id}' not found" + ) + + return schemas.ChatSession(**session) + + +@router.post( + "/inject", + response_model=None, + summary="Inject a network fault for troubleshooting practice", + description="Inject a realistic network fault into the GNS3 lab for troubleshooting training." +) +async def inject_issue( + request: schemas.ChatRequest, + http_request: Request, + project: Project = Depends(dep_project), + current_user: schemas.User = Depends(get_current_active_user), +) -> StreamingResponse: + """ + Inject a network fault for troubleshooting practice. + + This endpoint uses the GNS3 Copilot agent in troubleshooting_injection mode + to analyze the topology, select an appropriate fault, and inject it. + + The fault injection process is streamed via Server-Sent Events (SSE), + and the complete conversation is stored in the chat history for later review. + + The project must be opened to use fault injection. + """ + + # Get user authentication info + user_id = str(current_user.user_id) + + # Check if project is opened + if project.status != "opened": + log.warning( + "Fault injection rejected: project not opened. user_id=%s, project_id=%s, status=%s", + user_id, + project.id, + project.status + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Project must be opened to use fault injection. Current status: {project.status}" + ) + + log.info( + "Fault injection started: user_id=%s, project_id=%s, project_name=%s, session_id=%s", + user_id, + project.id, + project.name, + request.session_id or "(new)", + ) + + # Get JWT token from Authorization header + auth_header = http_request.headers.get("Authorization", "") + jwt_token = auth_header.replace("Bearer ", "") if auth_header else None + + # Get FastAPI app reference (for database access) + app = http_request.app + + # Get user's LLM config (with decrypted API key) + llm_config = await get_user_llm_config_full(user_id, app) + if not llm_config: + log.warning("LLM config not found for user: %s", user_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LLM configuration not found. Please configure your LLM settings first." + ) + + # Override copilot_mode to troubleshooting_injection + llm_config["copilot_mode"] = "troubleshooting_injection" + log.debug( + "LLM config loaded and mode set to troubleshooting_injection: user_id=%s, provider=%s, model=%s", + user_id, + llm_config.get("provider"), + llm_config.get("model"), + ) + + # Get or create AgentService for this project + agent_manager = await get_project_agent_manager() + agent_service = await agent_manager.get_agent(str(project.id), project.path) + log.debug("AgentService obtained for project: %s", project.id) + + # Generate session_id if not provided + session_id = request.session_id or str(uuid.uuid4()) + if not request.session_id: + log.debug("New session created for fault injection: %s", session_id) + + # Use default message if not provided + message = request.message or "Inject a network fault for troubleshooting practice" + + async def generate(): + """Generator for SSE streaming.""" + try: + log.debug("Starting fault injection stream: session_id=%s", session_id) + async for chunk in agent_service.stream_chat( + message=message, + session_id=session_id, + project_id=str(project.id), + user_id=user_id, + jwt_token=jwt_token, + mode=request.mode, + llm_config=llm_config + ): + try: + # Validate and serialize chunk + validated = schemas.ChatResponse(**chunk) + yield f"data: {json.dumps(validated.model_dump(exclude_none=True), ensure_ascii=False)}\n\n" + except Exception as e: + log.warning("Error serializing chunk: %s", e) + # Skip invalid chunks but continue streaming + continue + + # Final done message + log.debug("Fault injection stream completed: session_id=%s", session_id) + yield f"data: {json.dumps({'type': 'done', 'session_id': session_id})}\n\n" + + except Exception as e: + log.error("Error in fault injection stream: %s", e, exc_info=True) + yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + } + ) diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index bc0a249f8..4f1a3e862 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -24,10 +24,11 @@ from fastapi.encoders import jsonable_encoder from fastapi.routing import Mount from websockets.exceptions import ConnectionClosed, WebSocketException -from typing import List +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 @@ -162,19 +163,95 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice @router.get("/statistics", dependencies=[Depends(get_current_active_user)]) -async def statistics() -> List[dict]: +async def statistics() -> dict: """ - Return server statistics. + Return server statistics including compute resources, projects, and nodes. """ + controller = Controller.instance() + + # Compute statistics (existing behavior) compute_statistics = [] - for compute in list(Controller.instance().computes.values()): + for compute in list(controller.computes.values()): try: r = await compute.get("/statistics") compute_statistics.append({"compute_id": compute.id, "compute_name": compute.name, "statistics": r.json}) except ControllerError as e: log.error(f"Could not retrieve statistics on compute {compute.name}: {e}") - return compute_statistics + + # Project statistics + projects = list(controller.projects.values()) + project_stats = { + "total": len(projects), + "opened": sum(1 for p in projects if p.status == "opened"), + "closed": sum(1 for p in projects if p.status == "closed"), + } + + # Node statistics - distinguish open vs closed project nodes + open_project_nodes = [] + closed_project_nodes = [] + node_by_type = {} + node_by_status = {} + + for project in projects: + nodes = project.nodes.values() + if project.status == "closed": + closed_project_nodes.extend(nodes) + else: + open_project_nodes.extend(nodes) + + # Open project nodes have real status + for node in open_project_nodes: + node_type = getattr(node, "node_type", "unknown") + node_status = getattr(node, "status", "unknown") + node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 + node_by_status[node_status] = node_by_status.get(node_status, 0) + 1 + + # Closed project nodes don't have status, count them separately + for node in closed_project_nodes: + if isinstance(node, dict): + node_type = node.get("node_type", "unknown") + else: + node_type = getattr(node, "node_type", "unknown") + node_by_type[node_type] = node_by_type.get(node_type, 0) + 1 + + node_stats = { + "total": len(open_project_nodes) + len(closed_project_nodes), + "open_project_nodes": len(open_project_nodes), + "closed_project_nodes": len(closed_project_nodes), + "by_type": node_by_type, + "by_status": node_by_status, + } + + # Link statistics + all_links = [] + for project in projects: + all_links.extend(project.links.values()) + + def is_capturing(link): + if hasattr(link, "capturing"): + return link.capturing + if isinstance(link, dict): + return link.get("capturing", False) + return False + + link_capturing = sum(1 for link in all_links if is_capturing(link)) + + link_stats = { + "total": len(all_links), + "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, + } @router.get("/notifications", dependencies=[Depends(get_current_active_user)]) diff --git a/gns3server/api/routes/controller/copilot.py b/gns3server/api/routes/controller/copilot.py new file mode 100644 index 000000000..050792392 --- /dev/null +++ b/gns3server/api/routes/controller/copilot.py @@ -0,0 +1,60 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +API routes for GNS3 Copilot global operations (non-project). +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from gns3server import schemas +from .dependencies.authentication import get_current_active_user + +log = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post( + "/reload/skills", + dependencies=[Depends(get_current_active_user)], +) +async def reload_skills(current_user: schemas.User = Depends(get_current_active_user)) -> dict: + """ + Hot reload skills and prompts from the external GNS3-Skills repository. + + Reloads injection skills, system prompts, and forbidden commands + from the skills repository without restarting the server. + + Requires superadmin privileges. + """ + if not current_user.is_superadmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can reload skills", + ) + + try: + from gns3server.agent.gns3_copilot.skills.registry import ( + reload_skills_repository, + ) + + result = reload_skills_repository() + except ImportError: + return {"error": "AI Copilot is not available"} + + return result diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index 05e4d4ae7..3e06345d1 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -47,14 +47,20 @@ async def get_user_from_token( headers={"WWW-Authenticate": "Bearer"}, ) - username = auth_service.get_username_from_token(token) - user = await user_repo.get_user_by_username(username) + token_data = auth_service.get_token_data(token) + user = await user_repo.get_user_by_username(token_data.username) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + if token_data.token_version != user.token_version: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Token has been revoked for '{token_data.username}'", + headers={"WWW-Authenticate": "Bearer"}, + ) return user @@ -84,16 +90,30 @@ 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: - username = auth_service.get_username_from_token(token) - user = await user_repo.get_user_by_username(username) + token_data = auth_service.get_token_data(token) + user = await user_repo.get_user_by_username(token_data.username) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"Could not validate credentials for '{username}'" + detail=f"Could not validate credentials for '{token_data.username}'" + ) + if token_data.token_version != user.token_version: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Token has been revoked for '{token_data.username}'" ) # Super admin is always authorized @@ -103,7 +123,7 @@ async def get_current_active_user_from_websocket( if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"'{username}' is not an active user" + detail=f"'{token_data.username}' is not an active user" ) return user diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index 9a43152ed..26b9a2ed2 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -154,6 +154,17 @@ async def upload_image( if os.path.commonprefix([base_images_directory, full_path]) != base_images_directory: raise ControllerForbiddenError(f"Cannot write image, '{image_path}' is forbidden") + # If the client sends X-MD5-Checksum, check for a duplicate before consuming the upload stream + checksum_header = request.headers.get("X-MD5-Checksum") + if checksum_header: + check_dir = os.path.dirname(full_path) if image_dir else None + duplicate = await images_repo.get_image_by_checksum(checksum_header, check_dir) + if duplicate: + location = f" in '{check_dir}'" if check_dir else "" + raise ControllerError( + f"Image '{duplicate.filename}' with the same checksum already exists{location}" + ) + try: allow_raw_image = Config.instance().settings.Server.allow_raw_images image = await write_image(image_path, full_path, request.stream(), images_repo, allow_raw_image=allow_raw_image) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 67041eee2..2a6eec200 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -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,13 @@ 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 gns3server.agent.web_wireshark.manager import WebWiresharkManager 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 +219,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 +258,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 +296,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 +332,91 @@ 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= + + 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 + manager = WebWiresharkManager() + container_ip = await manager.get_container_ip(container_name) + + 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], diff --git a/gns3server/api/routes/controller/llm_model_configs.py b/gns3server/api/routes/controller/llm_model_configs.py new file mode 100644 index 000000000..a567bd1ef --- /dev/null +++ b/gns3server/api/routes/controller/llm_model_configs.py @@ -0,0 +1,717 @@ +#!/usr/bin/env python +# +# 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 . + +""" +API routes for LLM model configurations. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from uuid import UUID +from typing import List + +from gns3server import schemas +from gns3server.controller.controller_error import ( + ControllerBadRequestError, + ControllerNotFoundError, +) + +from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository +from gns3server.db.repositories.users import UsersRepository + +from .dependencies.database import get_repository +from .dependencies.rbac import has_privilege + +import logging + +log = logging.getLogger(__name__) + +router = APIRouter() + + +def _filter_api_key_from_config(config: dict) -> dict: + """ + Remove API key from config dict for security. + API keys should NEVER be returned via API endpoints. + + :param config: Configuration dictionary + :return: Configuration dictionary with api_key set to None + """ + filtered = config.copy() + if "api_key" in filtered: + filtered["api_key"] = None + return filtered + + +# ============================================================================ +# User LLM Model Configuration Endpoints +# ============================================================================ + +@router.get( + "/users/{user_id}/llm-model-configs", + response_model=schemas.LLMModelConfigInheritedResponse +) +async def get_user_llm_model_configs( + user_id: UUID, + current_user: schemas.User = Depends(has_privilege("User.Audit")), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigInheritedResponse: + """ + Get user's effective LLM model configurations (own + inherited from groups). + + Required privilege: User.Audit + """ + + try: + result = await llm_repo.get_user_effective_configs( + user_id, + current_user_id=current_user.user_id, + current_user_is_superadmin=current_user.is_superadmin + ) + return schemas.LLMModelConfigInheritedResponse( + configs=result["configs"], + default_config=result.get("default_config"), + total=len(result["configs"]) + ) + except Exception as e: + log.error(f"Failed to retrieve user LLM model configs: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configurations" + ) + + +@router.get( + "/users/{user_id}/llm-model-configs/own", + response_model=List[schemas.LLMModelConfigResponse], + dependencies=[Depends(has_privilege("User.Audit"))] +) +async def get_user_own_llm_model_configs( + user_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> List[schemas.LLMModelConfigResponse]: + """ + Get user's own LLM model configurations (excluding inherited ones). + + Required privilege: User.Audit + """ + + try: + configs = await llm_repo.get_user_configs(user_id) + return [ + schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + for config in configs + ] + except Exception as e: + log.error(f"Failed to retrieve user's own LLM model configs: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configurations" + ) + + +@router.get( + "/users/{user_id}/llm-model-configs/default", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("User.Audit"))] +) +async def get_user_default_llm_model_config( + user_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Get user's default LLM model configuration. + + Required privilege: User.Audit + """ + + try: + config = await llm_repo.get_user_default_config(user_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No default LLM model configuration found for user '{user_id}'" + ) + + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to retrieve user's default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configuration" + ) + + +@router.post( + "/users/{user_id}/llm-model-configs", + response_model=schemas.LLMModelConfigResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(has_privilege("User.Modify"))] +) +async def create_user_llm_model_config( + user_id: UUID, + config_create: schemas.LLMModelConfigCreate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Create a new LLM model configuration for a user. + + Required privilege: User.Modify + + IMPORTANT: context_limit is REQUIRED. Unit is K tokens (e.g., 128 = 128K = 128,000 tokens). + Please check your model provider's documentation for the current context window size. + """ + + # Verify user exists + user = await users_repo.get_user(user_id) + if not user: + raise ControllerNotFoundError(f"User '{user_id}' not found") + + # Validate context_limit is provided + if not hasattr(config_create, 'context_limit') or config_create.context_limit is None: + raise ControllerBadRequestError( + "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). " + "Please check your model provider's documentation for the current context window size " + "and specify it in the configuration." + ) + + try: + # Extract config fields (excluding table-level fields) + config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"}) + new_config = await llm_repo.create_user_config( + user_id, + config_create.name, + config_create.model_type, + config_fields, + is_default=config_create.is_default + ) + + return schemas.LLMModelConfigResponse( + config_id=new_config.config_id, + name=new_config.name, + model_type=new_config.model_type, + config=_filter_api_key_from_config(new_config.config), + user_id=new_config.user_id, + group_id=new_config.group_id, + is_default=new_config.is_default, + version=new_config.version, + created_at=new_config.created_at, + updated_at=new_config.updated_at + ) + except ValueError as e: + raise ControllerBadRequestError(str(e)) + except Exception as e: + log.error(f"Failed to create LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create LLM model configuration: {e}" + ) + + +@router.put( + "/users/{user_id}/llm-model-configs/{config_id}", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("User.Modify"))] +) +async def update_user_llm_model_config( + user_id: UUID, + config_id: UUID, + config_update: schemas.LLMModelConfigUpdate, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Update a user's LLM model configuration. + Supports optimistic locking via expected_version field. + + Required privilege: User.Modify + """ + + try: + # Build updates dict with only non-None values + updates = {k: v for k, v in config_update.model_dump().items() if v is not None} + + # Extract expected_version for optimistic locking + expected_version = updates.pop("expected_version", None) + + updated_config = await llm_repo.update_user_config( + config_id, + user_id, + updates, + expected_version=expected_version + ) + + if not updated_config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + + return schemas.LLMModelConfigResponse( + config_id=updated_config.config_id, + name=updated_config.name, + model_type=updated_config.model_type, + config=_filter_api_key_from_config(updated_config.config), + user_id=updated_config.user_id, + group_id=updated_config.group_id, + is_default=updated_config.is_default, + version=updated_config.version, + created_at=updated_config.created_at, + updated_at=updated_config.updated_at + ) + except HTTPException: + raise + except ValueError as e: + # Handle optimistic lock errors + if "Concurrent modification" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e) + ) + raise ControllerBadRequestError(str(e)) + except Exception as e: + log.error(f"Failed to update LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update LLM model configuration" + ) + + +@router.delete( + "/users/{user_id}/llm-model-configs/{config_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("User.Modify"))] +) +async def delete_user_llm_model_config( + user_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> None: + """ + Delete a user's LLM model configuration. + + Required privilege: User.Modify + """ + + try: + success = await llm_repo.delete_user_config(config_id, user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to delete LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete LLM model configuration" + ) + + +@router.put( + "/users/{user_id}/llm-model-configs/default/{config_id}", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("User.Modify"))] +) +async def set_user_default_llm_model_config( + user_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Set a user's default LLM model configuration. + + Required privilege: User.Modify + """ + + try: + success = await llm_repo.set_user_default_config(user_id, config_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + + # Get the updated config + config = await llm_repo.get_user_config(config_id) + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to set default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to set default LLM model configuration" + ) + + +# ============================================================================ +# Group LLM Model Configuration Endpoints +# ============================================================================ + +@router.get( + "/groups/{group_id}/llm-model-configs", + response_model=schemas.LLMModelConfigListResponse, + dependencies=[Depends(has_privilege("Group.Audit"))] +) +async def get_group_llm_model_configs( + group_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigListResponse: + """ + Get all LLM model configurations for a user group. + + Required privilege: Group.Audit + """ + + try: + configs = await llm_repo.get_group_configs(group_id) + config_responses = [ + schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + for config in configs + ] + + # Find default config (same logic as user configs) + default_config = None + for config in config_responses: + if config.is_default: + default_config = config + break + + # Fallback to first config if no default is marked + if default_config is None and config_responses: + default_config = config_responses[0] + + return schemas.LLMModelConfigListResponse( + configs=config_responses, + default_config=default_config, + total=len(config_responses) + ) + except Exception as e: + log.error(f"Failed to retrieve group LLM model configs: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configurations" + ) + + +@router.get( + "/groups/{group_id}/llm-model-configs/default", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("Group.Audit"))] +) +async def get_group_default_llm_model_config( + group_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Get group's default LLM model configuration. + + Required privilege: Group.Audit + """ + + try: + config = await llm_repo.get_group_default_config(group_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No default LLM model configuration found for group '{group_id}'" + ) + + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to retrieve group's default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configuration" + ) + + +@router.post( + "/groups/{group_id}/llm-model-configs", + response_model=schemas.LLMModelConfigResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(has_privilege("Group.Modify"))] +) +async def create_group_llm_model_config( + group_id: UUID, + config_create: schemas.LLMModelConfigCreate, + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Create a new LLM model configuration for a user group. + + Required privilege: Group.Modify + + IMPORTANT: context_limit is REQUIRED. Unit is K tokens (e.g., 128 = 128K = 128,000 tokens). + Please check your model provider's documentation for the current context window size. + """ + + # Verify group exists + group = await users_repo.get_user_group(group_id) + if not group: + raise ControllerNotFoundError(f"User group '{group_id}' not found") + + # Validate context_limit is provided + if not hasattr(config_create, 'context_limit') or config_create.context_limit is None: + raise ControllerBadRequestError( + "context_limit is required (unit: K tokens, e.g., 128 = 128K = 128,000 tokens). " + "Please check your model provider's documentation for the current context window size " + "and specify it in the configuration." + ) + + try: + # Extract config fields (excluding table-level fields) + config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"}) + new_config = await llm_repo.create_group_config( + group_id, + config_create.name, + config_create.model_type, + config_fields, + is_default=config_create.is_default + ) + + return schemas.LLMModelConfigResponse( + config_id=new_config.config_id, + name=new_config.name, + model_type=new_config.model_type, + config=_filter_api_key_from_config(new_config.config), + user_id=new_config.user_id, + group_id=new_config.group_id, + is_default=new_config.is_default, + version=new_config.version, + created_at=new_config.created_at, + updated_at=new_config.updated_at + ) + except ValueError as e: + raise ControllerBadRequestError(str(e)) + except Exception as e: + log.error(f"Failed to create LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create LLM model configuration: {e}" + ) + + +@router.put( + "/groups/{group_id}/llm-model-configs/{config_id}", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("Group.Modify"))] +) +async def update_group_llm_model_config( + group_id: UUID, + config_id: UUID, + config_update: schemas.LLMModelConfigUpdate, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Update a group's LLM model configuration. + Supports optimistic locking via expected_version field. + + Required privilege: Group.Modify + """ + + try: + # Build updates dict with only non-None values + updates = {k: v for k, v in config_update.model_dump().items() if v is not None} + + # Extract expected_version for optimistic locking + expected_version = updates.pop("expected_version", None) + + updated_config = await llm_repo.update_group_config( + config_id, + group_id, + updates, + expected_version=expected_version + ) + + if not updated_config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + + return schemas.LLMModelConfigResponse( + config_id=updated_config.config_id, + name=updated_config.name, + model_type=updated_config.model_type, + config=_filter_api_key_from_config(updated_config.config), + user_id=updated_config.user_id, + group_id=updated_config.group_id, + is_default=updated_config.is_default, + version=updated_config.version, + created_at=updated_config.created_at, + updated_at=updated_config.updated_at + ) + except HTTPException: + raise + except ValueError as e: + # Handle optimistic lock errors + if "Concurrent modification" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e) + ) + raise ControllerBadRequestError(str(e)) + except Exception as e: + log.error(f"Failed to update LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update LLM model configuration" + ) + + +@router.delete( + "/groups/{group_id}/llm-model-configs/{config_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Group.Modify"))] +) +async def delete_group_llm_model_config( + group_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> None: + """ + Delete a group's LLM model configuration. + + Required privilege: Group.Modify + """ + + try: + success = await llm_repo.delete_group_config(config_id, group_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to delete LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete LLM model configuration" + ) + + +@router.put( + "/groups/{group_id}/llm-model-configs/default/{config_id}", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("Group.Modify"))] +) +async def set_group_default_llm_model_config( + group_id: UUID, + config_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Set a group's default LLM model configuration. + + Required privilege: Group.Modify + """ + + try: + success = await llm_repo.set_group_default_config(group_id, config_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"LLM model configuration '{config_id}' not found" + ) + + # Get the updated config + config = await llm_repo.get_group_config(config_id) + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=_filter_api_key_from_config(config.config), + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to set default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to set default LLM model configuration" + ) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index f9cac15b1..0ee624ce4 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -22,10 +22,10 @@ import aiohttp import asyncio import ipaddress -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute -from typing import List, Callable +from typing import List, Callable, Optional from uuid import UUID from gns3server.controller import Controller @@ -105,6 +105,16 @@ async def dep_node(node_id: UUID, project: Project = Depends(dep_project)) -> No return node +def _check_node_type(node: Node, *required_types: str) -> None: + """ + Raise ControllerBadRequestError if node is not one of the required types. + """ + + if node.node_type not in required_types: + type_str = "/".join(required_types) + raise ControllerBadRequestError(f"This endpoint is only supported on a {type_str} node") + + @router.post( "", status_code=status.HTTP_201_CREATED, @@ -135,17 +145,40 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends( response_model_exclude_unset=True, dependencies=[Depends(has_privilege("Node.Audit"))] ) -def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]: +def get_nodes( + project: Project = Depends(dep_project), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") +) -> List[schemas.Node]: """ Return all nodes belonging to a given project. Required privilege: Node.Audit + + Query Parameters: + - tags: Filter by tags. Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ if project.status == "closed": # allow to retrieve nodes from a closed project - return project.nodes.values() - return [v.asdict() for v in project.nodes.values()] + nodes = list(project.nodes.values()) + else: + nodes = [v.asdict() for v in project.nodes.values()] + + # Filter by tags if provided (all filter tags have to match the node tags) + if tags: + filtered_nodes = [] + for node in nodes: + node_tags = node.get("tags") or [] + match = True + for tag_filter in tags: + if tag_filter not in node_tags: + match = False + break + if match: + filtered_nodes.append(node) + return filtered_nodes + return nodes @router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) @@ -156,7 +189,11 @@ async def start_all_nodes(project: Project = Depends(dep_project)) -> None: Required privilege: Node.PowerMgmt """ - await project.start_all() + try: + await project.start_all() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post("/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) @@ -167,7 +204,11 @@ async def stop_all_nodes(project: Project = Depends(dep_project)) -> None: Required privilege: Node.PowerMgmt """ - await project.stop_all() + try: + await project.stop_all() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post("/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) @@ -178,7 +219,11 @@ async def suspend_all_nodes(project: Project = Depends(dep_project)) -> None: Required privilege: Node.PowerMgmt """ - await project.suspend_all() + try: + await project.suspend_all() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post("/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) @@ -189,8 +234,12 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None: Required privilege: Node.PowerMgmt """ - await project.stop_all() - await project.start_all() + try: + await project.stop_all() + await project.start_all() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))]) @@ -270,15 +319,18 @@ async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Dep status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] ) -async def start_node(start_data: dict, node: Node = Depends(dep_node)) -> None: +async def start_node(start_data: Optional[dict] = None, node: Node = Depends(dep_node)) -> None: """ Start a node. Required privilege: Node.PowerMgmt """ - await node.start(data=start_data) - + try: + await node.start(data=start_data) + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post( "/{node_id}/stop", @@ -292,7 +344,11 @@ async def stop_node(node: Node = Depends(dep_node)) -> None: Required privilege: Node.PowerMgmt """ - await node.stop() + try: + await node.stop() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post( @@ -307,7 +363,11 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None: Required privilege: Node.PowerMgmt """ - await node.suspend() + try: + await node.suspend() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post( @@ -322,8 +382,11 @@ async def reload_node(node: Node = Depends(dep_node)) -> None: Required privilege: Node.PowerMgmt """ - await node.reload() - + try: + await node.reload() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise @router.post( "/{node_id}/isolate", @@ -384,8 +447,7 @@ async def auto_idlepc(node: Node = Depends(dep_node)) -> dict: Required privilege: Node.Audit """ - if node.node_type != "dynamips": - raise ControllerBadRequestError("Auto Idle-PC is only supported on a Dynamips node") + _check_node_type(node, "dynamips") return await node.dynamips_auto_idlepc() @@ -397,8 +459,7 @@ async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]: Required privilege: Node.Audit """ - if node.node_type != "dynamips": - raise ControllerBadRequestError("Idle-PC proposals is only supported on a Dynamips node") + _check_node_type(node, "dynamips") return await node.dynamips_idlepc_proposals() @@ -418,8 +479,7 @@ async def create_disk_image( Required privilege: Node.Allocate """ - if node.node_type != "qemu": - raise ControllerBadRequestError("Creating a disk image is only supported on a Qemu node") + _check_node_type(node, "qemu") await node.post(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True)) @@ -439,8 +499,7 @@ async def update_disk_image( Required privilege: Node.Allocate """ - if node.node_type != "qemu": - raise ControllerBadRequestError("Updating a disk image is only supported on a Qemu node") + _check_node_type(node, "qemu") await node.put(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True)) @@ -459,11 +518,27 @@ async def delete_disk_image( Required privilege: Node.Allocate """ - if node.node_type != "qemu": - raise ControllerBadRequestError("Deleting a disk image is only supported on a Qemu node") + _check_node_type(node, "qemu") await node.delete(f"/disk_image/{disk_name}") +@router.get("/{node_id}/files", response_model=List[schemas.NodeFile], dependencies=[Depends(has_privilege("Node.Audit"))]) +async def list_node_files(node: Node = Depends(dep_node)) -> List[schemas.NodeFile]: + """ + List files in a node directory with detailed metadata. + + Required privilege: Node.Audit + """ + + node_type = node.node_type + res = await node.compute.http_query( + "GET", + f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files", + timeout=None + ) + return res.json + + @router.get("/{node_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Node.Audit"))]) async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response: """ @@ -588,6 +663,80 @@ async def ws_console( except aiohttp.ClientError as e: log.error(f"Client error received when forwarding to compute console WebSocket: {e}") +@router.websocket("/{node_id}/console/vnc") +async def vnc_console( + websocket: WebSocket, + current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")), + node: Node = Depends(dep_node) +) -> None: + """ + VNC WebSocket console. + + Required privilege: Node.Console + """ + + if current_user is None: + return + + compute = node.compute + log.info( + f"New client {websocket.client.host}:{websocket.client.port} has connected to controller VNC console WebSocket" + ) + + compute_host = compute.host + try: + # handle IPv6 address + ip = ipaddress.ip_address(compute_host) + if isinstance(ip, ipaddress.IPv6Address): + compute_host = '[' + compute_host + ']' + except ValueError: + pass + + vnc_console_compute_url = ( + f"{websocket.url.scheme}://{compute_host}:{compute.port}/v3/compute/projects/" + f"{node.project.id}/{node.node_type}/nodes/{node.id}/console/vnc" + ) + + async def vnc_receive(vnc_console_compute): + """ + Receive binary WebSocket data from client and forward to compute VNC console WebSocket. + """ + + try: + while True: + data = await websocket.receive_bytes() + if data: + await vnc_console_compute.send_bytes(data) + except WebSocketDisconnect: + await vnc_console_compute.close() + log.info( + f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller" + f" VNC console WebSocket" + ) + + try: + # receive binary data from compute VNC console WebSocket and forward to client + log.info(f"Forwarding VNC console WebSocket to '{vnc_console_compute_url}'") + server_config = Config.instance().settings.Server + user = server_config.compute_username + password = server_config.compute_password + if not user: + raise ControllerForbiddenError("Compute username is not set") + user = user.strip() + if user and password: + auth = aiohttp.BasicAuth(user, password.get_secret_value(), "utf-8") + else: + auth = aiohttp.BasicAuth(user, "") + ssl_context = Controller.instance().ssl_context() + async with HTTPClient.get_client().ws_connect(vnc_console_compute_url, auth=auth, ssl_context=ssl_context) as ws: + asyncio.ensure_future(vnc_receive(ws)) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.BINARY: + await websocket.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + break + except aiohttp.ClientError as e: + log.error(f"Client error received when forwarding to compute VNC console WebSocket: {e}") @router.post( "/console/reset", diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 37c7e35b4..99861230c 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -113,6 +113,7 @@ async def get_projects( ) async def create_project( project_data: schemas.ProjectCreate, + current_user: schemas.User = Depends(get_current_active_user), ) -> schemas.Project: """ Create a new project. @@ -121,7 +122,9 @@ async def create_project( """ controller = Controller.instance() - project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True)) + project_dict = jsonable_encoder(project_data, exclude_unset=True) + project_dict["created_by"] = current_user.username + project = await controller.add_project(**project_dict) return project.asdict() diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py index c6dee1315..12c36d00c 100644 --- a/gns3server/api/routes/controller/symbols.py +++ b/gns3server/api/routes/controller/symbols.py @@ -27,7 +27,8 @@ from typing import List from gns3server.controller import Controller from gns3server import schemas -from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError +from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ControllerForbiddenError +from gns3server.utils.get_resource import get_resource from .dependencies.rbac import has_privilege @@ -57,10 +58,9 @@ def get_symbols() -> List[dict]: @router.get( "/{symbol_id:path}/raw", responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}, - # FIXME: this is a temporary workaround due to a bug in the web-ui: https://github.com/GNS3/gns3-web-ui/issues/1466 - # dependencies=[Depends(has_privilege("Symbol.Audit"))] + dependencies=[Depends(has_privilege("Symbol.Audit"))] ) -async def get_symbol(symbol_id: str) -> FileResponse: +async def get_symbol(symbol_id: str, request: Request) -> Response: """ Download a symbol file. @@ -70,7 +70,12 @@ async def get_symbol(symbol_id: str) -> FileResponse: controller = Controller.instance() try: symbol = controller.symbols.get_path(symbol_id) - return FileResponse(symbol) + return FileResponse( + symbol, + headers={ + "Access-Control-Allow-Origin": request.headers.get("origin", "*"), + } + ) except (KeyError, OSError) as e: raise ControllerNotFoundError(f"Could not get symbol file: {e}") @@ -131,3 +136,45 @@ async def upload_symbol(symbol_id: str, request: Request) -> None: # Reset the symbol list controller.symbols.list() + + +@router.delete( + "/{symbol_id:path}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Symbol.Allocate"))] +) +async def delete_symbol(symbol_id: str) -> None: + """ + Delete a custom symbol file. + + Required privilege: Symbol.Allocate + """ + + controller = Controller.instance() + + # Cannot delete built-in symbols + if symbol_id.startswith(":/"): + raise ControllerForbiddenError("Cannot delete built-in symbols") + + try: + symbol_path = controller.symbols.get_path(symbol_id) + except (KeyError, ControllerNotFoundError) as e: + raise ControllerNotFoundError(f"Symbol '{symbol_id}' not found: {e}") + + # Check if it's a built-in symbol (in resource directory) + symbols_resource_dir = get_resource("symbols") + if symbols_resource_dir and os.path.commonprefix([symbols_resource_dir, symbol_path]) == symbols_resource_dir: + raise ControllerForbiddenError("Cannot delete built-in symbols") + + # Delete the file + try: + os.remove(symbol_path) + log.info(f"Deleted symbol file '{symbol_path}'") + except OSError as e: + raise ControllerError(f"Could not delete symbol file '{symbol_path}': {e}") + + # Clear the symbol size cache + controller.symbols._symbol_size_cache.pop(symbol_id, None) + + # Reset the symbol list + controller.symbols.list() diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index 801ea2975..a8d0be8a3 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -26,7 +26,7 @@ import logging log = logging.getLogger(__name__) -from fastapi import APIRouter, Request, HTTPException, Depends, Response, status +from fastapi import APIRouter, Request, HTTPException, Depends, Response, status, Query from typing import List, Optional from uuid import UUID @@ -88,10 +88,10 @@ async def get_template( request_etag = request.headers.get("If-None-Match", "") template = await TemplatesService(templates_repo).get_template(template_id) - data = json.dumps(template) + data = json.dumps(template, default=str) template_etag = '"' + hashlib.md5(data.encode()).hexdigest() + '"' if template_etag == request_etag: - raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": template_etag}) else: response.headers["ETag"] = template_etag return template @@ -169,15 +169,34 @@ async def delete_template( async def get_templates( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Template]: """ Return all templates. Required privilege: Template.Audit + + Query Parameters: + - tags: Filter by tags. Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ templates = await TemplatesService(templates_repo).get_templates() + + # Filter by tags if provided (all filter tags have to match the node tags) + if tags: + filtered_templates = [] + for template in templates: + template_tags = template.get("tags") or [] + match = True + for tag_filter in tags: + if tag_filter not in template_tags: + match = False + break + if match: + filtered_templates.append(template) + templates = filtered_templates + if current_user.is_superadmin: return templates else: diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py index 0bd6373d6..1a19da98f 100644 --- a/gns3server/api/routes/controller/users.py +++ b/gns3server/api/routes/controller/users.py @@ -65,7 +65,10 @@ async def login( headers={"WWW-Authenticate": "Bearer"}, ) - token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer") + token = schemas.Token( + access_token=auth_service.create_access_token(user.username, token_version=user.token_version), + token_type="bearer" + ) return token @@ -87,10 +90,25 @@ async def authenticate( headers={"WWW-Authenticate": "Bearer"}, ) - token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer") + token = schemas.Token( + access_token=auth_service.create_access_token(user.username, token_version=user.token_version), + token_type="bearer" + ) return token +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout( + current_user: schemas.User = Depends(get_current_active_user), + users_repo: UsersRepository = Depends(get_repository(UsersRepository)), +) -> None: + """ + Logout the current user by revoking all existing tokens. + """ + + await users_repo.logout_user(current_user.user_id) + + @router.get("/me", response_model=schemas.User) async def get_logged_in_user(current_user: schemas.User = Depends(get_current_active_user)) -> schemas.User: """ diff --git a/gns3server/api/routes/index.py b/gns3server/api/routes/index.py index e97fb4092..365840b8c 100644 --- a/gns3server/api/routes/index.py +++ b/gns3server/api/routes/index.py @@ -36,8 +36,8 @@ async def root(): @router.get("/debug", response_class=HTMLResponse, deprecated=True, include_in_schema=False) def debug(request: Request): - kwargs = {"request": request, "gns3_version": __version__, "gns3_host": request.client.host} - return templates.TemplateResponse("index.html", kwargs) + kwargs = {"gns3_version": __version__, "gns3_host": request.client.host} + return templates.TemplateResponse(request=request, name="index.html", context=kwargs) @router.get("/static/web-ui/{file_path:path}", description="Web user interface", include_in_schema=False) @@ -46,13 +46,13 @@ async def web_ui(file_path: str): file_path = os.path.normpath(file_path).strip("/") file_path = os.path.join("static", "web-ui", file_path) - # Raise error if user try to escape - if file_path[0] == ".": + # Raise error if user tries to escape the web-ui directory + if not os.path.normpath(file_path).startswith(os.path.join("static", "web-ui")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) static = get_resource(file_path) - if static is None or not os.path.exists(static): + if static is None or not os.path.exists(static) or os.path.isdir(static): static = get_resource(os.path.join("static", "web-ui", "index.html")) if static is None: diff --git a/gns3server/api/server.py b/gns3server/api/server.py index acdda6409..196ddb504 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -46,7 +46,6 @@ from gns3server.controller.controller_error import ( from gns3server.api.routes import controller, index from gns3server.api.routes.compute import compute_api from gns3server.core import tasks -from gns3server.version import __version__ import logging @@ -56,9 +55,10 @@ log = logging.getLogger(__name__) def get_application() -> FastAPI: application = FastAPI( + lifespan=tasks.lifespan, title="GNS3 controller API", description="This page describes the public controller API for GNS3", - version="v3", + version="3.0.0", docs_url=None, redoc_url=None ) @@ -71,11 +71,9 @@ def get_application() -> FastAPI: allow_headers=["*"], ) - application.add_event_handler("startup", tasks.create_startup_handler(application)) - application.add_event_handler("shutdown", tasks.create_shutdown_handler(application)) application.include_router(index.router, tags=["Index"]) application.include_router(controller.router, prefix="/v3") - application.mount("/static", StaticFiles(packages=[('gns3server', 'static')]), name="static") + application.mount("/static", StaticFiles(packages=[('gns3server', 'static')], html=True), name="static") application.mount("/v3/compute", compute_api, name="compute") return application @@ -195,7 +193,7 @@ async def http_exception_handler(request: Request, exc: HTTPException): @app.exception_handler(SQLAlchemyError) -async def sqlalchemry_error_handler(request: Request, exc: SQLAlchemyError): +async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): log.error(f"Controller database error in {request.url.path} ({request.method}): {exc}") return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -207,7 +205,7 @@ async def sqlalchemry_error_handler(request: Request, exc: SQLAlchemyError): async def validation_exception_handler(request: Request, exc: RequestValidationError): log.error(f"Request validation error in {request.url.path} ({request.method}): {exc}") return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"message": str(exc)} ) diff --git a/gns3server/appliances/almalinux.gns3a b/gns3server/appliances/almalinux.gns3a index a8a2091ba..97ae3334a 100644 --- a/gns3server/appliances/almalinux.gns3a +++ b/gns3server/appliances/almalinux.gns3a @@ -25,6 +25,30 @@ "options": "-cpu host -nographic" }, "images": [ + { + "filename": "AlmaLinux-10-GenericCloud-10.0-20250528.0.x86_64.qcow2", + "version": "10.0", + "md5sum": "e5099e33f16014b6cc851482d865720d", + "filesize": 460062720, + "download_url": "https://vault.almalinux.org/10.0/cloud/x86_64/images/", + "direct_download_url": "https://vault.almalinux.org/10.0/cloud/x86_64/images/AlmaLinux-10-GenericCloud-10.0-20250528.0.x86_64.qcow2" + }, + { + "filename": "AlmaLinux-9-GenericCloud-9.6-20250522.x86_64.qcow2", + "version": "9.6", + "md5sum": "ae02074389961caf08bbe44439603061", + "filesize": 530776064, + "download_url": "https://vault.almalinux.org/9.6/cloud/x86_64/images/", + "direct_download_url": "https://vault.almalinux.org/9.6/cloud/x86_64/images/AlmaLinux-9-GenericCloud-9.6-20250522.x86_64.qcow2" + }, + { + "filename": "AlmaLinux-9-GenericCloud-9.5-20241120.x86_64.qcow2", + "version": "9.5", + "md5sum": "cb48bc1c609a1d3d62f48397acdd038e", + "filesize": 490995712, + "download_url": "https://vault.almalinux.org/9.5/cloud/x86_64/images/", + "direct_download_url": "https://vault.almalinux.org/9.5/cloud/x86_64/images/AlmaLinux-9-GenericCloud-9.5-20241120.x86_64" + }, { "filename": "AlmaLinux-9-GenericCloud-9.4-20240805.x86_64.qcow2", "version": "9.4", @@ -75,6 +99,27 @@ } ], "versions": [ + { + "name": "10.0", + "images": { + "hda_disk_image": "AlmaLinux-10-GenericCloud-10.0-20250528.0.x86_64.qcow2", + "cdrom_image": "almalinux-cloud-init-data.iso" + } + }, + { + "name": "9.6", + "images": { + "hda_disk_image": "AlmaLinux-9-GenericCloud-9.6-20250522.x86_64.qcow2", + "cdrom_image": "almalinux-cloud-init-data.iso" + } + }, + { + "name": "9.5", + "images": { + "hda_disk_image": "AlmaLinux-9-GenericCloud-9.5-20241120.x86_64.qcow2", + "cdrom_image": "almalinux-cloud-init-data.iso" + } + }, { "name": "9.4", "images": { diff --git a/gns3server/appliances/alpinet.gns3a b/gns3server/appliances/alpinet.gns3a new file mode 100644 index 000000000..7a7ecacbe --- /dev/null +++ b/gns3server/appliances/alpinet.gns3a @@ -0,0 +1,21 @@ +{ + "appliance_id": "a2209414-88a6-403b-a2e1-477a057ec954", + "name": "AlpiNet", + "category": "guest", + "description": "AlpiNet is a lightweight Alpine-based networking toolbox for GNS3.\n\nCOMPREHENSIVE TOOLKIT:\n• Network: ip, ifconfig, ping, traceroute, mtr, arping, nmap, tcpdump\n• Performance: iperf, iperf3\n• HTTP/Web: curl, wget\n• TCP/UDP: netcat, socat, telnet\n• Advanced: ethtool, bridge-utils, vlan\n• Firewall: iptables, ip6tables, nftables\n• File Transfer: FTP (lftp), TFTP, SSH (scp/sftp), rsync\n• DNS: host, nslookup, dig\n• System: nano, tmux, screen, htop, bash\n• Text Tools: grep, sed, awk, jq, less\n• Compression: tar, gzip\n• Utilities: tree, file, openssl\n\nType 'alpinet-tools' in the container for the complete tool list.\n\nOptimized for network testing, troubleshooting, and education.", + "vendor_name": "AlpiNet Project", + "vendor_url": "https://www.alpinelinux.org/", + "product_name": "AlpiNet", + "registry_version": 4, + "status": "stable", + "maintainer": "nazDridoy", + "maintainer_email": "nazdridoy399@gmail.com", + "usage": "The /root directory is persistent across restarts. Type 'alpinet-tools' to see all available utilities.", + "symbol": "AlpiNet.svg", + "docker": { + "adapters": 1, + "image": "gns3/alpinet:latest", + "console_type": "telnet", + "environment": "TERM=xterm" + } +} diff --git a/gns3server/appliances/arista-veos.gns3a b/gns3server/appliances/arista-veos.gns3a index 835556089..007a4bb7a 100644 --- a/gns3server/appliances/arista-veos.gns3a +++ b/gns3server/appliances/arista-veos.gns3a @@ -28,6 +28,13 @@ "options": "-cpu host" }, "images": [ + { + "filename": "vEOS64-lab-4.35.3F.qcow2", + "version": "4.35.3F", + "md5sum": "00d11e33dc4288f509441a4ab6319ca0", + "filesize": 638910464, + "download_url": "https://www.arista.com/en/support/software-download" + }, { "filename": "vEOS64-lab-4.33.2F.qcow2", "version": "4.33.2F", @@ -58,6 +65,13 @@ } ], "versions": [ + { + "name": "4.35.3F", + "images": { + "hda_disk_image": "Aboot-veos-serial-8.0.2.iso", + "hdb_disk_image": "vEOS64-lab-4.35.3F.qcow2" + } + }, { "name": "4.33.2F", "images": { diff --git a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a index 5c467f5dc..86016b329 100644 --- a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a +++ b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a @@ -2,7 +2,7 @@ "appliance_id": "4aa78f74-c769-43e0-9ef1-5dd2cf1909c6", "name": "Asterfusion AsterNOS-VPP", "category": "router", - "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 4GB RAM.", + "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 8GB RAM.", "vendor_name": "Asterfusion", "vendor_url": "https://cloudswit.ch/product/sonic-enterprise-distribution/#vAsterNOS", "documentation_url": "https://docs.asternos.com/routing", @@ -11,23 +11,32 @@ "status": "stable", "maintainer": "Asterfusion Product Team", "maintainer_email": "bd@cloudswit.ch", + "availability": "with-registration", "images": [ { - "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img.gz", - "version": "V6.1", + "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img", + "version": "6.1", "md5sum": "55834c3e8849ab226144d79ef76acf81", "filesize": 1318201015, "download_url": "https://asternos.dev/api/file/b2c9fd83-a9d3-46f3-9676-6b49dd481d6c", - "comments": "Registration is required to download the image." + "compression": "gzip" } ], "qemu": { "adapter_type": "virtio-net-pci", "adapters": 4, - "ram": 4096, + "ram": 8192, "cpus": 4, "arch": "x86_64", "console_type": "telnet", "kvm": "require" - } + }, + "versions": [ + { + "name": "6.1", + "images": { + "hda_disk_image": "AsterNOS-VPP_V6.1-R0101P02_x86.img" + } + } + ] } diff --git a/gns3server/appliances/bird.gns3a b/gns3server/appliances/bird.gns3a index b18e18e86..9ecf790cc 100644 --- a/gns3server/appliances/bird.gns3a +++ b/gns3server/appliances/bird.gns3a @@ -1,41 +1,56 @@ { - "appliance_id": "088df570-f637-46f5-8a68-85acde538e5e", + "appliance_id": "8fecbf89-5cd1-4aea-b735-5f36cf0efbb7", "name": "BIRD", "category": "router", "description": "The BIRD project aims to develop a fully functional dynamic IP routing daemon primarily targeted on (but not limited to) Linux, FreeBSD and other UNIX-like systems and distributed under the GNU General Public License.", "vendor_name": "CZ.NIC Labs", - "vendor_url": "http://bird.network.cz/", - "documentation_url": "http://bird.network.cz/?get_doc&f=bird.html", + "vendor_url": "https://bird.network.cz", + "documentation_url": "https://bird.network.cz/?get_doc&f=bird.html&v=20", "product_name": "BIRD internet routing daemon", "registry_version": 4, "status": "stable", - "maintainer": "GNS3 Team", - "maintainer_email": "developers@gns3.net", - "usage": "\n*** BIRD v1 is end-of-life ***\nPlease use the BIRD2 appliance.\n\nConfigure interfaces in /opt/bootlocal.sh, BIRD configuration is done in /usr/local/etc/bird", + "maintainer": "Bernhard Ehlers", + "maintainer_email": "dev-ehlers@mailbox.org", + "usage": "Username:\tgns3\nPassword:\tgns3\nTo become root, use \"sudo -s\".\n\nNetwork configuration:\nsudo nano /etc/network/interfaces\nsudo systemctl restart networking\n\nBIRD:\nRestart: sudo systemctl restart bird\nReconfigure: birdc configure", + "port_name_format": "eth{0}", "qemu": { - "adapter_type": "e1000", + "adapter_type": "virtio-net-pci", "adapters": 4, - "ram": 128, - "hda_disk_interface": "ide", + "ram": 512, + "hda_disk_interface": "scsi", "arch": "x86_64", "console_type": "telnet", "kvm": "allow" }, "images": [ { - "filename": "bird-tinycore64-1.5.0.img", - "version": "1.5.0", - "md5sum": "08d50ba2b1b262e2e03e4babf90abf69", - "filesize": 22413312, + "filename": "bird2-debian-2.14.qcow2", + "version": "2.14", + "md5sum": "029cf1756201ee79497c169502b08b88", + "filesize": 303717376, "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", - "direct_download_url": "http://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/bird-tinycore64-1.5.0.img" + "direct_download_url": "https://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/bird2-debian-2.14.qcow2" + }, + { + "filename": "bird2-debian-2.0.12.qcow2", + "version": "2.0.12", + "md5sum": "435218a2e90cba921cc7fde1d64a9419", + "filesize": 287965184, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "https://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/bird2-debian-2.0.12.qcow2" } ], "versions": [ { - "name": "1.5.0", + "name": "2.14", "images": { - "hda_disk_image": "bird-tinycore64-1.5.0.img" + "hda_disk_image": "bird2-debian-2.14.qcow2" + } + }, + { + "name": "2.0.12", + "images": { + "hda_disk_image": "bird2-debian-2.0.12.qcow2" } } ] diff --git a/gns3server/appliances/bird2.gns3a b/gns3server/appliances/bird2.gns3a deleted file mode 100644 index 670d56a83..000000000 --- a/gns3server/appliances/bird2.gns3a +++ /dev/null @@ -1,57 +0,0 @@ -{ - "appliance_id": "8fecbf89-5cd1-4aea-b735-5f36cf0efbb7", - "name": "BIRD2", - "category": "router", - "description": "The BIRD project aims to develop a fully functional dynamic IP routing daemon primarily targeted on (but not limited to) Linux, FreeBSD and other UNIX-like systems and distributed under the GNU General Public License.", - "vendor_name": "CZ.NIC Labs", - "vendor_url": "https://bird.network.cz", - "documentation_url": "https://bird.network.cz/?get_doc&f=bird.html&v=20", - "product_name": "BIRD internet routing daemon", - "registry_version": 4, - "status": "stable", - "maintainer": "Bernhard Ehlers", - "maintainer_email": "dev-ehlers@mailbox.org", - "usage": "Username:\tgns3\nPassword:\tgns3\nTo become root, use \"sudo -s\".\n\nNetwork configuration:\nsudo nano /etc/network/interfaces\nsudo systemctl restart networking\n\nBIRD:\nRestart: sudo systemctl restart bird\nReconfigure: birdc configure", - "port_name_format": "eth{0}", - "qemu": { - "adapter_type": "virtio-net-pci", - "adapters": 4, - "ram": 512, - "hda_disk_interface": "scsi", - "arch": "x86_64", - "console_type": "telnet", - "kvm": "allow" - }, - "images": [ - { - "filename": "bird2-debian-2.14.qcow2", - "version": "2.14", - "md5sum": "029cf1756201ee79497c169502b08b88", - "filesize": 303717376, - "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", - "direct_download_url": "https://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/bird2-debian-2.14.qcow2" - }, - { - "filename": "bird2-debian-2.0.12.qcow2", - "version": "2.0.12", - "md5sum": "435218a2e90cba921cc7fde1d64a9419", - "filesize": 287965184, - "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", - "direct_download_url": "https://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/bird2-debian-2.0.12.qcow2" - } - ], - "versions": [ - { - "name": "2.14", - "images": { - "hda_disk_image": "bird2-debian-2.14.qcow2" - } - }, - { - "name": "2.0.12", - "images": { - "hda_disk_image": "bird2-debian-2.0.12.qcow2" - } - } - ] -} diff --git a/gns3server/appliances/centos-cloud.gns3a b/gns3server/appliances/centos-cloud.gns3a index e8061267c..4bd373c10 100644 --- a/gns3server/appliances/centos-cloud.gns3a +++ b/gns3server/appliances/centos-cloud.gns3a @@ -27,20 +27,20 @@ }, "images": [ { - "filename": "CentOS-Stream-GenericCloud-x86_64-10-20250331.0.x86_64.qcow2", - "version": "Stream-10 (20250331.0)", - "md5sum": "776033371ca346001dd6390f0cbaf0d0", - "filesize": 952041472, + "filename": "CentOS-Stream-GenericCloud-10-20260504.0.x86_64.qcow2", + "version": "Stream-10 (20260504.0)", + "md5sum": "932b0bcc7e5c7404f91432979dc95314", + "filesize": 2173501440, "download_url": "https://cloud.centos.org/centos/10-stream/x86_64/images", - "direct_download_url": "https://cloud.centos.org/centos/10-stream/x86_64/images/CentOS-Stream-GenericCloud-x86_64-10-20250331.0.x86_64.qcow2" + "direct_download_url": "https://cloud.centos.org/centos/10-stream/x86_64/images/CentOS-Stream-GenericCloud-10-20260504.0.x86_64.qcow2" }, { - "filename": "CentOS-Stream-GenericCloud-9-20250331.0.x86_64.qcow2", - "version": "Stream-9 (20250331.0)", - "md5sum": "4aaeddc6ca497065522c75a7471f9bfd", - "filesize": 1250625536, + "filename": "CentOS-Stream-GenericCloud-9-20260504.0.x86_64.qcow2", + "version": "Stream-9 (20260504.0)", + "md5sum": "ebfbbd23ba7b85bbaf83c6c0626c2b70", + "filesize": 1877251072, "download_url": "https://cloud.centos.org/centos/9-stream/x86_64/images", - "direct_download_url": "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-20250331.0.x86_64.qcow2" + "direct_download_url": "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-20260504.0.x86_64.qcow2" }, { "filename": "CentOS-Stream-GenericCloud-8-20240603.0.x86_64.qcow2", @@ -61,16 +61,16 @@ ], "versions": [ { - "name": "Stream-10 (20250331.0)", + "name": "Stream-10 (20260504.0)", "images": { - "hda_disk_image": "CentOS-Stream-GenericCloud-x86_64-10-20250331.0.x86_64.qcow2", + "hda_disk_image": "CentOS-Stream-GenericCloud-10-20260504.0.x86_64.qcow2", "cdrom_image": "centos-cloud-init-data.iso" } }, { - "name": "Stream-9 (20250331.0)", + "name": "Stream-9 (20260504.0)", "images": { - "hda_disk_image": "CentOS-Stream-GenericCloud-9-20250331.0.x86_64.qcow2", + "hda_disk_image": "CentOS-Stream-GenericCloud-9-20260504.0.x86_64.qcow2", "cdrom_image": "centos-cloud-init-data.iso" } }, diff --git a/gns3server/appliances/cisco-1700.gns3a b/gns3server/appliances/cisco-1700.gns3a index 655778197..2a9f47708 100644 --- a/gns3server/appliances/cisco-1700.gns3a +++ b/gns3server/appliances/cisco-1700.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "cc2b1802-3520-4963-8ff7-c19b1f6418c5", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 1700", "category": "router", "description": "Cisco 1700 Router", diff --git a/gns3server/appliances/cisco-2600.gns3a b/gns3server/appliances/cisco-2600.gns3a index d565a086c..adbe05044 100644 --- a/gns3server/appliances/cisco-2600.gns3a +++ b/gns3server/appliances/cisco-2600.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "ed474fea-cd3b-4e2e-be84-a855a133060a", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 2600", "category": "router", "description": "Cisco 2600 Router", diff --git a/gns3server/appliances/cisco-2691.gns3a b/gns3server/appliances/cisco-2691.gns3a index 2fd995e65..3c9792eed 100644 --- a/gns3server/appliances/cisco-2691.gns3a +++ b/gns3server/appliances/cisco-2691.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "ed0f079e-a506-4cb4-b46c-714a80bbe2d3", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 2691", "category": "router", "description": "Cisco 2691 Router", diff --git a/gns3server/appliances/cisco-3620.gns3a b/gns3server/appliances/cisco-3620.gns3a index 9519d072c..9ba77bafb 100644 --- a/gns3server/appliances/cisco-3620.gns3a +++ b/gns3server/appliances/cisco-3620.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "7ada6336-7280-4306-9f32-6b1e242ae989", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 3620", "category": "router", "description": "Cisco 3620 Router", diff --git a/gns3server/appliances/cisco-3640.gns3a b/gns3server/appliances/cisco-3640.gns3a index bcfff3fdd..4d3efe89f 100644 --- a/gns3server/appliances/cisco-3640.gns3a +++ b/gns3server/appliances/cisco-3640.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "ef119e49-19fe-4239-9c6b-16ea12f6ec01", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 3640", "category": "router", "description": "Cisco 3640 Router", diff --git a/gns3server/appliances/cisco-3660.gns3a b/gns3server/appliances/cisco-3660.gns3a index ebb1f34b4..1f261a4d5 100644 --- a/gns3server/appliances/cisco-3660.gns3a +++ b/gns3server/appliances/cisco-3660.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "ac460a9c-9274-4ccf-a056-af50b699925f", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 3660", "category": "router", "description": "Cisco 3660 Router", diff --git a/gns3server/appliances/cisco-3725.gns3a b/gns3server/appliances/cisco-3725.gns3a index 1b0c7b3c9..6fed55eed 100644 --- a/gns3server/appliances/cisco-3725.gns3a +++ b/gns3server/appliances/cisco-3725.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "5b9c5293-a5a7-47a3-9df4-6cf658f2f378", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 3725", "category": "router", "description": "Cisco 3725 Router", diff --git a/gns3server/appliances/cisco-3745.gns3a b/gns3server/appliances/cisco-3745.gns3a index d0c6f2407..83c5cf22a 100644 --- a/gns3server/appliances/cisco-3745.gns3a +++ b/gns3server/appliances/cisco-3745.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "18737edb-e43f-4fb0-8a0f-88982cff58b1", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 3745", "category": "router", "description": "Cisco 3745 Multiservice Access Router", diff --git a/gns3server/appliances/cisco-7200.gns3a b/gns3server/appliances/cisco-7200.gns3a index 78d0be865..1441f7751 100644 --- a/gns3server/appliances/cisco-7200.gns3a +++ b/gns3server/appliances/cisco-7200.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "38aa3135-f169-4131-9b64-73605787b5ef", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco 7200", "category": "router", "description": "Cisco 7200 Router", diff --git a/gns3server/appliances/cisco-alpine-trex.gns3a b/gns3server/appliances/cisco-alpine-trex.gns3a new file mode 100644 index 000000000..5a65a070a --- /dev/null +++ b/gns3server/appliances/cisco-alpine-trex.gns3a @@ -0,0 +1,43 @@ +{ + "appliance_id": "924a2ef5-7b91-45ae-a1e4-7220338b76b0", + "name": "Cisco Alpine Linux TRex", + "category": "guest", + "description": "Alpine Linux VM image configured to run Cisco's Trex traffic generator in stateless mode", + "vendor_name": "Cisco", + "vendor_url": "http://www.cisco.com/", + "documentation_url": "https://trex-tgn.cisco.com/", + "product_name": "Alpine Linux Virt", + "product_url": "https://developer.cisco.com/modeling-labs/", + "registry_version": 4, + "status": "experimental", + "maintainer": "GNS3 Team", + "maintainer_email": "developers@gns3.net", + "usage": "By default, a \"cisco\" account with password \"cisco\" is created.", + "port_name_format": "eth{0}", + "qemu": { + "adapter_type": "virtio-net-pci", + "adapters": 3, + "ram": 512, + "hda_disk_interface": "virtio", + "arch": "x86_64", + "console_type": "vnc", + "kvm": "allow" + }, + "images": [ + { + "filename": "alpine-trex-3-21-3.qcow2", + "version": "2.87", + "md5sum": "54cf267bbc2ab7a1c71dc53bced6e128", + "filesize": 465174528, + "download_url": "https://u.cisco.com/my-learning/my-account" + } + ], + "versions": [ + { + "name": "2.87", + "images": { + "hda_disk_image": "alpine-trex-3-21-3.qcow2" + } + } + ] +} diff --git a/gns3server/appliances/cisco-asav.gns3a b/gns3server/appliances/cisco-asav.gns3a index 35e71c462..6f1830986 100644 --- a/gns3server/appliances/cisco-asav.gns3a +++ b/gns3server/appliances/cisco-asav.gns3a @@ -26,26 +26,33 @@ "kvm": "require" }, "images": [ + { + "filename": "asav9-23-1.qcow2", + "version": "9.23.1 CML", + "md5sum": "30454794b46fe07d1cd6a3ee72b511e1", + "filesize": 400687104, + "download_url": "https://u.cisco.com/my-learning/my-account" + }, { "filename": "asav9-22-1-1.qcow2", "version": "9.22.1.1 CML", "md5sum": "250a924cdc2370208eaac9d1dc8dc9e3", "filesize": 379518976, - "download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "asav9-18-2.qcow2", "version": "9.18.2 CML", "md5sum": "6f10fe106edfad9163625770a47a6b73", "filesize": 340262912, - "download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "asav9-16-2.qcow2", "version": "9.16.2 CML", "md5sum": "1f8db97063a7f738fddc81ac880a906c", "filesize": 262078976, - "download_url": "https://learningnetworkstore.cisco.com/cisco-modeling-labs-personal/cisco-modeling-labs-personal/CML-PERSONAL.html" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "asav9-16-2.qcow2", @@ -133,6 +140,12 @@ } ], "versions": [ + { + "name": "9.23.1 CML", + "images": { + "hda_disk_image": "asav9-23-1.qcow2" + } + }, { "name": "9.22.1.1 CML", "images": { diff --git a/gns3server/appliances/cisco-cat9k.gns3a b/gns3server/appliances/cisco-cat9k.gns3a index cd3538128..e5fde082e 100644 --- a/gns3server/appliances/cisco-cat9k.gns3a +++ b/gns3server/appliances/cisco-cat9k.gns3a @@ -31,14 +31,14 @@ "version": "17.12(1)", "md5sum": "e587e92186f42bdf69d7fa27f34425f7", "filesize": 2739404800, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "cat9kv-prd-17.10.01prd7.qcow2", "version": "17.10(1)", "md5sum": "ffdbace33d31deae33e2a920a96b79ef", "filesize": 2155806720, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ diff --git a/gns3server/appliances/cisco-csr1000v.gns3a b/gns3server/appliances/cisco-csr1000v.gns3a index 286431216..77d35a269 100644 --- a/gns3server/appliances/cisco-csr1000v.gns3a +++ b/gns3server/appliances/cisco-csr1000v.gns3a @@ -17,7 +17,7 @@ "qemu": { "adapter_type": "vmxnet3", "adapters": 4, - "ram": 3072, + "ram": 4096, "hda_disk_interface": "ide", "arch": "x86_64", "console_type": "telnet", @@ -134,21 +134,21 @@ "version": "16.5.1b", "md5sum": "ac11d33041b8ff6dc3553e324d02cccb", "filesize": 1209543680, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "csr1000v-universalk9.03.17.00.S.156-1.S-ext.qcow2", "version": "3.17", "md5sum": "06cbfcd11f3557391db64fe2a6015a6e", "filesize": 1346305024, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "csr1000v-universalk9.16.3.1-build2.qcow2", "version": "16.3.1-build2", "md5sum": "a770e96de928265515304c9c9d6b46b9", "filesize": 1280835584, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "csr1000v-universalk9.16.03.01.qcow2", @@ -162,21 +162,21 @@ "version": "16.3.2", "md5sum": "2e5803d23cd52cba5d55fa8306be5f13", "filesize": 1167720448, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "csr1000v-universalk9.16.4.1.qcow2", "version": "16.4.1", "md5sum": "3428e0dcf5132a1b11ab7696d8c61b2e", "filesize": 1261961216, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "csr1000v-universalk9.16.6.1.qcow2", "version": "16.6.1-VIRL", "md5sum": "996eb6dbf54781f6393bd689571a818c", "filesize": 1316301824, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ diff --git a/gns3server/appliances/cisco-iosv.gns3a b/gns3server/appliances/cisco-iosv.gns3a index 24b173c48..4ddc6cbc5 100644 --- a/gns3server/appliances/cisco-iosv.gns3a +++ b/gns3server/appliances/cisco-iosv.gns3a @@ -1,11 +1,12 @@ { "appliance_id": "3bf492b6-5717-4257-9bfd-b34617c6f133", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco IOSv", "category": "router", "description": "Cisco Virtual IOS allows user to run IOS on a standard computer.", "vendor_name": "Cisco", "vendor_url": "http://www.cisco.com/", - "documentation_url": "https://developer.cisco.com/docs/modeling-labs/iol/", + "documentation_url": "https://developer.cisco.com/docs/modeling-labs/iosv/", "product_name": "IOSv", "product_url": "https://developer.cisco.com/modeling-labs/", "registry_version": 4, @@ -25,82 +26,89 @@ "kvm": "require" }, "images": [ + { + "filename": "vios-adventerprisek9-m.spa.159-3.m10.qcow2", + "version": "15.9(3)M10", + "md5sum": "960ff630ce3fc030f6155a8e77d64105", + "filesize": 57323520, + "download_url": "https://u.cisco.com/my-learning/my-account" + }, { "filename": "vios-adventerprisek9-m.spa.159-3.m9.qcow2", "version": "15.9(3)M9", "md5sum": "01b707a2e33185d6d33e0255ced45d23", "filesize": 57323008, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.159-3.m8.qcow2", "version": "15.9(3)M8", "md5sum": "8d93a185c2fa778178a933f20b02150a", "filesize": 57319424, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.159-3.m6.qcow2", "version": "15.9(3)M6", "md5sum": "49a6977977263b2774bebc56e4e678ff", "filesize": 57309696, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.159-3.m4.qcow2", "version": "15.9(3)M4", "md5sum": "355b13ab821e64e2939fd7008d6304d7", "filesize": 57297920, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.159-3.m3.qcow2", "version": "15.9(3)M3", "md5sum": "12893843af18e4c62f13d07266755653", "filesize": 57296384, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.159-3.m2.qcow2", "version": "15.9(3)M2", "md5sum": "a19e998bc3086825c751d125af722329", "filesize": 57308672, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.spa.158-3.m2.qcow2", "version": "15.8(3)M2", "md5sum": "40e3d25b5b0cb13d639fcd2cf18e9965", "filesize": 57129984, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.vmdk.SPA.157-3.M3", "version": "15.7(3)M3", "md5sum": "37c148ffa14a82f418a6e9c2b049fafe", "filesize": 134021120, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.vmdk.SPA.156-2.T", "version": "15.6(2)T", "md5sum": "83707e3cc93646da58ee6563a68002b5", "filesize": 128450560, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.vmdk.SPA.156-1.T", "version": "15.6(1)T", "md5sum": "e7cb1bbd0c59280dd946feefa68fa270", "filesize": 128122880, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios-adventerprisek9-m.vmdk.SPA.155-3.M", "version": "15.5(3)M", "md5sum": "79f613ac3b179d5a64520730925130b2", "filesize": 127926272, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "IOSv_startup_config.img", @@ -112,6 +120,13 @@ } ], "versions": [ + { + "name": "15.9(3)M10", + "images": { + "hda_disk_image": "vios-adventerprisek9-m.spa.159-3.m10.qcow2", + "hdb_disk_image": "IOSv_startup_config.img" + } + }, { "name": "15.9(3)M9", "images": { diff --git a/gns3server/appliances/cisco-iosvl2.gns3a b/gns3server/appliances/cisco-iosvl2.gns3a index e9b869555..f279bf75f 100644 --- a/gns3server/appliances/cisco-iosvl2.gns3a +++ b/gns3server/appliances/cisco-iosvl2.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "4368f802-ddec-4863-adbd-a36a6d83cd4c", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco IOSvL2", "category": "multilayer_switch", "description": "Cisco Virtual IOS L2 allows user to run a IOS switching image on a standard computer.", @@ -30,35 +31,35 @@ "version": "15.2(20200924:215240)", "md5sum": "99ecab32de12410c533e6abd4e9710aa", "filesize": 90409984, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios_l2-adventerprisek9-m.ssa.high_iron_20190423.qcow2", "version": "15.2(6.0.81)E", "md5sum": "71cacb678f98a106f99e889b97b34686", "filesize": 44950016, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios_l2-adventerprisek9-m.SSA.high_iron_20180619.qcow2", "version": "15.2.1", "md5sum": "14b981002e40b660f2d7400401e04c14", "filesize": 44938752, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios_l2-adventerprisek9-m.03.2017.qcow2", "version": "15.2(20170321:233949)", "md5sum": "8f14b50083a14688dec2fc791706bb3e", "filesize": 41157632, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "vios_l2-adventerprisek9-m.vmdk.SSA.152-4.0.55.E", "version": "15.2(4.0.55)E", "md5sum": "1a3a21f5697cae64bb930895b986d71e", "filesize": 96862208, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ diff --git a/gns3server/appliances/cisco-iosxrv.gns3a b/gns3server/appliances/cisco-iosxrv.gns3a index e35a5541a..ffe80fd90 100644 --- a/gns3server/appliances/cisco-iosxrv.gns3a +++ b/gns3server/appliances/cisco-iosxrv.gns3a @@ -30,14 +30,14 @@ "version": "6.1.3", "md5sum": "1693b5d22a398587dd0fed2877d8dfac", "filesize": 428588544, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "iosxrv-k9-demo-6.0.1.qcow2", "version": "6.0.1", "md5sum": "0831ecf43628eccb752ebb275de9a62a", "filesize": 908132352, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ diff --git a/gns3server/appliances/cisco-iosxrv9k.gns3a b/gns3server/appliances/cisco-iosxrv9k.gns3a index c856f37f5..952049028 100644 --- a/gns3server/appliances/cisco-iosxrv9k.gns3a +++ b/gns3server/appliances/cisco-iosxrv9k.gns3a @@ -19,36 +19,50 @@ "adapter_type": "virtio-net-pci", "adapters": 7, "ram": 16384, + "cpus": 4, "hda_disk_interface": "ide", "arch": "x86_64", "console_type": "telnet", "kvm": "require", - "options": "-smp 4 -cpu host" + "options": "-cpu host" }, "images": [ + { + "filename": "xrv9k-fullk9-x-25.1.1.qcow2", + "version": "25.1.1", + "md5sum": "554d795d495badf90b6c75a2a342b409", + "filesize": 2002911232, + "download_url": "https://u.cisco.com/my-learning/my-account" + }, { "filename": "xrv9k-fullk9-x-24.3.1.qcow2", "version": "24.3.1", "md5sum": "bd047c457e29952f265583f299bab845", "filesize": 1704658432, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "xrv9k-fullk9-x-7.7.1.qcow2", "version": "7.7.1", "md5sum": "682fff40d2ff373d8da3342906553b54", "filesize": 1643905024, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "xrv9k-fullk9-x-7.1.1.qcow2", "version": "7.1.1", "md5sum": "dcf241e3f8df0151fec2c7bfac9d96ac", "filesize": 1443758080, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ + { + "name": "25.1.1", + "images": { + "hda_disk_image": "xrv9k-fullk9-x-25.1.1.qcow2" + } + }, { "name": "24.3.1", "images": { diff --git a/gns3server/appliances/cisco-iou-l2.gns3a b/gns3server/appliances/cisco-iou-l2.gns3a index 0f6613ac3..dd5966dd2 100644 --- a/gns3server/appliances/cisco-iou-l2.gns3a +++ b/gns3server/appliances/cisco-iou-l2.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "9593db48-558a-4914-bb78-a20605f39c65", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco IOU L2", "category": "multilayer_switch", "description": "Cisco IOS on UNIX Layer 2 image.", @@ -15,11 +16,17 @@ "iou": { "ethernet_adapters": 4, "serial_adapters": 0, - "nvram": 512, - "ram": 512, + "nvram": 256, + "ram": 1024, "startup_config": "iou_l2_base_startup-config.txt" }, "images": [ + { + "filename": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol", + "version": "17.16.1a", + "md5sum": "d55879318e51c5401fb9314d45c67728", + "filesize": 244265240 + }, { "filename": "x86_64_crb_linux_l2-adventerprisek9-ms.iol", "version": "17.15.1", @@ -31,27 +38,15 @@ "version": "17.12.1", "md5sum": "2b5055e4cef8fd257416d74a94adb626", "filesize": 240355720 - }, - { - "filename": "i86bi-linux-l2-ipbasek9-15.1g.bin", - "version": "15.1g", - "md5sum": "0b8b9e14ca99b68c654e44c4296857ba", - "filesize": 62137336 - }, - { - "filename": "i86bi-linux-l2-adventerprisek9-15.1a.bin", - "version": "15.1a", - "md5sum": "9549a20a7391fb849da32caa77a0d254", - "filesize": 72726092 - }, - { - "filename": "i86bi-linux-l2-adventerprisek9-15.2d.bin", - "version": "15.2d", - "md5sum": "f16db44433beb3e8c828db5ddad1de8a", - "filesize": 105036380 } ], "versions": [ + { + "name": "17.16.1a", + "images": { + "image": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol" + } + }, { "name": "17.15.1", "images": { @@ -63,24 +58,6 @@ "images": { "image": "x86_64_crb_linux_l2-adventerprisek9-ms" } - }, - { - "name": "15.1g", - "images": { - "image": "i86bi-linux-l2-ipbasek9-15.1g.bin" - } - }, - { - "name": "15.1a", - "images": { - "image": "i86bi-linux-l2-adventerprisek9-15.1a.bin" - } - }, - { - "name": "15.2d", - "images": { - "image": "i86bi-linux-l2-adventerprisek9-15.2d.bin" - } } ] } diff --git a/gns3server/appliances/cisco-iou-l3.gns3a b/gns3server/appliances/cisco-iou-l3.gns3a index 262019098..ae8273616 100644 --- a/gns3server/appliances/cisco-iou-l3.gns3a +++ b/gns3server/appliances/cisco-iou-l3.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "87bf6f58-1e5a-4ee2-afe7-715dabffcf18", + "tags": ["device_type:cisco_ios_telnet"], "name": "Cisco IOU L3", "category": "router", "description": "Cisco IOS on UNIX Layer 3 image.", @@ -15,11 +16,17 @@ "iou": { "ethernet_adapters": 2, "serial_adapters": 2, - "nvram": 512, - "ram": 512, + "nvram": 256, + "ram": 1024, "startup_config": "iou_l3_base_startup-config.txt" }, "images": [ + { + "filename": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol", + "version": "17.16.1a", + "md5sum": "39bdf959c3b99a3c81d66451f1cf0404", + "filesize": 293062920 + }, { "filename": "x86_64_crb_linux-adventerprisek9-ms.iol", "version": "17.15.1", @@ -31,27 +38,15 @@ "version": "17.12.1", "md5sum": "4a2fce8de21d1831fbceffd155e41ae7", "filesize": 288947184 - }, - { - "filename": "i86bi_LinuxL3-AdvEnterpriseK9-M2_157_3_May_2018.bin", - "version": "15.7(3)M2", - "md5sum": "d6874260c3daeeb96d10fc844ae0b93b", - "filesize": 184759244 - }, - { - "filename": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin", - "version": "155-2T", - "md5sum": "45e99761a95cbd3ee3924ecf0f3d89e5", - "filesize": 172982492 - }, - { - "filename": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin", - "version": "15.4.1T", - "md5sum": "2eabae17778316c49cbc80e8e81262f9", - "filesize": 152677848 } ], "versions": [ + { + "name": "17.16.1a", + "images": { + "image": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol" + } + }, { "name": "17.15.1", "images": { @@ -63,24 +58,6 @@ "images": { "image": "x86_64_crb_linux-adventerprisek9-ms" } - }, - { - "name": "15.7(3)M2", - "images": { - "image": "i86bi_LinuxL3-AdvEnterpriseK9-M2_157_3_May_2018.bin" - } - }, - { - "name": "155-2T", - "images": { - "image": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin" - } - }, - { - "name": "15.4.1T", - "images": { - "image": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin" - } } ] } diff --git a/gns3server/appliances/cisco-nxosv.gns3a b/gns3server/appliances/cisco-nxosv.gns3a index a71b0ad38..f6ca2af02 100644 --- a/gns3server/appliances/cisco-nxosv.gns3a +++ b/gns3server/appliances/cisco-nxosv.gns3a @@ -30,14 +30,14 @@ "version": "7.3.0", "md5sum": "b4cd6edf15ab4c6bce53c3f6c1e3a742", "filesize": 214368256, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" }, { "filename": "titanium-d1.7.2.0.D1.1.vmdk", "version": "7.2.0", "md5sum": "0ee38c7d717840cb4ca822f4870671d0", "filesize": 361103360, - "download_url": "https://learningnetworkstore.cisco.com/myaccount" + "download_url": "https://u.cisco.com/my-learning/my-account" } ], "versions": [ diff --git a/gns3server/appliances/cisco-nxosv9k-lite.gns3a b/gns3server/appliances/cisco-nxosv9k-lite.gns3a new file mode 100644 index 000000000..e309f0ec3 --- /dev/null +++ b/gns3server/appliances/cisco-nxosv9k-lite.gns3a @@ -0,0 +1,69 @@ +{ + "appliance_id": "4c7fd5da-be86-4168-8484-7071dc9c71fb", + "name": "Cisco NX-OSv 9000 Lite", + "category": "multilayer_switch", + "description": "The NX-OSv 9000 is a virtual platform that is designed to simulate the control plane aspects of a network element running Cisco Nexus 9000 software. The NX-OSv 9000 shares the same software image running on Cisco Nexus 9000 hardware platform although no specific hardware emulation is implemented. When the software runs as a virtual machine, line card (LC) ASIC provisioning or any interaction from the control plane to hardware ASIC is handled by the NX-OSv 9000 software data plane.\nThe NX-OSv 9000 for the Cisco Nexus 9000 Series provides a useful tool to enable the devops model and rapidly test changes to the infrastructure or to infrastructure automation tools. This enables network simulations in large scale for customers to validate configuration changes on a simulated network prior to applying them on a production network. Some users have also expressed interest in using the simulation system for feature test ,verification, and automation tooling development and test simualtion prior to deployment. NX-OSv 9000 can be used as a programmability vehicle to validate software defined networks (SDNs) and Network Function Virtualization (NFV) based solutions.\nThe lite version has a reduced memory footprint, resulting in a much smaller image size than the earlier images for Cisco Nexus 9300v and 9500v.", + "vendor_name": "Cisco", + "vendor_url": "http://www.cisco.com/", + "documentation_url": "https://developer.cisco.com/docs/modeling-labs/nx-os-9000/", + "product_name": "NX-OSv 9000", + "product_url": "https://developer.cisco.com/modeling-labs/", + "registry_version": 4, + "status": "stable", + "availability": "service-contract", + "maintainer": "GNS3 Team", + "maintainer_email": "developers@gns3.net", + "first_port_name": "mgmt0", + "port_name_format": "Ethernet1/{port1}", + "qemu": { + "adapter_type": "e1000", + "adapters": 10, + "ram": 6144, + "cpus": 2, + "hda_disk_interface": "sata", + "arch": "x86_64", + "console_type": "telnet", + "kvm": "require" + }, + "images": [ + { + "filename": "nexus9500v64-lite.10.5.5.M.qcow2", + "version": "9500v lite 10.5.5.M", + "md5sum": "a9aafd88f36a7f44a5a2ef3322cd9bde", + "filesize": 2617638912, + "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/10.5(5)" + }, + { + "filename": "nexus9300v64-lite.10.5.5.M.qcow2", + "version": "9300v lite 10.5.5.M", + "md5sum": "78610953005faab9505252baf4c86b78", + "filesize": 2617573376, + "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/10.5(5)" + }, + { + "filename": "OVMF-edk2-stable202305.fd", + "version": "stable202305", + "md5sum": "6c4cf1519fec4a4b95525d9ae562963a", + "filesize": 4194304, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download", + "compression": "zip" + } + ], + "versions": [ + { + "name": "9500v lite 10.5.5.M", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "nexus9500v64-lite.10.5.5.M.qcow2" + } + }, + { + "name": "9300v lite 10.5.5.M", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "nexus9300v64-lite.10.5.5.M.qcow2" + } + } + ] +} diff --git a/gns3server/appliances/cisco-nxosv9k.gns3a b/gns3server/appliances/cisco-nxosv9k.gns3a index 7798233ed..20d96baef 100644 --- a/gns3server/appliances/cisco-nxosv9k.gns3a +++ b/gns3server/appliances/cisco-nxosv9k.gns3a @@ -27,6 +27,20 @@ "kvm": "require" }, "images": [ + { + "filename": "nexus9500v64.10.5.5.M.qcow2", + "version": "9500v 10.5.5.M", + "md5sum": "c1d34bb3331d958d3219a56b6885150e", + "filesize": 2919170048, + "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/10.5(5)" + }, + { + "filename": "nexus9300v64.10.5.5.M.qcow2", + "version": "9300v 10.5.5.M", + "md5sum": "f1cc2342b920320654d232b68551ccfd", + "filesize": 2919104512, + "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/10.5(5)" + }, { "filename": "nexus9500v64.10.5.1.F.qcow2", "version": "9500v 10.5.1.F", @@ -80,6 +94,20 @@ } ], "versions": [ + { + "name": "9500v 10.5.5.M", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "nexus9500v64.10.5.5.M.qcow2" + } + }, + { + "name": "9300v 10.5.5.M", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "nexus9300v64.10.5.5.M.qcow2" + } + }, { "name": "9500v 10.5.1.F", "images": { diff --git a/gns3server/appliances/exos.gns3a b/gns3server/appliances/exos.gns3a index 2393b3e09..00e3dd5e3 100644 --- a/gns3server/appliances/exos.gns3a +++ b/gns3server/appliances/exos.gns3a @@ -7,7 +7,7 @@ "vendor_url": "https://www.extremenetworks.com", "documentation_url": "https://www.extremenetworks.com/support/documentation", "product_name": "EXOS VM", - "registry_version": 4, + "registry_version": 5, "status": "stable", "maintainer": "Extreme Networks", "maintainer_email": "GitHubscripting@extremenetworks.com", @@ -18,18 +18,25 @@ "qemu": { "adapter_type": "rtl8139", "adapters": 13, - "ram": 512, + "ram": 1024, "hda_disk_interface": "ide", "arch": "x86_64", "console_type": "telnet", "boot_priority": "dc", "kvm": "allow", - "options": "-cpu core2duo" + "options": "-nographic -cpu host -enable-kvm" }, "images": [ - { + { + "filename": "EXOS-VM_33.6.1.14.qcow2", + "version": "33.6.1.14", + "md5sum": "01ea4bea3f321f91910565bb223a2540", + "filesize": 278647296, + "direct_download_url": "https://akamai-ep.extremenetworks.com/Extreme_P/github-en/Virtual_EXOS/EXOS-VM_33.6.1.14.qcow2" + }, + { "filename": "EXOS-VM_32.7.2.19.qcow2", "version": "32.7.2.19", "md5sum": "eba580a2e18d2a9cc972c9ece8917ea8", @@ -48,6 +55,12 @@ "versions": [ + { + "name": "33.6.1.14", + "images": { + "hda_disk_image": "EXOS-VM_33.6.1.14.qcow2" + } + }, { "name": "32.7.2.19", "images": { diff --git a/gns3server/appliances/fedora-cloud.gns3a b/gns3server/appliances/fedora-cloud.gns3a index 70c334001..3b408ded9 100644 --- a/gns3server/appliances/fedora-cloud.gns3a +++ b/gns3server/appliances/fedora-cloud.gns3a @@ -26,6 +26,30 @@ "options": "-nographic" }, "images": [ + { + "filename": "Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2", + "version": "44-1.7", + "md5sum": "49784011eb752291bfead24d3be8163f", + "filesize": 583729152, + "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images", + "direct_download_url": "https://fedora.mirrorservice.org/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" + }, + { + "filename": "Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", + "version": "43-1.6", + "md5sum": "f2f671db53fae58b79bb2e2259fa3622", + "filesize": 583335936, + "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images", + "direct_download_url": "https://fedora.mirrorservice.org/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2" + }, + { + "filename": "Fedora-Cloud-Base-Generic-42-1.1.x86_64.qcow2", + "version": "42-1.1", + "md5sum": "2d660d2adc20b1fe793df5cb66f1d868", + "filesize": 532217856, + "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/42/Cloud/x86_64/images", + "direct_download_url": "https://fedora.mirrorservice.org/fedora/linux/releases/42/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-42-1.1.x86_64.qcow2" + }, { "filename": "Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2", "version": "41-1.4", @@ -34,30 +58,6 @@ "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Cloud/x86_64/images", "direct_download_url": "https://fedora.mirrorservice.org/fedora/linux/releases/41/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2" }, - { - "filename": "Fedora-Cloud-Base-Generic.x86_64-40-1.14.qcow2", - "version": "40-1.14", - "md5sum": "3eed4b1a9de35208ed30d9bb72c1522d", - "filesize": 397475840, - "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/40/Cloud/x86_64/images", - "direct_download_url": "https://fedora.mirrorservice.org/fedora/linux/releases/40/Cloud/x86_64/images/Fedora-Cloud-Base-Generic.x86_64-40-1.14.qcow2" - }, - { - "filename": "Fedora-Cloud-Base-39-1.5.x86_64.qcow2", - "version": "39-1.5", - "md5sum": "800a10df2d369891ed65900bcacacd47", - "filesize": 544604160, - "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/39/Cloud/x86_64/images", - "direct_download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/39/Cloud/x86_64/images/Fedora-Cloud-Base-39-1.5.x86_64.qcow2" - }, - { - "filename": "Fedora-Cloud-Base-38-1.6.x86_64.qcow2", - "version": "38-1.6", - "md5sum": "53ddfe7b28666d5ddc55e93ff06abad2", - "filesize": 497287168, - "download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/38/Cloud/x86_64/images", - "direct_download_url": "https://download.fedoraproject.org/pub/fedora/linux/releases/38/Cloud/x86_64/images/Fedora-Cloud-Base-38-1.6.x86_64.qcow2" - }, { "filename": "fedora-cloud-init-data.iso", "version": "1.0", @@ -68,33 +68,33 @@ } ], "versions": [ + { + "name": "44-1.7", + "images": { + "hda_disk_image": "Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2", + "cdrom_image": "fedora-cloud-init-data.iso" + } + }, + { + "name": "43-1.6", + "images": { + "hda_disk_image": "Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2", + "cdrom_image": "fedora-cloud-init-data.iso" + } + }, + { + "name": "42-1.1", + "images": { + "hda_disk_image": "Fedora-Cloud-Base-Generic-42-1.1.x86_64.qcow2", + "cdrom_image": "fedora-cloud-init-data.iso" + } + }, { "name": "41-1.4", "images": { "hda_disk_image": "Fedora-Cloud-Base-Generic-41-1.4.x86_64.qcow2", "cdrom_image": "fedora-cloud-init-data.iso" } - }, - { - "name": "40-1.14", - "images": { - "hda_disk_image": "Fedora-Cloud-Base-Generic.x86_64-40-1.14.qcow2", - "cdrom_image": "fedora-cloud-init-data.iso" - } - }, - { - "name": "39-1.5", - "images": { - "hda_disk_image": "Fedora-Cloud-Base-39-1.5.x86_64.qcow2", - "cdrom_image": "fedora-cloud-init-data.iso" - } - }, - { - "name": "38-1.6", - "images": { - "hda_disk_image": "Fedora-Cloud-Base-38-1.6.x86_64.qcow2", - "cdrom_image": "fedora-cloud-init-data.iso" - } } ] } diff --git a/gns3server/appliances/home-assistant.gns3a b/gns3server/appliances/home-assistant.gns3a new file mode 100644 index 000000000..d6cb3a5ae --- /dev/null +++ b/gns3server/appliances/home-assistant.gns3a @@ -0,0 +1,60 @@ +{ + "appliance_id": "4ac0d5bc-714d-4a5a-8af5-8fb452046acd", + "name": "Home Assistant", + "category": "guest", + "description": "Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts.", + "vendor_name": "Open Home Foundation", + "vendor_url": "https://www.openhomefoundation.org/", + "vendor_logo_url": "https://www.openhomefoundation.org/assets/images/logo/open-home-foundation.svg", + "documentation_url": "https://www.home-assistant.io/installation/alternative", + "product_name": "haos", + "product_url": "https://www.home-assistant.io/", + "registry_version": 8, + "status": "stable", + "availability": "free", + "maintainer": "Neyder Achahuanco Apaza", + "maintainer_email": "gns3@neyder.net", + "usage": "You will be able to reach Home Assistant on homeassistant.local:8123, you might need to access Home Assistant at homeassistant:8123 or http://X.X.X.X:8123 (replace X.X.X.X with your virtual machine\u2019s IP address).\nYou can access trough console with root user without password.", + "symbol": "home-assistant-logomark-color-on-light.svg", + "default_username": "cisco", + "default_password": "admin", + "settings": [ + { + "name": "Default template settings", + "default": true, + "template_type": "qemu", + "template_properties": + { + "adapter_type": "virtio-net-pci", + "platform": "x86_64", + "adapters": 1, + "ram": 2048, + "cpus": 2, + "hda_disk_interface": "virtio", + "console_type": "telnet", + "boot_priority": "cd", + "on_close": "shutdown_signal", + "uefi": true + } + } + ], + "images": [ + { + "filename": "haos_ova-17.1.qcow2", + "version": "17.1", + "md5sum": "e1b950430045859a3ac035cb420df158", + "filesize": 1024393216, + "download_url": "https://www.home-assistant.io/installation/alternative", + "direct_download_url": "https://github.com/home-assistant/operating-system/releases/download/17.1/haos_ova-17.1.qcow2.xz", + "compression": "xz" + } + ], + "versions": [ + { + "images": { + "hda_disk_image": "haos_ova-17.1.qcow2" + }, + "name": "17.1" + } + ] +} \ No newline at end of file diff --git a/gns3server/appliances/huawei-ce12800.gns3a b/gns3server/appliances/huawei-ce12800.gns3a index 8d05abeb2..bdb5433d1 100644 --- a/gns3server/appliances/huawei-ce12800.gns3a +++ b/gns3server/appliances/huawei-ce12800.gns3a @@ -1,5 +1,6 @@ { "appliance_id": "bd86792e-9870-4b42-8f16-cb2776f1d5d5", + "tags": ["device_type:gns3_huawei_telnet_ce"], "name": "HuaWei CE12800", "category": "multilayer_switch", "description": "CE12800 series switches are high-performance core switches designed for data center networks and high-end campus networks. The switches provide stable, reliable, secure, and high-performance Layer 2/Layer 3 switching services, to help build an elastic, virtualized, agile, and high-quality network.", diff --git a/gns3server/appliances/infix.gns3a b/gns3server/appliances/infix.gns3a index d9ab0321f..736863ba7 100644 --- a/gns3server/appliances/infix.gns3a +++ b/gns3server/appliances/infix.gns3a @@ -111,9 +111,51 @@ "md5sum": "e2eb1a4fff9d56815f37c2e82a6279fb", "version": "25.11.0", "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v25.11.0/infix-x86_64-disk-25.11.0.qcow2" + }, + { + "filename": "infix-x86_64-v26.01.0.qcow2", + "filesize": 307363840, + "md5sum": "bd3b9c56f98ba264c36e4d6f69931546", + "version": "26.01.0", + "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.01.0/infix-x86_64-v26.01.0.qcow2" + }, + { + "filename": "infix-x86_64-v26.02.1.qcow2", + "filesize": 316407808, + "md5sum": "0c4da508cfcb702c066f47227644b65f", + "version": "26.02.1", + "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.02.1/infix-x86_64-v26.02.1.qcow2" + }, + { + "filename": "infix-x86_64-v26.03.0.qcow2", + "filesize": 320339968, + "md5sum": "24cd1006734993dab338e5c75f80b875", + "version": "26.03.0", + "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.03.0/infix-x86_64-v26.03.0.qcow2" } ], "versions": [ + { + "name": "26.03.0", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "infix-x86_64-v26.03.0.qcow2" + } + }, + { + "name": "26.02.1", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "infix-x86_64-v26.02.1.qcow2" + } + }, + { + "name": "26.01.0", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "infix-x86_64-v26.01.0.qcow2" + } + }, { "name": "25.11.0", "images": { diff --git a/gns3server/appliances/juniper-vJunos-router.gns3a b/gns3server/appliances/juniper-vJunos-router.gns3a index 9669079ac..a9fb999b7 100644 --- a/gns3server/appliances/juniper-vJunos-router.gns3a +++ b/gns3server/appliances/juniper-vJunos-router.gns3a @@ -14,11 +14,11 @@ "maintainer_email": "github@sugarpapa.mozmail.com", "usage": "GNS3 SHOULD be a baremetal installation. Using the GNS3 VM MIGHT result in unwanted issues. Default user is root. No password is needed.", "symbol": "juniper-vmx.svg", - "first_port_name": "ge-0/0/0", + "first_port_name": "fxp0", "port_name_format": "ge-0/0/{port0}", "qemu": { "adapter_type": "virtio-net-pci", - "adapters": 17, + "adapters": 11, "ram": 5120, "cpus": 4, "hda_disk_interface": "virtio", @@ -72,4 +72,4 @@ "name": "23.2R1.15" } ] -} \ No newline at end of file +} diff --git a/gns3server/appliances/mikrotik-chr.gns3a b/gns3server/appliances/mikrotik-chr.gns3a index 48c1eade8..cda69de36 100644 --- a/gns3server/appliances/mikrotik-chr.gns3a +++ b/gns3server/appliances/mikrotik-chr.gns3a @@ -27,6 +27,24 @@ "options": "-nographic" }, "images": [ + { + "filename": "chr-7.22.1.img", + "version": "7.22.1", + "md5sum": "a67290634f489071eae2fd99dcadba86", + "filesize": 134217728, + "download_url": "http://www.mikrotik.com/download", + "direct_download_url": "https://download.mikrotik.com/routeros/7.22.1/chr-7.22.1.img.zip", + "compression": "zip" + }, + { + "filename": "chr-7.20.8.img", + "version": "7.20.8", + "md5sum": "a432cf89f405115163952eefd82934e4", + "filesize": 134217728, + "download_url": "http://www.mikrotik.com/download", + "direct_download_url": "https://download.mikrotik.com/routeros/7.20.8/chr-7.20.8.img.zip", + "compression": "zip" + }, { "filename": "chr-7.16.img", "version": "7.16", @@ -74,6 +92,18 @@ } ], "versions": [ + { + "name": "7.22.1", + "images": { + "hda_disk_image": "chr-7.22.1.img" + } + }, + { + "name": "7.20.8", + "images": { + "hda_disk_image": "chr-7.20.8.img" + } + }, { "name": "7.16", "images": { diff --git a/gns3server/appliances/openwrt.gns3a b/gns3server/appliances/openwrt.gns3a index ac3711d3a..84e9e6b57 100644 --- a/gns3server/appliances/openwrt.gns3a +++ b/gns3server/appliances/openwrt.gns3a @@ -23,6 +23,15 @@ "kvm": "allow" }, "images": [ + { + "filename": "openwrt-25.12.0-x86-64-generic-ext4-combined.img", + "version": "25.12.0", + "md5sum": "8c0e01307fc47c1f862a5d0c9a51c2c5", + "filesize": 126353408, + "download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/", + "direct_download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/openwrt-25.12.0-x86-64-generic-ext4-combined.img.gz", + "compression": "gzip" + }, { "filename": "openwrt-24.10.2-x86-64-generic-ext4-combined.img", "version": "24.10.2", @@ -232,6 +241,12 @@ } ], "versions": [ + { + "name": "25.12.0", + "images": { + "hda_disk_image": "openwrt-25.12.0-x86-64-generic-ext4-combined.img" + } + }, { "name": "24.10.2", "images": { diff --git a/gns3server/appliances/opnsense.gns3a b/gns3server/appliances/opnsense.gns3a index 433b4f2de..7d3756052 100644 --- a/gns3server/appliances/opnsense.gns3a +++ b/gns3server/appliances/opnsense.gns3a @@ -25,6 +25,15 @@ "kvm": "require" }, "images": [ + { + "filename": "OPNsense-26.1-nano-amd64.img", + "version": "26.1", + "md5sum": "3b774e8fb651a5d00aa6fa3e0d1b1ff0", + "filesize": 3221225472, + "download_url": "https://opnsense.c0urier.net/releases/26.1/", + "direct_download_url": "https://opnsense.c0urier.net/releases/26.1/OPNsense-26.1-nano-amd64.img.bz2", + "compression": "bzip2" + }, { "filename": "OPNsense-25.7-nano-amd64.img", "version": "25.7", @@ -90,6 +99,24 @@ } ], "versions": [ + { + "name": "26.1", + "images": { + "hda_disk_image": "OPNsense-26.1-nano-amd64.img" + } + }, + { + "name": "25.7", + "images": { + "hda_disk_image": "OPNsense-25.7-nano-amd64.img" + } + }, + { + "name": "25.1", + "images": { + "hda_disk_image": "OPNsense-25.1-nano-amd64.img" + } + }, { "name": "24.7", "images": { diff --git a/gns3server/appliances/oracle-linux-cloud.gns3a b/gns3server/appliances/oracle-linux-cloud.gns3a index 1b53077fc..184a42489 100644 --- a/gns3server/appliances/oracle-linux-cloud.gns3a +++ b/gns3server/appliances/oracle-linux-cloud.gns3a @@ -17,7 +17,7 @@ "qemu": { "adapter_type": "virtio-net-pci", "adapters": 1, - "ram": 1024, + "ram": 1536, "hda_disk_interface": "virtio", "arch": "x86_64", "console_type": "telnet", @@ -27,60 +27,36 @@ }, "images": [ { - "filename": "OL9U5_x86_64-kvm-b259.qcow2", - "version": "9.5", - "md5sum": "05e9b62c408ab49a02d6833fc683d1ad", - "filesize": 652935168, + "filename": "OL10U1_x86_64-kvm-b270.qcow2", + "version": "10.1", + "md5sum": "c3ed48965110e0cbbd75734d3c23a2ce", + "filesize": 1044119552, "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL9/u5/x86_64/OL9U5_x86_64-kvm-b259.qcow2" + "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL10/u1/x86_64/OL10U1_x86_64-kvm-b270.qcow2" }, { - "filename": "OL9U2_x86_64-kvm-b197.qcow", - "version": "9.2", - "md5sum": "2ff3d0bc8a243ad89c96215f303f1c73", - "filesize": 560791552, + "filename": "OL9U7_x86_64-kvm-b269.qcow2", + "version": "9.7", + "md5sum": "fe0c555620d2001bb4d4d95a53b43738", + "filesize": 792985600, "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL9/u2/x86_64/OL9U2_x86_64-kvm-b197.qcow" + "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL9/u7/x86_64/OL9U7_x86_64-kvm-b269.qcow2" }, { - "filename": "OL9U1_x86_64-kvm-b158.qcow", - "version": "9.1", - "md5sum": "9f32851b96fc38191892197fa11f7435", - "filesize": 539033600, - "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL9/u1/x86_64/OL9U1_x86_64-kvm-b158.qcow" - }, - { - "filename": "OL8U10_x86_64-kvm-b258.qcow2", + "filename": "OL8U10_x86_64-kvm-b271.qcow2", "version": "8.10", - "md5sum": "bb07581af5122515b6822595ded5deef", - "filesize": 1251672064, + "md5sum": "f3184e536af1c2aba6b6f499b1a7085a", + "filesize": 1585643520, "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL8/u10/x86_64/OL8U10_x86_64-kvm-b258.qcow2" + "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL8/u10/x86_64/OL8U10_x86_64-kvm-b271.qcow2" }, { - "filename": "OL8U8_x86_64-kvm-b198.qcow", - "version": "8.8", - "md5sum": "717622f373d77349cc102a3a325efbd3", - "filesize": 934215680, - "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL8/u8/x86_64/OL8U8_x86_64-kvm-b198.qcow" - }, - { - "filename": "OL8U7_x86_64-kvm-b148.qcow", - "version": "8.7", - "md5sum": "962cdde7e810888b9914e937222f687f", - "filesize": 913965056, - "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL8/u7/x86_64/OL8U7_x86_64-kvm-b148.qcow" - }, - { - "filename": "OL7U9_x86_64-kvm-b145.qcow", + "filename": "OL7U9_x86_64-kvm-b257.qcow2", "version": "7.9", - "md5sum": "e60d4145a69b34026db6121109ca9131", - "filesize": 725221376, + "md5sum": "d3af7cf0b8f44d298a67ce9ebe184494", + "filesize": 998244352, "download_url": "https://yum.oracle.com/oracle-linux-templates.html", - "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL7/u9/x86_64/OL7U9_x86_64-kvm-b145.qcow" + "direct_download_url": "https://yum.oracle.com/templates/OracleLinux/OL7/u9/x86_64/OL7U9_x86_64-kvm-b257.qcow2" }, { "filename": "oracle-cloud-init-data.iso", @@ -93,51 +69,30 @@ ], "versions": [ { - "name": "9.5", + "name": "10.1", "images": { - "hda_disk_image": "OL9U5_x86_64-kvm-b259.qcow2", + "hda_disk_image": "OL10U1_x86_64-kvm-b270.qcow2", "cdrom_image": "oracle-cloud-init-data.iso" } }, { - "name": "9.2", + "name": "9.7", "images": { - "hda_disk_image": "OL9U2_x86_64-kvm-b197.qcow", - "cdrom_image": "oracle-cloud-init-data.iso" - } - }, - { - "name": "9.1", - "images": { - "hda_disk_image": "OL9U1_x86_64-kvm-b158.qcow", + "hda_disk_image": "OL9U7_x86_64-kvm-b269.qcow2", "cdrom_image": "oracle-cloud-init-data.iso" } }, { "name": "8.10", "images": { - "hda_disk_image": "OL8U10_x86_64-kvm-b258.qcow2", - "cdrom_image": "oracle-cloud-init-data.iso" - } - }, - { - "name": "8.8", - "images": { - "hda_disk_image": "OL8U8_x86_64-kvm-b198.qcow", - "cdrom_image": "oracle-cloud-init-data.iso" - } - }, - { - "name": "8.7", - "images": { - "hda_disk_image": "OL8U7_x86_64-kvm-b148.qcow", + "hda_disk_image": "OL8U10_x86_64-kvm-b271.qcow2", "cdrom_image": "oracle-cloud-init-data.iso" } }, { "name": "7.9", "images": { - "hda_disk_image": "OL7U9_x86_64-kvm-b145.qcow", + "hda_disk_image": "OL7U9_x86_64-kvm-b257.qcow2", "cdrom_image": "oracle-cloud-init-data.iso" } } diff --git a/gns3server/appliances/rhel.gns3a b/gns3server/appliances/rhel.gns3a index c383c4b4d..8ee8916a6 100644 --- a/gns3server/appliances/rhel.gns3a +++ b/gns3server/appliances/rhel.gns3a @@ -13,11 +13,11 @@ "availability": "service-contract", "maintainer": "Da-Geek", "maintainer_email": "dageek@dageeks-geeks.gg", - "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat", + "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/rhel attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat", "qemu": { "adapter_type": "virtio-net-pci", "adapters": 1, - "ram": 1024, + "ram": 1536, "hda_disk_interface": "virtio", "arch": "x86_64", "console_type": "telnet", @@ -27,11 +27,32 @@ }, "images": [ { - "filename": "rhel-9.5-x86_64-kvm.qcow2", - "version": "9.5", - "md5sum": "8174396d5cb47727c59dd04dd9a05418", - "filesize": 974389248, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software" + "filename": "rhel-10.1-x86_64-kvm.qcow2", + "version": "10.1", + "md5sum": "b2f35b1ac0600de31cadf30a622a2fef", + "filesize": 1010827264, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.1/x86_64/product-software" + }, + { + "filename": "rhel-10.0-x86_64-kvm.qcow2", + "version": "10.0", + "md5sum": "ea5918fa116ec53f9ef1ce4be69ae2be", + "filesize": 855506944, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.0/x86_64/product-software" + }, + { + "filename": "rhel-9.7-x86_64-kvm.qcow2", + "version": "9.7", + "md5sum": "abc2f42cc5daeea6b03d0d2d478a0d1c", + "filesize": 1229979648, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.7/x86_64/product-software" + }, + { + "filename": "rhel-9.6-x86_64-kvm.qcow2", + "version": "9.6", + "md5sum": "056648d716275053ebe77b1d4f68c862", + "filesize": 1060438016, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.6/x86_64/product-software" }, { "filename": "rhel-9.4-x86_64-kvm.qcow2", @@ -40,34 +61,6 @@ "filesize": 957218816, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.4/x86_64/product-software" }, - { - "filename": "rhel-9.3-x86_64-kvm.qcow2", - "version": "9.3", - "md5sum": "409d8d15f5177db2617b0e3e02139b5c", - "filesize": 858193920, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.3/x86_64/product-software" - }, - { - "filename": "rhel-9.2-x86_64-kvm.qcow2", - "version": "9.2", - "md5sum": "f33845298b387dbcfbf162c6b4e3f8c8", - "filesize": 819265536, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.2/x86_64/product-software" - }, - { - "filename": "rhel-baseos-9.1-x86_64-kvm.qcow2", - "version": "9.1", - "md5sum": "622de743da83bcec1ad2959ecaedb8f4", - "filesize": 753401856, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.1/x86_64/product-software" - }, - { - "filename": "rhel-baseos-9.0-x86_64-kvm.qcow2", - "version": "9.0", - "md5sum": "4a41497d354fe99a4abf55f1ed73edcb", - "filesize": 696582144, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.0/x86_64/product-software" - }, { "filename": "rhel-8.10-x86_64-kvm.qcow2", "version": "8.10", @@ -75,13 +68,6 @@ "filesize": 1065091072, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.10/x86_64/product-software" }, - { - "filename": "rhel-8.9-x86_64-kvm.qcow2", - "version": "8.9", - "md5sum": "23295fe508678cbdebfbdbd41ef6e6e2", - "filesize": 971833344, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.9/x86_64/product-software" - }, { "filename": "rhel-8.8-x86_64-kvm.qcow2", "version": "8.8", @@ -89,13 +75,6 @@ "filesize": 926810112, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.8/x86_64/product-software" }, - { - "filename": "rhel-8.7-x86_64-kvm.qcow2", - "version": "8.7", - "md5sum": "ab71a2c4cc276441bf999f531e064507", - "filesize": 858128384, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.7/x86_64/product-software" - }, { "filename": "rhel-8.6-x86_64-kvm.qcow2", "version": "8.6", @@ -104,31 +83,10 @@ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.6/x86_64/product-software" }, { - "filename": "rhel-8.5-x86_64-kvm.qcow2", - "version": "8.5", - "md5sum": "1efb78dbb2033ba4ac6589a06c95c2d4", - "filesize": 779419648, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.5/x86_64/product-software" - }, - { - "filename": "rhel-8.4-x86_64-kvm.qcow2", - "version": "8.4", - "md5sum": "db4c3a72857b784dc6e96120351f2894", - "filesize": 727449600, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.4/x86_64/product-software" - }, - { - "filename": "rhel-8.3-x86_64-kvm.qcow2", - "version": "8.3", - "md5sum": "dd554c059e0910379fff88f677f4a4b3", - "filesize": 1316683776, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.3/x86_64/product-software" - }, - { - "filename": "rhel-server-7.9-x86_64-kvm.qcow2", + "filename": "rhel-server-7.9-update-12-x86_64-kvm.qcow2", "version": "7.9", - "md5sum": "8d6669b3e2bb8df15b9b4280936cf950", - "filesize": 827777024, + "md5sum": "f77fc7e3cf31a210a8244e486466ce34", + "filesize": 838036992, "download_url": "https://access.redhat.com/downloads/content/69/ver=/rhel---7/7.9/x86_64/product-software" }, { @@ -140,17 +98,39 @@ }, { "filename": "rhel-cloud-init.iso", - "version": "1.0", - "md5sum": "421745b0d13615ecd48696f98d8b6352", + "version": "1.1", + "md5sum": "f1f908d36e67f843dd94db1c2e8c8373", "filesize": 374784, - "download_url": "https://gitlab.com/neyder/rhel-cloud-init/raw/master/rhel-cloud-init.iso" + "download_url": "https://github.com/GNS3/gns3-registry/tree/master/cloud-init/rhel", + "direct_download_url": "https://github.com/GNS3/gns3-registry/raw/master/cloud-init/rhel/rhel-cloud-init.iso" } ], "versions": [ { - "name": "9.5", + "name": "10.1", "images": { - "hda_disk_image": "rhel-9.5-x86_64-kvm.qcow2", + "hda_disk_image": "rhel-10.1-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "10.0", + "images": { + "hda_disk_image": "rhel-10.0-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "9.7", + "images": { + "hda_disk_image": "rhel-9.7-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "9.6", + "images": { + "hda_disk_image": "rhel-9.6-x86_64-kvm.qcow2", "cdrom_image": "rhel-cloud-init.iso" } }, @@ -161,34 +141,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "9.3", - "images": { - "hda_disk_image": "rhel-9.3-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.2", - "images": { - "hda_disk_image": "rhel-9.2-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.1", - "images": { - "hda_disk_image": "rhel-baseos-9.1-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.0", - "images": { - "hda_disk_image": "rhel-baseos-9.0-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.10", "images": { @@ -196,13 +148,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.9", - "images": { - "hda_disk_image": "rhel-8.9-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.8", "images": { @@ -210,13 +155,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.7", - "images": { - "hda_disk_image": "rhel-8.7-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.6", "images": { @@ -224,31 +162,10 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.5", - "images": { - "hda_disk_image": "rhel-8.5-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "8.4", - "images": { - "hda_disk_image": "rhel-8.4-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "8.3", - "images": { - "hda_disk_image": "rhel-8.3-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "7.9", "images": { - "hda_disk_image": "rhel-server-7.9-x86_64-kvm.qcow2", + "hda_disk_image": "rhel-server-7.9-update-12-x86_64-kvm.qcow2", "cdrom_image": "rhel-cloud-init.iso" } }, diff --git a/gns3server/appliances/rockylinux.gns3a b/gns3server/appliances/rockylinux.gns3a index 49f7a4bc4..ef47a965b 100644 --- a/gns3server/appliances/rockylinux.gns3a +++ b/gns3server/appliances/rockylinux.gns3a @@ -17,7 +17,7 @@ "qemu": { "adapter_type": "virtio-net-pci", "adapters": 1, - "ram": 1024, + "ram": 1536, "hda_disk_interface": "virtio", "arch": "x86_64", "console_type": "telnet", @@ -26,6 +26,22 @@ "options": "-nographic -cpu host" }, "images": [ + { + "filename": "Rocky-10-GenericCloud-Base-10.1-20251116.0.x86_64.qcow2", + "version": "10.1", + "md5sum": "729d7235716bd41a0a15e07d2b44c679", + "filesize": 575406080, + "download_url": "https://download.rockylinux.org/pub/rocky/9/images/x86_64/", + "direct_download_url": "https://download.rockylinux.org/pub/rocky/10/images/x86_64/Rocky-10-GenericCloud-Base-10.1-20251116.0.x86_64.qcow2" + }, + { + "filename": "Rocky-9-GenericCloud-Base-9.7-20251123.2.x86_64.qcow2", + "version": "9.7", + "md5sum": "4a0cbfb45b97f09b690044429aaca1c8", + "filesize": 648806400, + "download_url": "https://download.rockylinux.org/pub/rocky/9/images/x86_64/", + "direct_download_url": "https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base-9.7-20251123.2.x86_64.qcow2" + }, { "filename": "Rocky-9-GenericCloud-Base-9.5-20241118.0.x86_64.qcow2", "version": "9.5", @@ -76,6 +92,20 @@ } ], "versions": [ + { + "name": "10.1", + "images": { + "hda_disk_image": "Rocky-10-GenericCloud-Base-10.1-20251116.0.x86_64.qcow2", + "cdrom_image": "rocky-cloud-init-data.iso" + } + }, + { + "name": "9.7", + "images": { + "hda_disk_image": "Rocky-9-GenericCloud-Base-9.7-20251123.2.x86_64.qcow2", + "cdrom_image": "rocky-cloud-init-data.iso" + } + }, { "name": "9.5", "images": { diff --git a/gns3server/appliances/tinycore-linux.gns3a b/gns3server/appliances/tinycore-linux.gns3a index 7fa608326..f5fd5273e 100644 --- a/gns3server/appliances/tinycore-linux.gns3a +++ b/gns3server/appliances/tinycore-linux.gns3a @@ -2,7 +2,7 @@ "appliance_id": "ed6b9f98-7de2-4d61-a3ed-ad4c3e323ace", "name": "Tiny Core Linux", "category": "guest", - "description": "Core Linux is a smaller variant of Tiny Core without a graphical desktop.\n\nIt provides a complete Linux system using only a few MiB.", + "description": "TinyCore is a Linux system with a graphical desktop using only a few MiB.", "vendor_name": "Team Tiny Core", "vendor_url": "http://distro.ibiblio.org/tinycorelinux", "documentation_url": "http://wiki.tinycorelinux.net/", @@ -25,6 +25,14 @@ "options": "-vga std -usbdevice tablet" }, "images": [ + { + "filename": "linux-tinycore-17.0.qcow2", + "version": "17.0", + "md5sum": "6434d4da25dd0277cc90b000bb4ac8f1", + "filesize": 35061760, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "http://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/linux-tinycore-17.0.qcow2" + }, { "filename": "linux-tinycore-11.1.qcow2", "version": "11.1", @@ -51,6 +59,12 @@ } ], "versions": [ + { + "name": "17.0", + "images": { + "hda_disk_image": "linux-tinycore-17.0.qcow2" + } + }, { "name": "11.1", "images": { diff --git a/gns3server/appliances/vyos.gns3a b/gns3server/appliances/vyos.gns3a index 10bf9ae4c..f89c82044 100644 --- a/gns3server/appliances/vyos.gns3a +++ b/gns3server/appliances/vyos.gns3a @@ -9,27 +9,54 @@ "documentation_url": "https://docs.vyos.io/", "product_name": "VyOS Universal Router", "product_url": "https://vyos.io/vyos-universal-router", - "registry_version": 4, + "registry_version": 8, "status": "stable", "availability": "service-contract", "maintainer": "VyOS Inc.", "maintainer_email": "support@vyos.io", - "usage": "\nDefault credentials:\nUser: vyos\nPassword: vyos", + "installation_instructions": "To install the appliance, you must:\n\n1. Have an active VyOS subscription (for details, visit https://vyos.io/).\n2. Download the QEMU/KVM image for your target version from the support portal: https://support.vyos.io/.\n3. Import the downloaded image for installation in the next step.\n\nAlternatively, you can:\n- Build your own image, or\n- Use any compatible qcow2 images (including rolling or stream releases) by selecting \"Create a new version\" and enabling the \"Allow custom files\" feature.", + "usage": "No extra installation steps are required - simply boot the router.\nFor more details, please refer to the documentation: https://docs.vyos.io/", + "default_username": "vyos", + "default_password": "vyos", "symbol": "vyos.svg", - "port_name_format": "eth{0}", - "qemu": { - "adapter_type": "virtio-net-pci", - "adapters": 10, - "ram": 2048, - "cpus": 4, - "hda_disk_interface": "virtio", - "arch": "x86_64", - "console_type": "telnet", - "boot_priority": "c", - "kvm": "require", - "on_close": "shutdown_signal" - }, + "settings": [ + { + "name": "default x86_64", + "default": true, + "template_type": "qemu", + "template_properties": { + "adapter_type": "virtio-net-pci", + "adapters": 10, + "port_name_format": "eth{0}", + "ram": 2048, + "cpus": 4, + "hda_disk_interface": "virtio", + "platform": "x86_64", + "console_type": "telnet", + "boot_priority": "c", + "options": "-cpu host", + "uefi": false, + "on_close": "shutdown_signal" + } + }, + { + "name": "1.5 x86_64", + "inherit_default_properties": true, + "template_type": "qemu", + "template_properties": { + "ram": 8192, + "cpus": 4 + } + } + ], "images": [ + { + "filename": "vyos-1.5.0-kvm-amd64.qcow2", + "version": "1.5.0", + "md5sum": "f84587bd6c45b139563a1d1d2fe0b2e8", + "filesize": 618528768, + "download_url": "https://support.vyos.io/" + }, { "filename": "vyos-1.4.4-kvm-amd64.qcow2", "version": "1.4.4", @@ -132,11 +159,18 @@ "filename": "vyos-1.2.5-amd64.qcow2", "version": "1.2.5", "md5sum": "110c22309ec480600446fd2fb4f27a0d", - "filesize": 411500544 , + "filesize": 411500544, "download_url": "https://support.vyos.io/" } ], "versions": [ + { + "name": "1.5.0", + "settings": "1.5 x86_64", + "images": { + "hda_disk_image": "vyos-1.5.0-kvm-amd64.qcow2" + } + }, { "name": "1.4.4", "images": { diff --git a/gns3server/appliances/westermo-weos.gns3a b/gns3server/appliances/westermo-weos.gns3a new file mode 100644 index 000000000..6ebce85d7 --- /dev/null +++ b/gns3server/appliances/westermo-weos.gns3a @@ -0,0 +1,72 @@ +{ + "appliance_id": "90afc191-6195-4ff2-905b-5470573a882b", + "name": "Westermo WeOS", + "category": "router", + "description": "Westermo Operating System (WeOS) is a leading-edge industrial network operating system that is being continuously developed whilst being subjected to exhaustive quality testing. It powers the most reliable switches and routers on the market, designed for harsh industrial environments. WeOS has extensive support for network and security protocols that have been made simple to configure and maintain. WeOS along with Westermo’s robust hardware provides the world’s most reliable network solutions.", + "vendor_name": "Westermo", + "vendor_url": "https://www.westermo.com/", + "vendor_logo_url": "https://api-0831ddf0-b06c-4a4e-bcf7-afba78872de1.digizuite.app/DigizuiteCore/CollaborationService/api/share/asset/4C90E538E869D47D2782840A06F726F6D7548D864857388E38218F", + "documentation_url": "https://docs.westermo.com/weos/weos-5/", + "product_name": "WeOS", + "product_url": "https://www.westermo.com/products/software/weos", + "registry_version": 4, + "status": "stable", + "availability": "free", + "maintainer": "Westermo", + "maintainer_email": "info@westermo.com", + "usage": "\nFactory Configuration\n=====================\nAll ports are in VLAN1 untagged. The system will acquire a link-local\nIPv4 address (169.254.0.0/16) and will also request an address using\nDHCP. mDNS is enabled and the device is reachable under these names\n(where XX-XX-XX corresponds to the last three octets in the device's\nMAC address): zero-XX-XX-XX.local, zero.local, and weos.local.\n\nManagement\n==========\nAccessible via console, ssh, and http/https.\n\nUsername: admin\nPassword: admin\n\nUpgrade\n=======\nTo upgrade a device, simply download the new version and replace the\nHDA (Primary Master) image under the HDD tab in the configuration pane\nof the device.\n", + "symbol": ":/symbols/classic/router.svg", + "port_name_format": "eth{0}", + "linked_clone": true, + "qemu": { + "adapter_type": "virtio-net-pci", + "adapters": 8, + "ram": 512, + "cpus": 1, + "hda_disk_interface": "virtio", + "hdb_disk_interface": "virtio", + "arch": "x86_64", + "console_type": "telnet", + "kvm": "allow", + "options": "-nographic --machine pc-i440fx-4.2" + }, + "images": [ + { + "filename": "Config-zero-1.0.0.disk", + "version": "1.0.0", + "md5sum": "afe7b74683971fec48802997f6470ca5", + "filesize": 17825792, + "direct_download_url": "https://dropzone.westermo.com/file.aspx?id=e6a7676d-85a7-4374-8961-c68aacb74921" + }, + { + "filename": "WeOS-zero-5.28.0.disk", + "version": "5.28.0", + "md5sum": "7b4e29761091c12ec2cd482f7c1a0bea", + "filesize": 75497472, + "direct_download_url": "https://dropzone.westermo.com/file.aspx?id=4926adbe-0cd6-4851-b70f-e9d60b3b3f06" + }, + { + "filename": "WeOS-zero-5.27.0.disk", + "version": "5.27.0", + "md5sum": "8bd4944b60eaeca391bb5d1dc0defb8b", + "filesize": 78643200, + "direct_download_url": "https://dropzone.westermo.com/file.aspx?id=1a7bf389-a4b1-4dae-b121-6fb3d6781542" + } + ], + "versions": [ + { + "name": "5.28.0", + "images": { + "hda_disk_image": "WeOS-zero-5.28.0.disk", + "hdb_disk_image": "Config-zero-1.0.0.disk" + } + }, + { + "name": "5.27.0", + "images": { + "hda_disk_image": "WeOS-zero-5.27.0.disk", + "hdb_disk_image": "Config-zero-1.0.0.disk" + } + } + ] +} diff --git a/gns3server/appliances/windows-11-dev-env.gns3a b/gns3server/appliances/windows-11-dev-env.gns3a index b9b453e8a..f6bbecc65 100644 --- a/gns3server/appliances/windows-11-dev-env.gns3a +++ b/gns3server/appliances/windows-11-dev-env.gns3a @@ -30,19 +30,11 @@ }, "images": [ { - "filename": "WinDev2308Eval-disk1.vmdk", - "version": "2308", - "md5sum": "6a9b4ed6d7481f7bbf8a054c797b1eee", - "filesize": 24945341952, - "download_url": "https://download.microsoft.com/download/7/1/3/7135f2ab-8528-49fc-9252-8d5d94c697ef/WinDev2308Eval.VMWare.zip", - "compression": "zip" - }, - { - "filename": "WinDev2212Eval-disk1.vmdk", - "version": "2212", - "md5sum": "c79f393a067b92e01a513a118d455ac8", - "filesize": 24620493824, - "download_url": "https://aka.ms/windev_VM_vmware", + "filename": "WinDev2407Eval-disk1.vmdk", + "version": "2407", + "md5sum": "66e7233c7922ef128c5b7c957d20bcbe", + "filesize": 25984826368, + "download_url": "https://download.microsoft.com/download/e/e/c/eec4775f-f2e8-4476-98b2-ca51502a6429/WinDev2407Eval.VMWare.zip", "compression": "zip" }, { @@ -57,17 +49,10 @@ ], "versions": [ { - "name": "2308", + "name": "2407", "images": { "bios_image": "OVMF-edk2-stable202305.fd", - "hda_disk_image": "WinDev2308Eval-disk1.vmdk" - } - }, - { - "name": "2212", - "images": { - "bios_image": "OVMF-edk2-stable202305.fd", - "hda_disk_image": "WinDev2212Eval-disk1.vmdk" + "hda_disk_image": "WinDev2407Eval-disk1.vmdk" } } ] diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 17a41fe7e..076b802d1 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -35,7 +35,6 @@ from gns3server.utils.interfaces import is_interface_up from uuid import UUID, uuid4 from typing import Type from ..config import Config -from ..utils.asyncio import wait_run_in_executor from ..utils import force_unix_path from .project_manager import ProjectManager from .port_manager import PortManager @@ -221,6 +220,9 @@ class BaseManager: if not hasattr(destination_node, "working_dir"): return destination_node + if hasattr(source_node, "status") and source_node.status != "stopped": + raise ComputeError("Cannot duplicate node data while the node is running") + destination_dir = destination_node.working_dir try: shutil.rmtree(destination_dir) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index c7a3821c1..8e7681d9e 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -23,12 +23,14 @@ import tempfile import psutil import platform import re +import asyncssh from fastapi import WebSocketDisconnect from gns3server.utils.interfaces import interfaces from gns3server.compute.compute_error import ComputeError from ..compute.port_manager import PortManager from ..utils.asyncio import wait_run_in_executor, locking +from ..utils.asyncio.ssh_server import AsyncioSSHServer from ..utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.ubridge.hypervisor import Hypervisor from gns3server.compute.ubridge.ubridge_error import UbridgeError @@ -54,8 +56,8 @@ class BaseNode: :param aux: auxiliary console TCP port :param aux_type: auxiliary console type :param linked_clone: The node base image is duplicate/overlay (Each node data are independent) - :param wrap_console: The console is wrapped using AsyncioTelnetServer - :param wrap_aux: The auxiliary console is wrapped using AsyncioTelnetServer + :param wrap_console: The console is wrapped using a proxy transport server + :param wrap_aux: The auxiliary console is wrapped using a proxy transport server """ def __init__( @@ -349,8 +351,10 @@ class BaseNode: Stop the node process. """ - await self.stop_wrap_console() - self.status = "stopped" + try: + await self.stop_wrap_console() + finally: + self.status = "stopped" def suspend(self): """ @@ -407,10 +411,10 @@ class BaseNode: return vnc_console_start_port_range, vnc_console_end_port_range - async def _wrap_telnet_proxy(self, internal_port, external_port): + async def _wrap_console_proxy(self, internal_port, external_port, console_type): """ - Start a telnet proxy for the console allowing multiple telnet clients - to be connected at the same time + Start a console proxy allowing multiple external clients to be + connected at the same time. """ remaining_trial = 60 @@ -418,7 +422,7 @@ class BaseNode: try: (self._wrap_console_reader, self._wrap_console_writer) = await asyncio.open_connection( host="127.0.0.1", - port=self._internal_console_port + port=internal_port ) break except (OSError, ConnectionRefusedError) as e: @@ -426,53 +430,67 @@ class BaseNode: raise e await asyncio.sleep(0.1) remaining_trial -= 1 - await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True) - server = AsyncioTelnetServer( - reader=self._wrap_console_reader, - writer=self._wrap_console_writer, - binary=True, - echo=True - ) + if console_type == "telnet": + await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True) + server = AsyncioTelnetServer( + reader=self._wrap_console_reader, + writer=self._wrap_console_writer, + binary=True, + echo=True + ) + elif console_type == "ssh": + server = AsyncioSSHServer(reader=self._wrap_console_reader, writer=self._wrap_console_writer) + else: + raise NodeError(f"Console wrapper does not support type {console_type}") + # warning: this will raise OSError exception if there is a problem... - telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, external_port) - self._wrapper_telnet_servers.append(telnet_server) + proxy_server = await server.start(self._manager.port_manager.console_host, external_port) + self._wrapper_telnet_servers.append(proxy_server) async def start_wrap_console(self): """ - Start a Telnet proxy servers for the console and auxiliary console allowing multiple telnet clients + Start console proxy servers for the console and auxiliary console allowing multiple clients to be connected at the same time """ - if self._wrap_console and self._console_type == "telnet": - await self._wrap_telnet_proxy(self._internal_console_port, self.console) + if self._wrap_console and self._console_type in ("telnet", "ssh"): + await self._wrap_console_proxy(self._internal_console_port, self.console, self._console_type) log.info( - f"New Telnet proxy server for console started " + f"New {self._console_type.upper()} proxy server for console started " f"(internal port = {self._internal_console_port}, external port = {self.console})" ) - if self._wrap_aux and self._aux_type == "telnet": - await self._wrap_telnet_proxy(self._internal_aux_port, self.aux) + if self._wrap_aux and self._aux_type in ("telnet", "ssh"): + await self._wrap_console_proxy(self._internal_aux_port, self.aux, self._aux_type) log.info( - f"New Telnet proxy server for auxiliary console started " + f"New {self._aux_type.upper()} proxy server for auxiliary console started " f"(internal port = {self._internal_aux_port}, external port = {self.aux})" ) async def stop_wrap_console(self): """ - Stops the telnet proxy servers. + Stops the console proxy servers. """ if self._wrap_console_writer: self._wrap_console_writer.close() - await self._wrap_console_writer.wait_closed() + try: + await self._wrap_console_writer.wait_closed() + except (ConnectionResetError, BrokenPipeError, OSError): + # Connection already closed, ignore error + pass for telnet_proxy_server in self._wrapper_telnet_servers: telnet_proxy_server.close() - await telnet_proxy_server.wait_closed() + try: + await telnet_proxy_server.wait_closed() + except (ConnectionResetError, BrokenPipeError, OSError): + # Connection already closed, ignore error + pass self._wrapper_telnet_servers = [] async def reset_wrap_console(self): """ - Reset the wrap console (restarts the Telnet proxy) + Reset the wrap console (restarts the console proxy) """ await self.stop_wrap_console() @@ -482,7 +500,7 @@ class BaseNode: """ Connect to console using Websocket. - :param ws: Websocket object + :param websocket: FastAPI WebSocket object """ log.info( @@ -491,27 +509,66 @@ class BaseNode: ) if self.status != "started": - raise NodeError(f"Node {self.name} is not started") + await websocket.close(code=1000) + log.warning(f"Cannot open console WebSocket: node {self.name} is not started") + return - if self._console_type != "telnet": - raise NodeError(f"Node {self.name} console type is not telnet") + if self._console_type not in ("telnet", "ssh"): + await websocket.close(code=1000) + log.warning( + f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' " + f"is not supported" + ) + return + + telnet_reader = None + telnet_writer = None + ssh_connection = None + ssh_process = None try: host = self._manager.port_manager.console_host - port = self.console - (telnet_reader, telnet_writer) = await asyncio.open_connection(host, port) - log.info(f"Connected to local Telnet server {host}:{port}") - except ConnectionError as e: - raise NodeError(f"Cannot connect to node {self.name} telnet server: {e}") + if self._console_type == "ssh": + # For SSH consoles (wrapped or not), connect to the external SSH port via an + # SSH client. The AsyncioSSHServer on that port handles multi-client broadcasting + # to/from the node's process streams. Connecting directly to _internal_console_port + # would conflict with the exclusive connection already held by the SSH proxy. + port = self.console + ssh_connection = await asyncssh.connect( + host, + port=port, + username="gns3", + known_hosts=None, + encoding=None, + ) + ssh_process = await ssh_connection.create_process(encoding=None, term_type="xterm") + telnet_reader = ssh_process.stdout + telnet_writer = ssh_process.stdin + else: + port = self.console + (telnet_reader, telnet_writer) = await asyncio.open_connection(host, port) + + log.info(f"Connected to local console stream {host}:{port} (console type={self._console_type})") + except (ConnectionError, OSError, asyncssh.Error) as e: + await websocket.close(code=1000) + log.warning(f"Cannot connect to node {self.name} console server: {e}") + return async def ws_forward(telnet_writer): try: while True: - data = await websocket.receive_text() - if data: - telnet_writer.write(data.encode()) - await telnet_writer.drain() + msg = await websocket.receive() + if msg["type"] == "websocket.disconnect": + break + if "text" in msg and msg["text"]: + data = msg["text"].encode() + elif "bytes" in msg and msg["bytes"]: + data = msg["bytes"] + else: + continue + telnet_writer.write(data) + await telnet_writer.drain() except WebSocketDisconnect: log.info( f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute" @@ -535,10 +592,99 @@ class BaseNode: done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: if task.exception(): - log.warning(f"Exception while forwarding WebSocket data to Telnet server {task.exception()}") + log.warning( + f"Exception while forwarding WebSocket data to " + f"{self._console_type.upper()} server: {task.exception()}" + ) for task in pending: task.cancel() + if ssh_connection: + if ssh_process: + ssh_process.close() + ssh_connection.close() + await ssh_connection.wait_closed() + + if telnet_writer and hasattr(telnet_writer, "close"): + telnet_writer.close() + if hasattr(telnet_writer, "wait_closed"): + await telnet_writer.wait_closed() + + async def start_vnc_websocket_console(self, websocket): + """ + Connect to VNC console using WebSocket. + + :param websocket: FastAPI WebSocket object + """ + + log.info( + f"New client {websocket.client.host}:{websocket.client.port} has connected to compute " + f"VNC console WebSocket" + ) + + if self.status != "started": + await websocket.close(code=1000) + log.warning(f"Cannot open VNC WebSocket: node {self.name} is not started") + return + if self._console_type != "vnc": + await websocket.close(code=1000) + log.warning( + f"Cannot open VNC WebSocket: node {self.name} console type '{self._console_type}' is not vnc" + ) + return + + try: + vnc_reader, vnc_writer = await asyncio.open_connection( + self._manager.port_manager.console_host, + self.console # VNC port + ) + log.info(f"Connected to VNC server {self._manager.port_manager.console_host}:{self.console}") + except ConnectionError as e: + await websocket.close(code=1000) + log.warning(f"Cannot connect to node {self.name} VNC server: {e}") + return + + async def ws_forward(vnc_writer): + # Browser → VNC: Forward binary WebSocket data to VNC server + try: + while True: + data = await websocket.receive_bytes() + if data: + vnc_writer.write(data) + await vnc_writer.drain() + except WebSocketDisconnect: + log.info( + f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute " + f"VNC console WebSocket" + ) + + async def vnc_forward(vnc_reader): + # VNC → Browser: Forward VNC frames to WebSocket + try: + while not vnc_reader.at_eof(): + data = await vnc_reader.read(65536) # Larger buffer for VNC frames + if data: + await websocket.send_bytes(data) + except Exception as e: + log.warning(f"Exception while forwarding VNC data to WebSocket: {e}") + + # Keep forwarding WebSocket data in both directions + if sys.version_info >= (3, 11, 0): + # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. + aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] + else: + aws = [ws_forward(vnc_writer), vnc_forward(vnc_reader)] + + done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) + for task in done: + if task.exception(): + log.warning(f"Exception while forwarding WebSocket data to VNC server: {task.exception()}") + for task in pending: + task.cancel() + + vnc_writer.close() + await vnc_writer.wait_closed() + @property def aux(self): """ @@ -973,7 +1119,8 @@ class BaseNode: def _get_custom_adapter_settings(self, adapter_number): - for custom_adapter in self.custom_adapters: - if custom_adapter["adapter_number"] == adapter_number: - return custom_adapter + if self.custom_adapters: + for custom_adapter in self.custom_adapters: + if custom_adapter["adapter_number"] == adapter_number: + return custom_adapter return {} diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index 567d1ef32..4a64a4d3a 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -32,7 +32,7 @@ from gns3server.config import Config from gns3server.utils.asyncio import locking from gns3server.compute.base_manager import BaseManager from gns3server.compute.docker.docker_vm import DockerVM -from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error +from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error log = logging.getLogger(__name__) @@ -78,8 +78,10 @@ class Docker(BaseManager): stderr=asyncio.subprocess.DEVNULL ) stdout, _ = await proc.communicate() - if proc.returncode == 1: + if proc.returncode == 1 or "static" in busybox_exec: # ldd returns 1 if the file is not a dynamic executable + # on Alpine/musl, ldd returns 0 even for static binaries, + # so also trust binaries named busybox-static or busybox.static log.info(f"Installing busybox from '{busybox_path}' to '{dst_busybox}'") shutil.copy2(busybox_path, dst_busybox, follow_symlinks=True) return @@ -238,6 +240,8 @@ class Docker(BaseManager): raise DockerHttp304Error(f"Docker has returned an error: {response.status} {body}") elif response.status == 404: raise DockerHttp404Error(f"Docker has returned an error: {response.status} {body}") + elif response.status == 409: + raise DockerHttp409Error(f"Docker has returned an error: {response.status} {body}") else: raise DockerError(f"Docker has returned an error: {response.status} {body}") return response diff --git a/gns3server/compute/docker/docker_error.py b/gns3server/compute/docker/docker_error.py index 298b9c3b8..0b77f778e 100644 --- a/gns3server/compute/docker/docker_error.py +++ b/gns3server/compute/docker/docker_error.py @@ -31,3 +31,7 @@ class DockerHttp304Error(DockerError): class DockerHttp404Error(DockerError): pass + + +class DockerHttp409Error(DockerError): + pass diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index a72c5173c..5219ad4c8 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -28,6 +28,7 @@ import subprocess import os import re +from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer from gns3server.utils.asyncio import wait_for_file_creation @@ -41,7 +42,7 @@ from ..base_node import BaseNode from ..adapters.ethernet_adapter import EthernetAdapter from ..nios.nio_udp import NIOUDP -from .docker_error import DockerError, DockerHttp304Error, DockerHttp404Error +from .docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error import logging @@ -188,7 +189,7 @@ class DockerVM(BaseNode): """ if not is_rfc1123_hostname_valid(new_name): - raise DockerError(f"'{new_name}' is an invalid name to rename Docker container '{self._name}'") + raise DockerError(f"'{new_name}' is an invalid name to rename Docker container '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen.") super(DockerVM, DockerVM).name.__set__(self, new_name) @property @@ -548,7 +549,28 @@ class DockerVM(BaseNode): params["Env"].append(f"GNS3_EXTRA_HOSTS={extra_hosts}") # Support name in Doker: [a-zA-Z0-9][a-zA-Z0-9_.-] - result = await self.manager.query("POST", f"containers/create?name={self.docker_name}", data=params) + try: + result = await self.manager.query("POST", f"containers/create?name={self.docker_name}", data=params) + except DockerHttp409Error: + # Container name already exists. This can happen when the server crashes + # and leaves containers behind. Try to remove the conflicting container. + log.warning(f"Container name '{self.docker_name}' is already in use, attempting to clean up the stale container...") + try: + # Try to get and remove the conflicting container + try: + container_info = await self.manager.query("GET", f"containers/{self.docker_name}/json") + container_id = container_info["Id"] + # Force remove the container + await self.manager.query("DELETE", f"containers/{container_id}", params={"force": 1, "v": 1}) + log.info(f"Removed stale container '{self.docker_name}' ({container_id})") + except DockerHttp404Error: + # Container doesn't exist anymore, race condition - just continue + pass + # Retry creating the container + result = await self.manager.query("POST", f"containers/create?name={self.docker_name}", data=params) + except DockerError as e: + log.error(f"Failed to clean up conflicting container '{self.docker_name}': {e}") + raise self._cid = result["Id"] log.info(f"Docker container '{self._name}' [{self._id}] created") if self._cpus > 0: @@ -646,7 +668,7 @@ class DockerVM(BaseNode): log.error(line) raise DockerError(logdata) - if self.console_type == "telnet": + if self.console_type in ("telnet", "ssh"): await self._start_console() elif self.console_type == "http" or self.console_type == "https": await self._start_http() @@ -681,16 +703,19 @@ class DockerVM(BaseNode): ) except OSError as e: raise DockerError(f"Could not start auxiliary console process: {e}") - server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True) + if self.aux_type == "telnet": + server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True) + transport = "Telnet" + else: + server = AsyncioSSHServer(reader=process.stdout, writer=process.stdin) + transport = "SSH" try: - self._telnet_servers.append( - await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.aux) - ) + self._telnet_servers.append(await server.start(self._manager.port_manager.console_host, self.aux)) except OSError as e: raise DockerError( - f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.aux}: {e}" + f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.aux}: {e}" ) - log.debug(f"Docker container '{self.name}' started listen for auxiliary telnet on {self.aux}") + log.debug(f"Docker container '{self.name}' started listening for auxiliary {self.aux_type} on {self.aux}") async def _fix_permissions(self): """ @@ -853,7 +878,7 @@ class DockerVM(BaseNode): async def _start_console(self): """ - Starts streaming the console via telnet + Starts streaming the console via telnet or ssh """ class InputStream: @@ -870,20 +895,23 @@ class DockerVM(BaseNode): output_stream = asyncio.StreamReader() input_stream = InputStream() - telnet = AsyncioTelnetServer( - reader=output_stream, - writer=input_stream, - echo=True, - naws=True, - window_size_changed_callback=self._window_size_changed_callback, - ) - try: - self._telnet_servers.append( - await asyncio.start_server(telnet.run, self._manager.port_manager.console_host, self.console) + if self.console_type == "telnet": + telnet = AsyncioTelnetServer( + reader=output_stream, + writer=input_stream, + echo=True, + naws=True, + window_size_changed_callback=self._window_size_changed_callback, ) + transport = "Telnet" + else: + telnet = AsyncioSSHServer(reader=output_stream, writer=input_stream) + transport = "SSH" + try: + self._telnet_servers.append(await telnet.start(self._manager.port_manager.console_host, self.console)) except OSError as e: raise DockerError( - f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" + f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" ) self._console_websocket = await self.manager.websocket_query( @@ -919,6 +947,9 @@ class DockerVM(BaseNode): Reset the console. """ + if self.console_type not in ("telnet", "ssh"): + return + if self._console_websocket: await self._console_websocket.close() await self._clean_servers() @@ -1058,8 +1089,13 @@ class DockerVM(BaseNode): # force - 1/True/true or 0/False/false, Kill then remove the container. Default false. try: await self.manager.query("DELETE", f"containers/{self._cid}", params={"force": 1, "v": 1}) - except DockerError: + except DockerHttp404Error: + # Container already removed (normal case) pass + except DockerError as e: + # Container deletion failed - log warning but don't block project close + # The stale container will be cleaned up when the project is opened again + log.warning(f"Failed to delete Docker container '{self.docker_name}': {e}") log.info("Docker container '{name}' [{image}] removed".format(name=self._name, image=self._image)) if release_nio_udp_ports: diff --git a/gns3server/compute/dynamips/__init__.py b/gns3server/compute/dynamips/__init__.py index eae8ab61e..3d620b6a2 100644 --- a/gns3server/compute/dynamips/__init__.py +++ b/gns3server/compute/dynamips/__init__.py @@ -32,7 +32,7 @@ import re log = logging.getLogger(__name__) -from gns3server.utils.interfaces import interfaces, is_interface_up +from gns3server.utils.interfaces import is_interface_up from gns3server.utils.asyncio import wait_run_in_executor, subprocess_check_output from gns3server.utils import parse_version from uuid import uuid4 @@ -478,36 +478,40 @@ class Dynamips(BaseManager): adapter = ADAPTER_MATRIX[adapter_name]() try: if vm.slots[slot_id] and not isinstance(vm.slots[slot_id], type(adapter)): - await vm.slot_remove_binding(slot_id) + if vm.slots[slot_id].removable(): + await vm.slot_remove_binding(slot_id) + else: + log.warning(f"Slot {slot_id} on router '{vm.name}' has a non-removable adapter, skipping replacement") + continue if not isinstance(vm.slots[slot_id], type(adapter)): await vm.slot_add_binding(slot_id, adapter) except IndexError: - raise DynamipsError(f"Slot {slot_id} doesn't exist on this router") + log.warning(f"Slot {slot_id} doesn't exist on router '{vm.name}', skipping") elif name.startswith("slot") and (value is None or value == ""): slot_id = int(name[-1]) try: - if vm.slots[slot_id]: + if vm.slots[slot_id] and vm.slots[slot_id].removable(): await vm.slot_remove_binding(slot_id) except IndexError: - raise DynamipsError(f"Slot {slot_id} doesn't exist on this router") + log.warning(f"Slot {slot_id} doesn't exist on router '{vm.name}', skipping") elif name.startswith("wic") and value in WIC_MATRIX: wic_slot_id = int(name[-1]) wic_name = value wic = WIC_MATRIX[wic_name]() try: - if vm.slots[0].wics[wic_slot_id] and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): + if vm.slots[0] and vm.slots[0].wics[wic_slot_id] and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): await vm.uninstall_wic(wic_slot_id) - if not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): + if vm.slots[0] and not isinstance(vm.slots[0].wics[wic_slot_id], type(wic)): await vm.install_wic(wic_slot_id, wic) - except IndexError: - raise DynamipsError(f"WIC slot {wic_slot_id} doesn't exist on this router") + except (IndexError, AttributeError): + log.warning(f"WIC slot {wic_slot_id} doesn't exist on router '{vm.name}', skipping") elif name.startswith("wic") and (value is None or value == ""): wic_slot_id = int(name[-1]) try: - if vm.slots[0].wics and vm.slots[0].wics[wic_slot_id]: + if vm.slots[0] and vm.slots[0].wics and vm.slots[0].wics[wic_slot_id]: await vm.uninstall_wic(wic_slot_id) - except IndexError: - raise DynamipsError(f"WIC slot {wic_slot_id} doesn't exist on this router") + except (IndexError, AttributeError): + log.warning(f"WIC slot {wic_slot_id} doesn't exist on router '{vm.name}', skipping") mmap_support = self.config.settings.Dynamips.mmap_support if mmap_support is False: @@ -640,6 +644,9 @@ class Dynamips(BaseManager): if not hasattr(source_node, "startup_config_path"): return await super().duplicate_node(source_node_id, destination_node_id) + if hasattr(source_node, "status") and source_node.status != "stopped": + raise DynamipsError("Cannot duplicate router data while the router is running") + try: with open(source_node.startup_config_path) as f: startup_config = f.read() diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 1eea64e3e..e6e750f83 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -80,7 +80,9 @@ class Router(BaseNode): raise DynamipsError(f"{name} is an invalid name to create a Dynamips node") super().__init__( - name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type + name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type, + wrap_console=(console_type == "ssh"), + wrap_aux=(aux_type == "ssh"), ) self._working_directory = os.path.join( @@ -248,10 +250,14 @@ class Router(BaseNode): ) if self._console is not None: - await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}') + # For SSH console, tell Dynamips to listen on the internal port so that + # the AsyncioSSHServer proxy can wrap it on the external console port. + con_port = self._internal_console_port if self._wrap_console and self._internal_console_port else self._console + await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {con_port}') if self.aux is not None: - await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self.aux}') + aux_port = self._internal_aux_port if self._wrap_aux and self._internal_aux_port else self.aux + await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {aux_port}') # get the default base MAC address mac_addr = await self._hypervisor.send(f'{self._platform} get_mac_addr "{self._name}"') @@ -327,6 +333,12 @@ class Router(BaseNode): self._memory_watcher = FileWatcher(self._memory_files(), self._memory_changed, strategy="hash", delay=30) monitor_process(self._hypervisor.process, self._termination_callback) + if self._wrap_console or self._wrap_aux: + try: + await self.start_wrap_console() + except OSError as e: + raise DynamipsError(f"Could not start Dynamips console wrapper: {e}") + async def _termination_callback(self, returncode): """ Called when the process has stopped. @@ -362,6 +374,7 @@ class Router(BaseNode): self._memory_watcher.close() self._memory_watcher = None await self.save_configs() + await self.stop_wrap_console() async def reload(self): """ @@ -1000,7 +1013,8 @@ class Router(BaseNode): """ self.console = console - await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self.console}') + con_port = self._internal_console_port if self._wrap_console and self._internal_console_port else self.console + await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {con_port}') async def set_console_type(self, console_type): """ @@ -1017,8 +1031,21 @@ class Router(BaseNode): self.console_type = console_type - if self._console and console_type == "telnet": - await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}') + if self._console: + if console_type == "ssh": + # Switching to SSH: ensure an internal port is allocated and redirect Dynamips to it. + self._wrap_console = True + if self._internal_console_port is None: + self._internal_console_port = self._manager.port_manager.get_free_tcp_port(self._project) + await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._internal_console_port}') + elif console_type == "telnet": + # Switching to telnet: stop SSH wrapper if running and redirect Dynamips to external port. + await self.stop_wrap_console() + self._wrap_console = False + if self._internal_console_port is not None: + self._manager.port_manager.release_tcp_port(self._internal_console_port, self._project) + self._internal_console_port = None + await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}') async def set_aux(self, aux): """ @@ -1028,7 +1055,8 @@ class Router(BaseNode): """ self.aux = aux - await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {aux}') + aux_port = self._internal_aux_port if self._wrap_aux and self._internal_aux_port else self.aux + await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {aux_port}') async def set_aux_type(self, aux_type): """ @@ -1047,8 +1075,20 @@ class Router(BaseNode): self.aux_type = aux_type - if self._aux and aux_type == "telnet": - await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._aux}') + if self._aux: + if aux_type == "ssh": + # Switching to SSH: ensure an internal aux port is allocated and redirect Dynamips to it. + self._wrap_aux = True + if self._internal_aux_port is None: + self._internal_aux_port = self._manager.port_manager.get_free_tcp_port(self._project) + await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._internal_aux_port}') + elif aux_type == "telnet": + # Switching to telnet: clear SSH wrapper state and redirect Dynamips to external port. + self._wrap_aux = False + if self._internal_aux_port is not None: + self._manager.port_manager.release_tcp_port(self._internal_aux_port, self._project) + self._internal_aux_port = None + await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._aux}') async def reset_console(self): """ @@ -1682,7 +1722,7 @@ class Router(BaseNode): """ if not is_ios_hostname_valid(new_name): - raise DynamipsError(f"{new_name} is an invalid name to rename router '{self._name}'") + raise DynamipsError(f"{new_name} is an invalid name to rename router '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer.") await self._hypervisor.send(f'vm rename "{self._name}" "{new_name}"') diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index d8fb2899b..ace86a8db 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -41,6 +41,7 @@ from .utils.iou_import import nvram_import from .utils.iou_export import nvram_export from gns3server.compute.ubridge.ubridge_error import UbridgeError from gns3server.utils.file_watcher import FileWatcher +from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.hostname import is_ios_hostname_valid from gns3server.utils.asyncio import locking @@ -97,11 +98,11 @@ class IOUVM(BaseNode): self._serial_adapters = [] self.ethernet_adapters = 2 # one adapter = 4 interfaces self.serial_adapters = 2 # one adapter = 4 interfaces - self._use_default_iou_values = True # for RAM & NVRAM values - self._nvram = 128 # Kilobytes + self._use_default_iou_values = False # for RAM & NVRAM values + self._nvram = 256 # Kilobytes self._startup_config = "" self._private_config = "" - self._ram = 256 # Megabytes + self._ram = 1024 # Megabytes self._application_id = application_id self._l1_keepalives = False # used to overcome the always-up Ethernet interfaces (not supported by all IOSes). @@ -367,7 +368,7 @@ class IOUVM(BaseNode): """ if not is_ios_hostname_valid(new_name): - raise IOUError(f"'{new_name}' is an invalid name to rename IOU node '{self._name}'") + raise IOUError(f"'{new_name}' is an invalid name to rename IOU node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name must start with a letter, end with a letter or digit, and be 63 characters or fewer.") if self.startup_config_file: content = self.startup_config_content content = re.sub(r"hostname .+$", "hostname " + new_name, content, flags=re.MULTILINE) @@ -630,7 +631,7 @@ class IOUVM(BaseNode): callback = functools.partial(self._termination_callback, "IOU") gns3server.utils.asyncio.monitor_process(self._iou_process, callback) except FileNotFoundError as e: - raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed") + raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead") except (OSError, subprocess.SubprocessError) as e: iou_stdout = self.read_iou_stdout() log.error(f"Could not start IOU {self._path}: {e}\n{iou_stdout}") @@ -643,21 +644,25 @@ class IOUVM(BaseNode): async def start_console(self): """ - Start the Telnet server to provide console access. + Start the console server to provide console access. """ - if self.console and self.console_type == "telnet": - server = AsyncioTelnetServer( - reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True - ) - try: - self._telnet_server = await asyncio.start_server( - server.run, self._manager.port_manager.console_host, self.console + if self.console and self.console_type in ("telnet", "ssh"): + if self.console_type == "telnet": + server = AsyncioTelnetServer( + reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True ) + error_prefix = "Telnet" + else: + server = AsyncioSSHServer(reader=self._iou_process.stdout, writer=self._iou_process.stdin) + error_prefix = "SSH" + try: + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: await self.stop() raise IOUError( - "Could not start Telnet server on socket {}:{}: {}".format( + "Could not start {} server on socket {}:{}: {}".format( + error_prefix, self._manager.port_manager.console_host, self.console, e ) ) diff --git a/gns3server/compute/nios/nio_udp.py b/gns3server/compute/nios/nio_udp.py index 32fe16b86..b7736a39e 100644 --- a/gns3server/compute/nios/nio_udp.py +++ b/gns3server/compute/nios/nio_udp.py @@ -78,5 +78,7 @@ class NIOUDP(NIO): "type": "nio_udp", "lport": self._lport, "rport": self._rport, - "rhost": self._rhost + "rhost": self._rhost, + "suspend": self._suspended, + "filters": self._filters } diff --git a/gns3server/compute/notification_manager.py b/gns3server/compute/notification_manager.py index 30f0a8050..f4eecf76c 100644 --- a/gns3server/compute/notification_manager.py +++ b/gns3server/compute/notification_manager.py @@ -53,8 +53,13 @@ class NotificationManager: :param kwargs: Add this meta to the notification (project_id for example) """ - for listener in self._listeners: - asyncio.get_running_loop().call_soon_threadsafe(listener.put_nowait, (action, event, kwargs)) + try: + for listener in self._listeners: + asyncio.get_running_loop().call_soon_threadsafe(listener.put_nowait, (action, event, kwargs)) + except RuntimeError: + # asyncio.get_running_loop() raises RuntimeError when called from + # a threadpool with no active event loop (e.g. disk space warnings) + pass @staticmethod def reset(): diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index 38ae07e85..175d9e916 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -18,8 +18,10 @@ import os import shutil import asyncio import hashlib +import datetime from uuid import UUID, uuid4 +from fastapi import HTTPException, status from gns3server.compute.compute_error import ComputeError, ComputeNotFoundError, ComputeForbiddenError from .port_manager import PortManager @@ -417,6 +419,67 @@ class Project: return files + async def list_node_files(self, node_path: str): + """ + List files in a specific node directory with detailed metadata. + + :param node_path: Relative path to node directory (e.g., "project-files/qemu/node-id") + :returns: Array of files in the node directory with metadata + """ + + node_full_path = os.path.normpath(os.path.join(self.path, node_path)) + + # Security check: ensure the path is within the project directory + if not os.path.commonpath([node_full_path, self.path]) == self.path: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, + detail="Path is outside the project directory") + + if not os.path.exists(node_full_path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Node directory not found") + + files = [] + try: + filenames = os.listdir(node_full_path) + except OSError as e: + log.error(f"Error listing node directory: {e}") + return files + + for filename in filenames: + file_path = os.path.join(node_full_path, filename) + if not os.path.isfile(file_path) or filename.endswith(".ghost"): + continue + + try: + # Get file stat information + stat_info = await wait_run_in_executor(os.stat, file_path) + + # Get file extension + _, extension = os.path.splitext(filename) + extension = extension.lstrip('.') + + # Format timestamps as ISO 8601 + try: + created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() + modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() + except (OSError, OverflowError, ValueError) as e: + log.warning(f"Invalid timestamp for '{filename}': {e}") + created_at = modified_at = "" + + file_info = { + "path": filename, + "size": stat_info.st_size, + "created_at": created_at, + "modified_at": modified_at, + "extension": extension + } + files.append(file_info) + except OSError as e: + log.warning(f"Error getting metadata for file '{filename}': {e}") + continue + + return files + def _hash_file(self, path): """ Compute and md5 hash for file diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index cc666f030..f91177111 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -197,7 +197,7 @@ class QemuVM(BaseNode): """ if not is_rfc1123_hostname_valid(new_name): - raise QemuError(f"'{new_name}' is an invalid name to rename Qemu node '{self._name}'") + raise QemuError(f"'{new_name}' is an invalid name to rename Qemu node '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen.") super(QemuVM, QemuVM).name.__set__(self, new_name) @property @@ -1131,7 +1131,6 @@ class QemuVM(BaseNode): await cancellable_wait_run_in_executor(md5sum, self._hdb_disk_image, self.working_dir) await cancellable_wait_run_in_executor(md5sum, self._hdc_disk_image, self.working_dir) await cancellable_wait_run_in_executor(md5sum, self._hdd_disk_image, self.working_dir) - super().create() async def start(self): @@ -1744,11 +1743,14 @@ class QemuVM(BaseNode): :param extend: new size """ + if self.is_running(): + raise QemuError(f"Cannot resize '{disk_name}' while the VM is running") + try: qemu_img_path = self._get_qemu_img() disk_path = os.path.join(self.working_dir, disk_name) if not os.path.exists(disk_path): - raise QemuError(f"Qemu disk image '{disk_name}' does not exist") + raise QemuError(f"Qemu disk image '{disk_name}' does not exist in the working directory") command = [qemu_img_path, "resize", disk_path, f"+{extend}M"] retcode = await self._qemu_img_exec(command) @@ -1770,7 +1772,7 @@ class QemuVM(BaseNode): disk_path = os.path.join(self.working_dir, disk_name) if not os.path.exists(disk_path): - raise QemuError(f"Qemu disk image '{disk_name}' does not exist") + raise QemuError(f"Qemu disk image '{disk_name}' does not exist in the working directory") try: os.remove(disk_path) @@ -1920,7 +1922,7 @@ class QemuVM(BaseNode): def _console_options(self): - if self._console_type == "telnet" and self._wrap_console: + if self._console_type in ("telnet", "ssh") and self._wrap_console: return self._serial_options(self._internal_console_port, self.console) elif self._console_type == "vnc": return self._vnc_options(self.console) @@ -1936,7 +1938,7 @@ class QemuVM(BaseNode): if self._aux_type != "none" and self._aux_type == self._console_type: raise QemuError(f"Auxiliary console type {self._aux_type} cannot be the same as console type") - if self._aux_type == "telnet" and self._wrap_aux: + if self._aux_type in ("telnet", "ssh") and self._wrap_aux: return self._serial_options(self._internal_aux_port, self.aux) elif self._aux_type == "vnc": return self._vnc_options(self.aux) @@ -2258,24 +2260,6 @@ class QemuVM(BaseNode): return options - async def resize_disk(self, drive_name, extend): - - if self.is_running(): - raise QemuError(f"Cannot resize {drive_name} while the VM is running") - - if self.linked_clone: - disk_image_path = os.path.join(self.working_dir, f"{drive_name}_disk.qcow2") - if not os.path.exists(disk_image_path): - disk_image = getattr(self, f"_{drive_name}_disk_image") - await self._create_linked_clone(drive_name, disk_image, disk_image_path) - else: - disk_image_path = getattr(self, f"{drive_name}_disk_image") - - if not os.path.exists(disk_image_path): - raise QemuError(f"Disk path '{disk_image_path}' does not exist") - qemu_img_path = self._get_qemu_img() - await self.manager.resize_disk(qemu_img_path, disk_image_path, extend) - def _cdrom_option(self): options = [] @@ -2679,7 +2663,7 @@ class QemuVM(BaseNode): await self._clear_save_vm_stated() else: command.extend(await self._saved_state_option()) - if self._console_type == "telnet": + if self._console_type in ("telnet", "ssh"): command.extend(await self._disable_graphics()) if self._tpm: command.extend(self._tpm_options()) @@ -2715,17 +2699,19 @@ class QemuVM(BaseNode): if not disk_image: continue answer[f"hd{drive}_disk_image"] = self.manager.get_relative_image_path(disk_image, self.working_dir) - answer[f"hd{drive}_disk_image_md5sum"] = md5sum(disk_image, self.working_dir) - local_disk = os.path.join(self.working_dir, f"hd{drive}_disk.qcow2") if os.path.exists(local_disk): try: qcow2 = Qcow2(local_disk) if qcow2.backing_file: - answer[f"hd{drive}_disk_image_backed"] = os.path.basename(local_disk) + # update disk image path to the local disk image and add the backing file name in the answer + answer[f"hd{drive}_disk_image_backing_file"] = os.path.basename(qcow2.backing_file) + answer[f"hd{drive}_disk_image"] = os.path.basename(local_disk) except (Qcow2Error, OSError) as e: log.error(f"Could not read qcow2 disk image '{local_disk}': {e}") continue + # only compute the md5sum if the disk exists to avoid computing one for a backing file + answer[f"hd{drive}_disk_image_md5sum"] = md5sum(local_disk, self.working_dir) answer["cdrom_image"] = self.manager.get_relative_image_path(self._cdrom_image, self.working_dir) answer["cdrom_image_md5sum"] = md5sum(self._cdrom_image, self.working_dir) diff --git a/gns3server/compute/virtualbox/__init__.py b/gns3server/compute/virtualbox/__init__.py index 5627fb5ef..ccc5a6952 100644 --- a/gns3server/compute/virtualbox/__init__.py +++ b/gns3server/compute/virtualbox/__init__.py @@ -68,6 +68,8 @@ class VirtualBox(BaseManager): vboxmanage_path = vboxmanage_path_osx if not vboxmanage_path: vboxmanage_path = shutil.which("vboxmanage") + if not vboxmanage_path: + vboxmanage_path = shutil.which("VBoxManage") if vboxmanage_path and not os.path.exists(vboxmanage_path): log.error(f"VBoxManage path '{vboxmanage_path}' doesn't exist") diff --git a/gns3server/compute/virtualbox/virtualbox_vm.py b/gns3server/compute/virtualbox/virtualbox_vm.py index 5f4cd2923..bcaa8b683 100644 --- a/gns3server/compute/virtualbox/virtualbox_vm.py +++ b/gns3server/compute/virtualbox/virtualbox_vm.py @@ -30,6 +30,7 @@ import tempfile import xml.etree.ElementTree as ET from gns3server.utils import parse_version +from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.asyncio.serial import asyncio_open_serial from gns3server.utils.asyncio import locking @@ -1011,22 +1012,25 @@ class VirtualBoxVM(BaseNode): Starts remote console support for this VM. """ - if self.console and self.console_type == "telnet": + if self.console and self.console_type in ("telnet", "ssh"): pipe_name = self._get_pipe_name() try: self._remote_pipe = await asyncio_open_serial(pipe_name) except OSError as e: raise VirtualBoxError(f"Could not open serial pipe '{pipe_name}': {e}") - server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True) + if self.console_type == "telnet": + server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True) + transport = "Telnet" + else: + server = AsyncioSSHServer(reader=self._remote_pipe, writer=self._remote_pipe) + transport = "SSH" try: - self._telnet_server = await asyncio.start_server( - server.run, self._manager.port_manager.console_host, self.console - ) + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit( "log.warning", { - "message": f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" + "message": f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" }, ) diff --git a/gns3server/compute/vmware/vmware_vm.py b/gns3server/compute/vmware/vmware_vm.py index 22e766b63..1e0fa3b69 100644 --- a/gns3server/compute/vmware/vmware_vm.py +++ b/gns3server/compute/vmware/vmware_vm.py @@ -24,6 +24,7 @@ import asyncio import tempfile import platform +from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.asyncio.serial import asyncio_open_serial from gns3server.utils import parse_version @@ -912,22 +913,25 @@ class VMwareVM(BaseNode): Starts remote console support for this VM. """ - if self.console and self.console_type == "telnet": + if self.console and self.console_type in ("telnet", "ssh"): pipe_name = self._get_pipe_name() try: self._remote_pipe = await asyncio_open_serial(self._get_pipe_name()) except OSError as e: raise VMwareError(f"Could not open serial pipe '{pipe_name}': {e}") - server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True) + if self.console_type == "telnet": + server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True) + transport = "Telnet" + else: + server = AsyncioSSHServer(reader=self._remote_pipe, writer=self._remote_pipe) + transport = "SSH" try: - self._telnet_server = await asyncio.start_server( - server.run, self._manager.port_manager.console_host, self.console - ) + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit( "log.warning", { - "message": f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" + "message": f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" }, ) diff --git a/gns3server/config.py b/gns3server/config.py index 0aee21172..c579a5328 100644 --- a/gns3server/config.py +++ b/gns3server/config.py @@ -97,8 +97,20 @@ class Config: if self._main_config_file is None: - # TODO: migrate versioned config file from a previous version of GNS3 (for instance 2.2 -> 3.0) + support profiles - # migrate post version 2.2.0 config files if they exist + if not os.path.exists(versioned_user_dir): + # Try to migrate the configuration files and database from the previous version if it exists + previous_version = f"{__version_info__[0]}.{int(__version_info__[1]) - 1}" + if self._profile: + previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version, "profiles", self._profile) + else: + previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version) + if os.path.exists(previous_versioned_user_dir): + try: + shutil.copytree(previous_versioned_user_dir, versioned_user_dir, symlinks=True, ignore_dangling_symlinks=True) + log.info(f"Migrated configuration files and database from '{previous_versioned_user_dir}' to '{versioned_user_dir}'") + except OSError as e: + log.error(f"Cannot migrate old config files and database from '{previous_versioned_user_dir}: {e}") + os.makedirs(versioned_user_dir, exist_ok=True) try: # migrate the server config file @@ -225,6 +237,17 @@ class Config: except OSError as e: log.error(f"Could not read JWT secret key file '{jwt_secret_key_path}': {e}") + def _load_encryption_key(self): + """ + Load the encryption key for sensitive data (API keys, etc.). + """ + from .utils.encryption import init_encryption + + try: + init_encryption(self._settings.Server.secrets_dir) + except Exception as e: + log.error(f"Could not initialize encryption: {e}") + def _load_secret_files(self): """ Load the secret files. @@ -234,6 +257,7 @@ class Config: self._settings.Server.secrets_dir = os.path.dirname(self.server_config) self._load_jwt_secret_key() + self._load_encryption_key() def read_config(self): """ @@ -249,6 +273,8 @@ class Config: if not parsed_files: log.warning("No configuration file could be found or read") self._settings = ServerConfig() + # Still load secret files even without a config file + self._load_secret_files() return for file in parsed_files: diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 0a99ea338..c19a60077 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -91,6 +91,9 @@ udp_end_port_range = 30000 ; uBridge executable location, default: search in PATH ;ubridge_path = ubridge +; Option to enable or disable compute HTTP authentication +enable_http_auth = True + ; Username for compute HTTP authentication, "gns3" is the default if not specified compute_username = gns3 ; Password for compute HTTP authentication, a randomly generated password is used if not specified @@ -110,6 +113,14 @@ enable_builtin_templates = True ; Install built-in appliances install_builtin_appliances = True +; Git repository URL for GNS3 Copilot external skills +; This repository provides injection skills, prompts, and device skills +; skills_repo_url = https://github.com/yueguobin/GNS3-Skills.git +; Git branch for the skills repository +; skills_repo_branch = main +; Automatically pull updates from the skills repository when reloading +; skills_auto_update = false + ; check if hardware virtualization is used by other emulators (KVM, VMware or VirtualBox) hardware_virtualization_check = True @@ -157,4 +168,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 \ No newline at end of file +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 \ No newline at end of file diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 539121a42..9cfe2bcd7 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -22,12 +22,16 @@ import shutil import asyncio import random import json +import threading try: import importlib_resources except ImportError: from importlib import resources as importlib_resources +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + from ..config import Config from ..utils import parse_version, md5sum from ..utils.images import default_images_directory @@ -51,6 +55,39 @@ import logging log = logging.getLogger(__name__) +class _ProjectsDirectoryEventHandler(FileSystemEventHandler): + """ + Watchdog handler for project directory changes. + """ + + def __init__(self, controller, projects_path): + self._controller = controller + self._projects_path = os.path.normpath(projects_path) + + def on_created(self, event): + self._handle_event(event) + + def on_modified(self, event): + self._handle_event(event) + + def on_moved(self, event): + self._handle_event(event) + + def _handle_event(self, event): + if event.is_directory: + # Only react to direct child directories of the projects path + # to avoid noise from subdirectory changes in existing projects + for path in (getattr(event, "src_path", ""), getattr(event, "dest_path", "")): + if path and os.path.dirname(os.path.normpath(path)) == self._projects_path: + self._controller._notify_projects_directory_event() + return + else: + for path in (getattr(event, "src_path", ""), getattr(event, "dest_path", "")): + if path and path.endswith(".gns3"): + self._controller._notify_projects_directory_event() + return + + class Controller: """ The controller is responsible to manage one or more computes. @@ -68,11 +105,19 @@ class Controller: self._iou_license_settings = {"iourc_content": "", "license_check": False} self._vars_loaded = False self._vars_file = Config.instance().controller_vars + self._project_auto_open_task_handle = None + self._projects_observer = None + self._projects_scan_handle = None + self._projects_scan_lock = asyncio.Lock() + self._projects_monitor_loop = None + self._projects_monitor_thread_id = None log.info(f'Loading controller vars file "{self._vars_file}"') async def start(self, computes=None): log.info("Controller is starting") + self._projects_monitor_loop = asyncio.get_running_loop() + self._projects_monitor_thread_id = threading.get_ident() await self._install_base_configs() await self._install_custom_symbols() installed_disks = await self._install_builtin_disks() @@ -129,6 +174,7 @@ class Controller: compute_id=str(c.compute_id), connect=False, **c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}), + ssl_context=self._ssl_context ) except (ControllerError, KeyError): pass # Skip not available servers at loading @@ -139,9 +185,14 @@ class Controller: log.warning(str(e)) await self.load_projects() + self._start_projects_monitor() # start to auto open projects (if configured) 5 seconds after the controller has started - asyncio.get_event_loop().call_later(5, asyncio.create_task, self._project_auto_open()) + self._project_auto_open_task_handle = asyncio.get_event_loop().call_later( + 5, + lambda: asyncio.create_task(self._project_auto_open()) + ) + def _create_ssl_context(self, server_config): @@ -179,6 +230,9 @@ class Controller: async def stop(self): log.info("Controller is stopping") + self._stop_projects_monitor() + if self._project_auto_open_task_handle is not None and not self._project_auto_open_task_handle.cancelled(): + self._project_auto_open_task_handle.cancel() for project in self._projects.values(): await project.close() for compute in self._computes.values(): @@ -235,7 +289,7 @@ class Controller: try: os.makedirs(os.path.dirname(self._vars_file), exist_ok=True) with open(self._vars_file, 'w+') as f: - json.dump(controller_vars, f, indent=4) + json.dump(controller_vars, f, indent=4, sort_keys=True) except OSError as e: log.error(f"Cannot write controller vars file '{self._vars_file}': {e}") @@ -294,21 +348,106 @@ class Controller: Preload the list of projects from disk """ - server_config = Config.instance().settings.Server - projects_path = os.path.expanduser(server_config.projects_path) + async with self._projects_scan_lock: + server_config = Config.instance().settings.Server + projects_path = os.path.expanduser(server_config.projects_path) + os.makedirs(projects_path, exist_ok=True) + try: + for project_path in os.listdir(projects_path): + project_dir = os.path.join(projects_path, project_path) + if os.path.isdir(project_dir): + for file in os.listdir(project_dir): + if file.endswith(".gns3"): + project_file = os.path.join(project_dir, file) + try: + await self.load_project(project_file, load=False) + except (ControllerError, NotImplementedError): + pass # Skip not compatible projects + except Exception as e: + log.warning(f"Could not load project from '{project_file}': {e}", exc_info=True) + except OSError as e: + log.error(str(e)) + + def _start_projects_monitor(self): + """ + Monitor the projects directory for newly added projects. + """ + + if self._projects_observer is not None: + return + + projects_path = self.projects_directory() os.makedirs(projects_path, exist_ok=True) + try: - for project_path in os.listdir(projects_path): - project_dir = os.path.join(projects_path, project_path) - if os.path.isdir(project_dir): - for file in os.listdir(project_dir): - if file.endswith(".gns3"): - try: - await self.load_project(os.path.join(project_dir, file), load=False) - except (ControllerError, NotImplementedError): - pass # Skip not compatible projects + observer = Observer() + observer.schedule(_ProjectsDirectoryEventHandler(self, projects_path), projects_path, recursive=True) + observer.start() + self._projects_observer = observer + log.info(f"Watching projects directory '{projects_path}' for changes") except OSError as e: - log.error(str(e)) + log.warning(f"Could not watch projects directory '{projects_path}': {e}") + + def _stop_projects_monitor(self): + """ + Stop projects directory monitoring. + """ + + if self._projects_scan_handle is not None: + self._projects_scan_handle.cancel() + self._projects_scan_handle = None + + self._projects_monitor_loop = None + + observer = self._projects_observer + if observer is None: + return + + self._projects_observer = None + observer.stop() + observer.join(timeout=2) + + def _notify_projects_directory_event(self): + """ + Callback invoked by watchdog threads when the projects directory changes. + """ + + loop = self._projects_monitor_loop + if loop is None: + return + + if threading.get_ident() == self._projects_monitor_thread_id: + self._schedule_projects_scan() + return + + try: + loop.call_soon_threadsafe(self._schedule_projects_scan) + except RuntimeError: + pass # Event loop may be closed during shutdown + + def _schedule_projects_scan(self, delay=0.5): + """ + Debounce projects directory scans to avoid excessive rescans during copy operations. + """ + + if self._projects_scan_handle is not None: + self._projects_scan_handle.cancel() + + loop = self._projects_monitor_loop or asyncio.get_running_loop() + self._projects_scan_handle = loop.call_later(delay, lambda: asyncio.create_task(self._scan_projects_directory())) + + async def _scan_projects_directory(self): + """ + Refresh loaded projects after a filesystem change. + """ + + self._projects_scan_handle = None + if self._projects_observer is None: + return # Monitor was stopped, skip the scan + try: + await self.load_projects() + except Exception as e: + log.warning(f"Projects directory rescan failed: {e}") @staticmethod diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py index 9e93ee424..629765b8b 100644 --- a/gns3server/controller/appliance.py +++ b/gns3server/controller/appliance.py @@ -44,6 +44,10 @@ class Appliance: def status(self): return self._data["status"] + @property + def registry_version(self): + return self._data.get("registry_version") + @property def symbol(self): return self._data.get("symbol") diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 75d576747..0420e78cc 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -15,12 +15,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sys import os import json import asyncio -import aiofiles -import shutil import platformdirs @@ -29,6 +26,7 @@ from aiohttp.client_exceptions import ClientError from uuid import UUID from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError from .appliance import Appliance from ..config import Config @@ -36,10 +34,10 @@ from ..utils.asyncio import locking from ..utils.http_client import HTTPClient from .controller_error import ControllerBadRequestError, ControllerNotFoundError, ControllerError from .appliance_to_template import ApplianceToTemplate -from ..utils.images import InvalidImageError, write_image, md5sum -from ..utils.asyncio import wait_run_in_executor +from ..utils.images import InvalidImageError, write_image, read_image_info from gns3server import schemas +from gns3server.schemas.controller.appliances import ApplianceModel from gns3server.utils.images import default_images_directory from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.templates import TemplatesRepository @@ -183,17 +181,16 @@ class ApplianceManager: if image_in_db: version_images[appliance_key] = image_in_db.filename else: - # check if the image is on disk - # FIXME: still necessary? the image should have been discovered and saved in the db already + # check if the image is on disk but it not yet in the database image_path = os.path.join(image_dir, appliance_file) - if os.path.exists(image_path) and \ - await wait_run_in_executor( - md5sum, - image_path, - cache_to_md5file=False - ) == image_checksum: - async with aiofiles.open(image_path, "rb") as f: - await write_image(appliance_file, image_path, f, images_repo, allow_raw_image=True) + if os.path.exists(image_path): + image_info = await read_image_info(image_path) + if image_info.get("checksum") == image_checksum: + log.info(f"Adding image '{image_path}' to the database") + try: + await images_repo.add_image(**image_info) + except SQLAlchemyError as e: + log.warning(f"Error while adding image '{image['path']}' to the database: {e}") else: # download the image if there is a direct download URL direct_download_url = image.get("direct_download_url") @@ -252,7 +249,8 @@ class ApplianceManager: appliances_info = self._find_appliances_from_image_checksum(image_checksum) for appliance, image_version in appliances_info: try: - schemas.Appliance.model_validate(appliance.asdict()) + # Validate with discriminated union - automatically routes to correct version + ApplianceModel.model_validate(appliance.asdict()) except ValidationError as e: log.warning(f"Could not validate appliance '{appliance.id}': {e}") if appliance.versions: @@ -283,7 +281,8 @@ class ApplianceManager: raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'") try: - schemas.Appliance.model_validate(appliance.asdict()) + # Validate with discriminated union - automatically routes to correct version + ApplianceModel.model_validate(appliance.asdict()) except ValidationError as e: raise ControllerError(message=f"Could not validate appliance '{appliance_id}': {e}") @@ -310,7 +309,7 @@ class ApplianceManager: f"appliance '{appliance_id}'") template_data = await self._appliance_to_template(appliance) - await self._create_template(template_data, templates_repo, rbac_repo, current_user) + return await self._create_template(template_data, templates_repo, rbac_repo, current_user) def load_appliances(self, symbol_theme: str = None) -> None: """ @@ -338,7 +337,9 @@ class ApplianceManager: appliance = Appliance(path, json.load(f), builtin=builtin) json_data = appliance.asdict() # Check if loaded without error if appliance.status != "broken": - schemas.Appliance.model_validate(json_data) + # Validate using discriminated union - automatically routes to correct version + log.debug(f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}") + ApplianceModel.model_validate(json_data) self._appliances[appliance.id] = appliance if not appliance.symbol or appliance.symbol.startswith(":/symbols/"): # apply a default symbol if the appliance has none or a default symbol diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index f014c568b..658dc42c7 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -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: diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index c01fddaef..c33cf5315 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -375,17 +375,24 @@ class Compute: log.info(f"Connecting to compute '{self._id}'") response = await self._run_http_query("GET", "/capabilities") except ComputeError as e: + # Update connection status and notify UI + self._connected = False + self._last_error = str(e) + self._controller.notification.controller_emit("compute.updated", self.asdict()) + if report_failed_connection: raise log.warning(f"Cannot connect to compute '{self._id}': {e}") - # Try to reconnect after 5 seconds if server unavailable only if not during tests (otherwise we create a ressource usage bomb) + # Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb) if not hasattr(sys, "_called_from_test") or not sys._called_from_test: self._connection_failure += 1 - # After 5 failure we close the project using the compute to avoid sync issues + # After 10 failures we close the project using the compute to avoid sync issues if self._connection_failure == 10: log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {e}") await self._controller.close_compute_projects(self) - asyncio.get_event_loop().call_later(5, lambda: asyncio.ensure_future(self._try_reconnect())) + # Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s + delay = min(5 * (2 ** (self._connection_failure - 1)), 300) + asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect())) return except web.HTTPNotFound: raise ControllerNotFoundError(f"The server {self._id} is not a GNS3 server or it's a 1.X server") @@ -478,7 +485,7 @@ class Compute: self._connected = False log.info(f"Connection closed to compute '{self._id}' WebSocket '{ws_url}'") - # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a ressources usage bomb) + # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb) from gns3server.api.server import app if not app.state.exiting and not hasattr(sys, "_called_from_test"): log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") diff --git a/gns3server/controller/drawing.py b/gns3server/controller/drawing.py index ce8581475..daf072983 100644 --- a/gns3server/controller/drawing.py +++ b/gns3server/controller/drawing.py @@ -115,7 +115,7 @@ class Drawing: data = base64.decodebytes(data.split(",", 1)[1].encode()) - # We compute an hash of the image file to avoid duplication + # We compute a hash of the image file to avoid duplication filename = hashlib.md5(data).hexdigest() + "." + extension elem.set(href, filename) diff --git a/gns3server/controller/export_project.py b/gns3server/controller/export_project.py index 29226fc7a..89b5a8140 100644 --- a/gns3server/controller/export_project.py +++ b/gns3server/controller/export_project.py @@ -81,7 +81,7 @@ async def export_project( keep_compute_ids, allow_all_nodes, temporary_dir, - reset_mac_addresses, + reset_mac_addresses ) # Export the local files @@ -107,6 +107,10 @@ async def export_project( # save empty directories for directory in dirs: path = os.path.join(root, directory) + if path == temporary_dir: + continue + if include_snapshots is False and path.endswith("snapshots"): + continue if not os.listdir(path): zstream.write(path, os.path.relpath(path, project._path)) except FileNotFoundError as e: @@ -168,7 +172,7 @@ def _is_exportable(path, include_snapshots=False): """ # do not export snapshots by default - if include_snapshots is False and path.endswith("snapshots"): + if include_snapshots is False and os.path.dirname(path).endswith("snapshots"): return False # do not export directories of snapshots @@ -194,7 +198,14 @@ def _is_exportable(path, include_snapshots=False): async def _patch_project_file( - project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses + project, + path, + zstream, + include_images, + keep_compute_ids, + allow_all_nodes, + temporary_dir, + reset_mac_addresses ): """ Patch a project file (.gns3) to export a project. @@ -268,7 +279,7 @@ async def _patch_project_file( for compute_id, image_type, image in remote_images: await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir) - zstream.writestr("project.gns3", json.dumps(topology).encode()) + zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode()) return images @@ -289,7 +300,7 @@ def _export_local_image(image, zstream): # Some modules don't have images continue - directory = os.path.split(images_directory)[-1:][0] + directory = os.path.basename(images_directory) if os.path.exists(image): path = image else: @@ -309,7 +320,7 @@ async def _export_remote_images(project, compute_id, image_type, image, project_ log.debug(f"Downloading image '{image}' from compute '{compute_id}'") try: - compute = [compute for compute in project.computes if compute.id == compute_id][0] + compute = next(c for c in project.computes if c.id == compute_id) except IndexError: raise ControllerNotFoundError(f"Cannot export image from '{compute_id}' compute. Compute doesn't exist.") diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index 4af3717ce..18ff6cd3a 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -17,7 +17,6 @@ import os import sys -import stat import json import uuid import shutil @@ -41,8 +40,18 @@ Handle the import of project from a .gns3project """ -async def import_project(controller, project_id, stream, location=None, name=None, keep_compute_ids=False, - auto_start=False, auto_open=False, auto_close=True): +async def import_project( + controller, + project_id, + stream, + location=None, + name=None, + reset_mac_addresses=False, + keep_compute_ids=False, + auto_start=False, + auto_open=False, + auto_close=True, +): """ Import a project contain in a zip file @@ -53,7 +62,9 @@ async def import_project(controller, project_id, stream, location=None, name=Non :param stream: A io.BytesIO of the zipfile :param location: Directory for the project if None put in the default directory :param name: Wanted project name, generate one from the .gns3 if None + :param reset_mac_addresses: Reset MAC addresses for each node :param keep_compute_ids: keep compute IDs unchanged + :param project_name: Original project name when restoring a snapshot :returns: Project """ @@ -74,12 +85,14 @@ async def import_project(controller, project_id, stream, location=None, name=Non # We import the project on top of an existing project (snapshots) if topology["project_id"] == project_id: project_name = topology["name"] + restoring_snapshot = True else: # If the project name is already used we generate a new one if name: project_name = controller.get_free_project_name(name) else: project_name = controller.get_free_project_name(topology["name"]) + restoring_snapshot = False except (ValueError, KeyError): raise ControllerError("Cannot import project, the project.gns3 file is corrupted") @@ -107,27 +120,11 @@ async def import_project(controller, project_id, stream, location=None, name=Non topology["auto_open"] = auto_open topology["auto_close"] = auto_close - # Generate a new node id - node_old_to_new = {} - for node in topology["topology"]["nodes"]: - if "node_id" in node: - node_old_to_new[node["node_id"]] = str(uuid.uuid4()) - _move_node_file(path, node["node_id"], node_old_to_new[node["node_id"]]) - node["node_id"] = node_old_to_new[node["node_id"]] - else: - node["node_id"] = str(uuid.uuid4()) + if not restoring_snapshot: + # Do not re-generate IDs if we are restoring a snapshot because they should be the same in a project + regenerate_topology_ids(topology, path, reset_mac_addresses=reset_mac_addresses) - # Update link to use new id - for link in topology["topology"]["links"]: - link["link_id"] = str(uuid.uuid4()) - for node in link["nodes"]: - node["node_id"] = node_old_to_new[node["node_id"]] - - # Generate new drawings id - for drawing in topology["topology"]["drawings"]: - drawing["drawing_id"] = str(uuid.uuid4()) - - # Modify the compute id of the node depending of compute capacity + # Modify the compute id of the node depending on compute capacity if not keep_compute_ids: # For some VM type we move them to the GNS3 VM if possible # unless it's a linux host without GNS3 VM @@ -137,17 +134,16 @@ async def import_project(controller, project_id, stream, location=None, name=Non node["compute_id"] = "vm" else: # Round-robin through available compute resources. - # computes = [] - # for compute_id in controller.computes: - # compute = controller.get_compute(compute_id) - # # only use the local compute or any connected compute - # if compute_id == "local" or compute.connected: - # computes.append(compute_id) - # else: - # log.warning(compute.name, "is not connected!") - compute_nodes = itertools.cycle(controller.computes) - for node in topology["topology"]["nodes"]: - node["compute_id"] = next(compute_nodes) + # Only use computes that are connected to avoid import failures + available_computes = {compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected} + if available_computes: + compute_nodes = itertools.cycle(available_computes) + for node in topology["topology"]["nodes"]: + node["compute_id"] = next(compute_nodes) + else: + # No remote computes are connected, use local only + for node in topology["topology"]["nodes"]: + node["compute_id"] = "local" compute_created = set() for node in topology["topology"]["nodes"]: @@ -172,7 +168,7 @@ async def import_project(controller, project_id, stream, location=None, name=Non # We change the project_id to avoid erasing the project topology["project_id"] = project_id with open(dot_gns3_path, "w+") as f: - json.dump(topology, f, indent=4) + json.dump(topology, f, indent=4, sort_keys=True) os.remove(os.path.join(path, "project.gns3")) images_path = os.path.join(path, "images") @@ -180,8 +176,8 @@ async def import_project(controller, project_id, stream, location=None, name=Non await _import_images(controller, images_path) snapshots_path = os.path.join(path, "snapshots") - if os.path.exists(snapshots_path): - await _import_snapshots(snapshots_path, project_name, project_id) + if not restoring_snapshot and os.path.exists(snapshots_path): + await update_snapshots(snapshots_path, path, project_name, project_id, reset_mac_addresses=reset_mac_addresses) project = await controller.load_project(dot_gns3_path, load=False) return project @@ -205,6 +201,41 @@ def _create_symbolic_links(zip_file, path): except OSError as e: raise ControllerError(f"Cannot create symbolic link: {e}") +def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False): + """ + Regenerate IDs in the topology and move the files of the nodes to match the new IDs. + This is necessary because IDs must be unique across projects. + + :param topology: topology content + :param new_project_path: new project path + :param reset_mac_addresses: reset MAC addresses + """ + + # Generate new node IDs + node_old_to_new = {} + for node in topology["topology"]["nodes"]: + new_node_id = str(uuid.uuid4()) + if "node_id" in node: + node_old_to_new[node["node_id"]] = new_node_id + _move_node_file(new_project_path, node["node_id"], new_node_id) + node["node_id"] = new_node_id + if reset_mac_addresses: + if "properties" in node: + for prop, value in node["properties"].items(): + # reset the MAC address + if prop in ("mac_addr", "mac_address"): + node["properties"][prop] = None + + # Generate new link IDs + for link in topology["topology"]["links"]: + link["link_id"] = str(uuid.uuid4()) + for node in link["nodes"]: + node["node_id"] = node_old_to_new[node["node_id"]] + + # Generate new drawings IDs + for drawing in topology["topology"]["drawings"]: + drawing["drawing_id"] = str(uuid.uuid4()) + def _move_node_file(path, old_id, new_id): """ Move a file from a node when changing its id @@ -273,16 +304,17 @@ async def _import_images(controller, images_path): os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC) -async def _import_snapshots(snapshots_path, project_name, project_id): +async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True): """ - Import the snapshots and update their project name and ID to be the same as the main project. + Load the snapshots and update their project name and project ID to be the same as the main project. + Regenerate all the node, link and drawing IDs """ - for snapshot in os.listdir(snapshots_path): - if not snapshot.endswith(".gns3project"): + for snapshot in os.listdir(snapshots_dir): + if not (snapshot.endswith(".gns3snapshot") or snapshot.endswith(".gns3project")): continue - snapshot_path = os.path.join(snapshots_path, snapshot) - with tempfile.TemporaryDirectory(dir=snapshots_path) as tmpdir: + snapshot_path = os.path.join(snapshots_dir, snapshot) + with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir: # extract everything to a temporary directory try: @@ -302,9 +334,9 @@ async def _import_snapshots(snapshots_path, project_name, project_id): topology_file_path = os.path.join(tmpdir, "project.gns3") with open(topology_file_path, encoding="utf-8") as f: topology = json.load(f) - topology["name"] = project_name topology["project_id"] = project_id + regenerate_topology_ids(topology, tmpdir, reset_mac_addresses) with open(topology_file_path, "w+", encoding="utf-8") as f: json.dump(topology, f, indent=4, sort_keys=True) except OSError as e: @@ -326,6 +358,7 @@ async def _import_snapshots(snapshots_path, project_name, project_id): async with aiofiles.open(snapshot_path, "wb+") as f: async for chunk in zstream: await f.write(chunk) + log.info("Project '{}': updated and repacked snapshot file '{}'".format(project_name, snapshot)) except OSError as e: raise ControllerError( f"Cannot update snapshot '{os.path.basename(snapshot)}': the snapshot cannot be recreated: {e}" diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index ae224b7dc..ecd74b045 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -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, } diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index b3d77aa54..3378f055f 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -27,6 +27,7 @@ from .controller_error import ( ComputeError, ComputeConflictError ) +from .node_types import BUILTIN_NODE_TYPES from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory from ..utils.images import images_directories from ..utils import macaddress_to_int, int_to_macaddress @@ -97,6 +98,7 @@ class Node: self._y = 0 self._z = 1 # default z value is 1 self._locked = False + self._tags = [] self._ports = None self._symbol = None self._custom_adapters = [] @@ -111,7 +113,7 @@ class Node: self._first_port_name = None self._console_auto_start = False - # This properties will be recompute + # This properties will be recomputed ignore_properties = ("width", "height", "hover_symbol") self.properties = kwargs.pop("properties", {}) @@ -218,6 +220,14 @@ class Node: def properties(self, val): self._properties = val + @property + def tags(self): + return self._tags + + @tags.setter + def tags(self, val): + self._tags = val + def _base_config_file_content(self, path): if not os.path.isabs(path): path = os.path.join(self.project.controller.configs_path(), path) @@ -416,6 +426,7 @@ class Node: await self.parse_node_response(response.json) return True trial += 1 + return False async def update(self, **kwargs): """ @@ -443,8 +454,7 @@ class Node: if ( prop == "name" and self.status == "started" - and self._node_type - not in ("cloud", "nat", "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch") + and self._node_type not in BUILTIN_NODE_TYPES ): raise ControllerError("Sorry, it is not possible to rename a node that is already powered on") setattr(self, prop, kwargs[prop]) @@ -529,37 +539,43 @@ class Node: del data[k] del self._properties[k] # We send the file only one time data["name"] = self._name + + # For remote computes, convert absolute image paths to relative paths + # The remote compute will search for the image in its own configured directories + if self._compute.id != "local": + # Image path fields for various node types + image_path_fields = { + # Common fields (IOU, Docker, etc.) + "path", "image", + # QEMU-specific fields + "hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image", + "cdrom_image", "bios_image", "initrd", "kernel_image", + # VMware-specific fields + "vmx_path", + } + + for field in image_path_fields: + if field in data and data[field] and os.path.isabs(data[field]): + data[field] = os.path.basename(data[field]) + if self._console: # console is optional for builtin nodes data["console"] = self._console - if self._console_type and self._node_type not in ( - "cloud", - "nat", - "ethernet_hub", - "frame_relay_switch", - "atm_switch", - ): + if self._console_type and self._node_type not in (BUILTIN_NODE_TYPES - {"ethernet_switch"}): # console_type is not supported by all builtin nodes excepting Ethernet switch data["console_type"] = self._console_type if self._aux: # aux is optional for builtin nodes data["aux"] = self._aux - if self._aux_type and self._node_type not in ( - "cloud", - "nat", - "ethernet_switch", - "ethernet_hub", - "frame_relay_switch", - "atm_switch", - ): + if self._aux_type and self._node_type not in BUILTIN_NODE_TYPES: # aux_type is not supported by all builtin nodes data["aux_type"] = self._aux_type if self.custom_adapters: data["custom_adapters"] = self.custom_adapters # None properties are not be sent because it can mean the emulator doesn't support it - for key in list(data.keys()): - if data[key] is None or data[key] == {} or key in self.CONTROLLER_ONLY_PROPERTIES: + for key, value in list(data.items()): + if value is None or value == {} or key in self.CONTROLLER_ONLY_PROPERTIES: del data[key] return data @@ -617,7 +633,7 @@ class Node: Reset the console """ - if self._console and self._console_type == "telnet": + if self._console and self._console_type in ("telnet", "ssh"): try: await self.post("/console/reset", timeout=240) except asyncio.TimeoutError: @@ -748,10 +764,11 @@ class Node: elif self._node_type == "docker": for adapter_number in range(0, self._properties["adapters"]): custom_adapter_settings = {} - for custom_adapter in self.custom_adapters: - if custom_adapter["adapter_number"] == adapter_number: - custom_adapter_settings = custom_adapter - break + if self.custom_adapters: + for custom_adapter in self.custom_adapters: + if custom_adapter["adapter_number"] == adapter_number: + custom_adapter_settings = custom_adapter + break port_name = f"eth{adapter_number}" port_name = custom_adapter_settings.get("port_name", port_name) mac_address = custom_adapter_settings.get("mac_address") @@ -823,6 +840,7 @@ class Node: "port_segment_size": self._port_segment_size, "first_port_name": self._first_port_name, "custom_adapters": self._custom_adapters, + "tags": self._tags, } if topology_dump: diff --git a/gns3server/controller/node_types.py b/gns3server/controller/node_types.py new file mode 100644 index 000000000..4c4683c62 --- /dev/null +++ b/gns3server/controller/node_types.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# +# 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 . + +""" +Shared node type constants used across the controller. +""" + +# Node types that are always running (builtin/virtual switch nodes). +# These nodes do not require explicit start/stop and have limited feature support. +BUILTIN_NODE_TYPES = frozenset({ + "cloud", + "nat", + "ethernet_switch", + "ethernet_hub", + "frame_relay_switch", + "atm_switch", +}) \ No newline at end of file diff --git a/gns3server/controller/ports/port.py b/gns3server/controller/ports/port.py index e0a0406ba..52e5fe4c0 100644 --- a/gns3server/controller/ports/port.py +++ b/gns3server/controller/ports/port.py @@ -83,9 +83,9 @@ class Port: # If port name format has changed we use the port name as the short name (1.X behavior) if self._short_name: return self._short_name - elif "/" in self._name: + elif self._name and '/' in self._name: return self._name.replace(self.long_name_type(), self.short_name_type()) - elif self._name.startswith(f"{self.long_name_type()}{self._interface_number}"): + elif self._name and self._name.startswith(f"{self.long_name_type()}{self._interface_number}"): return self.short_name_type() + f"{self._interface_number}" return self._name diff --git a/gns3server/controller/ports/port_factory.py b/gns3server/controller/ports/port_factory.py index 070e391cf..9aa0f98e1 100644 --- a/gns3server/controller/ports/port_factory.py +++ b/gns3server/controller/ports/port_factory.py @@ -68,10 +68,11 @@ class StandardPortFactory: for adapter_number in range(adapter_number, ethernet_adapters + adapter_number): custom_adapter_settings = {} - for custom_adapter in custom_adapters: - if custom_adapter["adapter_number"] == adapter_number: - custom_adapter_settings = custom_adapter - break + if custom_adapters: + for custom_adapter in custom_adapters: + if custom_adapter["adapter_number"] == adapter_number: + custom_adapter_settings = custom_adapter + break for port_number in range(0, port_by_adapter): if first_port_name and adapter_number == 0: diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 6423cdeef..e0a38838c 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sys import re import os import json @@ -30,6 +29,7 @@ import zipfile import pathlib from uuid import UUID, uuid4 +from fastapi import HTTPException, status from .node import Node from .compute import ComputeError @@ -45,8 +45,9 @@ from ..utils.asyncio import locking from ..utils.asyncio import aiozipstream from ..utils.asyncio import wait_run_in_executor from .export_project import export_project -from .import_project import import_project, _move_node_file +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 @@ -97,6 +98,7 @@ class Project: show_interface_labels=False, variables=None, supplier=None, + created_by=None, ): self._controller = controller @@ -117,6 +119,8 @@ class Project: self._show_interface_labels = show_interface_labels self._variables = variables self._supplier = supplier + self._created_by = created_by + self._snapshots_config_file = "snapshots.conf" self._loading = False self._closing = False @@ -207,19 +211,7 @@ class Project: self._drawings = {} self._snapshots = {} self._computes = [] - - # List the available snapshots - snapshot_dir = os.path.join(self.path, "snapshots") - if os.path.exists(snapshot_dir): - for snap in os.listdir(snapshot_dir): - if snap.endswith(".gns3project"): - try: - snapshot = Snapshot(self, filename=snap) - except ValueError: - log.error("Invalid snapshot file: {}".format(snap)) - continue - self._snapshots[snapshot.id] = snapshot - + self._load_snapshot_config() # Create the project on demand on the compute node self._project_created_on_compute = set() @@ -380,6 +372,21 @@ class Project: """ self._supplier = supplier + @property + def created_by(self): + """ + Username of the user who created the project + :return: str or None + """ + return self._created_by + + @created_by.setter + def created_by(self, created_by): + """ + Setter for the username of the user who created the project + """ + self._created_by = created_by + @property def auto_start(self): """ @@ -509,7 +516,7 @@ class Project: name = base_name.format(number, id=number, name="Node") except KeyError as e: raise ControllerError("{" + e.args[0] + "} is not a valid replacement string in the node name") - except (ValueError, IndexError) as e: + except (ValueError, IndexError): raise ControllerError(f"{base_name} is not a valid replacement string in the node name") if name not in self._allocated_node_names: self._allocated_node_names.add(name) @@ -791,6 +798,59 @@ class Project: except KeyError: raise ControllerNotFoundError(f"Snapshot ID {snapshot_id} doesn't exist") + def _load_snapshot_config(self): + + snapshot_dir = os.path.join(self.path, "snapshots") + self._snapshot_conf_path = os.path.join(snapshot_dir, self._snapshots_config_file) + self._snapshot_conf = [] + if os.path.isfile(self._snapshot_conf_path): + try: + with open(self._snapshot_conf_path, encoding="utf-8") as f: + self._snapshot_conf = json.load(f) + except (OSError, UnicodeDecodeError, ValueError) as e: + raise ControllerError(f"Could not read snapshot config {e}") + + # Load all legacy snapshots (.gns3project files) to create an initial snapshot config if it doesn't exist + if os.path.exists(snapshot_dir) and not self._snapshot_conf: + for snap in os.listdir(snapshot_dir): + if snap.endswith(".gns3project"): + try: + snapshot = Snapshot(self, filename=snap) + except ValueError: + log.error("Invalid snapshot file: {}".format(snap)) + continue + self._snapshots[snapshot.id] = snapshot + else: + # Create the Snapshot instances from the snapshot config file + for snapshot_entry in self._snapshot_conf: + try: + path = os.path.join(snapshot_dir, snapshot_entry["filename"]) + if not os.path.isfile(path): + log.warning("Snapshot file '{}' does not exist".format(path)) + continue + snapshot_entry.pop("project_id") + snapshot = Snapshot(self, **snapshot_entry) + self._snapshots[snapshot.id] = snapshot + except KeyError: + log.error("Invalid entry in snapshot config file: {}".format(snapshot_entry)) + continue + + self._save_snapshot_config() + + def _save_snapshot_config(self): + + if not self._snapshots: + return + + self._snapshot_conf = [] + for snapshot in self._snapshots.values(): + self._snapshot_conf.append(snapshot.asdict()) + try: + with open(self._snapshot_conf_path, 'w+') as f: + json.dump(self._snapshot_conf, f, indent=4) + except OSError as e: + log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e)) + @open_required async def snapshot(self, name): """ @@ -804,12 +864,14 @@ class Project: snapshot = Snapshot(self, name=name) await snapshot.create() self._snapshots[snapshot.id] = snapshot + self._save_snapshot_config() return snapshot @open_required async def delete_snapshot(self, snapshot_id): snapshot = self.get_snapshot(snapshot_id) del self._snapshots[snapshot.id] + self._save_snapshot_config() os.remove(snapshot.path) @locking @@ -820,18 +882,27 @@ class Project: log.warning(f"Closing project '{self.name}' ignored because it is being loaded") return self._closing = True - await self.stop_all() + try: + await self.stop_all() + except HTTPException as e: + if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise for compute in list(self._project_created_on_compute): try: await compute.post(f"/projects/{self._id}/close", dont_connect=True) - # We don't care if a compute is down at this step - except (ComputeError, ControllerError, TimeoutError): - pass + except (ComputeError, ControllerError, TimeoutError) as e: + log.warning(f"Could not close project '{self._id}' on compute '{compute.id}': {e}") self._clean_pictures() self._status = "closed" 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() + self.reset() self._closing = False @@ -868,16 +939,112 @@ 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. + + This should be called when the project is closed to free resources. + """ + try: + from gns3server.agent.gns3_copilot.project_agent_manager import get_project_agent_manager + + agent_manager = await get_project_agent_manager() + if agent_manager.has_agent(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("Failed to cleanup AgentService for project '%s': %s", self.name, e) + async def delete(self): + # Check compute connectivity before open() to avoid 120s timeout + # when remote computes are unreachable + disconnected = self._get_disconnected_computes() + if disconnected: + compute_names = ", ".join([f"'{c.name}'" for c in disconnected]) + raise ControllerForbiddenError( + f"Cannot delete project '{self.name}': {len(disconnected)} compute(s) are disconnected: {compute_names}. " + f"Please fix the connection or delete the project manually on those computes." + ) + if self._status != "opened": try: await self.open() except ControllerError as e: # ignore missing images or other conflicts when deleting a 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: @@ -889,20 +1056,59 @@ class Project: raise ControllerError(f"Cannot delete project directory {self.path}: {str(e)}") self.emit_controller_notification("project.deleted", self.asdict()) + def _get_disconnected_computes(self): + """ + Check compute connectivity by reading the topology file directly, + without opening the project (which would try to connect to computes). + Returns a list of disconnected Compute objects. + """ + if self._status == "opened": + # Project is already open, use the already-loaded _computes list + compute_ids = self._computes + else: + # Read compute IDs from topology file without connecting + path = self._topology_file() + if not os.path.exists(path): + return [] + try: + project_data = load_topology(path) + except (ValueError, OSError) as e: + log.warning(f"Could not read topology file for project '{self._name}': {e}") + return [] + topology = project_data.get("topology", {}) + compute_ids = set() + for node in topology.get("nodes", []): + compute_id = node.get("compute_id") + if compute_id: + compute_ids.add(compute_id) + + disconnected = [] + for compute_id in compute_ids: + try: + compute = self._controller.get_compute(compute_id) + if not compute.connected: + disconnected.append(compute) + except ControllerError: + log.warning(f"Compute '{compute_id}' not found in controller") + return disconnected + async def delete_on_computes(self): """ Delete the project on computes but not on controller """ for compute in list(self._project_created_on_compute): if compute.id != "local": - await compute.delete(f"/projects/{self._id}") + try: + await compute.delete(f"/projects/{self._id}") + except (ComputeError, TimeoutError) as e: + log.warning(f"Could not delete project '{self._id}' on compute '{compute.id}': {e}") self._project_created_on_compute.remove(compute) @classmethod def _get_default_project_directory(cls): """ Return the default location for the project directory - depending of the operating system + depending on the operating system """ server_config = Config.instance().settings.Server @@ -967,7 +1173,9 @@ class Project: topology = project_data["topology"] for compute in topology.get("computes", []): - await self.controller.add_compute(**compute) + compute_id = compute.get("compute_id") + if compute_id not in self._controller._computes: + await self.controller.add_compute(**compute) # Get all compute used in the project # used to allocate application IDs for IOU nodes. @@ -976,6 +1184,16 @@ class Project: if compute_id not in self._computes: self._computes.append(compute_id) + # Check compute connectivity before creating nodes to avoid + # 120-second timeout when a remote compute is unreachable + disconnected = self._get_disconnected_computes() + if disconnected: + compute_names = ", ".join([f"'{c.name}'" for c in disconnected]) + raise ControllerError( + f"Cannot open project '{self.name}': {len(disconnected)} compute(s) are disconnected: {compute_names}. " + f"Please check the connection and try again." + ) + for node in topology.get("nodes", []): compute = self.controller.get_compute(node.pop("compute_id")) name = node.pop("name") @@ -1109,8 +1327,8 @@ class Project: self, tmpdir, keep_compute_ids=True, - allow_all_nodes=True, - reset_mac_addresses=reset_mac_addresses, + include_snapshots=True, + allow_all_nodes=True ) # export the project to a temporary location @@ -1120,13 +1338,15 @@ class Project: async for chunk in zstream: await f.write(chunk) - # import the temporary project + new_project_id = str(uuid.uuid4()) + # import the duplicated project with open(project_path, "rb") as f: project = await import_project( self._controller, - str(uuid.uuid4()), + new_project_id, f, name=name, + reset_mac_addresses=reset_mac_addresses, keep_compute_ids=True ) @@ -1139,6 +1359,62 @@ class Project: return project + async def _fast_duplication(self, name=None, location=None, reset_mac_addresses=True): + """ + Fast duplication of a project. + + Copy the project files directly rather than in an import-export fashion. + + :param name: Name of the new project. A new one will be generated in case of conflicts + :param location: Parent directory of the new project + :param reset_mac_addresses: Reset MAC addresses for the duplicated project + """ + + # remote replication is not supported with remote computes + for compute in self.computes: + if compute.id != "local": + log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id)) + return None + # work dir + p_work = pathlib.Path(location or self.path).parent.absolute() + t0 = time.time() + new_project_id = str(uuid.uuid4()) + if location: + new_project_path = p_work.joinpath(location) + else: + new_project_path = p_work.joinpath(new_project_id) + # copy dir + await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True) + log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)) + topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes()) + project_name = name or topology["name"] + # If the project name is already used we generate a new one + project_name = self.controller.get_free_project_name(project_name) + topology["name"] = project_name + # To avoid unexpected behavior (project start without manual operations just after import) + topology["auto_start"] = False + topology["auto_open"] = False + topology["auto_close"] = False + + # regenerate IDs for the duplicated project + regenerate_topology_ids(topology, new_project_path, reset_mac_addresses) + + # dump the updated .gns3 project file + dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name)) + topology["project_id"] = new_project_id + with open(dot_gns3_path, "w+") as f: + json.dump(topology, f, indent=4, sort_keys=True) + + # update the snapshots with new IDs + snapshots_dir = os.path.join(new_project_path, "snapshots") + if os.path.isdir(snapshots_dir): + await update_snapshots(snapshots_dir, new_project_path, project_name, new_project_id) + + os.remove(new_project_path.joinpath('{}.gns3'.format(self.name))) + project = await self.controller.load_project(dot_gns3_path, load=False) + log.info("Project '{}': fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0)) + return project + def is_running(self): """ If a node is started or paused return True @@ -1263,9 +1539,6 @@ class Project: :returns: New node """ - if node.status != "stopped" and not node.is_always_running(): - raise ControllerError("Cannot duplicate node data while the node is running") - data = copy.deepcopy(node.asdict(topology_dump=True)) # Some properties like internal ID should not be duplicated for unique_property in ( @@ -1286,7 +1559,13 @@ class Project: data["z"] = z data["locked"] = False # duplicated node must not be locked new_node_uuid = str(uuid.uuid4()) - new_node = await self.add_node(node.compute, node.name, new_node_uuid, node_type=node_type, **data) + new_node = await self.add_node( + node.compute, + node.name, + new_node_uuid, + node_type=node_type, + **data + ) try: await node.post("/duplicate", timeout=None, data={"destination_node_id": new_node_uuid}) except ControllerNotFoundError: @@ -1327,73 +1606,9 @@ class Project: "show_interface_labels": self._show_interface_labels, "supplier": self._supplier, "variables": self._variables, + "created_by": self._created_by, } def __repr__(self): return f"" - async def _fast_duplication(self, name=None, reset_mac_addresses=True): - """ - Fast duplication of a project. - - Copy the project files directly rather than in an import-export fashion. - - :param name: Name of the new project. A new one will be generated in case of conflicts - :param reset_mac_addresses: Reset MAC addresses for the new project - """ - - # remote replication is not supported with remote computes - for compute in self.computes: - if compute.id != "local": - log.warning("Fast duplication is not supported with remote compute: '{}'".format(compute.id)) - return None - # work dir - p_work = pathlib.Path(self.path).parent.absolute() - t0 = time.time() - new_project_id = str(uuid.uuid4()) - new_project_path = p_work.joinpath(new_project_id) - # copy dir - await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True) - log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0)) - topology = json.loads(new_project_path.joinpath('{}.gns3'.format(self.name)).read_bytes()) - project_name = name or topology["name"] - # If the project name is already used we generate a new one - project_name = self.controller.get_free_project_name(project_name) - topology["name"] = project_name - # To avoid unexpected behavior (project start without manual operations just after import) - topology["auto_start"] = False - topology["auto_open"] = False - topology["auto_close"] = False - # change node ID - node_old_to_new = {} - for node in topology["topology"]["nodes"]: - new_node_id = str(uuid.uuid4()) - if "node_id" in node: - node_old_to_new[node["node_id"]] = new_node_id - _move_node_file(new_project_path, node["node_id"], new_node_id) - node["node_id"] = new_node_id - if reset_mac_addresses: - if "properties" in node: - for prop, value in node["properties"].items(): - # reset the MAC address - if prop in ("mac_addr", "mac_address"): - node["properties"][prop] = None - # change link ID - for link in topology["topology"]["links"]: - link["link_id"] = str(uuid.uuid4()) - for node in link["nodes"]: - node["node_id"] = node_old_to_new[node["node_id"]] - # Generate new drawings id - for drawing in topology["topology"]["drawings"]: - drawing["drawing_id"] = str(uuid.uuid4()) - - # And we dump the updated.gns3 - dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name)) - topology["project_id"] = new_project_id - with open(dot_gns3_path, "w+") as f: - json.dump(topology, f, indent=4) - - os.remove(new_project_path.joinpath('{}.gns3'.format(self.name))) - project = await self.controller.load_project(dot_gns3_path, load=False) - log.info("Project '{}' fast duplicated in {:.4f} seconds".format(project.name, time.time() - t0)) - return project diff --git a/gns3server/controller/snapshot.py b/gns3server/controller/snapshot.py index 5491bcbbf..ce98dcbe0 100644 --- a/gns3server/controller/snapshot.py +++ b/gns3server/controller/snapshot.py @@ -36,32 +36,47 @@ import logging log = logging.getLogger(__name__) +# Used to extract the date and time from the filename +FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S" + +# Used to create a description of the snapshot with a human-readable date and time +DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S" + class Snapshot: """ A snapshot object """ - def __init__(self, project, name=None, filename=None): + def __init__(self, project, snapshot_id=None, name=None, filename=None, created_at=None, description=None): assert filename or name, "You need to pass a name or a filename" - self._id = str( - uuid.uuid4() - ) # We don't need to keep id between project loading because they are use only as key for operation like delete, update.. but have no impact on disk + if snapshot_id: + self._id = snapshot_id + else: + self._id = str(uuid.uuid4()) + self._project = project if name: self._name = name - self._created_at = datetime.now(timezone.utc).timestamp() - filename = ( - self._name - + "_" - + datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime("%d%m%y_%H%M%S") - + ".gns3project" - ) + + if created_at: + self._created_at = created_at + else: + self._created_at = int(datetime.now(timezone.utc).timestamp()) + if not filename: + filename = self._name + ".gns3snapshot" else: self._name = filename.rsplit("_", 2)[0] datestring = filename.replace(self._name + "_", "").split(".")[0] - self._created_at = (datetime.strptime(datestring, "%d%m%y_%H%M%S").replace(tzinfo=timezone.utc).timestamp()) + self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()) + + if not description: + date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT) + description = "Snapshot '{}' taken on {}".format(self._name, date) + + self._description = description + self._filename = filename self._path = os.path.join(project.path, "snapshots", filename) @property @@ -72,13 +87,17 @@ class Snapshot: def name(self): return self._name + @property + def description(self): + return self._description + @property def path(self): return self._path @property def created_at(self): - return int(self._created_at) + return self._created_at async def create(self): """ @@ -117,14 +136,22 @@ class Snapshot: await self._project.close(ignore_notification=True) try: + begin = time.time() # delete the current project files project_files_path = os.path.join(self._project.path, "project-files") if os.path.exists(project_files_path): - await wait_run_in_executor(shutil.rmtree, project_files_path) + await wait_run_in_executor(shutil.rmtree, project_files_path, ignore_errors=True) with open(self._path, "rb") as f: - project = await import_project(self._project.controller, self._project.id, f, location=self._project.path, - auto_start=self._project.auto_start, auto_open=self._project.auto_open, - auto_close=self._project.auto_close) + project = await import_project( + self._project.controller, + self._project.id, + f, + location=self._project.path, + auto_start=self._project.auto_start, + auto_open=self._project.auto_open, + auto_close=self._project.auto_close + ) + log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin)) except (OSError, PermissionError) as e: raise ControllerError(str(e)) await project.open() @@ -135,6 +162,8 @@ class Snapshot: return { "snapshot_id": self._id, "name": self._name, - "created_at": int(self._created_at), - "project_id": self._project.id, + "created_at": self._created_at, + "description": self._description, + "filename": self._filename, + "project_id": self._project.id } diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py index 417848fd1..150283c39 100644 --- a/gns3server/controller/topology.py +++ b/gns3server/controller/topology.py @@ -87,6 +87,7 @@ def project_to_topology(project): "show_interface_labels": project.show_interface_labels, "variables": project.variables, "supplier": project.supplier, + "created_by": project.created_by, "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "type": "topology", "revision": GNS3_FILE_FORMAT_REVISION, @@ -174,7 +175,8 @@ def load_topology(path): # Version GNS3 2.2 dev (for project created with 2.2dev). # Appliance ID has been replaced by Template ID if topo["revision"] == 9: - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: if "appliance_id" in node: node["template_id"] = node["appliance_id"] del node["appliance_id"] @@ -217,7 +219,8 @@ def _convert_2_2_0(topo, topo_path): topo["revision"] = 10 - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: if "properties" in node: if node["node_type"] in ("qemu", "docker") and not is_rfc1123_hostname_valid(node["name"]): new_name = to_rfc1123_hostname(node["name"]) @@ -245,7 +248,8 @@ def _convert_2_1_0(topo, topo_path): # to avoid overlapping grids topo["drawing_grid_size"] = topo["grid_size"] - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: # make sure console_type is not None but "none" string if "console_type" in node and node["console_type"] is None: node["console_type"] = "none" @@ -271,7 +275,8 @@ def _convert_2_0_0(topo, topo_path): """ topo["revision"] = 8 - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: if "properties" in node: if node["node_type"] == "vpcs": if "startup_script_path" in node["properties"]: @@ -300,7 +305,8 @@ def _convert_2_0_0_beta_2(topo, topo_path): topo_dir = os.path.dirname(topo_path) topo["revision"] = 7 - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: if node["node_type"] == "dynamips": node_id = node["node_id"] dynamips_id = node["properties"]["dynamips_id"] @@ -327,7 +333,8 @@ def _convert_2_0_0_alpha(topo, topo_path): * No more option for VMware / VirtualBox remote console (always use telnet) """ topo["revision"] = 6 - for node in topo.get("topology", {}).get("nodes", []): + nodes = topo.get("topology", {}).get("nodes", []) + for node in nodes: if node.get("console_type") == "serial": node["console_type"] = "telnet" if node["node_type"] in ("vmware", "virtualbox"): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 4788f61ab..330e6a4f7 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -18,6 +18,7 @@ from .controller_error import ControllerError, ControllerNotFoundError from .link import Link +from .node_types import BUILTIN_NODE_TYPES class UDPLink(Link): @@ -32,6 +33,18 @@ class UDPLink(Link): Use for the debug exports """ return self._link_data + + def _get_node_filters(self, node1, node2): + """ + Determine which node gets the active filters applied. + + :returns: Tuple of (node1_filters, node2_filters) + """ + filter_node = self._get_filter_node() + return ( + self.get_active_filters() if filter_node == node1 else {}, + self.get_active_filters() if filter_node == node2 else {}, + ) async def create(self): """ @@ -57,13 +70,7 @@ class UDPLink(Link): response = await node2.compute.post(f"/projects/{self._project.id}/ports/udp") self._node2_port = response.json["udp_port"] - node1_filters = {} - node2_filters = {} - filter_node = self._get_filter_node() - if filter_node == node1: - node1_filters = self.get_active_filters() - elif filter_node == node2: - node2_filters = self.get_active_filters() + node1_filters, node2_filters = self._get_node_filters(node1, node2) # Create the tunnel on both side self._link_data.append( @@ -108,13 +115,7 @@ class UDPLink(Link): node1 = self._nodes[0]["node"] node2 = self._nodes[1]["node"] - node1_filters = {} - node2_filters = {} - filter_node = self._get_filter_node() - if filter_node == node1: - node1_filters = self.get_active_filters() - elif filter_node == node2: - node2_filters = self.get_active_filters() + node1_filters, node2_filters = self._get_node_filters(node1, node2) adapter_number1 = self._nodes[0]["adapter_number"] port_number1 = self._nodes[0]["port_number"] @@ -174,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 """ @@ -188,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): """ @@ -213,18 +214,16 @@ class UDPLink(Link): :returns: Node where the capture should run """ - ALWAYS_RUNNING_NODES_TYPE = ("cloud", "nat", "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch") - for node in self._nodes: if ( node["node"].compute.id == "local" - and node["node"].node_type in ALWAYS_RUNNING_NODES_TYPE + and node["node"].node_type in BUILTIN_NODE_TYPES and node["node"].status == "started" ): return node for node in self._nodes: - if node["node"].node_type in ALWAYS_RUNNING_NODES_TYPE and node["node"].status == "started": + if node["node"].node_type in BUILTIN_NODE_TYPES and node["node"].status == "started": return node for node in self._nodes: diff --git a/gns3server/core/tasks.py b/gns3server/core/tasks.py index 24de59405..99ab6c122 100644 --- a/gns3server/core/tasks.py +++ b/gns3server/core/tasks.py @@ -17,8 +17,8 @@ import asyncio -from typing import Callable from fastapi import FastAPI +from contextlib import asynccontextmanager from gns3server.controller import Controller from gns3server.config import Config @@ -32,68 +32,78 @@ import logging log = logging.getLogger(__name__) +auto_discover_images_task_handle = None -def create_startup_handler(app: FastAPI) -> Callable: + +@asynccontextmanager +async def lifespan(app: FastAPI): + + await startup(app) + yield + await shutdown(app) + + +async def startup(app: FastAPI) -> None: """ Tasks to be performed when the server is starting. """ - async def start_app() -> None: - loop = asyncio.get_event_loop() - logger = logging.getLogger("asyncio") - logger.setLevel(logging.ERROR) + loop = asyncio.get_event_loop() + logger = logging.getLogger("asyncio") + logger.setLevel(logging.ERROR) - if log.getEffectiveLevel() == logging.DEBUG: - # On debug version we enable info that - # coroutine is not called in a way await/await - loop.set_debug(True) + if log.getEffectiveLevel() == logging.DEBUG: + # On debug version we enable info that + # coroutine is not called in a way await/await + loop.set_debug(True) - # connect to the database - await connect_to_db(app) + # connect to the database + await connect_to_db(app) - # retrieve the computes from the database - computes = await get_computes(app) + # retrieve the computes from the database + computes = await get_computes(app) - await Controller.instance().start(computes) + await Controller.instance().start(computes) - # Because with a large image collection - # without md5sum already computed we start the - # computing with server start - from gns3server.compute.qemu import Qemu + # Because with a large image collection + # without md5sum already computed we start the + # computing with server start + from gns3server.compute.qemu import Qemu - if Config.instance().settings.Server.auto_discover_images is True: - # Start the discovering new images on file system 5 seconds after the server has started - # to give it a chance to process API requests - loop.call_later(5, asyncio.create_task, discover_images_on_filesystem(app)) + if Config.instance().settings.Server.auto_discover_images is True: + # Start the discovering new images on file system 5 seconds after the server has started + # to give it a chance to process API requests + global auto_discover_images_task_handle + auto_discover_images_task_handle = asyncio.get_event_loop().call_later( + 5, + lambda: asyncio.create_task(discover_images_on_filesystem(app)) + ) - for module in MODULES: - log.debug(f"Loading module {module.__name__}") - m = module.instance() - m.port_manager = PortManager.instance() - - return start_app + for module in MODULES: + log.debug(f"Loading module {module.__name__}") + m = module.instance() + m.port_manager = PortManager.instance() -def create_shutdown_handler(app: FastAPI) -> Callable: +async def shutdown(app: FastAPI) -> None: """ Tasks to be performed when the server is exiting. """ - async def shutdown_handler() -> None: - await HTTPClient.close_session() - await Controller.instance().stop() + if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled(): + auto_discover_images_task_handle.cancel() + await HTTPClient.close_session() + await Controller.instance().stop() - for module in MODULES: - log.debug(f"Unloading module {module.__name__}") - m = module.instance() - await m.unload() + for module in MODULES: + log.debug(f"Unloading module {module.__name__}") + m = module.instance() + await m.unload() - if PortManager.instance().tcp_ports: - log.warning(f"TCP ports are still used {PortManager.instance().tcp_ports}") + if PortManager.instance().tcp_ports: + log.warning(f"TCP ports are still used {PortManager.instance().tcp_ports}") - if PortManager.instance().udp_ports: - log.warning(f"UDP ports are still used {PortManager.instance().udp_ports}") + if PortManager.instance().udp_ports: + log.warning(f"UDP ports are still used {PortManager.instance().udp_ports}") - await disconnect_from_db(app) - - return shutdown_handler + await disconnect_from_db(app) diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py index 3861711d3..6fa2f8374 100644 --- a/gns3server/crash_report.py +++ b/gns3server/crash_report.py @@ -58,7 +58,7 @@ class CrashReport: Report crash to a third party service """ - DSN = "https://1c0ae79fd51b77c674718c6942337300@o19455.ingest.us.sentry.io/38482" + DSN = "https://2fe45e33c13e7f8cc1f5483e323765f8@o19455.ingest.us.sentry.io/38482" _instance = None def __init__(self): diff --git a/gns3server/custom_symbols/AlpiNet.svg b/gns3server/custom_symbols/AlpiNet.svg new file mode 100644 index 000000000..985d9af01 --- /dev/null +++ b/gns3server/custom_symbols/AlpiNet.svg @@ -0,0 +1,40 @@ + + + + diff --git a/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg b/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg new file mode 100755 index 000000000..7bce628cf --- /dev/null +++ b/gns3server/custom_symbols/home-assistant-logomark-color-on-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py index ed7c01425..5e7afb07b 100644 --- a/gns3server/db/models/__init__.py +++ b/gns3server/db/models/__init__.py @@ -23,6 +23,7 @@ from .privileges import Privilege from .computes import Compute from .images import Image from .pools import Resource, ResourcePool +from .llm_model_configs import LLMModelConfig from .templates import ( Template, CloudTemplate, diff --git a/gns3server/db/models/llm_model_configs.py b/gns3server/db/models/llm_model_configs.py new file mode 100644 index 000000000..77d65cbb7 --- /dev/null +++ b/gns3server/db/models/llm_model_configs.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# +# 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 . + +from sqlalchemy import Column, Boolean, ForeignKey, CheckConstraint, Index, Integer, String, text, JSON +from sqlalchemy.orm import relationship + +from .base import BaseTable, generate_uuid, GUID + +import logging + +log = logging.getLogger(__name__) + + +class LLMModelConfig(BaseTable): + """ + LLM model configuration for users and user groups. + Supports inheritance: users can inherit configs from their groups. + """ + + __tablename__ = "llm_model_configs" + + config_id = Column(GUID, primary_key=True, default=generate_uuid) + name = Column(String(100), nullable=False) # Configuration name (table-level for indexing) + model_type = Column(String(50), nullable=False) # Model type: text, vision, stt, tts, multimodal, etc. + config = Column(JSON, nullable=False) # Config fields: provider, base_url, model, temperature, api_key, etc. + user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True) + group_id = Column(GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"), nullable=True) + is_default = Column(Boolean, default=False, nullable=False) + version = Column(Integer, default=0, nullable=False) # Optimistic locking version + + # Relationships + user = relationship("User", backref="llm_model_configs") + group = relationship("UserGroup", backref="llm_model_configs") + + # Constraints + __table_args__ = ( + # Ensure a config belongs to either a user or a group, not both + CheckConstraint( + "(user_id IS NOT NULL AND group_id IS NULL) OR " + "(user_id IS NULL AND group_id IS NOT NULL)", + name="single_owner_check" + ), + # Validate model_type values + CheckConstraint( + "model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')", + name="valid_model_type_check" + ), + # Each user can have at most one default config (partial unique index, PostgreSQL only) + Index("unique_user_default", "user_id", unique=True, + postgresql_where=text("is_default = TRUE AND user_id IS NOT NULL")), + # Each group can have at most one default config (partial unique index, PostgreSQL only) + Index("unique_group_default", "group_id", unique=True, + postgresql_where=text("is_default = TRUE AND group_id IS NOT NULL")), + # Indexes for efficient queries + Index("idx_llm_model_configs_user_id", "user_id"), + Index("idx_llm_model_configs_group_id", "group_id"), + Index("idx_llm_model_configs_model_type", "model_type"), + # GIN index for JSON config (PostgreSQL only) + Index("idx_llm_model_configs_config", "config", postgresql_using="gin"), + ) + + def __repr__(self): + owner = f"user_{self.user_id}" if self.user_id else f"group_{self.group_id}" + return f"" diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index c43642295..f0100352c 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -16,7 +16,7 @@ # along with this program. If not, see . -from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType +from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, JSON from sqlalchemy.orm import relationship from .base import BaseTable, generate_uuid, GUID @@ -36,6 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) + tags = Column(JSON) compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") @@ -50,7 +51,7 @@ class CloudTemplate(Template): __tablename__ = "cloud_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) - ports_mapping = Column(PickleType) + ports_mapping = Column(JSON) remote_console_host = Column(String) remote_console_port = Column(Integer) remote_console_type = Column(String) @@ -76,10 +77,10 @@ class DockerTemplate(Template): console_http_path = Column(String) console_resolution = Column(String) extra_hosts = Column(String) - extra_volumes = Column(PickleType) + extra_volumes = Column(JSON) memory = Column(Integer) cpus = Column(Float) - custom_adapters = Column(PickleType) + custom_adapters = Column(JSON) __mapper_args__ = {"polymorphic_identity": "docker", "polymorphic_load": "selectin"} @@ -132,7 +133,7 @@ class EthernetHubTemplate(Template): __tablename__ = "ethernet_hub_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) - ports_mapping = Column(PickleType) + ports_mapping = Column(JSON) __mapper_args__ = {"polymorphic_identity": "ethernet_hub", "polymorphic_load": "selectin"} @@ -142,7 +143,7 @@ class EthernetSwitchTemplate(Template): __tablename__ = "ethernet_switch_templates" template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True) - ports_mapping = Column(PickleType) + ports_mapping = Column(JSON) console_type = Column(String) __mapper_args__ = {"polymorphic_identity": "ethernet_switch", "polymorphic_load": "selectin"} @@ -210,7 +211,7 @@ class QemuTemplate(Template): cpu_throttling = Column(Integer) process_priority = Column(String) options = Column(String) - custom_adapters = Column(PickleType) + custom_adapters = Column(JSON) __mapper_args__ = {"polymorphic_identity": "qemu", "polymorphic_load": "selectin"} @@ -233,7 +234,7 @@ class VirtualBoxTemplate(Template): on_close = Column(String) console_type = Column(String) console_auto_start = Column(Boolean) - custom_adapters = Column(PickleType) + custom_adapters = Column(JSON) __mapper_args__ = {"polymorphic_identity": "virtualbox", "polymorphic_load": "selectin"} @@ -255,7 +256,7 @@ class VMwareTemplate(Template): on_close = Column(String) console_type = Column(String) console_auto_start = Column(Boolean) - custom_adapters = Column(PickleType) + custom_adapters = Column(JSON) __mapper_args__ = {"polymorphic_identity": "vmware", "polymorphic_load": "selectin"} diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py index f68bc49fc..5c14c3c15 100644 --- a/gns3server/db/models/users.py +++ b/gns3server/db/models/users.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, event +from sqlalchemy import Table, Boolean, Column, Integer, String, DateTime, ForeignKey, event from sqlalchemy.orm import relationship from .base import Base, BaseTable, generate_uuid, GUID @@ -45,6 +45,7 @@ class User(BaseTable): full_name = Column(String) hashed_password = Column(String) last_login = Column(DateTime) + token_version = Column(Integer, default=0, nullable=False, server_default="0") is_active = Column(Boolean, default=True) is_superadmin = Column(Boolean, default=False) groups = relationship("UserGroup", secondary=user_group_map, back_populates="users") diff --git a/gns3server/db/repositories/computes.py b/gns3server/db/repositories/computes.py index 578fb0c02..181b92aa6 100644 --- a/gns3server/db/repositories/computes.py +++ b/gns3server/db/repositories/computes.py @@ -69,10 +69,8 @@ class ComputesRepository(BaseRepository): async def update_compute(self, compute_id: UUID, compute_update: schemas.ComputeUpdate) -> Optional[models.Compute]: update_values = compute_update.model_dump(exclude_unset=True) - - password = compute_update.password - if password: - update_values["password"] = password.get_secret_value() + if compute_update.password is not None: + update_values["password"] = compute_update.password.get_secret_value() query = update(models.Compute).\ where(models.Compute.compute_id == compute_id).\ diff --git a/gns3server/db/repositories/images.py b/gns3server/db/repositories/images.py index 9d83e72ee..8f6c64dc9 100644 --- a/gns3server/db/repositories/images.py +++ b/gns3server/db/repositories/images.py @@ -50,14 +50,20 @@ class ImagesRepository(BaseRepository): result = await self._db_session.execute(query) return result.scalars().one_or_none() - async def get_image_by_checksum(self, checksum: str) -> Optional[models.Image]: + async def get_image_by_checksum(self, checksum: str, image_dir: str = None) -> Optional[models.Image]: """ Get an image by its checksum. """ - query = select(models.Image).where(models.Image.checksum == checksum) - result = await self._db_session.execute(query) - return result.scalars().first() + if image_dir: + query = select(models.Image).\ + where(models.Image.checksum == checksum, models.Image.path.startswith(image_dir)) + result = await self._db_session.execute(query) + return result.scalars().one_or_none() + else: + query = select(models.Image).where(models.Image.checksum == checksum) + result = await self._db_session.execute(query) + return result.scalars().first() async def get_images(self, image_type=None) -> List[models.Image]: """ @@ -114,7 +120,7 @@ class ImagesRepository(BaseRepository): await self._db_session.execute(query) await self._db_session.commit() - image_db = await self.get_image_by_checksum(checksum) + image_db = await self.get_image(image_path) if image_db: await self._db_session.refresh(image_db) # force refresh of updated_at value return image_db diff --git a/gns3server/db/repositories/llm_model_configs.py b/gns3server/db/repositories/llm_model_configs.py new file mode 100644 index 000000000..f5ed28b46 --- /dev/null +++ b/gns3server/db/repositories/llm_model_configs.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python +# +# 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 . + +from uuid import UUID +from typing import Optional, List, Dict, Any +from sqlalchemy import select, update, delete, and_ +from datetime import datetime + +import logging + +from .base import BaseRepository +import gns3server.db.models as models + +log = logging.getLogger(__name__) + + +class LLMModelConfigsRepository(BaseRepository): + """Repository for LLM model configurations with inheritance support.""" + + @staticmethod + def _hide_api_key(config: Dict[str, Any]) -> Dict[str, Any]: + """ + Remove API key from config dict for security. + API keys should NEVER be returned via API endpoints. + + :param config: Configuration dictionary + :return: Configuration dictionary with api_key set to None + """ + config_copy = config.copy() + if "api_key" in config_copy: + config_copy["api_key"] = None + return config_copy + + # User configuration methods + + async def get_user_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]: + """Get a user's LLM model configuration by ID.""" + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.user_id.isnot(None) + ) + ) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def get_user_configs(self, user_id: UUID) -> List[models.LLMModelConfig]: + """Get all LLM model configurations for a user.""" + query = select(models.LLMModelConfig).where( + models.LLMModelConfig.user_id == user_id + ).order_by(models.LLMModelConfig.created_at) + result = await self._db_session.execute(query) + return result.scalars().all() + + async def get_user_default_config(self, user_id: UUID) -> Optional[models.LLMModelConfig]: + """Get a user's default LLM model configuration.""" + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.user_id == user_id, + models.LLMModelConfig.is_default + ) + ) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def create_user_config( + self, + user_id: UUID, + name: str, + model_type: str, + config_data: Dict[str, Any], + is_default: bool = False + ) -> models.LLMModelConfig: + """Create a new LLM model configuration for a user.""" + # Encrypt API key if present + from gns3server.utils.encryption import encrypt + config_to_store = config_data.copy() + if "api_key" in config_to_store and config_to_store["api_key"]: + try: + config_to_store["api_key"] = encrypt(config_to_store["api_key"]) + except Exception as e: + log.error(f"Failed to encrypt API key: {e}") + raise + + # Set timestamps manually for SQLite compatibility + now = datetime.utcnow() + db_config = models.LLMModelConfig( + name=name, + model_type=model_type, + config=config_to_store, + user_id=user_id, + is_default=is_default, + created_at=now, + updated_at=now + ) + self._db_session.add(db_config) + await self._db_session.commit() + await self._db_session.refresh(db_config) + return db_config + + async def update_user_config( + self, + config_id: UUID, + user_id: UUID, + updates: Dict[str, Any], + expected_version: Optional[int] = None + ) -> Optional[models.LLMModelConfig]: + """ + Update a user's LLM model configuration. + Uses optimistic locking to prevent concurrent modifications. + + :param config_id: Configuration ID + :param user_id: User ID + :param updates: Dictionary of fields to update + :param expected_version: Expected version for optimistic locking (raises error if mismatch) + :raises ValueError: If version mismatch (concurrent modification) + """ + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.user_id == user_id + ) + ) + result = await self._db_session.execute(query) + db_config = result.scalars().first() + + if not db_config: + return None + + # Check optimistic lock if expected_version is provided + if expected_version is not None and db_config.version != expected_version: + raise ValueError( + f"Concurrent modification detected. Expected version {expected_version}, " + f"but current version is {db_config.version}. Please retry." + ) + + # Encrypt API key if present in updates + from gns3server.utils.encryption import encrypt + + # Table-level fields + if "name" in updates and updates["name"] is not None: + db_config.name = updates["name"] + if "model_type" in updates and updates["model_type"] is not None: + db_config.model_type = updates["model_type"] + if "is_default" in updates and updates["is_default"] is not None: + db_config.is_default = updates["is_default"] + + # Config JSONB fields + config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"] + current_config = db_config.config.copy() + + for field in config_fields: + if field in updates and updates[field] is not None: + if field == "api_key": + # Encrypt API key + try: + current_config[field] = encrypt(updates[field]) + except Exception as e: + log.error(f"Failed to encrypt API key: {e}") + raise + else: + current_config[field] = updates[field] + + # Handle extra config fields + for key, value in updates.items(): + if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields: + if value is not None: + current_config[key] = value + + db_config.config = current_config + # Increment version for optimistic locking + db_config.version = db_config.version + 1 + # Update timestamp manually for SQLite compatibility + db_config.updated_at = datetime.utcnow() + await self._db_session.commit() + await self._db_session.refresh(db_config) + return db_config + + async def delete_user_config(self, config_id: UUID, user_id: UUID) -> bool: + """Delete a user's LLM model configuration.""" + query = delete(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.user_id == user_id + ) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + async def set_user_default_config(self, user_id: UUID, config_id: UUID) -> bool: + """Set a user's default LLM model configuration.""" + now = datetime.utcnow() + # First, unset current default + await self._db_session.execute( + update(models.LLMModelConfig) + .where( + and_( + models.LLMModelConfig.user_id == user_id, + models.LLMModelConfig.is_default + ) + ) + .values(is_default=False, updated_at=now) + ) + + # Set new default + query = update(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.user_id == user_id + ) + ).values(is_default=True, updated_at=now) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + # Group configuration methods + + async def get_group_config(self, config_id: UUID) -> Optional[models.LLMModelConfig]: + """Get a group's LLM model configuration by ID.""" + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.group_id.isnot(None) + ) + ) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def get_group_configs(self, group_id: UUID) -> List[models.LLMModelConfig]: + """Get all LLM model configurations for a group.""" + query = select(models.LLMModelConfig).where( + models.LLMModelConfig.group_id == group_id + ).order_by(models.LLMModelConfig.created_at) + result = await self._db_session.execute(query) + return result.scalars().all() + + async def get_group_default_config(self, group_id: UUID) -> Optional[models.LLMModelConfig]: + """Get a group's default LLM model configuration.""" + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.group_id == group_id, + models.LLMModelConfig.is_default + ) + ) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def create_group_config( + self, + group_id: UUID, + name: str, + model_type: str, + config_data: Dict[str, Any], + is_default: bool = False + ) -> models.LLMModelConfig: + """Create a new LLM model configuration for a group.""" + # Encrypt API key if present + from gns3server.utils.encryption import encrypt + config_to_store = config_data.copy() + if "api_key" in config_to_store and config_to_store["api_key"]: + try: + config_to_store["api_key"] = encrypt(config_to_store["api_key"]) + except Exception as e: + log.error(f"Failed to encrypt API key: {e}") + raise + + # Set timestamps manually for SQLite compatibility + now = datetime.utcnow() + db_config = models.LLMModelConfig( + name=name, + model_type=model_type, + config=config_to_store, + group_id=group_id, + is_default=is_default, + created_at=now, + updated_at=now + ) + self._db_session.add(db_config) + await self._db_session.commit() + await self._db_session.refresh(db_config) + return db_config + + async def update_group_config( + self, + config_id: UUID, + group_id: UUID, + updates: Dict[str, Any], + expected_version: Optional[int] = None + ) -> Optional[models.LLMModelConfig]: + """ + Update a group's LLM model configuration. + Uses optimistic locking to prevent concurrent modifications. + + :param config_id: Configuration ID + :param group_id: Group ID + :param updates: Dictionary of fields to update + :param expected_version: Expected version for optimistic locking (raises error if mismatch) + :raises ValueError: If version mismatch (concurrent modification) + """ + query = select(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.group_id == group_id + ) + ) + result = await self._db_session.execute(query) + db_config = result.scalars().first() + + if not db_config: + return None + + # Check optimistic lock if expected_version is provided + if expected_version is not None and db_config.version != expected_version: + raise ValueError( + f"Concurrent modification detected. Expected version {expected_version}, " + f"but current version is {db_config.version}. Please retry." + ) + + # Encrypt API key if present in updates + from gns3server.utils.encryption import encrypt + + # Table-level fields + if "name" in updates and updates["name"] is not None: + db_config.name = updates["name"] + if "model_type" in updates and updates["model_type"] is not None: + db_config.model_type = updates["model_type"] + if "is_default" in updates and updates["is_default"] is not None: + db_config.is_default = updates["is_default"] + + # Config JSONB fields + config_fields = ["provider", "base_url", "model", "temperature", "api_key", "max_tokens"] + current_config = db_config.config.copy() + + for field in config_fields: + if field in updates and updates[field] is not None: + if field == "api_key": + # Encrypt API key + try: + current_config[field] = encrypt(updates[field]) + except Exception as e: + log.error(f"Failed to encrypt API key: {e}") + raise + else: + current_config[field] = updates[field] + + # Handle extra config fields + for key, value in updates.items(): + if key not in ["name", "model_type", "is_default", "expected_version"] + config_fields: + if value is not None: + current_config[key] = value + + db_config.config = current_config + # Increment version for optimistic locking + db_config.version = db_config.version + 1 + # Update timestamp manually for SQLite compatibility + db_config.updated_at = datetime.utcnow() + await self._db_session.commit() + await self._db_session.refresh(db_config) + return db_config + + async def delete_group_config(self, config_id: UUID, group_id: UUID) -> bool: + """Delete a group's LLM model configuration.""" + query = delete(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.group_id == group_id + ) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + async def set_group_default_config(self, group_id: UUID, config_id: UUID) -> bool: + """Set a group's default LLM model configuration.""" + now = datetime.utcnow() + # First, unset current default + await self._db_session.execute( + update(models.LLMModelConfig) + .where( + and_( + models.LLMModelConfig.group_id == group_id, + models.LLMModelConfig.is_default + ) + ) + .values(is_default=False, updated_at=now) + ) + + # Set new default + query = update(models.LLMModelConfig).where( + and_( + models.LLMModelConfig.config_id == config_id, + models.LLMModelConfig.group_id == group_id + ) + ).values(is_default=True, updated_at=now) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + # Inheritance methods + + async def get_user_effective_configs( + self, + user_id: UUID, + current_user_id: Optional[UUID] = None, + current_user_is_superadmin: bool = False + ) -> Dict[str, Any]: + """ + Get user's effective configurations (own + inherited from groups). + Returns a dict with 'configs' list and 'default_config'. + + API key visibility: ALWAYS hidden for security (never returned via API). + """ + # Get user's own configs + user_configs = await self.get_user_configs(user_id) + + # Get user's groups + query = select(models.UserGroup).\ + join(models.UserGroup.users).\ + filter(models.User.user_id == user_id) + result = await self._db_session.execute(query) + user_groups = result.scalars().all() + + # Get group configs + group_configs_map = {} # group_id -> [configs] + group_names_map = {} # group_id -> group_name + for group in user_groups: + configs = await self.get_group_configs(group.user_group_id) + if configs: + group_configs_map[group.user_group_id] = configs + group_names_map[group.user_group_id] = group.name + + # Build result with API keys always hidden + configs_with_source = [] + default_config = None + + # Add user's configs + for config in user_configs: + # Always hide API key for security + config_dict = self._hide_api_key(config.config) + + configs_with_source.append({ + "config_id": config.config_id, + "name": config.name, + "model_type": config.model_type, + "config": config_dict, + "user_id": config.user_id, + "group_id": config.group_id, + "is_default": config.is_default, + "version": config.version, + "created_at": config.created_at, + "updated_at": config.updated_at, + "source": "user", + "group_name": None + }) + + # Add inherited group configs (always shown, regardless of user configs) + for group_id, configs in group_configs_map.items(): + for config in configs: + # Always hide API key for security + config_dict = self._hide_api_key(config.config) + + configs_with_source.append({ + "config_id": config.config_id, + "name": config.name, + "model_type": config.model_type, + "config": config_dict, + "user_id": config.user_id, + "group_id": config.group_id, + "is_default": config.is_default, + "version": config.version, + "created_at": config.created_at, + "updated_at": config.updated_at, + "source": "group", + "group_name": group_names_map[group_id] + }) + + # Select default_config with proper priority: + # 1. User's config marked with is_default: true + # 2. Group's config marked with is_default: true + # 3. First config in the list (user configs come first) + for config in configs_with_source: + if config["is_default"] and config["source"] == "user": + default_config = config + break + + if default_config is None: + for config in configs_with_source: + if config["is_default"] and config["source"] == "group": + default_config = config + break + + # Fallback to first config if no default is marked + if default_config is None and configs_with_source: + default_config = configs_with_source[0] + + return { + "configs": configs_with_source, + "default_config": default_config + } diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py index ea53c2e33..ee005b2bf 100644 --- a/gns3server/db/repositories/users.py +++ b/gns3server/db/repositories/users.py @@ -113,6 +113,18 @@ class UsersRepository(BaseRepository): await self._db_session.refresh(user_db) # force refresh of updated_at value return user_db + async def logout_user(self, user_id: UUID) -> None: + """ + Increment token_version to invalidate all existing tokens for the user. + """ + + query = update(models.User).\ + where(models.User.user_id == user_id).\ + values(token_version=models.User.token_version + 1) + + await self._db_session.execute(query) + await self._db_session.commit() + async def delete_user(self, user_id: UUID) -> bool: """ Delete a user. diff --git a/gns3server/db/tasks.py b/gns3server/db/tasks.py index bfb7dac48..887ff9bf1 100644 --- a/gns3server/db/tasks.py +++ b/gns3server/db/tasks.py @@ -21,10 +21,11 @@ import os from fastapi import FastAPI from pydantic import ValidationError -from typing import List +from typing import List, Optional from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError +import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from alembic import command, config from alembic.script import ScriptDirectory @@ -87,12 +88,51 @@ async def connect_to_db(app: FastAPI) -> None: current_rev, head_rev = await conn.run_sync(check_revision, alembic_cfg) log.info(f"Current database revision is {current_rev}") if current_rev is None: - await conn.run_sync(Base.metadata.create_all) - await conn.run_sync(run_stamp, alembic_cfg) + # No version tracking found. Check if this is a truly new database + # or an old database that needs migration. + def check_db_state(connection): + # Check if llm_model_configs table exists + inspector = sa.inspect(connection) + tables = inspector.get_table_names() + + if 'users' not in tables: + return 'new' # Truly new database + + # Check for new feature columns that indicate this is already migrated + columns = [col['name'] for col in inspector.get_columns('users')] + if 'llm_model_configs' in tables: + # The llm_model_configs table already exists (created from code) + return 'new_with_llm_configs' + else: + # Old database without llm_model_configs table, needs migration + return 'old_needs_migration' + + db_state = await conn.run_sync(check_db_state) + + if db_state == 'new': + # Truly new database: create all tables and stamp + await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(run_stamp, alembic_cfg) + await conn.commit() + log.info("Created new database and stamped to head revision") + elif db_state == 'new_with_llm_configs': + # Database already has llm_model_configs table (from Base.metadata.create_all) + # Just stamp the version + await conn.run_sync(run_stamp, alembic_cfg) + await conn.commit() + log.info("Database has llm_model_configs table, stamped to head revision") + else: + # Old database without llm_model_configs table: run migrations + log.info("Old database detected, running migrations to add llm_model_configs table...") + await conn.run_sync(run_upgrade, alembic_cfg) + await conn.commit() + log.info("Database migrations completed successfully") elif current_rev != head_rev: # upgrade the database if needed + log.info(f"Upgrading database from revision {current_rev} to {head_rev}...") await conn.run_sync(run_upgrade, alembic_cfg) await conn.commit() + log.info("Database upgrade completed successfully") app.state._db_engine = engine except SQLAlchemyError as e: log.fatal(f"Error while connecting to database '{db_url}: {e}") @@ -269,3 +309,99 @@ async def monitor_images_on_filesystem(app: FastAPI): log.info(f"Discovered image '{image_path}' has been added to the database") except SQLAlchemyError as e: log.warning(f"Error while adding image '{image_path}' to the database: {e}") + + +async def get_user_llm_config_full(user_id: str, app: FastAPI) -> Optional[dict]: + """ + Get user's full LLM configuration with decrypted API key for Copilot. + + This is a system-level function that bypasses API security restrictions. + It retrieves the complete configuration including decrypted API keys, + even for inherited group configurations. + + Args: + user_id: User UUID + app: FastAPI application instance + + Returns: + Dictionary with LLM configuration (provider, model, api_key, etc.) + or None if not found. + """ + from uuid import UUID + from gns3server.db.repositories.llm_model_configs import LLMModelConfigsRepository + from gns3server.utils.encryption import decrypt, is_encrypted + + try: + user_uuid = UUID(user_id) if isinstance(user_id, str) else user_id + + async with AsyncSession(app.state._db_engine, expire_on_commit=False) as session: + repo = LLMModelConfigsRepository(session) + + # Get effective configs (own + inherited from groups) + result = await repo.get_user_effective_configs( + user_uuid, + current_user_id=user_uuid, # Viewing own config + current_user_is_superadmin=False + ) + + if not result or not result.get("default_config"): + log.warning(f"No default LLM configuration found for user {user_id}") + return None + + default_config = result["default_config"] + config_id = default_config["config_id"] + source = default_config["source"] # "user" or "group" + + # Get full config from database + if source == "user": + full_config = await repo.get_user_config(config_id) + else: + full_config = await repo.get_group_config(config_id) + + if not full_config: + log.error(f"Failed to retrieve full config from database: config_id={config_id}") + return None + + # Decrypt API key + config_data = full_config.config.copy() + if "api_key" in config_data and config_data["api_key"]: + try: + if is_encrypted(config_data["api_key"]): + config_data["api_key"] = decrypt(config_data["api_key"]) + log.debug(f"Successfully decrypted API key for user {user_id}") + except Exception as e: + log.error(f"Failed to decrypt API key: {e}") + config_data["api_key"] = None + + # Build configuration dict + llm_config = { + "config_id": str(full_config.config_id), + "name": full_config.name, + "model_type": str(full_config.model_type), + "source": source, + "group_name": default_config.get("group_name"), + "user_id": str(full_config.user_id) if full_config.user_id else None, + "group_id": str(full_config.group_id) if full_config.group_id else None, + **config_data # provider, api_key, model, temperature, etc. + } + + # Validate required fields + if not llm_config.get("provider"): + log.error(f"LLM config missing 'provider' field: {config_id}") + return None + + if not llm_config.get("model"): + log.error(f"LLM config missing 'model' field: {config_id}") + return None + + log.info( + f"Retrieved LLM config for user {user_id}: " + f"provider={llm_config.get('provider')}, model={llm_config.get('model')}, source={source}" + ) + + return llm_config + + except Exception as e: + log.error(f"Failed to retrieve LLM config for user {user_id}: {e}", exc_info=True) + return None + diff --git a/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py new file mode 100644 index 000000000..fa7b99c6d --- /dev/null +++ b/gns3server/db_migrations/versions/20260303_create_llm_model_configs_table.py @@ -0,0 +1,95 @@ +"""create llm_model_configs table + +Revision ID: 20260303_create_llm_model_configs +Revises: 98083573d011 +Create Date: 2026-03-03 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '20260303_create_llm_model_configs' +down_revision = '98083573d011' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Get the current connection and dialect + conn = op.get_bind() + inspector = inspect(conn) + dialect_name = conn.dialect.name + + # Check if table already exists (idempotent for databases created from code) + tables = inspector.get_table_names() + + if 'llm_model_configs' in tables: + # Table already exists from Base.metadata.create_all, skip creation + return + + # Create llm_model_configs table with cross-database compatible types + op.create_table( + 'llm_model_configs', + sa.Column('config_id', sa.String(32), primary_key=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('model_type', sa.String(50), nullable=False), + sa.Column('config', sa.JSON(), nullable=False), + sa.Column('user_id', sa.String(32), sa.ForeignKey('users.user_id', ondelete='CASCADE'), nullable=True), + sa.Column('group_id', sa.String(32), sa.ForeignKey('user_groups.user_group_id', ondelete='CASCADE'), nullable=True), + sa.Column('is_default', sa.Boolean(), default=False, nullable=False), + sa.Column('version', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.CheckConstraint( + "(user_id IS NOT NULL AND group_id IS NULL) OR " + "(user_id IS NULL AND group_id IS NOT NULL)", + name='single_owner_check' + ), + sa.CheckConstraint( + "model_type IN ('text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other')", + name='valid_model_type_check' + ), + ) + + # Create indexes for efficient queries + op.create_index('idx_llm_model_configs_user_id', 'llm_model_configs', ['user_id']) + op.create_index('idx_llm_model_configs_group_id', 'llm_model_configs', ['group_id']) + op.create_index('idx_llm_model_configs_model_type', 'llm_model_configs', ['model_type']) + + # PostgreSQL-specific indexes + if dialect_name == 'postgresql': + # GIN index for JSONB config column + op.create_index('idx_llm_model_configs_config', 'llm_model_configs', ['config'], postgresql_using='gin') + + # Partial unique indexes for default configs + op.execute(""" + CREATE UNIQUE INDEX unique_user_default + ON llm_model_configs (user_id) + WHERE is_default = TRUE AND user_id IS NOT NULL + """) + op.execute(""" + CREATE UNIQUE INDEX unique_group_default + ON llm_model_configs (group_id) + WHERE is_default = TRUE AND group_id IS NOT NULL + """) + + +def downgrade() -> None: + # Get the current connection and dialect + conn = op.get_bind() + dialect_name = conn.dialect.name + + # Drop indexes + op.drop_index('idx_llm_model_configs_model_type', table_name='llm_model_configs') + op.drop_index('idx_llm_model_configs_group_id', table_name='llm_model_configs') + op.drop_index('idx_llm_model_configs_user_id', table_name='llm_model_configs') + + if dialect_name == 'postgresql': + op.drop_index('idx_llm_model_configs_config', table_name='llm_model_configs') + op.drop_index('unique_user_default', table_name='llm_model_configs') + op.drop_index('unique_group_default', table_name='llm_model_configs') + + # Drop table + op.drop_table('llm_model_configs') diff --git a/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py new file mode 100644 index 000000000..eeafe3afd --- /dev/null +++ b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py @@ -0,0 +1,26 @@ +"""Add tags field to templates + +Revision ID: 98083573d011 +Revises: 9a5292aa4389 +Create Date: 2026-02-23 18:23:59.857607 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '98083573d011' +down_revision = '9a5292aa4389' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('templates', sa.Column('tags', sa.String())) + + +def downgrade() -> None: + + op.drop_column('templates', 'tags') diff --git a/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py new file mode 100644 index 000000000..f9f82cc43 --- /dev/null +++ b/gns3server/db_migrations/versions/aff810fc119a_add_token_version_to_users_table.py @@ -0,0 +1,24 @@ +"""add token version to users table + +Revision ID: aff810fc119a +Revises: ec4b7b198555 +Create Date: 2026-04-06 19:49:12.155446 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'aff810fc119a' +down_revision = 'ec4b7b198555' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0')) + +def downgrade() -> None: + + op.drop_column('users', 'token_version') diff --git a/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py b/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py new file mode 100644 index 000000000..6b393940e --- /dev/null +++ b/gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py @@ -0,0 +1,134 @@ +"""convert PickleType fields to JSON + +Revision ID: ec4b7b198555 +Revises: 20260303_create_llm_model_configs +Create Date: 2026-04-03 19:51:06.173013 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'ec4b7b198555' +down_revision = '20260303_create_llm_model_configs' +branch_labels = None +depends_on = None + + +def convert_pickle_to_json(conn, table_name: str, column_name: str) -> None: + """ + Convert a PickleType column to JSON for all rows in a table. + """ + + import pickle + import json + + result = conn.execute(sa.text(f"SELECT template_id, {column_name} FROM {table_name}")) + for row in result: + column_data = getattr(row, column_name) + if column_data: + # Unpickle and convert to JSON + data = pickle.loads(column_data) + if data: + json_data = json.dumps(data) + conn.execute( + sa.text(f"UPDATE {table_name} SET {column_name} = :data WHERE template_id = :template_id"), + {"data": json_data, "template_id": row.template_id} + ) + else: + # Set NULL if there is no data to be converted + conn.execute( + sa.text(f"UPDATE {table_name} SET {column_name} = NULL WHERE template_id = :template_id"), + {"template_id": row.template_id} + ) + + +def convert_json_to_pickle(conn, table_name: str, column_name: str) -> None: + """ + Convert a JSON column to PickleType for all rows in a table. + """ + + import pickle + import json + + result = conn.execute(sa.text(f"SELECT template_id, {column_name} FROM {table_name}")) + for row in result: + column_data = getattr(row, column_name) + if column_data: + # Parse JSON and convert to pickle + data = json.loads(column_data) + if data: + pickle_data = pickle.dumps(data) + conn.execute( + sa.text(f"UPDATE {table_name} SET {column_name} = :data WHERE template_id = :template_id"), + {"data": pickle_data, "template_id": row.template_id} + ) + else: + # Set NULL if there is no data to be converted + conn.execute( + sa.text(f"UPDATE {table_name} SET {column_name} = NULL WHERE template_id = :template_id"), + {"template_id": row.template_id} + ) + + +def upgrade() -> None: + + # Get connection + conn = op.get_bind() + + # Convert PickleType fields to JSON + convert_pickle_to_json(conn, "cloud_templates", "ports_mapping") + convert_pickle_to_json(conn, "docker_templates", "extra_volumes") + convert_pickle_to_json(conn, "docker_templates", "custom_adapters") + convert_pickle_to_json(conn, "ethernet_hub_templates", "ports_mapping") + convert_pickle_to_json(conn, "ethernet_switch_templates", "ports_mapping") + convert_pickle_to_json(conn, "qemu_templates", "custom_adapters") + convert_pickle_to_json(conn, "virtualbox_templates", "custom_adapters") + convert_pickle_to_json(conn, "vmware_templates", "custom_adapters") + + with op.batch_alter_table('cloud_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.JSON()) + with op.batch_alter_table('docker_templates') as batch_op: + batch_op.alter_column('extra_volumes', type_=sa.JSON()) + batch_op.alter_column('custom_adapters', type_=sa.JSON()) + with op.batch_alter_table('ethernet_hub_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.JSON()) + with op.batch_alter_table('ethernet_switch_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.JSON()) + with op.batch_alter_table('qemu_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.JSON()) + with op.batch_alter_table('virtualbox_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.JSON()) + with op.batch_alter_table('vmware_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.JSON()) + +def downgrade() -> None: + + # Get connection + conn = op.get_bind() + + # Convert JSON fields back to PickleType + convert_json_to_pickle(conn, "cloud_templates", "ports_mapping") + convert_json_to_pickle(conn, "docker_templates", "extra_volumes") + convert_json_to_pickle(conn, "docker_templates", "custom_adapters") + convert_json_to_pickle(conn, "ethernet_hub_templates", "ports_mapping") + convert_json_to_pickle(conn, "ethernet_switch_templates", "ports_mapping") + convert_json_to_pickle(conn, "qemu_templates", "custom_adapters") + convert_json_to_pickle(conn, "virtualbox_templates", "custom_adapters") + convert_json_to_pickle(conn, "vmware_templates", "custom_adapters") + + with op.batch_alter_table('cloud_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.PickleType()) + with op.batch_alter_table('docker_templates') as batch_op: + batch_op.alter_column('extra_volumes', type_=sa.PickleType()) + batch_op.alter_column('custom_adapters', type_=sa.PickleType()) + with op.batch_alter_table('ethernet_hub_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.PickleType()) + with op.batch_alter_table('ethernet_switch_templates') as batch_op: + batch_op.alter_column('ports_mapping', type_=sa.PickleType()) + with op.batch_alter_table('qemu_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.PickleType()) + with op.batch_alter_table('virtualbox_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.PickleType()) + with op.batch_alter_table('vmware_templates') as batch_op: + batch_op.alter_column('custom_adapters', type_=sa.PickleType()) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a23d61d96..0c58eee28 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -20,16 +20,41 @@ 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 -from .controller.appliances import ApplianceVersion, Appliance +from .controller.appliances import ApplianceVersion, ApplianceVersionV8, Appliance from .controller.drawings import Drawing from .controller.gns3vm import GNS3VM from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node -from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression +from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression, NodeFile from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup + +# Conditionally import AI-related schemas +try: + from .controller.llm_model_configs import ( + LLMModelConfigData, + LLMModelConfigCreate, + LLMModelConfigUpdate, + LLMModelConfigResponse, + LLMModelConfigWithSource, + LLMModelConfigInheritedResponse, + LLMModelConfigListResponse + ) + from .controller.chat import ( + OpenAIToolCall, + ChatRequest, + ChatResponse, + OpenAIMessage, + ConversationHistory, + ChatSession, + RenameSession + ) +except ImportError: + # AI schemas are not available (should not happen as they don't depend on external libs) + pass + from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool from .controller.tokens import Token diff --git a/gns3server/schemas/common.py b/gns3server/schemas/common.py index 31aa9f473..ce37c337f 100644 --- a/gns3server/schemas/common.py +++ b/gns3server/schemas/common.py @@ -55,6 +55,7 @@ class ConsoleType(str, Enum): vnc = "vnc" telnet = "telnet" + ssh = "ssh" http = "http" https = "https" spice = "spice" @@ -68,4 +69,5 @@ class AuxType(str, Enum): """ telnet = "telnet" + ssh = "ssh" none = "none" diff --git a/gns3server/schemas/compute/cloud_nodes.py b/gns3server/schemas/compute/cloud_nodes.py index 6ffe24cc8..a4dbfae40 100644 --- a/gns3server/schemas/compute/cloud_nodes.py +++ b/gns3server/schemas/compute/cloud_nodes.py @@ -88,6 +88,7 @@ class UDPPort(BaseModel): class CloudConsoleType(str, Enum): telnet = "telnet" + ssh = "ssh" vnc = "vnc" spice = "spice" http = "http" diff --git a/gns3server/schemas/compute/dynamips_nodes.py b/gns3server/schemas/compute/dynamips_nodes.py index 3f636461f..1ab8e649b 100644 --- a/gns3server/schemas/compute/dynamips_nodes.py +++ b/gns3server/schemas/compute/dynamips_nodes.py @@ -87,6 +87,7 @@ class DynamipsConsoleType(str, Enum): """ telnet = "telnet" + ssh = "ssh" none = "none" diff --git a/gns3server/schemas/compute/iou_nodes.py b/gns3server/schemas/compute/iou_nodes.py index 7e3d3a91b..fe3fe8570 100644 --- a/gns3server/schemas/compute/iou_nodes.py +++ b/gns3server/schemas/compute/iou_nodes.py @@ -39,7 +39,7 @@ class IOUBase(BaseModel): ram: Optional[int] = Field(None, gt=0, description="Amount of RAM in MB") nvram: Optional[int] = Field(None, gt=0, description="Amount of NVRAM in KB") l1_keepalives: Optional[bool] = Field(None, description="Use default IOU values") - use_default_iou_values: Optional[bool] = Field(None, description="Always up Ethernet interfaces") + use_default_iou_values: Optional[bool] = Field(None, description="Use default IOU values") startup_config_content: Optional[str] = Field(None, description="Content of IOU startup configuration file") private_config_content: Optional[str] = Field(None, description="Content of IOU private configuration file") diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 686c85327..cf69f9e30 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -34,7 +34,7 @@ class UDPNIO(BaseModel): lport: int = Field(..., gt=0, le=65535, description="Local port") rhost: str = Field(..., description="Remote host") rport: int = Field(..., gt=0, le=65535, description="Remote port") - suspend: Optional[int] = Field(None, description="Suspend the NIO") + suspend: Optional[bool] = Field(None, description="Suspend the NIO") filters: Optional[dict] = Field(None, description="Packet filters") diff --git a/gns3server/schemas/compute/qemu_nodes.py b/gns3server/schemas/compute/qemu_nodes.py index 8d7e23dd3..999a53b1a 100644 --- a/gns3server/schemas/compute/qemu_nodes.py +++ b/gns3server/schemas/compute/qemu_nodes.py @@ -61,6 +61,7 @@ class QemuConsoleType(str, Enum): vnc = "vnc" telnet = "telnet" + ssh = "ssh" spice = "spice" spice_agent = "spice+agent" none = "none" @@ -167,19 +168,19 @@ class QemuBase(BaseModel): aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port") aux_type: Optional[QemuConsoleType] = Field(None, description="Auxiliary console type") hda_disk_image: Optional[str] = Field(None, description="QEMU hda disk image path") - hda_disk_image_backed: Optional[str] = Field(None, description="QEMU hda backed disk image path") + hda_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hda backing file disk image path") hda_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hda disk image checksum") hda_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hda interface") hdb_disk_image: Optional[str] = Field(None, description="QEMU hdb disk image path") - hdb_disk_image_backed: Optional[str] = Field(None, description="QEMU hdb backed disk image path") + hdb_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hdb backing file disk image path") hdb_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hdb disk image checksum") hdb_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hdb interface") hdc_disk_image: Optional[str] = Field(None, description="QEMU hdc disk image path") - hdc_disk_image_backed: Optional[str] = Field(None, description="QEMU hdc backed disk image path") + hdc_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hdc backing file disk image path") hdc_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hdc disk image checksum") hdc_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hdc interface") hdd_disk_image: Optional[str] = Field(None, description="QEMU hdd disk image path") - hdd_disk_image_backed: Optional[str] = Field(None, description="QEMU hdd backed disk image path") + hdd_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hdd backing file disk image path") hdd_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hdd disk image checksum") hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hdd interface") cdrom_image: Optional[str] = Field(None, description="QEMU cdrom image path") diff --git a/gns3server/schemas/compute/virtualbox_nodes.py b/gns3server/schemas/compute/virtualbox_nodes.py index cdfc6ae31..faa6ed7ee 100644 --- a/gns3server/schemas/compute/virtualbox_nodes.py +++ b/gns3server/schemas/compute/virtualbox_nodes.py @@ -28,6 +28,7 @@ class VirtualBoxConsoleType(str, Enum): """ telnet = "telnet" + ssh = "ssh" none = "none" diff --git a/gns3server/schemas/compute/vmware_nodes.py b/gns3server/schemas/compute/vmware_nodes.py index 7e0a78f08..770f6f7c6 100644 --- a/gns3server/schemas/compute/vmware_nodes.py +++ b/gns3server/schemas/compute/vmware_nodes.py @@ -28,6 +28,7 @@ class VMwareConsoleType(str, Enum): """ telnet = "telnet" + ssh = "ssh" none = "none" diff --git a/gns3server/schemas/compute/vpcs_nodes.py b/gns3server/schemas/compute/vpcs_nodes.py index a0adf91b6..1b2278536 100644 --- a/gns3server/schemas/compute/vpcs_nodes.py +++ b/gns3server/schemas/compute/vpcs_nodes.py @@ -28,6 +28,7 @@ class ConsoleType(str, Enum): """ telnet = "telnet" + ssh = "ssh" none = "none" diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 34ea90690..e1a3ab5a9 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -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" @@ -114,6 +125,7 @@ class BuiltinSymbolTheme(str, Enum): class ServerSettings(BaseModel): local: bool = False + enable_http_auth: bool = True name: str = f"{socket.gethostname()} (controller)" protocol: ServerProtocol = ServerProtocol.http host: str = "0.0.0.0" @@ -147,6 +159,9 @@ class ServerSettings(BaseModel): allow_remote_console: bool = False enable_builtin_templates: bool = True install_builtin_appliances: bool = True + skills_repo_url: str = "https://github.com/yueguobin/GNS3-Skills.git" + skills_repo_branch: str = "main" + skills_auto_update: bool = True model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) @field_validator("additional_images_paths", mode="before") @@ -195,3 +210,4 @@ class ServerConfig(BaseModel): Qemu: QemuSettings = QemuSettings() VirtualBox: VirtualBoxSettings = VirtualBoxSettings() VMware: VMwareSettings = VMwareSettings() + WebWireshark: WebWiresharkSettings = WebWiresharkSettings() diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index e88a73394..6a0ef5720 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2021 GNS3 Technologies Inc. +# 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 @@ -14,15 +14,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# Generated from JSON schema using https://github.com/koxudaxi/datamodel-code-generator +# Unified Pydantic model supporting both appliance registry versions using discriminated unions from enum import Enum -from typing import List, Optional, Union +from typing import Annotated, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, EmailStr, Field, confloat, conint, constr +from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +# ============================================================================ +# Shared Enums +# ============================================================================ + class Category(str, Enum): + """Appliance category enum""" router = 'router' multilayer_switch = 'multilayer_switch' @@ -31,17 +36,8 @@ class Category(str, Enum): guest = 'guest' -class RegistryVersion(int, Enum): - - version1 = 1 - version2 = 2 - version3 = 3 - version4 = 4 - version5 = 5 - version6 = 6 - - class Status(str, Enum): + """Appliance status enum""" stable = 'stable' experimental = 'experimental' @@ -49,6 +45,7 @@ class Status(str, Enum): class Availability(str, Enum): + """Image availability enum""" free = 'free' with_registration = 'with-registration' @@ -56,52 +53,32 @@ class Availability(str, Enum): service_contract = 'service-contract' -class ConsoleType(str, Enum): +class Compression(str, Enum): + """Compression type enum""" - telnet = 'telnet' - vnc = 'vnc' - http = 'http' - https = 'https' - none = 'none' + bzip2 = 'bzip2' + gzip = 'gzip' + lzma = 'lzma' + xz = 'xz' + rar = 'rar' + zip = 'zip' + field_7z = '7z' -class Docker(BaseModel): +class DynamipsPlatform(str, Enum): + """Dynamips platform enum""" - adapters: int = Field(..., title='Number of ethernet adapters') - image: str = Field(..., title='Docker image in the Docker Hub') - start_command: Optional[str] = Field( - None, - title='Command executed when the container start. Empty will use the default', - ) - environment: Optional[str] = Field(None, title='One KEY=VAR environment by line') - console_type: Optional[ConsoleType] = Field( - None, title='Type of console connection for the administration of the appliance' - ) - console_http_port: Optional[int] = Field( - None, description='Internal port in the container of the HTTP server' - ) - console_http_path: Optional[str] = Field( - None, description='Path of the web interface' - ) - extra_hosts: Optional[str] = Field( - None, description='Hosts which will be written to /etc/hosts into container' - ) - extra_volumes: Optional[List[str]] = Field( - None, - description='Additional directories to make persistent that are not included in the images VOLUME directive', - ) + c1700 = 'c1700' + c2600 = 'c2600' + c2691 = 'c2691' + c3725 = 'c3725' + c3745 = 'c3745' + c3600 = 'c3600' + c7200 = 'c7200' -class Iou(BaseModel): - - ethernet_adapters: int = Field(..., title='Number of ethernet adapters') - serial_adapters: int = Field(..., title='Number of serial adapters') - nvram: int = Field(..., title='Host NVRAM') - ram: int = Field(..., title='Host RAM') - startup_config: str = Field(..., title='Config loaded at startup') - - -class Chassis(str, Enum): +class DynamipsChassis(str, Enum): + """Dynamips chassis enum""" chassis_1720 = '1720' chassis_1721 = '1721' @@ -120,26 +97,56 @@ class Chassis(str, Enum): chassis_3620 = '3620' chassis_3640 = '3640' chassis_3660 = '3660' + _ = '' -class Platform(str, Enum): +class DynamipsSlot(str, Enum): + """Dynamips slot enum""" - c1700 = 'c1700' - c2600 = 'c2600' - c2691 = 'c2691' - c3725 = 'c3725' - c3745 = 'c3745' - c3600 = 'c3600' - c7200 = 'c7200' + C7200_IO_2FE = 'C7200-IO-2FE' + C7200_IO_FE = 'C7200-IO-FE' + C7200_IO_GE_E = 'C7200-IO-GE-E' + NM_16ESW = 'NM-16ESW' + NM_1E = 'NM-1E' + NM_1FE_TX = 'NM-1FE-TX' + NM_4E = 'NM-4E' + NM_4T = 'NM-4T' + PA_2FE_TX = 'PA-2FE-TX' + PA_4E = 'PA-4E' + PA_4T_ = 'PA-4T+' + PA_8E = 'PA-8E' + PA_8T = 'PA-8T' + PA_A1 = 'PA-A1' + PA_FE_TX = 'PA-FE-TX' + PA_GE = 'PA-GE' + PA_POS_OC3 = 'PA-POS-OC3' + C2600_MB_2FE = 'C2600-MB-2FE' + C2600_MB_1E = 'C2600-MB-1E' + C1700_MB_1FE = 'C1700-MB-1FE' + C2600_MB_2E = 'C2600-MB-2E' + C2600_MB_1FE = 'C2600-MB-1FE' + C1700_MB_WIC1 = 'C1700-MB-WIC1' + GT96100_FE = 'GT96100-FE' + Leopard_2FE = 'Leopard-2FE' + _ = '' +class DynamipsWic(str, Enum): + """Dynamips WIC enum""" -class Midplane(str, Enum): + WIC_1ENET = 'WIC-1ENET' + WIC_1T = 'WIC-1T' + WIC_2T = 'WIC-2T' + _ = '' + +class DynamipsMidplane(str, Enum): + """Dynamips midplane enum""" std = 'std' vxr = 'vxr' -class Npe(str, Enum): +class DynamipsNpe(str, Enum): + """Dynamips NPE enum""" npe_100 = 'npe-100' npe_150 = 'npe-150' @@ -151,12 +158,21 @@ class Npe(str, Enum): npe_g2 = 'npe-g2' -class AdapterType(str, Enum): +class QemuConsoleType(str, Enum): + """Qemu console type enum""" + + telnet = 'telnet' + ssh = 'ssh' + vnc = 'vnc' + spice = 'spice' + spice_agent = 'spice+agent' + none = 'none' + + +class QemuAdapterType(str, Enum): + """Qemu adapter type enum""" e1000 = 'e1000' - e1000_82544gc = 'e1000-82544gc' - e1000_82545em = 'e1000-82545em' - e1000e = 'e1000e' i82550 = 'i82550' i82551 = 'i82551' i82557a = 'i82557a' @@ -173,28 +189,14 @@ class AdapterType(str, Enum): igb = 'igb' ne2k_pci = 'ne2k_pci' pcnet = 'pcnet' - rocker = 'rocker' rtl8139 = 'rtl8139' virtio = 'virtio' virtio_net_pci = 'virtio-net-pci' vmxnet3 = 'vmxnet3' -class DiskInterface(str, Enum): - - ide = 'ide' - sata = 'sata' - nvme = 'nvme' - scsi = 'scsi' - sd = 'sd' - mtd = 'mtd' - floppy = 'floppy' - pflash = 'pflash' - virtio = 'virtio' - none = 'none' - - -class Arch(str, Enum): +class QemuPlatform(str, Enum): + """Qemu platform enum""" aarch64 = 'aarch64' alpha = 'alpha' @@ -226,16 +228,23 @@ class Arch(str, Enum): xtensaeb = 'xtensaeb' -class ConsoleType1(str, Enum): +class QemuDiskInterface(str, Enum): + """Disk interface enum""" - telnet = 'telnet' - vnc = 'vnc' - spice = 'spice' - spice_agent = 'spice+agent' + ide = 'ide' + sata = 'sata' + nvme = 'nvme' + scsi = 'scsi' + sd = 'sd' + mtd = 'mtd' + floppy = 'floppy' + pflash = 'pflash' + virtio = 'virtio' none = 'none' -class BootPriority(str, Enum): +class QemuBootPriority(str, Enum): + """Boot priority enum""" c = 'c' d = 'd' @@ -248,14 +257,16 @@ class BootPriority(str, Enum): nd = 'nd' -class Kvm(str, Enum): +class QemuOnClose(str, Enum): + """Qemu on_close action enum""" - require = 'require' - allow = 'allow' - disable = 'disable' + power_off = 'power_off' + shutdown_signal = 'shutdown_signal' + save_vm_state = 'save_vm_state' -class ProcessPriority(str, Enum): +class QemuProcessPriority(str, Enum): + """Qemu process priority enum""" realtime = 'realtime' very_high = 'very high' @@ -263,80 +274,118 @@ class ProcessPriority(str, Enum): normal = 'normal' low = 'low' very_low = 'very low' - null = 'null' + + +class DockerConsoleType(str, Enum): + """Docker console type enum""" + + telnet = 'telnet' + ssh = 'ssh' + vnc = 'vnc' + http = 'http' + https = 'https' + none = 'none' + + +class ChecksumType(str, Enum): + """Checksum type enum""" + + md5 = 'md5' + + +class TemplateType(str, Enum): + """Template type enum""" + + docker = 'docker' + iou = 'iou' + dynamips = 'dynamips' + qemu = 'qemu' + +# ============================================================================ +# Version 1-6 Specific Enums +# ============================================================================ + +class Kvm(str, Enum): + """KVM requirements enum""" + + require = 'require' + allow = 'allow' + disable = 'disable' + +# ============================================================================ +# Version 1-6 Models +# ============================================================================ + +class Docker(BaseModel): + """Docker configuration for v1-6""" + + adapters: int = Field(..., title='Number of Ethernet adapters') + image: str = Field(..., title='Docker image in the Docker Hub') + start_command: Optional[str] = Field(None, title='Command executed when the container start. Empty will use the default') + environment: Optional[str] = Field(None, title='One KEY=VAR environment by line') + console_type: Optional[DockerConsoleType] = Field(None, title='Type of console connection for the administration of the appliance') + console_http_port: Optional[int] = Field(None, description='Internal port in the container of the HTTP server') + console_http_path: Optional[str] = Field(None, description='Path of the web interface') + extra_hosts: Optional[str] = Field(None, description='Hosts which will be written to /etc/hosts into container') + extra_volumes: Optional[List[str]] = Field(None, description='Additional directories to make persistent that are not included in the images VOLUME directive') + + +class Iou(BaseModel): + """IOU configuration for v1-6""" + + ethernet_adapters: int = Field(..., title='Number of Ethernet adapters') + serial_adapters: int = Field(..., title='Number of serial adapters') + nvram: int = Field(..., title='Host NVRAM') + ram: int = Field(..., title='Host RAM') + startup_config: str = Field(..., title='Config loaded at startup') + + +class Dynamips(BaseModel): + """Dynamips configuration for v1-6""" + + chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type') + platform: DynamipsPlatform = Field(..., title='Platform type') + ram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of ram') + nvram: Annotated[int, Field(ge=1)] = Field(..., title='Amount of nvram') + startup_config: Optional[str] = Field(None, title='Config loaded at startup') + wic0: Optional[DynamipsWic] = None + wic1: Optional[DynamipsWic] = None + wic2: Optional[DynamipsWic] = None + slot0: Optional[DynamipsSlot] = None + slot1: Optional[DynamipsSlot] = None + slot2: Optional[DynamipsSlot] = None + slot3: Optional[DynamipsSlot] = None + slot4: Optional[DynamipsSlot] = None + slot5: Optional[DynamipsSlot] = None + slot6: Optional[DynamipsSlot] = None + midplane: Optional[DynamipsMidplane] = None + npe: Optional[DynamipsNpe] = None class Qemu(BaseModel): + """QEMU configuration for v1-6""" - adapter_type: AdapterType = Field(..., title='Type of network adapter') + adapter_type: QemuAdapterType = Field(..., title='Type of network adapter') adapters: int = Field(..., title='Number of adapters') - ram: int = Field(..., title='Ram allocated to the appliance (MB)') + ram: int = Field(..., title='RAM allocated to the appliance (MB)') cpus: Optional[int] = Field(None, title='Number of Virtual CPU') - hda_disk_interface: Optional[DiskInterface] = Field( - None, title='Disk interface for the installed hda_disk_image' - ) - hdb_disk_interface: Optional[DiskInterface] = Field( - None, title='Disk interface for the installed hdb_disk_image' - ) - hdc_disk_interface: Optional[DiskInterface] = Field( - None, title='Disk interface for the installed hdc_disk_image' - ) - hdd_disk_interface: Optional[DiskInterface] = Field( - None, title='Disk interface for the installed hdd_disk_image' - ) - arch: Arch = Field(..., title='Architecture emulated') - console_type: ConsoleType1 = Field( - ..., title='Type of console connection for the administration of the appliance' - ) - boot_priority: Optional[BootPriority] = Field( - None, - title='Disk boot priority. Refer to -boot option in qemu manual for more details.', - ) - kernel_command_line: Optional[str] = Field( - None, title='Command line parameters send to the kernel' - ) + hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image') + hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image') + hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image') + hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image') + arch: QemuPlatform = Field(..., title='Architecture emulated') + console_type: QemuConsoleType = Field(..., title='Type of console connection for the administration of the appliance') + boot_priority: Optional[QemuBootPriority] = Field(None, title='Disk boot priority') + kernel_command_line: Optional[str] = Field(None, title='Command line parameters sent to the kernel') kvm: Kvm = Field(..., title='KVM requirements') - options: Optional[str] = Field( - None, title='Optional additional qemu command line options' - ) - cpu_throttling: Optional[confloat(ge=0.0, le=100.0)] = Field( - None, title='Throttle the CPU' - ) - process_priority: Optional[ProcessPriority] = Field( - None, title='Process priority for QEMU' - ) - - -class Compression(str, Enum): - - bzip2 = 'bzip2' - gzip = 'gzip' - lzma = 'lzma' - xz = 'xz' - rar = 'rar' - zip = 'zip' - field_7z = '7z' - - -class ApplianceImage(BaseModel): - - filename: str = Field(..., title='Filename') - version: str = Field(..., title='Version of the file') - md5sum: str = Field(..., title='md5sum of the file', pattern='^[a-f0-9]{32}$') - filesize: int = Field(..., title='File size in bytes') - download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field( - None, title='Download url where you can download the appliance from a browser' - ) - direct_download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field( - None, - title='Optional. Non authenticated url to the image file where you can download the image.', - ) - compression: Optional[Compression] = Field( - None, title='Optional, compression type of direct download url image.' - ) + options: Optional[str] = Field(None, title='Optional additional qemu command line options') + cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU') + on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed') + process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU') class ApplianceVersionImages(BaseModel): + """Appliance version images configuration for v1-6""" kernel_image: Optional[str] = Field(None, title='Kernel image') initrd: Optional[str] = Field(None, title='Initrd disk image') @@ -350,101 +399,141 @@ class ApplianceVersionImages(BaseModel): class ApplianceVersion(BaseModel): + """Appliance version definition for v1-6""" name: str = Field(..., title='Name of the version') idlepc: Optional[str] = Field(None, pattern='^0x[0-9a-f]{8}') images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version') -class DynamipsSlot(str, Enum): +class ApplianceImage(BaseModel): + """Appliance image definition - compatible with both versions""" - C7200_IO_2FE = 'C7200-IO-2FE' - C7200_IO_FE = 'C7200-IO-FE' - C7200_IO_GE_E = 'C7200-IO-GE-E' - NM_16ESW = 'NM-16ESW' - NM_1E = 'NM-1E' - NM_1FE_TX = 'NM-1FE-TX' - NM_4E = 'NM-4E' - NM_4T = 'NM-4T' - PA_2FE_TX = 'PA-2FE-TX' - PA_4E = 'PA-4E' - PA_4T_ = 'PA-4T+' - PA_8E = 'PA-8E' - PA_8T = 'PA-8T' - PA_A1 = 'PA-A1' - PA_FE_TX = 'PA-FE-TX' - PA_GE = 'PA-GE' - PA_POS_OC3 = 'PA-POS-OC3' - C2600_MB_2FE = 'C2600-MB-2FE' - C2600_MB_1E = 'C2600-MB-1E' - C1700_MB_1FE = 'C1700-MB-1FE' - C2600_MB_2E = 'C2600-MB-2E' - C2600_MB_1FE = 'C2600-MB-1FE' - C1700_MB_WIC1 = 'C1700-MB-WIC1' - GT96100_FE = 'GT96100-FE' - Leopard_2FE = 'Leopard-2FE' - _ = '' + filename: str = Field(..., title='Filename') + version: str = Field(..., title='Version of the file') + md5sum: Optional[str] = Field(None, title='md5sum of the file', pattern='^[a-f0-9]{32}$') + filesize: int = Field(..., title='File size in bytes') + download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, + title='Download url where you can download the appliance from a browser' + ) + direct_download_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, + title='Optional. Non authenticated url to the image file where you can download the image.', + ) + compression: Optional[Compression] = Field( + None, title='Optional, compression type of direct download url image.' + ) + checksum: Optional[str] = Field(None, title='checksum of the image file') + checksum_type: Optional[ChecksumType] = Field(None, title='checksum type of the image file') + compression_target: Optional[str] = Field( + None, title='Optional, file name of the image file inside the compressed file.' + ) -class DynamipsWic(str, Enum): +# ============================================================================ +# Version 8 Models +# ============================================================================ - WIC_1ENET = 'WIC-1ENET' - WIC_1T = 'WIC-1T' - WIC_2T = 'WIC-2T' +class CustomAdapterItem(BaseModel): + """Custom adapter configuration (v8)""" + + adapter_number: int = Field(..., title='Adapter number') + port_name: Optional[str] = Field(None, title='Custom port name') + adapter_type: Optional[QemuAdapterType] = Field(None, title='Custom adapter type') + mac_address: Optional[str] = Field( + None, + title='Custom MAC address', + pattern=r'^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$', + ) -class Dynamips(BaseModel): +class DockerPropertiesV8(BaseModel): + """Docker template properties (v8)""" - chassis: Optional[Chassis] = Field(None, title='Chassis type') - platform: Platform = Field(..., title='Platform type') - ram: conint(ge=1) = Field(..., title='Amount of ram') - nvram: conint(ge=1) = Field(..., title='Amount of nvram') + name: Optional[str] = Field(None, title='Name of the template') + category: Optional[Category] = Field(None, title='Category of the template') + default_name_format: Optional[str] = Field(None, title='Default name format') + usage: Optional[str] = Field(None, title='How to use the template') + symbol: Optional[str] = Field(None, title='Symbol of the template') + image: str = Field(..., title='Docker image') + adapters: Optional[int] = Field(None, title='Number of ethernet adapters') + start_command: Optional[str] = Field( + None, title='Command executed when the container start. Empty will use the default' + ) + environment: Optional[str] = Field(None, title='One KEY=VAR environment by line') + console_type: Optional[DockerConsoleType] = Field( + None, title='Type of console' + ) + console_http_port: Optional[int] = Field( + None, title='Internal port in the container of the HTTP server' + ) + console_http_path: Optional[str] = Field(None, title='Path of the web interface') + console_resolution: Optional[str] = Field( + None, + title='Console resolution for VNC, for example 1024x768', + pattern=r'^[0-9]+x[0-9]+$', + ) + extra_hosts: Optional[str] = Field(None, title='Docker extra hosts (added to /etc/hosts)') + extra_volumes: Optional[List[str]] = Field( + None, title='Additional directories to make persistent' + ) + + +class IouPropertiesV8(BaseModel): + """IOU template properties (v8)""" + + name: Optional[str] = Field(None, title='Name of the template') + category: Optional[Category] = Field(None, title='Category of the template') + default_name_format: Optional[str] = Field(None, title='Default name format') + usage: Optional[str] = Field(None, title='How to use the template') + symbol: Optional[str] = Field(None, title='Symbol of the template') + ethernet_adapters: Optional[int] = Field(None, title='Number of ethernet adapters') + serial_adapters: Optional[int] = Field(None, title='Number of serial adapters') + ram: Optional[int] = Field(None, title='Host RAM') + nvram: Optional[int] = Field(None, title='Host NVRAM') startup_config: Optional[str] = Field(None, title='Config loaded at startup') - wic0: Optional[DynamipsWic] = None - wic1: Optional[DynamipsWic] = None - wic2: Optional[DynamipsWic] = None - slot0: Optional[DynamipsSlot] = None - slot1: Optional[DynamipsSlot] = None - slot2: Optional[DynamipsSlot] = None - slot3: Optional[DynamipsSlot] = None - slot4: Optional[DynamipsSlot] = None - slot5: Optional[DynamipsSlot] = None - slot6: Optional[DynamipsSlot] = None - midplane: Optional[Midplane] = None - npe: Optional[Npe] = None -class Appliance(BaseModel): +class DynamipsPropertiesV8(BaseModel): + """Dynamips template properties (v8)""" - appliance_id: UUID = Field(..., title='Appliance ID') - name: str = Field(..., title='Appliance name') - builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not') - category: Category = Field(..., title='Category of the appliance') - description: str = Field( - ..., title='Description of the appliance. Could be a marketing description' - ) - vendor_name: str = Field(..., title='Name of the vendor') - vendor_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(None, title='Website of the vendor') - documentation_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field( - None, - title='An optional documentation for using the appliance on vendor website', - ) - product_name: str = Field(..., title='Product name') - product_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field( - None, title='An optional product url on vendor website' - ) - registry_version: RegistryVersion = Field( - ..., title='Version of the registry compatible with this appliance' - ) - status: Status = Field(..., title='Document if the appliance is working or not') - availability: Optional[Availability] = Field( - None, - title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly', - ) - maintainer: str = Field(..., title='Maintainer name') - maintainer_email: Optional[Union[EmailStr, constr(max_length=0)]] = Field(None, title='Maintainer email') - usage: Optional[str] = Field(None, title='How to use the appliance') - symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + name: Optional[str] = Field(None, title='Name of the template') + category: Optional[Category] = Field(None, title='Category of the template') + default_name_format: Optional[str] = Field(None, title='Default name format') + usage: Optional[str] = Field(None, title='How to use the template') + symbol: Optional[str] = Field(None, title='Symbol of the template') + chassis: Optional[DynamipsChassis] = Field(None, title='Chassis type') + platform: Optional[DynamipsPlatform] = Field(None, title='Platform type') + ram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of ram') + nvram: Optional[Annotated[int, Field(ge=1)]] = Field(None, title='Amount of nvram') + idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}') + startup_config: Optional[str] = Field(None, title='Config loaded at startup') + wic0: Optional[str] = Field(None) + wic1: Optional[str] = Field(None) + wic2: Optional[str] = Field(None) + slot0: Optional[str] = Field(None) + slot1: Optional[str] = Field(None) + slot2: Optional[str] = Field(None) + slot3: Optional[str] = Field(None) + slot4: Optional[str] = Field(None) + slot5: Optional[str] = Field(None) + slot6: Optional[str] = Field(None) + midplane: Optional[DynamipsMidplane] = Field(None) + npe: Optional[DynamipsNpe] = Field(None) + + +class QemuPropertiesV8(BaseModel): + """Qemu template properties (v8)""" + + name: Optional[str] = Field(None, title='Name of the template') + category: Optional[Category] = Field(None, title='Category of the template') + default_name_format: Optional[str] = Field(None, title='Default name format') + usage: Optional[str] = Field(None, title='How to use the template') + symbol: Optional[str] = Field(None, title='Symbol of the template') + adapter_type: Optional[QemuAdapterType] = Field(None, title='Type of network adapter') + adapters: Optional[int] = Field(None, title='Number of adapters') + custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters') first_port_name: Optional[str] = Field( None, title='Optional name of the first networking port example: eth0' ) @@ -455,12 +544,179 @@ class Appliance(BaseModel): None, title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2', ) - linked_clone: Optional[bool] = Field( - None, title="False if you don't want to use a single image for all nodes" + linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes") + ram: Optional[int] = Field(None, title='Ram allocated to the appliance (MB)') + cpus: Optional[int] = Field(None, title='Number of Virtual CPU') + hda_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hda_disk_image') + hdb_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdb_disk_image') + hdc_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdc_disk_image') + hdd_disk_interface: Optional[QemuDiskInterface] = Field(None, title='Disk interface for the installed hdd_disk_image') + platform: Optional[QemuPlatform] = Field(None, title='Platform to emulate') + console_type: Optional[QemuConsoleType] = Field( + None, title='Type of console connection for the administration of the appliance' ) + boot_priority: Optional[QemuBootPriority] = Field( + None, + title='Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.', + ) + kernel_command_line: Optional[str] = Field(None, title='Command line parameters send to the kernel') + options: Optional[str] = Field(None, title='Optional additional qemu command line options') + cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU') + tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)') + uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode') + on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed') + process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU') + + +class TemplateSetting(BaseModel): + """Emulator settings configuration (v8)""" + + name: Optional[str] = Field(None, title='Name of the settings set') + default: Optional[bool] = Field(None, title='Whether these are the default settings') + inherit_default_properties: Optional[bool] = Field(True, title='Whether the default properties should be used') + template_type: TemplateType = Field(..., title='Type of emulator properties') + template_properties: Union[QemuPropertiesV8, DynamipsPropertiesV8, IouPropertiesV8, DockerPropertiesV8] = Field( + ..., + title='Properties for the template' + ) + + +class ApplianceVersionV8(BaseModel): + """Appliance version definition (v8)""" + + name: str = Field(..., title='Name of the version') + settings: Optional[str] = Field(None, title='Template settings to use to run the version') + category: Optional[Category] = Field(None, title='Category of the version') + installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version') + usage: Optional[str] = Field(None, title='Optional instructions about using the version') + default_username: Optional[str] = Field(None, title='Default username for the version') + default_password: Optional[str] = Field(None, title='Default password for the version') + symbol: Optional[str] = Field(None, title='An optional symbol for the version') + images: Optional[ApplianceVersionImages] = Field(None, title='Images used for this version') + + +# ============================================================================ +# Child Models with Discriminated Union +# ============================================================================ + +class ApplianceV1_6(BaseModel): + """GNS3 Appliance model for registry versions 1-6""" + + registry_version: Literal[1, 2, 3, 4, 5, 6] = Field(..., title='Version of the registry compatible with this appliance') + appliance_id: UUID = Field(..., title='Appliance ID') + name: str = Field(..., title='Appliance name') + builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not') + category: Category = Field(..., title='Category of the appliance') + description: str = Field(..., title='Description of the appliance. Could be a marketing description') + vendor_name: str = Field(..., title='Name of the vendor') + vendor_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='Website of the vendor') + documentation_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field( + None, + title='An optional documentation for using the appliance on vendor website' + ) + product_name: str = Field(..., title='Product name') + product_url: Optional[Union[AnyUrl, Annotated[str, Field(max_length=0)]]] = Field(None, title='An optional product url on vendor website') + status: Status = Field(..., title='Document if the appliance is working or not') + availability: Optional[Availability] = Field( + None, + title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly', + ) + maintainer: str = Field(..., title='Maintainer name') + maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field(None, title='Maintainer email') + usage: Optional[str] = Field(None, title='How to use the appliance') + symbol: Optional[str] = Field(None, title='An optional symbol for the appliance') + first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0') + port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}') + port_segment_size: Optional[int] = Field( + None, + title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2', + ) + linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes") docker: Optional[Docker] = Field(None, title='Docker specific options') 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') + + +class ApplianceV8(BaseModel): + """GNS3 Appliance model for registry version 8""" + + registry_version: Literal[8] = Field( + ..., + title='Version of the registry compatible with this appliance (version >=8 introduced breaking changes)', + ) + appliance_id: str = Field( + ..., + title='Appliance ID', + pattern=r'^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$', + ) + name: str = Field(..., title='Appliance name') + builtin: Optional[bool] = Field(None, title='Whether the appliance is builtin or not') + category: Category = Field(..., title='Category of the appliance') + description: str = Field(..., title='Description of the appliance. Could be a marketing description') + vendor_name: str = Field(..., title='Name of the vendor') + vendor_url: AnyUrl = Field(..., title='Website of the vendor') + vendor_logo_url: Optional[AnyUrl] = Field(None, title='Link to the vendor logo (used by the GNS3 marketplace)') + documentation_url: Optional[AnyUrl] = Field(None, title='An optional documentation for using the appliance on vendor website') + product_name: str = Field(..., title='Product name') + product_url: Optional[AnyUrl] = Field(None, title='An optional product url on vendor website') + status: Status = Field(..., title='Document if the appliance is working or not') + availability: Optional[Availability] = Field( + None, + title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly', + ) + maintainer: str = Field(..., title='Maintainer name') + maintainer_email: EmailStr = Field(..., title='Maintainer email') + installation_instructions: Optional[str] = Field(None, title='Optional installation instructions') + usage: Optional[str] = Field(None, title='How to use the appliance') + 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') + + +# ============================================================================ +# Discriminated Union +# ============================================================================ + +# Define the discriminated union type +ApplianceUnion = Annotated[ + Union[ + Annotated[ApplianceV1_6, Tag('v1_6')], + Annotated[ApplianceV8, Tag('v8')], + ], + Discriminator('registry_version'), +] +""" +Discriminated union type supporting both registry versions 1-6 and 8. +Uses registry_version field to automatically route to correct model. +""" + +# For type hints in function signatures +Appliance = ApplianceUnion + +# Create a validator wrapper for convenience +from pydantic import TypeAdapter + +_appliance_validator = TypeAdapter(ApplianceUnion) + + +class ApplianceModel: + """ + Wrapper class to provide model_validate() method for the Appliance union type. + This allows seamless usage of schemas.Appliance.model_validate() in existing code. + """ + + @staticmethod + def model_validate(data: dict) -> Union[ApplianceV1_6, ApplianceV8]: + """ + Validate appliance data and return appropriate model instance. + Automatically routes to ApplianceV1_6 or ApplianceV8 based on registry_version. + """ + return _appliance_validator.validate_python(data) diff --git a/gns3server/schemas/controller/chat.py b/gns3server/schemas/controller/chat.py new file mode 100644 index 000000000..36f58036e --- /dev/null +++ b/gns3server/schemas/controller/chat.py @@ -0,0 +1,117 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Chat API schemas for GNS3 Copilot integration. +""" + +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any, Literal + + +class OpenAIToolCall(BaseModel): + """Tool call information (OpenAI compatible format).""" + + id: str = Field(..., description="Tool call ID") + type: Literal["function"] = Field(default="function", description="Tool call type") + function: Dict[str, Any] = Field(..., description="Function name and arguments") + + +class ChatRequest(BaseModel): + """Chat request model.""" + + message: str = Field(..., description="User message content") + session_id: Optional[str] = Field(None, description="Session ID (auto-generated if not provided)") + stream: bool = Field(default=True, description="Enable streaming response") + temperature: Optional[float] = Field( + None, + description="LLM temperature parameter (NOTE: currently not used. " + "Temperature is loaded from user's LLM config in database. " + "Reserved for future runtime override support.)" + ) + mode: Literal["text"] = Field(default="text", description="Interaction mode") + + +class ChatResponse(BaseModel): + """Chat streaming response model.""" + + type: Literal[ + "content", # AI text content + "tool_call", # Tool call request + "tool_start", # Tool execution started + "tool_end", # Tool execution completed + "error", # Error message + "done", # Stream ended + "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") + tool_call: Optional[OpenAIToolCall] = Field(None, description="Tool call (for type=tool_call)") + tool_name: Optional[str] = Field(None, description="Tool name (for type=tool_start/end)") + tool_output: Optional[str] = Field(None, description="Tool output (for type=tool_end)") + error: Optional[str] = Field(None, description="Error message (for type=error)") + session_id: Optional[str] = Field(None, description="Session ID (for type=heartbeat/done)") + + +class OpenAIMessage(BaseModel): + """Message model for conversation history.""" + + id: str = Field(..., description="Message ID") + role: Literal["user", "assistant", "system", "tool"] = Field(..., description="Message role") + content: str = Field(..., description="Message content") + name: Optional[str] = Field(None, description="Tool message name") + tool_call_id: Optional[str] = Field(None, description="Associated tool call ID (for tool messages)") + tool_calls: Optional[List[OpenAIToolCall]] = Field(None, description="Tool calls (for assistant messages)") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Message metadata (includes created_at)") + + +class ConversationHistory(BaseModel): + """Conversation history model.""" + + thread_id: str = Field(..., description="Thread/session ID") + title: str = Field(..., description="Conversation title") + messages: List[OpenAIMessage] = Field(default_factory=list, description="Conversation messages") + created_at: Optional[str] = Field(None, description="Creation timestamp (ISO 8601)") + updated_at: Optional[str] = Field(None, description="Last update timestamp (ISO 8601)") + llm_calls: int = Field(default=0, description="Total LLM calls in this conversation") + + +class ChatSession(BaseModel): + """Chat session model.""" + + id: Optional[int] = Field(None, description="Database ID") + thread_id: str = Field(..., description="Thread/session ID") + user_id: str = Field(..., description="User ID") + project_id: str = Field(..., description="Associated GNS3 project ID") + title: str = Field(..., description="Session title") + message_count: int = Field(default=0, description="Number of messages") + llm_calls_count: int = Field(default=0, description="Number of LLM calls") + input_tokens: int = Field(default=0, description="Input tokens used") + output_tokens: int = Field(default=0, description="Output tokens generated") + total_tokens: int = Field(default=0, description="Total tokens used") + last_message_at: Optional[str] = Field(None, description="Last message timestamp (ISO 8601)") + created_at: Optional[str] = Field(None, description="Creation timestamp (ISO 8601)") + updated_at: Optional[str] = Field(None, description="Last update timestamp (ISO 8601)") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Session metadata") + stats: Dict[str, Any] = Field(default_factory=dict, description="Session statistics") + pinned: bool = Field(default=False, description="Whether the session is pinned to the top") + + +class RenameSession(BaseModel): + """Rename session request model.""" + + title: str = Field(..., description="New session title", min_length=1, max_length=255) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index cdcf6047f..258696ea3 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import BaseModel, Field -from typing import List, Optional +from typing import List, Optional, Tuple from enum import Enum from uuid import UUID, uuid4 @@ -47,6 +47,10 @@ class LinkStyle(BaseModel): color: Optional[str] = None width: Optional[int] = None type: Optional[int] = None + link_type: Optional[str] = None + bezier_curviness: Optional[int] = None + flowchart_roundness: Optional[int] = None + control_offset: Optional[Tuple[float, float]] = None class LinkBase(BaseModel): @@ -92,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): @@ -113,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 diff --git a/gns3server/schemas/controller/llm_model_configs.py b/gns3server/schemas/controller/llm_model_configs.py new file mode 100644 index 000000000..bcac68c1c --- /dev/null +++ b/gns3server/schemas/controller/llm_model_configs.py @@ -0,0 +1,207 @@ +# +# 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 . + +from typing import Optional, Literal, Union +from pydantic import BaseModel, Field, ConfigDict, field_validator +from uuid import UUID + +from .base import DateTimeModelMixin + + +# Valid model types +ModelType = Literal['text', 'vision', 'stt', 'tts', 'multimodal', 'embedding', 'reranking', 'other'] + + +# Core model config schema (stored in config JSONB field) +class LLMModelConfigData(BaseModel): + """ + LLM model configuration data. + Stored in the config JSONB column (provider, base_url, model, etc.). + + IMPORTANT: context_limit is REQUIRED to ensure proper context window management. + Model providers frequently update context limits, so users must configure this value. + + NOTE: context_limit unit is K tokens (1 K = 1000 tokens). + Example: 128 means 128K tokens (128,000 tokens). + """ + + provider: str = Field(..., description="LLM provider (e.g., 'openai', 'anthropic', 'ollama')") + base_url: str = Field(..., description="API base URL") + model: str = Field(..., description="Model name") + temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="Temperature parameter") + api_key: Optional[str] = Field(None, description="API key (will be encrypted)") + max_tokens: Optional[int] = Field(None, gt=0, description="Max tokens for generation") + context_limit: int = Field( + ..., gt=0, description="Model context window limit in K tokens (e.g., 128 = 128K tokens)" + ) + context_strategy: Literal["conservative", "balanced", "aggressive"] = Field( + "balanced", description="Context trimming strategy: conservative (60%), balanced (75%), aggressive (85%)" + ) + copilot_mode: Optional[str] = Field( + None, + description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + ) + + # Allow extra fields for extensibility + # Ensure all fields are included in serialization, even if None + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +# Request schemas +class LLMModelConfigCreate(BaseModel): + """Request to create a new LLM model configuration.""" + + name: str = Field(..., min_length=1, max_length=100, description="Configuration name") + model_type: ModelType = Field(..., description="Model type") + is_default: Optional[bool] = Field(False, description="Set as default configuration") + # Config fields + provider: str = Field(..., description="LLM provider") + base_url: str = Field(..., description="API base URL") + model: str = Field(..., description="Model name") + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + api_key: Optional[str] = None + max_tokens: Optional[int] = Field(None, gt=0) + context_limit: int = Field( + ..., gt=0, description="Model context window limit in K tokens (e.g., 128 = 128K tokens)" + ) + context_strategy: Literal["conservative", "balanced", "aggressive"] = Field( + "balanced", description="Context trimming strategy" + ) + copilot_mode: Optional[str] = Field( + None, + description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + ) + + # Allow extra config fields + model_config = ConfigDict(extra="allow") + + +class LLMModelConfigUpdate(BaseModel): + """Request to update an existing LLM model configuration.""" + + # Table-level fields + name: Optional[str] = Field(None, min_length=1, max_length=100) + model_type: Optional[ModelType] = None + is_default: Optional[bool] = None + expected_version: Optional[int] = Field(None, description="Expected version for optimistic locking") + + # Config fields + provider: Optional[str] = None + base_url: Optional[str] = None + model: Optional[str] = None + temperature: Optional[float] = Field(None, ge=0.0, le=2.0) + api_key: Optional[str] = None + max_tokens: Optional[Union[int, str]] = Field(None, description="Max tokens for generation (can be null)") + context_limit: Optional[int] = Field( + None, gt=0, description="Model context window limit in K tokens (e.g., 128 = 128K tokens)" + ) + context_strategy: Optional[Literal["conservative", "balanced", "aggressive"]] = Field( + None, description="Context trimming strategy" + ) + copilot_mode: Optional[str] = Field( + None, + description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + ) + + # Allow extra config fields + model_config = ConfigDict(extra="allow") + + @field_validator('max_tokens', mode='before') + @classmethod + def validate_max_tokens(cls, v): + """Handle string 'null' values for max_tokens.""" + if v == "null" or v == "": + return None + if v is None: + return None + # Convert to int if it's a valid integer string + if isinstance(v, str) and v.isdigit(): + return int(v) + return v + + +# Response schema without API key (for security) +class LLMModelConfigDataWithoutSecret(BaseModel): + """LLM model configuration data WITHOUT sensitive information.""" + + provider: str = Field(..., description="LLM provider (e.g., 'openai', 'anthropic', 'ollama')") + base_url: str = Field(..., description="API base URL") + model: str = Field(..., description="Model name") + temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="Temperature parameter") + api_key: Optional[str] = Field(None, description="API key (always hidden in API responses)") + max_tokens: Optional[int] = Field(None, gt=0, description="Max tokens for generation") + context_limit: int = Field( + ..., gt=0, description="Model context window limit in K tokens (e.g., 128 = 128K tokens)" + ) + context_strategy: Literal["conservative", "balanced", "aggressive"] = Field( + "balanced", description="Context trimming strategy: conservative (60%), balanced (75%), aggressive (85%)" + ) + copilot_mode: Optional[str] = Field( + None, + description="GNS3-Copilot mode: 'teaching_assistant' or 'lab_automation_assistant'" + ) + + # Allow extra fields for extensibility + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +# Response schemas +class LLMModelConfigResponse(DateTimeModelMixin): + """LLM model configuration response (without API key for security).""" + + config_id: UUID + name: str + model_type: ModelType + config: LLMModelConfigDataWithoutSecret + user_id: Optional[UUID] = None + group_id: Optional[UUID] = None + is_default: bool + version: int = Field(..., description="Optimistic locking version") + + model_config = ConfigDict(from_attributes=True) + + +class LLMModelConfigWithSource(DateTimeModelMixin): + """Model configuration with source information (for inheritance, without API key for security).""" + + config_id: UUID + name: str + model_type: ModelType + config: LLMModelConfigDataWithoutSecret + user_id: Optional[UUID] = None + group_id: Optional[UUID] = None + is_default: bool + version: int + source: str = Field(..., description="Source: 'user' or 'group'") + group_name: Optional[str] = Field(None, description="Group name if source is 'group'") + + model_config = ConfigDict(from_attributes=True, extra="allow") + + +class LLMModelConfigInheritedResponse(BaseModel): + """Response containing user's effective configs (own + inherited from groups).""" + + configs: list[LLMModelConfigWithSource] + default_config: Optional[LLMModelConfigWithSource] = None + total: int + + +class LLMModelConfigListResponse(BaseModel): + """Response containing a list of model configurations with default.""" + + configs: list[LLMModelConfigResponse] + default_config: Optional[LLMModelConfigResponse] = None + total: int diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index d732c80a9..840bfdce6 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field, model_validator -from typing import List, Optional, Union, Any +from pydantic import BaseModel, Field +from typing import List, Optional, Union from enum import Enum from uuid import UUID, uuid4 @@ -128,11 +128,15 @@ class NodeBase(BaseModel): z: Optional[int] = 1 locked: Optional[bool] = Field(False, description="Whether the element locked or not") port_name_format: Optional[str] = Field( - None, descript_port_name_formation="Formatting for port name {0} will be replace by port number" + None, description="Formatting for port name {0} will be replace by port number" ) port_segment_size: Optional[int] = Field(None, description="Size of the port segment") first_port_name: Optional[str] = Field(None, description="Name of the first port") custom_adapters: Optional[List[CustomAdapter]] = None + tags: Optional[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + ) class NodeCreate(NodeBase): diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index 4d98e7c5b..f537b122f 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -96,6 +96,7 @@ class Project(ProjectBase): name: Optional[str] = None status: Optional[ProjectStatus] = None filename: Optional[str] = None + created_by: Optional[str] = Field(None, description="Username of the user who created the project") class ProjectFile(BaseModel): @@ -104,6 +105,18 @@ class ProjectFile(BaseModel): md5sum: str = Field(..., description="File checksum") +class NodeFile(BaseModel): + """ + Detailed file information for node files. + """ + + path: str = Field(..., description="File name") + size: int = Field(..., description="File size in bytes") + created_at: str = Field(..., description="File creation time (ISO 8601)") + modified_at: str = Field(..., description="File modification time (ISO 8601)") + extension: str = Field(..., description="File extension") + + class ProjectCompression(str, Enum): """ Supported project compression. diff --git a/gns3server/schemas/controller/snapshots.py b/gns3server/schemas/controller/snapshots.py index 90a7667eb..cb6657fba 100644 --- a/gns3server/schemas/controller/snapshots.py +++ b/gns3server/schemas/controller/snapshots.py @@ -16,6 +16,7 @@ from pydantic import BaseModel, Field +from typing import Optional from uuid import UUID @@ -24,8 +25,8 @@ class SnapshotBase(BaseModel): Common properties for snapshot. """ - name: str - + name: str = Field(..., description="Name of the snapshot") + description: Optional[str] = Field(None, description="Description of the snapshot") class SnapshotCreate(SnapshotBase): """ @@ -39,4 +40,7 @@ class Snapshot(SnapshotBase): snapshot_id: UUID project_id: UUID + name: str = Field(..., description="Name of the snapshot") + filename: str = Field(..., description="Filename of the snapshot") + description: str = Field(..., description="Description of the snapshot") created_at: int = Field(..., description="Date of the snapshot (UTC timestamp)") diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 78cd9cc25..dee74b7b9 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import ConfigDict, BaseModel, Field -from typing import Optional, Union +from typing import Optional, List from enum import Enum from uuid import UUID @@ -48,6 +48,10 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" + tags: Optional[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + ) class TemplateCreate(TemplateBase): diff --git a/gns3server/schemas/controller/templates/iou_templates.py b/gns3server/schemas/controller/templates/iou_templates.py index e906f74f6..6dd83d14b 100644 --- a/gns3server/schemas/controller/templates/iou_templates.py +++ b/gns3server/schemas/controller/templates/iou_templates.py @@ -30,9 +30,9 @@ class IOUTemplate(TemplateBase): path: str = Field(..., description="Path of IOU executable") ethernet_adapters: Optional[int] = Field(2, ge=0, description="Number of ethernet adapters") serial_adapters: Optional[int] = Field(2, ge=0, description="Number of serial adapters") - ram: Optional[int] = Field(256, gt=0, description="Amount of RAM in MB") - nvram: Optional[int] = Field(128, gt=0, description="Amount of NVRAM in KB") - use_default_iou_values: Optional[bool] = Field(True, description="Use default IOU values") + ram: Optional[int] = Field(1024, gt=0, description="Amount of RAM in MB") + nvram: Optional[int] = Field(256, gt=0, description="Amount of NVRAM in KB") + use_default_iou_values: Optional[bool] = Field(False, description="Use default IOU values") startup_config: Optional[str] = Field("iou_l3_base_startup-config.txt", description="Startup-config of IOU") private_config: Optional[str] = Field("", description="Private-config of IOU") l1_keepalives: Optional[bool] = Field(False, description="Always keep up Ethernet interface (does not always work)") diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index 2adc4b2bd..86c1a9377 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -27,3 +27,4 @@ class Token(BaseModel): class TokenData(BaseModel): username: Optional[str] = None + token_version: int = 0 diff --git a/gns3server/schemas/controller/topology.py b/gns3server/schemas/controller/topology.py index d62930eea..1b9dcfddc 100644 --- a/gns3server/schemas/controller/topology.py +++ b/gns3server/schemas/controller/topology.py @@ -66,6 +66,7 @@ class Topology(BaseModel): show_interface_labels: Optional[bool] = Field(None, description="Show interface labels on the drawing area") supplier: Optional[Supplier] = Field(None, description="Supplier of the project") variables: Optional[List[Variable]] = Field(None, description="Variables required to run the project") + created_by: Optional[str] = Field(None, description="Username of the user who created the project") def main(): diff --git a/gns3server/server.py b/gns3server/server.py index 6ae18fdfb..be2b62564 100644 --- a/gns3server/server.py +++ b/gns3server/server.py @@ -240,7 +240,10 @@ class Server: self._set_config_defaults_from_command_line(args) config = Config.instance().settings - if not config.Server.compute_password.get_secret_value(): + + if not config.Server.enable_http_auth: + log.info("Compute authentication is disabled") + elif not config.Server.compute_password.get_secret_value(): alphabet = string.ascii_letters + string.digits + string.punctuation generated_password = ''.join(secrets.choice(alphabet) for _ in range(16)) config.Server.compute_password = SecretStr(generated_password) diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index c9bfaa565..9b9c6ffa7 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -45,12 +45,12 @@ class AuthService: return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8')) - def create_access_token(self, username, secret_key: str = None, expires_in: int = 0) -> str: + def create_access_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str: if not expires_in: expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in) - to_encode = {"sub": username, "exp": expire} + to_encode = {"sub": username, "exp": expire, "ver": token_version} if secret_key is None: secret_key = Config.instance().settings.Controller.jwt_secret_key if secret_key is None: @@ -61,7 +61,7 @@ class AuthService: encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key) return encoded_jwt - def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]: + def get_token_data(self, token: str, secret_key: str = None) -> TokenData: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -80,7 +80,11 @@ class AuthService: username: str = payload.claims.get("sub") if username is None: raise credentials_exception - token_data = TokenData(username=username) + token_version: int = payload.claims.get("ver", 0) + token_data = TokenData(username=username, token_version=token_version) except (JoseError, ValidationError, ValueError): raise credentials_exception - return token_data.username + return token_data + + def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]: + return self.get_token_data(token, secret_key).username diff --git a/gns3server/services/templates.py b/gns3server/services/templates.py index 0589f6797..92b78633e 100644 --- a/gns3server/services/templates.py +++ b/gns3server/services/templates.py @@ -110,6 +110,7 @@ BUILTIN_TEMPLATES = [ "base_script_file": "vpcs_base_config.txt", "compute_id": None, "builtin": True, + "tags": ["device_type:gns3_vpcs_telnet"], }, { "template_id": uuid.uuid5(uuid.NAMESPACE_X500, "ethernet_switch"), @@ -197,7 +198,7 @@ class TemplatesService: images_to_add_to_template = [] if template_type == "dynamips": - if settings["image"]: + if settings.get("image"): image = await self._find_image(settings["image"]) if image.image_type != "ios": raise ControllerBadRequestError( @@ -205,7 +206,7 @@ class TemplatesService: ) images_to_add_to_template.append(image) elif template_type == "iou": - if settings["path"]: + if settings.get("path"): image = await self._find_image(settings["path"]) if image.image_type != "iou": raise ControllerBadRequestError( @@ -280,6 +281,14 @@ class TemplatesService: if not db_template: raise ControllerNotFoundError(f"Template '{template_id}' not found") + # Check for duplicate name (excluding current template) + if template_update.name is not None: + existing_template = await self._templates_repo.get_template_by_name_and_version( + template_update.name, db_template.version + ) + if existing_template and existing_template.template_id != template_id: + raise ControllerError(f"A template with name '{template_update.name}' already exists") + try: # validate the update settings update_settings = jsonable_encoder(template_update, exclude_unset=True) diff --git a/gns3server/static/swagger-ui-bundle.js b/gns3server/static/swagger-ui-bundle.js index 452621990..bf4fc1a17 100644 --- a/gns3server/static/swagger-ui-bundle.js +++ b/gns3server/static/swagger-ui-bundle.js @@ -1,2 +1,2 @@ /*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ -!function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s,o,i={69119:(s,o)=>{"use strict";Object.defineProperty(o,"__esModule",{value:!0}),o.BLANK_URL=o.relativeFirstCharacters=o.whitespaceEscapeCharsRegex=o.urlSchemeRegex=o.ctrlCharactersRegex=o.htmlCtrlEntityRegex=o.htmlEntitiesRegex=o.invalidProtocolRegex=void 0,o.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,o.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g,o.htmlCtrlEntityRegex=/&(newline|tab);/gi,o.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,o.urlSchemeRegex=/^.+(:|:)/gim,o.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g,o.relativeFirstCharacters=[".","/"],o.BLANK_URL="about:blank"},16750:(s,o,i)=>{"use strict";o.J=void 0;var u=i(69119);function decodeURI(s){try{return decodeURIComponent(s)}catch(o){return s}}o.J=function sanitizeUrl(s){if(!s)return u.BLANK_URL;var o,i,_=decodeURI(s);do{o=(_=decodeURI(_=(i=_,i.replace(u.ctrlCharactersRegex,"").replace(u.htmlEntitiesRegex,(function(s,o){return String.fromCharCode(o)}))).replace(u.htmlCtrlEntityRegex,"").replace(u.ctrlCharactersRegex,"").replace(u.whitespaceEscapeCharsRegex,"").trim())).match(u.ctrlCharactersRegex)||_.match(u.htmlEntitiesRegex)||_.match(u.htmlCtrlEntityRegex)||_.match(u.whitespaceEscapeCharsRegex)}while(o&&o.length>0);var w=_;if(!w)return u.BLANK_URL;if(function isRelativeUrlWithoutProtocol(s){return u.relativeFirstCharacters.indexOf(s[0])>-1}(w))return w;var x=w.match(u.urlSchemeRegex);if(!x)return w;var C=x[0];return u.invalidProtocolRegex.test(C)?u.BLANK_URL:w}},67526:(s,o)=>{"use strict";o.byteLength=function byteLength(s){var o=getLens(s),i=o[0],u=o[1];return 3*(i+u)/4-u},o.toByteArray=function toByteArray(s){var o,i,w=getLens(s),x=w[0],C=w[1],j=new _(function _byteLength(s,o,i){return 3*(o+i)/4-i}(0,x,C)),L=0,B=C>0?x-4:x;for(i=0;i>16&255,j[L++]=o>>8&255,j[L++]=255&o;2===C&&(o=u[s.charCodeAt(i)]<<2|u[s.charCodeAt(i+1)]>>4,j[L++]=255&o);1===C&&(o=u[s.charCodeAt(i)]<<10|u[s.charCodeAt(i+1)]<<4|u[s.charCodeAt(i+2)]>>2,j[L++]=o>>8&255,j[L++]=255&o);return j},o.fromByteArray=function fromByteArray(s){for(var o,u=s.length,_=u%3,w=[],x=16383,C=0,j=u-_;Cj?j:C+x));1===_?(o=s[u-1],w.push(i[o>>2]+i[o<<4&63]+"==")):2===_&&(o=(s[u-2]<<8)+s[u-1],w.push(i[o>>10]+i[o>>4&63]+i[o<<2&63]+"="));return w.join("")};for(var i=[],u=[],_="undefined"!=typeof Uint8Array?Uint8Array:Array,w="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",x=0;x<64;++x)i[x]=w[x],u[w.charCodeAt(x)]=x;function getLens(s){var o=s.length;if(o%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=s.indexOf("=");return-1===i&&(i=o),[i,i===o?0:4-i%4]}function encodeChunk(s,o,u){for(var _,w,x=[],C=o;C>18&63]+i[w>>12&63]+i[w>>6&63]+i[63&w]);return x.join("")}u["-".charCodeAt(0)]=62,u["_".charCodeAt(0)]=63},48287:(s,o,i)=>{"use strict";const u=i(67526),_=i(251),w="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;o.Buffer=Buffer,o.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},o.INSPECT_MAX_BYTES=50;const x=2147483647;function createBuffer(s){if(s>x)throw new RangeError('The value "'+s+'" is invalid for option "size"');const o=new Uint8Array(s);return Object.setPrototypeOf(o,Buffer.prototype),o}function Buffer(s,o,i){if("number"==typeof s){if("string"==typeof o)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,o,i)}function from(s,o,i){if("string"==typeof s)return function fromString(s,o){"string"==typeof o&&""!==o||(o="utf8");if(!Buffer.isEncoding(o))throw new TypeError("Unknown encoding: "+o);const i=0|byteLength(s,o);let u=createBuffer(i);const _=u.write(s,o);_!==i&&(u=u.slice(0,_));return u}(s,o);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const o=new Uint8Array(s);return fromArrayBuffer(o.buffer,o.byteOffset,o.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,o,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,o,i);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const u=s.valueOf&&s.valueOf();if(null!=u&&u!==s)return Buffer.from(u,o,i);const _=function fromObject(s){if(Buffer.isBuffer(s)){const o=0|checked(s.length),i=createBuffer(o);return 0===i.length||s.copy(i,0,0,o),i}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(_)return _;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),o,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const o=s.length<0?0:0|checked(s.length),i=createBuffer(o);for(let u=0;u=x)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+x.toString(16)+" bytes");return 0|s}function byteLength(s,o){if(Buffer.isBuffer(s))return s.length;if(ArrayBuffer.isView(s)||isInstance(s,ArrayBuffer))return s.byteLength;if("string"!=typeof s)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof s);const i=s.length,u=arguments.length>2&&!0===arguments[2];if(!u&&0===i)return 0;let _=!1;for(;;)switch(o){case"ascii":case"latin1":case"binary":return i;case"utf8":case"utf-8":return utf8ToBytes(s).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*i;case"hex":return i>>>1;case"base64":return base64ToBytes(s).length;default:if(_)return u?-1:utf8ToBytes(s).length;o=(""+o).toLowerCase(),_=!0}}function slowToString(s,o,i){let u=!1;if((void 0===o||o<0)&&(o=0),o>this.length)return"";if((void 0===i||i>this.length)&&(i=this.length),i<=0)return"";if((i>>>=0)<=(o>>>=0))return"";for(s||(s="utf8");;)switch(s){case"hex":return hexSlice(this,o,i);case"utf8":case"utf-8":return utf8Slice(this,o,i);case"ascii":return asciiSlice(this,o,i);case"latin1":case"binary":return latin1Slice(this,o,i);case"base64":return base64Slice(this,o,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,o,i);default:if(u)throw new TypeError("Unknown encoding: "+s);s=(s+"").toLowerCase(),u=!0}}function swap(s,o,i){const u=s[o];s[o]=s[i],s[i]=u}function bidirectionalIndexOf(s,o,i,u,_){if(0===s.length)return-1;if("string"==typeof i?(u=i,i=0):i>2147483647?i=2147483647:i<-2147483648&&(i=-2147483648),numberIsNaN(i=+i)&&(i=_?0:s.length-1),i<0&&(i=s.length+i),i>=s.length){if(_)return-1;i=s.length-1}else if(i<0){if(!_)return-1;i=0}if("string"==typeof o&&(o=Buffer.from(o,u)),Buffer.isBuffer(o))return 0===o.length?-1:arrayIndexOf(s,o,i,u,_);if("number"==typeof o)return o&=255,"function"==typeof Uint8Array.prototype.indexOf?_?Uint8Array.prototype.indexOf.call(s,o,i):Uint8Array.prototype.lastIndexOf.call(s,o,i):arrayIndexOf(s,[o],i,u,_);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(s,o,i,u,_){let w,x=1,C=s.length,j=o.length;if(void 0!==u&&("ucs2"===(u=String(u).toLowerCase())||"ucs-2"===u||"utf16le"===u||"utf-16le"===u)){if(s.length<2||o.length<2)return-1;x=2,C/=2,j/=2,i/=2}function read(s,o){return 1===x?s[o]:s.readUInt16BE(o*x)}if(_){let u=-1;for(w=i;wC&&(i=C-j),w=i;w>=0;w--){let i=!0;for(let u=0;u_&&(u=_):u=_;const w=o.length;let x;for(u>w/2&&(u=w/2),x=0;x>8,_=i%256,w.push(_),w.push(u);return w}(o,s.length-i),s,i,u)}function base64Slice(s,o,i){return 0===o&&i===s.length?u.fromByteArray(s):u.fromByteArray(s.slice(o,i))}function utf8Slice(s,o,i){i=Math.min(s.length,i);const u=[];let _=o;for(;_239?4:o>223?3:o>191?2:1;if(_+x<=i){let i,u,C,j;switch(x){case 1:o<128&&(w=o);break;case 2:i=s[_+1],128==(192&i)&&(j=(31&o)<<6|63&i,j>127&&(w=j));break;case 3:i=s[_+1],u=s[_+2],128==(192&i)&&128==(192&u)&&(j=(15&o)<<12|(63&i)<<6|63&u,j>2047&&(j<55296||j>57343)&&(w=j));break;case 4:i=s[_+1],u=s[_+2],C=s[_+3],128==(192&i)&&128==(192&u)&&128==(192&C)&&(j=(15&o)<<18|(63&i)<<12|(63&u)<<6|63&C,j>65535&&j<1114112&&(w=j))}}null===w?(w=65533,x=1):w>65535&&(w-=65536,u.push(w>>>10&1023|55296),w=56320|1023&w),u.push(w),_+=x}return function decodeCodePointsArray(s){const o=s.length;if(o<=C)return String.fromCharCode.apply(String,s);let i="",u=0;for(;uu.length?(Buffer.isBuffer(o)||(o=Buffer.from(o)),o.copy(u,_)):Uint8Array.prototype.set.call(u,o,_);else{if(!Buffer.isBuffer(o))throw new TypeError('"list" argument must be an Array of Buffers');o.copy(u,_)}_+=o.length}return u},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const s=this.length;if(s%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let o=0;oi&&(s+=" ... "),""},w&&(Buffer.prototype[w]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(s,o,i,u,_){if(isInstance(s,Uint8Array)&&(s=Buffer.from(s,s.offset,s.byteLength)),!Buffer.isBuffer(s))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof s);if(void 0===o&&(o=0),void 0===i&&(i=s?s.length:0),void 0===u&&(u=0),void 0===_&&(_=this.length),o<0||i>s.length||u<0||_>this.length)throw new RangeError("out of range index");if(u>=_&&o>=i)return 0;if(u>=_)return-1;if(o>=i)return 1;if(this===s)return 0;let w=(_>>>=0)-(u>>>=0),x=(i>>>=0)-(o>>>=0);const C=Math.min(w,x),j=this.slice(u,_),L=s.slice(o,i);for(let s=0;s>>=0,isFinite(i)?(i>>>=0,void 0===u&&(u="utf8")):(u=i,i=void 0)}const _=this.length-o;if((void 0===i||i>_)&&(i=_),s.length>0&&(i<0||o<0)||o>this.length)throw new RangeError("Attempt to write outside buffer bounds");u||(u="utf8");let w=!1;for(;;)switch(u){case"hex":return hexWrite(this,s,o,i);case"utf8":case"utf-8":return utf8Write(this,s,o,i);case"ascii":case"latin1":case"binary":return asciiWrite(this,s,o,i);case"base64":return base64Write(this,s,o,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,s,o,i);default:if(w)throw new TypeError("Unknown encoding: "+u);u=(""+u).toLowerCase(),w=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const C=4096;function asciiSlice(s,o,i){let u="";i=Math.min(s.length,i);for(let _=o;_u)&&(i=u);let _="";for(let u=o;ui)throw new RangeError("Trying to access beyond buffer length")}function checkInt(s,o,i,u,_,w){if(!Buffer.isBuffer(s))throw new TypeError('"buffer" argument must be a Buffer instance');if(o>_||os.length)throw new RangeError("Index out of range")}function wrtBigUInt64LE(s,o,i,u,_){checkIntBI(o,u,_,s,i,7);let w=Number(o&BigInt(4294967295));s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w,w>>=8,s[i++]=w;let x=Number(o>>BigInt(32)&BigInt(4294967295));return s[i++]=x,x>>=8,s[i++]=x,x>>=8,s[i++]=x,x>>=8,s[i++]=x,i}function wrtBigUInt64BE(s,o,i,u,_){checkIntBI(o,u,_,s,i,7);let w=Number(o&BigInt(4294967295));s[i+7]=w,w>>=8,s[i+6]=w,w>>=8,s[i+5]=w,w>>=8,s[i+4]=w;let x=Number(o>>BigInt(32)&BigInt(4294967295));return s[i+3]=x,x>>=8,s[i+2]=x,x>>=8,s[i+1]=x,x>>=8,s[i]=x,i+8}function checkIEEE754(s,o,i,u,_,w){if(i+u>s.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("Index out of range")}function writeFloat(s,o,i,u,w){return o=+o,i>>>=0,w||checkIEEE754(s,0,i,4),_.write(s,o,i,u,23,4),i+4}function writeDouble(s,o,i,u,w){return o=+o,i>>>=0,w||checkIEEE754(s,0,i,8),_.write(s,o,i,u,52,8),i+8}Buffer.prototype.slice=function slice(s,o){const i=this.length;(s=~~s)<0?(s+=i)<0&&(s=0):s>i&&(s=i),(o=void 0===o?i:~~o)<0?(o+=i)<0&&(o=0):o>i&&(o=i),o>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s],_=1,w=0;for(;++w>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s+--o],_=1;for(;o>0&&(_*=256);)u+=this[s+--o]*_;return u},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),this[s]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]|this[s+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(s,o){return s>>>=0,o||checkOffset(s,2,this.length),this[s]<<8|this[s+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),(this[s]|this[s+1]<<8|this[s+2]<<16)+16777216*this[s+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),16777216*this[s]+(this[s+1]<<16|this[s+2]<<8|this[s+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(s){validateNumber(s>>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=o+256*this[++s]+65536*this[++s]+this[++s]*2**24,_=this[++s]+256*this[++s]+65536*this[++s]+i*2**24;return BigInt(u)+(BigInt(_)<>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=o*2**24+65536*this[++s]+256*this[++s]+this[++s],_=this[++s]*2**24+65536*this[++s]+256*this[++s]+i;return(BigInt(u)<>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=this[s],_=1,w=0;for(;++w=_&&(u-=Math.pow(2,8*o)),u},Buffer.prototype.readIntBE=function readIntBE(s,o,i){s>>>=0,o>>>=0,i||checkOffset(s,o,this.length);let u=o,_=1,w=this[s+--u];for(;u>0&&(_*=256);)w+=this[s+--u]*_;return _*=128,w>=_&&(w-=Math.pow(2,8*o)),w},Buffer.prototype.readInt8=function readInt8(s,o){return s>>>=0,o||checkOffset(s,1,this.length),128&this[s]?-1*(255-this[s]+1):this[s]},Buffer.prototype.readInt16LE=function readInt16LE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s]|this[s+1]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt16BE=function readInt16BE(s,o){s>>>=0,o||checkOffset(s,2,this.length);const i=this[s+1]|this[s]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt32LE=function readInt32LE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]|this[s+1]<<8|this[s+2]<<16|this[s+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),this[s]<<24|this[s+1]<<16|this[s+2]<<8|this[s+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(s){validateNumber(s>>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=this[s+4]+256*this[s+5]+65536*this[s+6]+(i<<24);return(BigInt(u)<>>=0,"offset");const o=this[s],i=this[s+7];void 0!==o&&void 0!==i||boundsError(s,this.length-8);const u=(o<<24)+65536*this[++s]+256*this[++s]+this[++s];return(BigInt(u)<>>=0,o||checkOffset(s,4,this.length),_.read(this,s,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(s,o){return s>>>=0,o||checkOffset(s,4,this.length),_.read(this,s,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),_.read(this,s,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(s,o){return s>>>=0,o||checkOffset(s,8,this.length),_.read(this,s,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(s,o,i,u){if(s=+s,o>>>=0,i>>>=0,!u){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let _=1,w=0;for(this[o]=255&s;++w>>=0,i>>>=0,!u){checkInt(this,s,o,i,Math.pow(2,8*i)-1,0)}let _=i-1,w=1;for(this[o+_]=255&s;--_>=0&&(w*=256);)this[o+_]=s/w&255;return o+i},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,255,0),this[o]=255&s,o+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,65535,0),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o+3]=s>>>24,this[o+2]=s>>>16,this[o+1]=s>>>8,this[o]=255&s,o+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,4294967295,0),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeIntLE=function writeIntLE(s,o,i,u){if(s=+s,o>>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,s,o,i,u-1,-u)}let _=0,w=1,x=0;for(this[o]=255&s;++_>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,s,o,i,u-1,-u)}let _=i-1,w=1,x=0;for(this[o+_]=255&s;--_>=0&&(w*=256);)s<0&&0===x&&0!==this[o+_+1]&&(x=1),this[o+_]=(s/w|0)-x&255;return o+i},Buffer.prototype.writeInt8=function writeInt8(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,1,127,-128),s<0&&(s=255+s+1),this[o]=255&s,o+1},Buffer.prototype.writeInt16LE=function writeInt16LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=255&s,this[o+1]=s>>>8,o+2},Buffer.prototype.writeInt16BE=function writeInt16BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,2,32767,-32768),this[o]=s>>>8,this[o+1]=255&s,o+2},Buffer.prototype.writeInt32LE=function writeInt32LE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),this[o]=255&s,this[o+1]=s>>>8,this[o+2]=s>>>16,this[o+3]=s>>>24,o+4},Buffer.prototype.writeInt32BE=function writeInt32BE(s,o,i){return s=+s,o>>>=0,i||checkInt(this,s,o,4,2147483647,-2147483648),s<0&&(s=4294967295+s+1),this[o]=s>>>24,this[o+1]=s>>>16,this[o+2]=s>>>8,this[o+3]=255&s,o+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(s,o=0){return wrtBigUInt64LE(this,s,o,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(s,o=0){return wrtBigUInt64BE(this,s,o,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(s,o,i){return writeFloat(this,s,o,!0,i)},Buffer.prototype.writeFloatBE=function writeFloatBE(s,o,i){return writeFloat(this,s,o,!1,i)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(s,o,i){return writeDouble(this,s,o,!0,i)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(s,o,i){return writeDouble(this,s,o,!1,i)},Buffer.prototype.copy=function copy(s,o,i,u){if(!Buffer.isBuffer(s))throw new TypeError("argument should be a Buffer");if(i||(i=0),u||0===u||(u=this.length),o>=s.length&&(o=s.length),o||(o=0),u>0&&u=this.length)throw new RangeError("Index out of range");if(u<0)throw new RangeError("sourceEnd out of bounds");u>this.length&&(u=this.length),s.length-o>>=0,i=void 0===i?this.length:i>>>0,s||(s=0),"number"==typeof s)for(_=o;_=u+4;i-=3)o=`_${s.slice(i-3,i)}${o}`;return`${s.slice(0,i)}${o}`}function checkIntBI(s,o,i,u,_,w){if(s>i||s3?0===o||o===BigInt(0)?`>= 0${u} and < 2${u} ** ${8*(w+1)}${u}`:`>= -(2${u} ** ${8*(w+1)-1}${u}) and < 2 ** ${8*(w+1)-1}${u}`:`>= ${o}${u} and <= ${i}${u}`,new j.ERR_OUT_OF_RANGE("value",_,s)}!function checkBounds(s,o,i){validateNumber(o,"offset"),void 0!==s[o]&&void 0!==s[o+i]||boundsError(o,s.length-(i+1))}(u,_,w)}function validateNumber(s,o){if("number"!=typeof s)throw new j.ERR_INVALID_ARG_TYPE(o,"number",s)}function boundsError(s,o,i){if(Math.floor(s)!==s)throw validateNumber(s,i),new j.ERR_OUT_OF_RANGE(i||"offset","an integer",s);if(o<0)throw new j.ERR_BUFFER_OUT_OF_BOUNDS;throw new j.ERR_OUT_OF_RANGE(i||"offset",`>= ${i?1:0} and <= ${o}`,s)}E("ERR_BUFFER_OUT_OF_BOUNDS",(function(s){return s?`${s} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),E("ERR_INVALID_ARG_TYPE",(function(s,o){return`The "${s}" argument must be of type number. Received type ${typeof o}`}),TypeError),E("ERR_OUT_OF_RANGE",(function(s,o,i){let u=`The value of "${s}" is out of range.`,_=i;return Number.isInteger(i)&&Math.abs(i)>2**32?_=addNumericalSeparator(String(i)):"bigint"==typeof i&&(_=String(i),(i>BigInt(2)**BigInt(32)||i<-(BigInt(2)**BigInt(32)))&&(_=addNumericalSeparator(_)),_+="n"),u+=` It must be ${o}. Received ${_}`,u}),RangeError);const L=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(s,o){let i;o=o||1/0;const u=s.length;let _=null;const w=[];for(let x=0;x55295&&i<57344){if(!_){if(i>56319){(o-=3)>-1&&w.push(239,191,189);continue}if(x+1===u){(o-=3)>-1&&w.push(239,191,189);continue}_=i;continue}if(i<56320){(o-=3)>-1&&w.push(239,191,189),_=i;continue}i=65536+(_-55296<<10|i-56320)}else _&&(o-=3)>-1&&w.push(239,191,189);if(_=null,i<128){if((o-=1)<0)break;w.push(i)}else if(i<2048){if((o-=2)<0)break;w.push(i>>6|192,63&i|128)}else if(i<65536){if((o-=3)<0)break;w.push(i>>12|224,i>>6&63|128,63&i|128)}else{if(!(i<1114112))throw new Error("Invalid code point");if((o-=4)<0)break;w.push(i>>18|240,i>>12&63|128,i>>6&63|128,63&i|128)}}return w}function base64ToBytes(s){return u.toByteArray(function base64clean(s){if((s=(s=s.split("=")[0]).trim().replace(L,"")).length<2)return"";for(;s.length%4!=0;)s+="=";return s}(s))}function blitBuffer(s,o,i,u){let _;for(_=0;_=o.length||_>=s.length);++_)o[_+i]=s[_];return _}function isInstance(s,o){return s instanceof o||null!=s&&null!=s.constructor&&null!=s.constructor.name&&s.constructor.name===o.name}function numberIsNaN(s){return s!=s}const B=function(){const s="0123456789abcdef",o=new Array(256);for(let i=0;i<16;++i){const u=16*i;for(let _=0;_<16;++_)o[u+_]=s[i]+s[_]}return o}();function defineBigIntMethod(s){return"undefined"==typeof BigInt?BufferBigIntNotDefined:s}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}},17965:(s,o,i)=>{"use strict";var u=i(16426),_={"text/plain":"Text","text/html":"Url",default:"Text"};s.exports=function copy(s,o){var i,w,x,C,j,L,B=!1;o||(o={}),i=o.debug||!1;try{if(x=u(),C=document.createRange(),j=document.getSelection(),(L=document.createElement("span")).textContent=s,L.ariaHidden="true",L.style.all="unset",L.style.position="fixed",L.style.top=0,L.style.clip="rect(0, 0, 0, 0)",L.style.whiteSpace="pre",L.style.webkitUserSelect="text",L.style.MozUserSelect="text",L.style.msUserSelect="text",L.style.userSelect="text",L.addEventListener("copy",(function(u){if(u.stopPropagation(),o.format)if(u.preventDefault(),void 0===u.clipboardData){i&&console.warn("unable to use e.clipboardData"),i&&console.warn("trying IE specific stuff"),window.clipboardData.clearData();var w=_[o.format]||_.default;window.clipboardData.setData(w,s)}else u.clipboardData.clearData(),u.clipboardData.setData(o.format,s);o.onCopy&&(u.preventDefault(),o.onCopy(u.clipboardData))})),document.body.appendChild(L),C.selectNodeContents(L),j.addRange(C),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");B=!0}catch(u){i&&console.error("unable to copy using execCommand: ",u),i&&console.warn("trying IE specific stuff");try{window.clipboardData.setData(o.format||"text",s),o.onCopy&&o.onCopy(window.clipboardData),B=!0}catch(u){i&&console.error("unable to copy using clipboardData: ",u),i&&console.error("falling back to prompt"),w=function format(s){var o=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return s.replace(/#{\s*key\s*}/g,o)}("message"in o?o.message:"Copy to clipboard: #{key}, Enter"),window.prompt(w,s)}}finally{j&&("function"==typeof j.removeRange?j.removeRange(C):j.removeAllRanges()),L&&document.body.removeChild(L),x()}return B}},2205:function(s,o,i){var u;u=void 0!==i.g?i.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var o,i=String(s),u=i.length,_=-1,w="",x=i.charCodeAt(0);++_=1&&o<=31||127==o||0==_&&o>=48&&o<=57||1==_&&o>=48&&o<=57&&45==x?"\\"+o.toString(16)+" ":0==_&&1==u&&45==o||!(o>=128||45==o||95==o||o>=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)?"\\"+i.charAt(_):i.charAt(_):w+="�";return w};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(u)},81919:(s,o,i)=>{"use strict";var u=i(48287).Buffer;function isSpecificValue(s){return s instanceof u||s instanceof Date||s instanceof RegExp}function cloneSpecificValue(s){if(s instanceof u){var o=u.alloc?u.alloc(s.length):new u(s.length);return s.copy(o),o}if(s instanceof Date)return new Date(s.getTime());if(s instanceof RegExp)return new RegExp(s);throw new Error("Unexpected situation")}function deepCloneArray(s){var o=[];return s.forEach((function(s,i){"object"==typeof s&&null!==s?Array.isArray(s)?o[i]=deepCloneArray(s):isSpecificValue(s)?o[i]=cloneSpecificValue(s):o[i]=_({},s):o[i]=s})),o}function safeGetProperty(s,o){return"__proto__"===o?void 0:s[o]}var _=s.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var s,o,i=arguments[0];return Array.prototype.slice.call(arguments,1).forEach((function(u){"object"!=typeof u||null===u||Array.isArray(u)||Object.keys(u).forEach((function(w){return o=safeGetProperty(i,w),(s=safeGetProperty(u,w))===i?void 0:"object"!=typeof s||null===s?void(i[w]=s):Array.isArray(s)?void(i[w]=deepCloneArray(s)):isSpecificValue(s)?void(i[w]=cloneSpecificValue(s)):"object"!=typeof o||null===o||Array.isArray(o)?void(i[w]=_({},s)):void(i[w]=_(o,s))}))})),i}},14744:s=>{"use strict";var o=function isMergeableObject(s){return function isNonNullObject(s){return!!s&&"object"==typeof s}(s)&&!function isSpecial(s){var o=Object.prototype.toString.call(s);return"[object RegExp]"===o||"[object Date]"===o||function isReactElement(s){return s.$$typeof===i}(s)}(s)};var i="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function cloneUnlessOtherwiseSpecified(s,o){return!1!==o.clone&&o.isMergeableObject(s)?deepmerge(function emptyTarget(s){return Array.isArray(s)?[]:{}}(s),s,o):s}function defaultArrayMerge(s,o,i){return s.concat(o).map((function(s){return cloneUnlessOtherwiseSpecified(s,i)}))}function getKeys(s){return Object.keys(s).concat(function getEnumerableOwnPropertySymbols(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter((function(o){return Object.propertyIsEnumerable.call(s,o)})):[]}(s))}function propertyIsOnObject(s,o){try{return o in s}catch(s){return!1}}function mergeObject(s,o,i){var u={};return i.isMergeableObject(s)&&getKeys(s).forEach((function(o){u[o]=cloneUnlessOtherwiseSpecified(s[o],i)})),getKeys(o).forEach((function(_){(function propertyIsUnsafe(s,o){return propertyIsOnObject(s,o)&&!(Object.hasOwnProperty.call(s,o)&&Object.propertyIsEnumerable.call(s,o))})(s,_)||(propertyIsOnObject(s,_)&&i.isMergeableObject(o[_])?u[_]=function getMergeFunction(s,o){if(!o.customMerge)return deepmerge;var i=o.customMerge(s);return"function"==typeof i?i:deepmerge}(_,i)(s[_],o[_],i):u[_]=cloneUnlessOtherwiseSpecified(o[_],i))})),u}function deepmerge(s,i,u){(u=u||{}).arrayMerge=u.arrayMerge||defaultArrayMerge,u.isMergeableObject=u.isMergeableObject||o,u.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var _=Array.isArray(i);return _===Array.isArray(s)?_?u.arrayMerge(s,i,u):mergeObject(s,i,u):cloneUnlessOtherwiseSpecified(i,u)}deepmerge.all=function deepmergeAll(s,o){if(!Array.isArray(s))throw new Error("first argument should be an array");return s.reduce((function(s,i){return deepmerge(s,i,o)}),{})};var u=deepmerge;s.exports=u},42838:function(s){s.exports=function(){"use strict";const{entries:s,setPrototypeOf:o,isFrozen:i,getPrototypeOf:u,getOwnPropertyDescriptor:_}=Object;let{freeze:w,seal:x,create:C}=Object,{apply:j,construct:L}="undefined"!=typeof Reflect&&Reflect;w||(w=function freeze(s){return s}),x||(x=function seal(s){return s}),j||(j=function apply(s,o,i){return s.apply(o,i)}),L||(L=function construct(s,o){return new s(...o)});const B=unapply(Array.prototype.forEach),$=unapply(Array.prototype.pop),V=unapply(Array.prototype.push),U=unapply(String.prototype.toLowerCase),z=unapply(String.prototype.toString),Y=unapply(String.prototype.match),Z=unapply(String.prototype.replace),ee=unapply(String.prototype.indexOf),ie=unapply(String.prototype.trim),ae=unapply(Object.prototype.hasOwnProperty),le=unapply(RegExp.prototype.test),ce=unconstruct(TypeError);function unapply(s){return function(o){for(var i=arguments.length,u=new Array(i>1?i-1:0),_=1;_2&&void 0!==arguments[2]?arguments[2]:U;o&&o(s,null);let w=u.length;for(;w--;){let o=u[w];if("string"==typeof o){const s=_(o);s!==o&&(i(u)||(u[w]=s),o=s)}s[o]=!0}return s}function cleanArray(s){for(let o=0;o/gm),$e=x(/\${[\w\W]*}/gm),ze=x(/^data-[\-\w.\u00B7-\uFFFF]/),We=x(/^aria-[\-\w]+$/),He=x(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Ye=x(/^(?:\w+script|data):/i),Xe=x(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Qe=x(/^html$/i),et=x(/^[a-z][.\w]*(-[.\w]+)+$/i);var tt=Object.freeze({__proto__:null,MUSTACHE_EXPR:Re,ERB_EXPR:qe,TMPLIT_EXPR:$e,DATA_ATTR:ze,ARIA_ATTR:We,IS_ALLOWED_URI:He,IS_SCRIPT_OR_DATA:Ye,ATTR_WHITESPACE:Xe,DOCTYPE_NAME:Qe,CUSTOM_ELEMENT:et});const rt={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},nt=function getGlobal(){return"undefined"==typeof window?null:window},st=function _createTrustedTypesPolicy(s,o){if("object"!=typeof s||"function"!=typeof s.createPolicy)return null;let i=null;const u="data-tt-policy-suffix";o&&o.hasAttribute(u)&&(i=o.getAttribute(u));const _="dompurify"+(i?"#"+i:"");try{return s.createPolicy(_,{createHTML:s=>s,createScriptURL:s=>s})}catch(s){return console.warn("TrustedTypes policy "+_+" could not be created."),null}};function createDOMPurify(){let o=arguments.length>0&&void 0!==arguments[0]?arguments[0]:nt();const DOMPurify=s=>createDOMPurify(s);if(DOMPurify.version="3.1.6",DOMPurify.removed=[],!o||!o.document||o.document.nodeType!==rt.document)return DOMPurify.isSupported=!1,DOMPurify;let{document:i}=o;const u=i,_=u.currentScript,{DocumentFragment:x,HTMLTemplateElement:j,Node:L,Element:Re,NodeFilter:qe,NamedNodeMap:$e=o.NamedNodeMap||o.MozNamedAttrMap,HTMLFormElement:ze,DOMParser:We,trustedTypes:Ye}=o,Xe=Re.prototype,et=lookupGetter(Xe,"cloneNode"),ot=lookupGetter(Xe,"remove"),it=lookupGetter(Xe,"nextSibling"),at=lookupGetter(Xe,"childNodes"),lt=lookupGetter(Xe,"parentNode");if("function"==typeof j){const s=i.createElement("template");s.content&&s.content.ownerDocument&&(i=s.content.ownerDocument)}let ct,ut="";const{implementation:pt,createNodeIterator:ht,createDocumentFragment:dt,getElementsByTagName:mt}=i,{importNode:gt}=u;let yt={};DOMPurify.isSupported="function"==typeof s&&"function"==typeof lt&&pt&&void 0!==pt.createHTMLDocument;const{MUSTACHE_EXPR:vt,ERB_EXPR:bt,TMPLIT_EXPR:_t,DATA_ATTR:Et,ARIA_ATTR:wt,IS_SCRIPT_OR_DATA:St,ATTR_WHITESPACE:xt,CUSTOM_ELEMENT:kt}=tt;let{IS_ALLOWED_URI:Ct}=tt,Ot=null;const At=addToSet({},[...pe,...de,...fe,...be,...we]);let jt=null;const It=addToSet({},[...Se,...xe,...Pe,...Te]);let Pt=Object.seal(C(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Mt=null,Tt=null,Nt=!0,Rt=!0,Dt=!1,Lt=!0,Bt=!1,Ft=!0,qt=!1,$t=!1,Vt=!1,Ut=!1,zt=!1,Wt=!1,Kt=!0,Ht=!1;const Jt="user-content-";let Gt=!0,Yt=!1,Xt={},Zt=null;const Qt=addToSet({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let er=null;const tr=addToSet({},["audio","video","img","source","image","track"]);let rr=null;const nr=addToSet({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),sr="http://www.w3.org/1998/Math/MathML",ir="http://www.w3.org/2000/svg",ar="http://www.w3.org/1999/xhtml";let lr=ar,cr=!1,ur=null;const pr=addToSet({},[sr,ir,ar],z);let dr=null;const fr=["application/xhtml+xml","text/html"],mr="text/html";let gr=null,yr=null;const vr=i.createElement("form"),br=function isRegexOrFunction(s){return s instanceof RegExp||s instanceof Function},_r=function _parseConfig(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!yr||yr!==s){if(s&&"object"==typeof s||(s={}),s=clone(s),dr=-1===fr.indexOf(s.PARSER_MEDIA_TYPE)?mr:s.PARSER_MEDIA_TYPE,gr="application/xhtml+xml"===dr?z:U,Ot=ae(s,"ALLOWED_TAGS")?addToSet({},s.ALLOWED_TAGS,gr):At,jt=ae(s,"ALLOWED_ATTR")?addToSet({},s.ALLOWED_ATTR,gr):It,ur=ae(s,"ALLOWED_NAMESPACES")?addToSet({},s.ALLOWED_NAMESPACES,z):pr,rr=ae(s,"ADD_URI_SAFE_ATTR")?addToSet(clone(nr),s.ADD_URI_SAFE_ATTR,gr):nr,er=ae(s,"ADD_DATA_URI_TAGS")?addToSet(clone(tr),s.ADD_DATA_URI_TAGS,gr):tr,Zt=ae(s,"FORBID_CONTENTS")?addToSet({},s.FORBID_CONTENTS,gr):Qt,Mt=ae(s,"FORBID_TAGS")?addToSet({},s.FORBID_TAGS,gr):{},Tt=ae(s,"FORBID_ATTR")?addToSet({},s.FORBID_ATTR,gr):{},Xt=!!ae(s,"USE_PROFILES")&&s.USE_PROFILES,Nt=!1!==s.ALLOW_ARIA_ATTR,Rt=!1!==s.ALLOW_DATA_ATTR,Dt=s.ALLOW_UNKNOWN_PROTOCOLS||!1,Lt=!1!==s.ALLOW_SELF_CLOSE_IN_ATTR,Bt=s.SAFE_FOR_TEMPLATES||!1,Ft=!1!==s.SAFE_FOR_XML,qt=s.WHOLE_DOCUMENT||!1,Ut=s.RETURN_DOM||!1,zt=s.RETURN_DOM_FRAGMENT||!1,Wt=s.RETURN_TRUSTED_TYPE||!1,Vt=s.FORCE_BODY||!1,Kt=!1!==s.SANITIZE_DOM,Ht=s.SANITIZE_NAMED_PROPS||!1,Gt=!1!==s.KEEP_CONTENT,Yt=s.IN_PLACE||!1,Ct=s.ALLOWED_URI_REGEXP||He,lr=s.NAMESPACE||ar,Pt=s.CUSTOM_ELEMENT_HANDLING||{},s.CUSTOM_ELEMENT_HANDLING&&br(s.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Pt.tagNameCheck=s.CUSTOM_ELEMENT_HANDLING.tagNameCheck),s.CUSTOM_ELEMENT_HANDLING&&br(s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Pt.attributeNameCheck=s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),s.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Pt.allowCustomizedBuiltInElements=s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Bt&&(Rt=!1),zt&&(Ut=!0),Xt&&(Ot=addToSet({},we),jt=[],!0===Xt.html&&(addToSet(Ot,pe),addToSet(jt,Se)),!0===Xt.svg&&(addToSet(Ot,de),addToSet(jt,xe),addToSet(jt,Te)),!0===Xt.svgFilters&&(addToSet(Ot,fe),addToSet(jt,xe),addToSet(jt,Te)),!0===Xt.mathMl&&(addToSet(Ot,be),addToSet(jt,Pe),addToSet(jt,Te))),s.ADD_TAGS&&(Ot===At&&(Ot=clone(Ot)),addToSet(Ot,s.ADD_TAGS,gr)),s.ADD_ATTR&&(jt===It&&(jt=clone(jt)),addToSet(jt,s.ADD_ATTR,gr)),s.ADD_URI_SAFE_ATTR&&addToSet(rr,s.ADD_URI_SAFE_ATTR,gr),s.FORBID_CONTENTS&&(Zt===Qt&&(Zt=clone(Zt)),addToSet(Zt,s.FORBID_CONTENTS,gr)),Gt&&(Ot["#text"]=!0),qt&&addToSet(Ot,["html","head","body"]),Ot.table&&(addToSet(Ot,["tbody"]),delete Mt.tbody),s.TRUSTED_TYPES_POLICY){if("function"!=typeof s.TRUSTED_TYPES_POLICY.createHTML)throw ce('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof s.TRUSTED_TYPES_POLICY.createScriptURL)throw ce('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ct=s.TRUSTED_TYPES_POLICY,ut=ct.createHTML("")}else void 0===ct&&(ct=st(Ye,_)),null!==ct&&"string"==typeof ut&&(ut=ct.createHTML(""));w&&w(s),yr=s}},Er=addToSet({},["mi","mo","mn","ms","mtext"]),wr=addToSet({},["foreignobject","annotation-xml"]),Sr=addToSet({},["title","style","font","a","script"]),xr=addToSet({},[...de,...fe,...ye]),kr=addToSet({},[...be,..._e]),Cr=function _checkValidNamespace(s){let o=lt(s);o&&o.tagName||(o={namespaceURI:lr,tagName:"template"});const i=U(s.tagName),u=U(o.tagName);return!!ur[s.namespaceURI]&&(s.namespaceURI===ir?o.namespaceURI===ar?"svg"===i:o.namespaceURI===sr?"svg"===i&&("annotation-xml"===u||Er[u]):Boolean(xr[i]):s.namespaceURI===sr?o.namespaceURI===ar?"math"===i:o.namespaceURI===ir?"math"===i&&wr[u]:Boolean(kr[i]):s.namespaceURI===ar?!(o.namespaceURI===ir&&!wr[u])&&!(o.namespaceURI===sr&&!Er[u])&&!kr[i]&&(Sr[i]||!xr[i]):!("application/xhtml+xml"!==dr||!ur[s.namespaceURI]))},Or=function _forceRemove(s){V(DOMPurify.removed,{element:s});try{lt(s).removeChild(s)}catch(o){ot(s)}},Ar=function _removeAttribute(s,o){try{V(DOMPurify.removed,{attribute:o.getAttributeNode(s),from:o})}catch(s){V(DOMPurify.removed,{attribute:null,from:o})}if(o.removeAttribute(s),"is"===s&&!jt[s])if(Ut||zt)try{Or(o)}catch(s){}else try{o.setAttribute(s,"")}catch(s){}},jr=function _initDocument(s){let o=null,u=null;if(Vt)s=""+s;else{const o=Y(s,/^[\r\n\t ]+/);u=o&&o[0]}"application/xhtml+xml"===dr&&lr===ar&&(s=''+s+"");const _=ct?ct.createHTML(s):s;if(lr===ar)try{o=(new We).parseFromString(_,dr)}catch(s){}if(!o||!o.documentElement){o=pt.createDocument(lr,"template",null);try{o.documentElement.innerHTML=cr?ut:_}catch(s){}}const w=o.body||o.documentElement;return s&&u&&w.insertBefore(i.createTextNode(u),w.childNodes[0]||null),lr===ar?mt.call(o,qt?"html":"body")[0]:qt?o.documentElement:w},Ir=function _createNodeIterator(s){return ht.call(s.ownerDocument||s,s,qe.SHOW_ELEMENT|qe.SHOW_COMMENT|qe.SHOW_TEXT|qe.SHOW_PROCESSING_INSTRUCTION|qe.SHOW_CDATA_SECTION,null)},Pr=function _isClobbered(s){return s instanceof ze&&("string"!=typeof s.nodeName||"string"!=typeof s.textContent||"function"!=typeof s.removeChild||!(s.attributes instanceof $e)||"function"!=typeof s.removeAttribute||"function"!=typeof s.setAttribute||"string"!=typeof s.namespaceURI||"function"!=typeof s.insertBefore||"function"!=typeof s.hasChildNodes)},Mr=function _isNode(s){return"function"==typeof L&&s instanceof L},Tr=function _executeHook(s,o,i){yt[s]&&B(yt[s],(s=>{s.call(DOMPurify,o,i,yr)}))},Nr=function _sanitizeElements(s){let o=null;if(Tr("beforeSanitizeElements",s,null),Pr(s))return Or(s),!0;const i=gr(s.nodeName);if(Tr("uponSanitizeElement",s,{tagName:i,allowedTags:Ot}),s.hasChildNodes()&&!Mr(s.firstElementChild)&&le(/<[/\w]/g,s.innerHTML)&&le(/<[/\w]/g,s.textContent))return Or(s),!0;if(s.nodeType===rt.progressingInstruction)return Or(s),!0;if(Ft&&s.nodeType===rt.comment&&le(/<[/\w]/g,s.data))return Or(s),!0;if(!Ot[i]||Mt[i]){if(!Mt[i]&&Dr(i)){if(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,i))return!1;if(Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(i))return!1}if(Gt&&!Zt[i]){const o=lt(s)||s.parentNode,i=at(s)||s.childNodes;if(i&&o)for(let u=i.length-1;u>=0;--u){const _=et(i[u],!0);_.__removalCount=(s.__removalCount||0)+1,o.insertBefore(_,it(s))}}return Or(s),!0}return s instanceof Re&&!Cr(s)?(Or(s),!0):"noscript"!==i&&"noembed"!==i&&"noframes"!==i||!le(/<\/no(script|embed|frames)/i,s.innerHTML)?(Bt&&s.nodeType===rt.text&&(o=s.textContent,B([vt,bt,_t],(s=>{o=Z(o,s," ")})),s.textContent!==o&&(V(DOMPurify.removed,{element:s.cloneNode()}),s.textContent=o)),Tr("afterSanitizeElements",s,null),!1):(Or(s),!0)},Rr=function _isValidAttribute(s,o,u){if(Kt&&("id"===o||"name"===o)&&(u in i||u in vr))return!1;if(Rt&&!Tt[o]&&le(Et,o));else if(Nt&&le(wt,o));else if(!jt[o]||Tt[o]){if(!(Dr(s)&&(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,s)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(s))&&(Pt.attributeNameCheck instanceof RegExp&&le(Pt.attributeNameCheck,o)||Pt.attributeNameCheck instanceof Function&&Pt.attributeNameCheck(o))||"is"===o&&Pt.allowCustomizedBuiltInElements&&(Pt.tagNameCheck instanceof RegExp&&le(Pt.tagNameCheck,u)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(u))))return!1}else if(rr[o]);else if(le(Ct,Z(u,xt,"")));else if("src"!==o&&"xlink:href"!==o&&"href"!==o||"script"===s||0!==ee(u,"data:")||!er[s])if(Dt&&!le(St,Z(u,xt,"")));else if(u)return!1;return!0},Dr=function _isBasicCustomElement(s){return"annotation-xml"!==s&&Y(s,kt)},Lr=function _sanitizeAttributes(s){Tr("beforeSanitizeAttributes",s,null);const{attributes:o}=s;if(!o)return;const i={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:jt};let u=o.length;for(;u--;){const _=o[u],{name:w,namespaceURI:x,value:C}=_,j=gr(w);let L="value"===w?C:ie(C);if(i.attrName=j,i.attrValue=L,i.keepAttr=!0,i.forceKeepAttr=void 0,Tr("uponSanitizeAttribute",s,i),L=i.attrValue,Ft&&le(/((--!?|])>)|<\/(style|title)/i,L)){Ar(w,s);continue}if(i.forceKeepAttr)continue;if(Ar(w,s),!i.keepAttr)continue;if(!Lt&&le(/\/>/i,L)){Ar(w,s);continue}Bt&&B([vt,bt,_t],(s=>{L=Z(L,s," ")}));const V=gr(s.nodeName);if(Rr(V,j,L)){if(!Ht||"id"!==j&&"name"!==j||(Ar(w,s),L=Jt+L),ct&&"object"==typeof Ye&&"function"==typeof Ye.getAttributeType)if(x);else switch(Ye.getAttributeType(V,j)){case"TrustedHTML":L=ct.createHTML(L);break;case"TrustedScriptURL":L=ct.createScriptURL(L)}try{x?s.setAttributeNS(x,w,L):s.setAttribute(w,L),Pr(s)?Or(s):$(DOMPurify.removed)}catch(s){}}}Tr("afterSanitizeAttributes",s,null)},Br=function _sanitizeShadowDOM(s){let o=null;const i=Ir(s);for(Tr("beforeSanitizeShadowDOM",s,null);o=i.nextNode();)Tr("uponSanitizeShadowNode",o,null),Nr(o)||(o.content instanceof x&&_sanitizeShadowDOM(o.content),Lr(o));Tr("afterSanitizeShadowDOM",s,null)};return DOMPurify.sanitize=function(s){let o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=null,_=null,w=null,C=null;if(cr=!s,cr&&(s="\x3c!--\x3e"),"string"!=typeof s&&!Mr(s)){if("function"!=typeof s.toString)throw ce("toString is not a function");if("string"!=typeof(s=s.toString()))throw ce("dirty is not a string, aborting")}if(!DOMPurify.isSupported)return s;if($t||_r(o),DOMPurify.removed=[],"string"==typeof s&&(Yt=!1),Yt){if(s.nodeName){const o=gr(s.nodeName);if(!Ot[o]||Mt[o])throw ce("root node is forbidden and cannot be sanitized in-place")}}else if(s instanceof L)i=jr("\x3c!----\x3e"),_=i.ownerDocument.importNode(s,!0),_.nodeType===rt.element&&"BODY"===_.nodeName||"HTML"===_.nodeName?i=_:i.appendChild(_);else{if(!Ut&&!Bt&&!qt&&-1===s.indexOf("<"))return ct&&Wt?ct.createHTML(s):s;if(i=jr(s),!i)return Ut?null:Wt?ut:""}i&&Vt&&Or(i.firstChild);const j=Ir(Yt?s:i);for(;w=j.nextNode();)Nr(w)||(w.content instanceof x&&Br(w.content),Lr(w));if(Yt)return s;if(Ut){if(zt)for(C=dt.call(i.ownerDocument);i.firstChild;)C.appendChild(i.firstChild);else C=i;return(jt.shadowroot||jt.shadowrootmode)&&(C=gt.call(u,C,!0)),C}let $=qt?i.outerHTML:i.innerHTML;return qt&&Ot["!doctype"]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&le(Qe,i.ownerDocument.doctype.name)&&($="\n"+$),Bt&&B([vt,bt,_t],(s=>{$=Z($,s," ")})),ct&&Wt?ct.createHTML($):$},DOMPurify.setConfig=function(){_r(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),$t=!0},DOMPurify.clearConfig=function(){yr=null,$t=!1},DOMPurify.isValidAttribute=function(s,o,i){yr||_r({});const u=gr(s),_=gr(o);return Rr(u,_,i)},DOMPurify.addHook=function(s,o){"function"==typeof o&&(yt[s]=yt[s]||[],V(yt[s],o))},DOMPurify.removeHook=function(s){if(yt[s])return $(yt[s])},DOMPurify.removeHooks=function(s){yt[s]&&(yt[s]=[])},DOMPurify.removeAllHooks=function(){yt={}},DOMPurify}return createDOMPurify()}()},78004:s=>{"use strict";class SubRange{constructor(s,o){this.low=s,this.high=o,this.length=1+o-s}overlaps(s){return!(this.highs.high)}touches(s){return!(this.high+1s.high)}add(s){return new SubRange(Math.min(this.low,s.low),Math.max(this.high,s.high))}subtract(s){return s.low<=this.low&&s.high>=this.high?[]:s.low>this.low&&s.highs+o.length),0)}add(s,o){var _add=s=>{for(var o=0;o{for(var o=0;o{for(var o=0;o{for(var i=o.low;i<=o.high;)s.push(i),i++;return s}),[])}subranges(){return this.ranges.map((s=>({low:s.low,high:s.high,length:1+s.high-s.low})))}}s.exports=DRange},37007:s=>{"use strict";var o,i="object"==typeof Reflect?Reflect:null,u=i&&"function"==typeof i.apply?i.apply:function ReflectApply(s,o,i){return Function.prototype.apply.call(s,o,i)};o=i&&"function"==typeof i.ownKeys?i.ownKeys:Object.getOwnPropertySymbols?function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s).concat(Object.getOwnPropertySymbols(s))}:function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s)};var _=Number.isNaN||function NumberIsNaN(s){return s!=s};function EventEmitter(){EventEmitter.init.call(this)}s.exports=EventEmitter,s.exports.once=function once(s,o){return new Promise((function(i,u){function errorListener(i){s.removeListener(o,resolver),u(i)}function resolver(){"function"==typeof s.removeListener&&s.removeListener("error",errorListener),i([].slice.call(arguments))}eventTargetAgnosticAddListener(s,o,resolver,{once:!0}),"error"!==o&&function addErrorHandlerIfEventEmitter(s,o,i){"function"==typeof s.on&&eventTargetAgnosticAddListener(s,"error",o,i)}(s,errorListener,{once:!0})}))},EventEmitter.EventEmitter=EventEmitter,EventEmitter.prototype._events=void 0,EventEmitter.prototype._eventsCount=0,EventEmitter.prototype._maxListeners=void 0;var w=10;function checkListener(s){if("function"!=typeof s)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof s)}function _getMaxListeners(s){return void 0===s._maxListeners?EventEmitter.defaultMaxListeners:s._maxListeners}function _addListener(s,o,i,u){var _,w,x;if(checkListener(i),void 0===(w=s._events)?(w=s._events=Object.create(null),s._eventsCount=0):(void 0!==w.newListener&&(s.emit("newListener",o,i.listener?i.listener:i),w=s._events),x=w[o]),void 0===x)x=w[o]=i,++s._eventsCount;else if("function"==typeof x?x=w[o]=u?[i,x]:[x,i]:u?x.unshift(i):x.push(i),(_=_getMaxListeners(s))>0&&x.length>_&&!x.warned){x.warned=!0;var C=new Error("Possible EventEmitter memory leak detected. "+x.length+" "+String(o)+" listeners added. Use emitter.setMaxListeners() to increase limit");C.name="MaxListenersExceededWarning",C.emitter=s,C.type=o,C.count=x.length,function ProcessEmitWarning(s){console&&console.warn&&console.warn(s)}(C)}return s}function onceWrapper(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function _onceWrap(s,o,i){var u={fired:!1,wrapFn:void 0,target:s,type:o,listener:i},_=onceWrapper.bind(u);return _.listener=i,u.wrapFn=_,_}function _listeners(s,o,i){var u=s._events;if(void 0===u)return[];var _=u[o];return void 0===_?[]:"function"==typeof _?i?[_.listener||_]:[_]:i?function unwrapListeners(s){for(var o=new Array(s.length),i=0;i0&&(x=o[0]),x instanceof Error)throw x;var C=new Error("Unhandled error."+(x?" ("+x.message+")":""));throw C.context=x,C}var j=w[s];if(void 0===j)return!1;if("function"==typeof j)u(j,this,o);else{var L=j.length,B=arrayClone(j,L);for(i=0;i=0;w--)if(i[w]===o||i[w].listener===o){x=i[w].listener,_=w;break}if(_<0)return this;0===_?i.shift():function spliceOne(s,o){for(;o+1=0;u--)this.removeListener(s,o[u]);return this},EventEmitter.prototype.listeners=function listeners(s){return _listeners(this,s,!0)},EventEmitter.prototype.rawListeners=function rawListeners(s){return _listeners(this,s,!1)},EventEmitter.listenerCount=function(s,o){return"function"==typeof s.listenerCount?s.listenerCount(o):listenerCount.call(s,o)},EventEmitter.prototype.listenerCount=listenerCount,EventEmitter.prototype.eventNames=function eventNames(){return this._eventsCount>0?o(this._events):[]}},85587:(s,o,i)=>{"use strict";var u=i(26311),_=create(Error);function create(s){return FormattedError.displayName=s.displayName||s.name,FormattedError;function FormattedError(o){return o&&(o=u.apply(null,arguments)),new s(o)}}s.exports=_,_.eval=create(EvalError),_.range=create(RangeError),_.reference=create(ReferenceError),_.syntax=create(SyntaxError),_.type=create(TypeError),_.uri=create(URIError),_.create=create},26311:s=>{!function(){var o;function format(s){for(var o,i,u,_,w=1,x=[].slice.call(arguments),C=0,j=s.length,L="",B=!1,$=!1,nextArg=function(){return x[w++]},slurpNumber=function(){for(var i="";/\d/.test(s[C]);)i+=s[C++],o=s[C];return i.length>0?parseInt(i):null};C{function deepFreeze(s){return s instanceof Map?s.clear=s.delete=s.set=function(){throw new Error("map is read-only")}:s instanceof Set&&(s.add=s.clear=s.delete=function(){throw new Error("set is read-only")}),Object.freeze(s),Object.getOwnPropertyNames(s).forEach((function(o){var i=s[o];"object"!=typeof i||Object.isFrozen(i)||deepFreeze(i)})),s}var o=deepFreeze,i=deepFreeze;o.default=i;class Response{constructor(s){void 0===s.data&&(s.data={}),this.data=s.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function escapeHTML(s){return s.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function inherit(s,...o){const i=Object.create(null);for(const o in s)i[o]=s[o];return o.forEach((function(s){for(const o in s)i[o]=s[o]})),i}const emitsWrappingTags=s=>!!s.kind;class HTMLRenderer{constructor(s,o){this.buffer="",this.classPrefix=o.classPrefix,s.walk(this)}addText(s){this.buffer+=escapeHTML(s)}openNode(s){if(!emitsWrappingTags(s))return;let o=s.kind;s.sublanguage||(o=`${this.classPrefix}${o}`),this.span(o)}closeNode(s){emitsWrappingTags(s)&&(this.buffer+="")}value(){return this.buffer}span(s){this.buffer+=``}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(s){this.top.children.push(s)}openNode(s){const o={kind:s,children:[]};this.add(o),this.stack.push(o)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(s){return this.constructor._walk(s,this.rootNode)}static _walk(s,o){return"string"==typeof o?s.addText(o):o.children&&(s.openNode(o),o.children.forEach((o=>this._walk(s,o))),s.closeNode(o)),s}static _collapse(s){"string"!=typeof s&&s.children&&(s.children.every((s=>"string"==typeof s))?s.children=[s.children.join("")]:s.children.forEach((s=>{TokenTree._collapse(s)})))}}class TokenTreeEmitter extends TokenTree{constructor(s){super(),this.options=s}addKeyword(s,o){""!==s&&(this.openNode(o),this.addText(s),this.closeNode())}addText(s){""!==s&&this.add(s)}addSublanguage(s,o){const i=s.root;i.kind=o,i.sublanguage=!0,this.add(i)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(s){return s?"string"==typeof s?s:s.source:null}const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;const _="[a-zA-Z]\\w*",w="[a-zA-Z_]\\w*",x="\\b\\d+(\\.\\d+)?",C="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",j="\\b(0b[01]+)",L={begin:"\\\\[\\s\\S]",relevance:0},B={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[L]},$={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[L]},V={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT=function(s,o,i={}){const u=inherit({className:"comment",begin:s,end:o,contains:[]},i);return u.contains.push(V),u.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),u},U=COMMENT("//","$"),z=COMMENT("/\\*","\\*/"),Y=COMMENT("#","$"),Z={className:"number",begin:x,relevance:0},ee={className:"number",begin:C,relevance:0},ie={className:"number",begin:j,relevance:0},ae={className:"number",begin:x+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},le={begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[L,{begin:/\[/,end:/\]/,relevance:0,contains:[L]}]}]},ce={className:"title",begin:_,relevance:0},pe={className:"title",begin:w,relevance:0},de={begin:"\\.\\s*"+w,relevance:0};var fe=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:_,UNDERSCORE_IDENT_RE:w,NUMBER_RE:x,C_NUMBER_RE:C,BINARY_NUMBER_RE:j,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(s={})=>{const o=/^#![ ]*\//;return s.binary&&(s.begin=function concat(...s){return s.map((s=>source(s))).join("")}(o,/.*\b/,s.binary,/\b.*/)),inherit({className:"meta",begin:o,end:/$/,relevance:0,"on:begin":(s,o)=>{0!==s.index&&o.ignoreMatch()}},s)},BACKSLASH_ESCAPE:L,APOS_STRING_MODE:B,QUOTE_STRING_MODE:$,PHRASAL_WORDS_MODE:V,COMMENT,C_LINE_COMMENT_MODE:U,C_BLOCK_COMMENT_MODE:z,HASH_COMMENT_MODE:Y,NUMBER_MODE:Z,C_NUMBER_MODE:ee,BINARY_NUMBER_MODE:ie,CSS_NUMBER_MODE:ae,REGEXP_MODE:le,TITLE_MODE:ce,UNDERSCORE_TITLE_MODE:pe,METHOD_GUARD:de,END_SAME_AS_BEGIN:function(s){return Object.assign(s,{"on:begin":(s,o)=>{o.data._beginMatch=s[1]},"on:end":(s,o)=>{o.data._beginMatch!==s[1]&&o.ignoreMatch()}})}});function skipIfhasPrecedingDot(s,o){"."===s.input[s.index-1]&&o.ignoreMatch()}function beginKeywords(s,o){o&&s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",s.__beforeBegin=skipIfhasPrecedingDot,s.keywords=s.keywords||s.beginKeywords,delete s.beginKeywords,void 0===s.relevance&&(s.relevance=0))}function compileIllegal(s,o){Array.isArray(s.illegal)&&(s.illegal=function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}(...s.illegal))}function compileMatch(s,o){if(s.match){if(s.begin||s.end)throw new Error("begin & end are not supported with match");s.begin=s.match,delete s.match}}function compileRelevance(s,o){void 0===s.relevance&&(s.relevance=1)}const ye=["of","and","for","in","not","or","if","then","parent","list","value"];function compileKeywords(s,o,i="keyword"){const u={};return"string"==typeof s?compileList(i,s.split(" ")):Array.isArray(s)?compileList(i,s):Object.keys(s).forEach((function(i){Object.assign(u,compileKeywords(s[i],o,i))})),u;function compileList(s,i){o&&(i=i.map((s=>s.toLowerCase()))),i.forEach((function(o){const i=o.split("|");u[i[0]]=[s,scoreForKeyword(i[0],i[1])]}))}}function scoreForKeyword(s,o){return o?Number(o):function commonKeyword(s){return ye.includes(s.toLowerCase())}(s)?0:1}function compileLanguage(s,{plugins:o}){function langRe(o,i){return new RegExp(source(o),"m"+(s.case_insensitive?"i":"")+(i?"g":""))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(s,o){o.position=this.position++,this.matchIndexes[this.matchAt]=o,this.regexes.push([o,s]),this.matchAt+=function countMatchGroups(s){return new RegExp(s.toString()+"|").exec("").length-1}(s)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const s=this.regexes.map((s=>s[1]));this.matcherRe=langRe(function join(s,o="|"){let i=0;return s.map((s=>{i+=1;const o=i;let _=source(s),w="";for(;_.length>0;){const s=u.exec(_);if(!s){w+=_;break}w+=_.substring(0,s.index),_=_.substring(s.index+s[0].length),"\\"===s[0][0]&&s[1]?w+="\\"+String(Number(s[1])+o):(w+=s[0],"("===s[0]&&i++)}return w})).map((s=>`(${s})`)).join(o)}(s),!0),this.lastIndex=0}exec(s){this.matcherRe.lastIndex=this.lastIndex;const o=this.matcherRe.exec(s);if(!o)return null;const i=o.findIndex(((s,o)=>o>0&&void 0!==s)),u=this.matchIndexes[i];return o.splice(0,i),Object.assign(o,u)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(s){if(this.multiRegexes[s])return this.multiRegexes[s];const o=new MultiRegex;return this.rules.slice(s).forEach((([s,i])=>o.addRule(s,i))),o.compile(),this.multiRegexes[s]=o,o}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(s,o){this.rules.push([s,o]),"begin"===o.type&&this.count++}exec(s){const o=this.getMatcher(this.regexIndex);o.lastIndex=this.lastIndex;let i=o.exec(s);if(this.resumingScanAtSamePosition())if(i&&i.index===this.lastIndex);else{const o=this.getMatcher(0);o.lastIndex=this.lastIndex+1,i=o.exec(s)}return i&&(this.regexIndex+=i.position+1,this.regexIndex===this.count&&this.considerAll()),i}}if(s.compilerExtensions||(s.compilerExtensions=[]),s.contains&&s.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return s.classNameAliases=inherit(s.classNameAliases||{}),function compileMode(o,i){const u=o;if(o.isCompiled)return u;[compileMatch].forEach((s=>s(o,i))),s.compilerExtensions.forEach((s=>s(o,i))),o.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((s=>s(o,i))),o.isCompiled=!0;let _=null;if("object"==typeof o.keywords&&(_=o.keywords.$pattern,delete o.keywords.$pattern),o.keywords&&(o.keywords=compileKeywords(o.keywords,s.case_insensitive)),o.lexemes&&_)throw new Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return _=_||o.lexemes||/\w+/,u.keywordPatternRe=langRe(_,!0),i&&(o.begin||(o.begin=/\B|\b/),u.beginRe=langRe(o.begin),o.endSameAsBegin&&(o.end=o.begin),o.end||o.endsWithParent||(o.end=/\B|\b/),o.end&&(u.endRe=langRe(o.end)),u.terminatorEnd=source(o.end)||"",o.endsWithParent&&i.terminatorEnd&&(u.terminatorEnd+=(o.end?"|":"")+i.terminatorEnd)),o.illegal&&(u.illegalRe=langRe(o.illegal)),o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((function(s){return function expandOrCloneMode(s){s.variants&&!s.cachedVariants&&(s.cachedVariants=s.variants.map((function(o){return inherit(s,{variants:null},o)})));if(s.cachedVariants)return s.cachedVariants;if(dependencyOnParent(s))return inherit(s,{starts:s.starts?inherit(s.starts):null});if(Object.isFrozen(s))return inherit(s);return s}("self"===s?o:s)}))),o.contains.forEach((function(s){compileMode(s,u)})),o.starts&&compileMode(o.starts,i),u.matcher=function buildModeRegex(s){const o=new ResumableMultiRegex;return s.contains.forEach((s=>o.addRule(s.begin,{rule:s,type:"begin"}))),s.terminatorEnd&&o.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&o.addRule(s.illegal,{type:"illegal"}),o}(u),u}(s)}function dependencyOnParent(s){return!!s&&(s.endsWithParent||dependencyOnParent(s.starts))}function BuildVuePlugin(s){const o={props:["language","code","autodetect"],data:function(){return{detectedLanguage:"",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!s.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let o={};return this.autoDetect?(o=s.highlightAuto(this.code),this.detectedLanguage=o.language):(o=s.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),o.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(s){return Boolean(s||""===s)}(this.autodetect)},ignoreIllegals:()=>!0},render(s){return s("pre",{},[s("code",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:o,VuePlugin:{install(s){s.component("highlightjs",o)}}}}const be={"after:highlightElement":({el:s,result:o,text:i})=>{const u=nodeStream(s);if(!u.length)return;const _=document.createElement("div");_.innerHTML=o.value,o.value=function mergeStreams(s,o,i){let u=0,_="";const w=[];function selectStream(){return s.length&&o.length?s[0].offset!==o[0].offset?s[0].offset"}function close(s){_+=""}function render(s){("start"===s.event?open:close)(s.node)}for(;s.length||o.length;){let o=selectStream();if(_+=escapeHTML(i.substring(u,o[0].offset)),u=o[0].offset,o===s){w.reverse().forEach(close);do{render(o.splice(0,1)[0]),o=selectStream()}while(o===s&&o.length&&o[0].offset===u);w.reverse().forEach(open)}else"start"===o[0].event?w.push(o[0].node):w.pop(),render(o.splice(0,1)[0])}return _+escapeHTML(i.substr(u))}(u,nodeStream(_),i)}};function tag(s){return s.nodeName.toLowerCase()}function nodeStream(s){const o=[];return function _nodeStream(s,i){for(let u=s.firstChild;u;u=u.nextSibling)3===u.nodeType?i+=u.nodeValue.length:1===u.nodeType&&(o.push({event:"start",offset:i,node:u}),i=_nodeStream(u,i),tag(u).match(/br|hr|img|input/)||o.push({event:"stop",offset:i,node:u}));return i}(s,0),o}const _e={},error=s=>{console.error(s)},warn=(s,...o)=>{console.log(`WARN: ${s}`,...o)},deprecated=(s,o)=>{_e[`${s}/${o}`]||(console.log(`Deprecated as of ${s}. ${o}`),_e[`${s}/${o}`]=!0)},we=escapeHTML,Se=inherit,xe=Symbol("nomatch");var Pe=function(s){const i=Object.create(null),u=Object.create(null),_=[];let w=!0;const x=/(^(<[^>]+>|\t|)+|\n)/gm,C="Could not find the language '{}', did you forget to load/include a language module?",j={disableAutodetect:!0,name:"Plain text",contains:[]};let L={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(s){return L.noHighlightRe.test(s)}function highlight(s,o,i,u){let _="",w="";"object"==typeof o?(_=s,i=o.ignoreIllegals,w=o.language,u=void 0):(deprecated("10.7.0","highlight(lang, code, ...args) has been deprecated."),deprecated("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),w=s,_=o);const x={code:_,language:w};fire("before:highlight",x);const C=x.result?x.result:_highlight(x.language,x.code,i,u);return C.code=x.code,fire("after:highlight",C),C}function _highlight(s,o,u,x){function keywordData(s,o){const i=B.case_insensitive?o[0].toLowerCase():o[0];return Object.prototype.hasOwnProperty.call(s.keywords,i)&&s.keywords[i]}function processBuffer(){null!=U.subLanguage?function processSubLanguage(){if(""===Z)return;let s=null;if("string"==typeof U.subLanguage){if(!i[U.subLanguage])return void Y.addText(Z);s=_highlight(U.subLanguage,Z,!0,z[U.subLanguage]),z[U.subLanguage]=s.top}else s=highlightAuto(Z,U.subLanguage.length?U.subLanguage:null);U.relevance>0&&(ee+=s.relevance),Y.addSublanguage(s.emitter,s.language)}():function processKeywords(){if(!U.keywords)return void Y.addText(Z);let s=0;U.keywordPatternRe.lastIndex=0;let o=U.keywordPatternRe.exec(Z),i="";for(;o;){i+=Z.substring(s,o.index);const u=keywordData(U,o);if(u){const[s,_]=u;if(Y.addText(i),i="",ee+=_,s.startsWith("_"))i+=o[0];else{const i=B.classNameAliases[s]||s;Y.addKeyword(o[0],i)}}else i+=o[0];s=U.keywordPatternRe.lastIndex,o=U.keywordPatternRe.exec(Z)}i+=Z.substr(s),Y.addText(i)}(),Z=""}function startNewMode(s){return s.className&&Y.openNode(B.classNameAliases[s.className]||s.className),U=Object.create(s,{parent:{value:U}}),U}function endOfMode(s,o,i){let u=function startsWith(s,o){const i=s&&s.exec(o);return i&&0===i.index}(s.endRe,i);if(u){if(s["on:end"]){const i=new Response(s);s["on:end"](o,i),i.isMatchIgnored&&(u=!1)}if(u){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return endOfMode(s.parent,o,i)}function doIgnore(s){return 0===U.matcher.regexIndex?(Z+=s[0],1):(le=!0,0)}function doBeginMatch(s){const o=s[0],i=s.rule,u=new Response(i),_=[i.__beforeBegin,i["on:begin"]];for(const i of _)if(i&&(i(s,u),u.isMatchIgnored))return doIgnore(o);return i&&i.endSameAsBegin&&(i.endRe=function escape(s){return new RegExp(s.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}(o)),i.skip?Z+=o:(i.excludeBegin&&(Z+=o),processBuffer(),i.returnBegin||i.excludeBegin||(Z=o)),startNewMode(i),i.returnBegin?0:o.length}function doEndMatch(s){const i=s[0],u=o.substr(s.index),_=endOfMode(U,s,u);if(!_)return xe;const w=U;w.skip?Z+=i:(w.returnEnd||w.excludeEnd||(Z+=i),processBuffer(),w.excludeEnd&&(Z=i));do{U.className&&Y.closeNode(),U.skip||U.subLanguage||(ee+=U.relevance),U=U.parent}while(U!==_.parent);return _.starts&&(_.endSameAsBegin&&(_.starts.endRe=_.endRe),startNewMode(_.starts)),w.returnEnd?0:i.length}let j={};function processLexeme(i,_){const x=_&&_[0];if(Z+=i,null==x)return processBuffer(),0;if("begin"===j.type&&"end"===_.type&&j.index===_.index&&""===x){if(Z+=o.slice(_.index,_.index+1),!w){const o=new Error("0 width match regex");throw o.languageName=s,o.badRule=j.rule,o}return 1}if(j=_,"begin"===_.type)return doBeginMatch(_);if("illegal"===_.type&&!u){const s=new Error('Illegal lexeme "'+x+'" for mode "'+(U.className||"")+'"');throw s.mode=U,s}if("end"===_.type){const s=doEndMatch(_);if(s!==xe)return s}if("illegal"===_.type&&""===x)return 1;if(ae>1e5&&ae>3*_.index){throw new Error("potential infinite loop, way more iterations than matches")}return Z+=x,x.length}const B=getLanguage(s);if(!B)throw error(C.replace("{}",s)),new Error('Unknown language: "'+s+'"');const $=compileLanguage(B,{plugins:_});let V="",U=x||$;const z={},Y=new L.__emitter(L);!function processContinuations(){const s=[];for(let o=U;o!==B;o=o.parent)o.className&&s.unshift(o.className);s.forEach((s=>Y.openNode(s)))}();let Z="",ee=0,ie=0,ae=0,le=!1;try{for(U.matcher.considerAll();;){ae++,le?le=!1:U.matcher.considerAll(),U.matcher.lastIndex=ie;const s=U.matcher.exec(o);if(!s)break;const i=processLexeme(o.substring(ie,s.index),s);ie=s.index+i}return processLexeme(o.substr(ie)),Y.closeAllNodes(),Y.finalize(),V=Y.toHTML(),{relevance:Math.floor(ee),value:V,language:s,illegal:!1,emitter:Y,top:U}}catch(i){if(i.message&&i.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:i.message,context:o.slice(ie-100,ie+100),mode:i.mode},sofar:V,relevance:0,value:we(o),emitter:Y};if(w)return{illegal:!1,relevance:0,value:we(o),emitter:Y,language:s,top:U,errorRaised:i};throw i}}function highlightAuto(s,o){o=o||L.languages||Object.keys(i);const u=function justTextHighlightResult(s){const o={relevance:0,emitter:new L.__emitter(L),value:we(s),illegal:!1,top:j};return o.emitter.addText(s),o}(s),_=o.filter(getLanguage).filter(autoDetection).map((o=>_highlight(o,s,!1)));_.unshift(u);const w=_.sort(((s,o)=>{if(s.relevance!==o.relevance)return o.relevance-s.relevance;if(s.language&&o.language){if(getLanguage(s.language).supersetOf===o.language)return 1;if(getLanguage(o.language).supersetOf===s.language)return-1}return 0})),[x,C]=w,B=x;return B.second_best=C,B}const B={"before:highlightElement":({el:s})=>{L.useBR&&(s.innerHTML=s.innerHTML.replace(/\n/g,"").replace(//g,"\n"))},"after:highlightElement":({result:s})=>{L.useBR&&(s.value=s.value.replace(/\n/g,"
"))}},$=/^(<[^>]+>|\t)+/gm,V={"after:highlightElement":({result:s})=>{L.tabReplace&&(s.value=s.value.replace($,(s=>s.replace(/\t/g,L.tabReplace))))}};function highlightElement(s){let o=null;const i=function blockLanguage(s){let o=s.className+" ";o+=s.parentNode?s.parentNode.className:"";const i=L.languageDetectRe.exec(o);if(i){const o=getLanguage(i[1]);return o||(warn(C.replace("{}",i[1])),warn("Falling back to no-highlight mode for this block.",s)),o?i[1]:"no-highlight"}return o.split(/\s+/).find((s=>shouldNotHighlight(s)||getLanguage(s)))}(s);if(shouldNotHighlight(i))return;fire("before:highlightElement",{el:s,language:i}),o=s;const _=o.textContent,w=i?highlight(_,{language:i,ignoreIllegals:!0}):highlightAuto(_);fire("after:highlightElement",{el:s,result:w,text:_}),s.innerHTML=w.value,function updateClassName(s,o,i){const _=o?u[o]:i;s.classList.add("hljs"),_&&s.classList.add(_)}(s,i,w.language),s.result={language:w.language,re:w.relevance,relavance:w.relevance},w.second_best&&(s.second_best={language:w.second_best.language,re:w.second_best.relevance,relavance:w.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead.");document.querySelectorAll("pre code").forEach(highlightElement)};let U=!1;function highlightAll(){if("loading"===document.readyState)return void(U=!0);document.querySelectorAll("pre code").forEach(highlightElement)}function getLanguage(s){return s=(s||"").toLowerCase(),i[s]||i[u[s]]}function registerAliases(s,{languageName:o}){"string"==typeof s&&(s=[s]),s.forEach((s=>{u[s.toLowerCase()]=o}))}function autoDetection(s){const o=getLanguage(s);return o&&!o.disableAutodetect}function fire(s,o){const i=s;_.forEach((function(s){s[i]&&s[i](o)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function boot(){U&&highlightAll()}),!1),Object.assign(s,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(s){return deprecated("10.2.0","fixMarkup will be removed entirely in v11.0"),deprecated("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),function fixMarkup(s){return L.tabReplace||L.useBR?s.replace(x,(s=>"\n"===s?L.useBR?"
":s:L.tabReplace?s.replace(/\t/g,L.tabReplace):s)):s}(s)},highlightElement,highlightBlock:function deprecateHighlightBlock(s){return deprecated("10.7.0","highlightBlock will be removed entirely in v12.0"),deprecated("10.7.0","Please use highlightElement now."),highlightElement(s)},configure:function configure(s){s.useBR&&(deprecated("10.3.0","'useBR' will be removed entirely in v11.0"),deprecated("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),L=Se(L,s)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."),U=!0},registerLanguage:function registerLanguage(o,u){let _=null;try{_=u(s)}catch(s){if(error("Language definition for '{}' could not be registered.".replace("{}",o)),!w)throw s;error(s),_=j}_.name||(_.name=o),i[o]=_,_.rawDefinition=u.bind(null,s),_.aliases&®isterAliases(_.aliases,{languageName:o})},unregisterLanguage:function unregisterLanguage(s){delete i[s];for(const o of Object.keys(u))u[o]===s&&delete u[o]},listLanguages:function listLanguages(){return Object.keys(i)},getLanguage,registerAliases,requireLanguage:function requireLanguage(s){deprecated("10.4.0","requireLanguage will be removed entirely in v11."),deprecated("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844");const o=getLanguage(s);if(o)return o;throw new Error("The '{}' language is required, but not loaded.".replace("{}",s))},autoDetection,inherit:Se,addPlugin:function addPlugin(s){!function upgradePluginAPI(s){s["before:highlightBlock"]&&!s["before:highlightElement"]&&(s["before:highlightElement"]=o=>{s["before:highlightBlock"](Object.assign({block:o.el},o))}),s["after:highlightBlock"]&&!s["after:highlightElement"]&&(s["after:highlightElement"]=o=>{s["after:highlightBlock"](Object.assign({block:o.el},o))})}(s),_.push(s)},vuePlugin:BuildVuePlugin(s).VuePlugin}),s.debugMode=function(){w=!1},s.safeMode=function(){w=!0},s.versionString="10.7.3";for(const s in fe)"object"==typeof fe[s]&&o(fe[s]);return Object.assign(s,fe),s.addPlugin(B),s.addPlugin(be),s.addPlugin(V),s}({});s.exports=Pe},35344:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function bash(s){const o={},i={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[o]}]};Object.assign(o,{className:"variable",variants:[{begin:concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},i]});const u={className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},_={begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},w={className:"string",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE,o,u]};u.contains.push(w);const x={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,o]},C=s.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),j={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"},contains:[C,s.SHEBANG(),j,x,s.HASH_COMMENT_MODE,_,w,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},o]}}},73402:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function http(s){const o="HTTP/(2|1\\.[01])",i={className:"attribute",begin:concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}},u=[i,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+o+" \\d{3})",end:/$/,contains:[{className:"meta",begin:o},{className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},{begin:"(?=^[A-Z]+ (.*?) "+o+"$)",end:/$/,contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:o},{className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},s.inherit(i,{relevance:0})]}}},95089:s=>{const o="[A-Za-z$_][0-9A-Za-z$_]*",i=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],u=["true","false","null","undefined","NaN","Infinity"],_=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function javascript(s){const w=o,x="<>",C="",j={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(s,o)=>{const i=s[0].length+s.index,u=s.input[i];"<"!==u?">"===u&&(((s,{after:o})=>{const i="",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:s.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:L,contains:ce}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:x,end:C},{begin:j.begin,"on:begin":j.isTrulyOpeningTag,end:j.end}],subLanguage:"xml",contains:[{begin:j.begin,end:j.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:L,contains:["self",s.inherit(s.TITLE_MODE,{begin:w}),pe],illegal:/%/},{beginKeywords:"while if switch catch for"},{className:"function",begin:s.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,contains:[pe,s.inherit(s.TITLE_MODE,{begin:w})]},{variants:[{begin:"\\."+w},{begin:"\\$"+w}],relevance:0},{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{beginKeywords:"extends"},s.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[s.inherit(s.TITLE_MODE,{begin:w}),"self",pe]},{begin:"(get|set)\\s+(?="+w+"\\()",end:/\{/,keywords:"get set",contains:[s.inherit(s.TITLE_MODE,{begin:w}),{begin:/\(\)/},pe]},{begin:/\$[(.]/}]}}},65772:s=>{s.exports=function json(s){const o={literal:"true false null"},i=[s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE],u=[s.QUOTE_STRING_MODE,s.C_NUMBER_MODE],_={end:",",endsWithParent:!0,excludeEnd:!0,contains:u,keywords:o},w={begin:/\{/,end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE],illegal:"\\n"},s.inherit(_,{begin:/:/})].concat(i),illegal:"\\S"},x={begin:"\\[",end:"\\]",contains:[s.inherit(_)],illegal:"\\S"};return u.push(w,x),i.forEach((function(s){u.push(s)})),{name:"JSON",contains:u,keywords:o,illegal:"\\S"}}},26571:s=>{s.exports=function powershell(s){const o={$pattern:/-?[A-z\.\-]+\b/,keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},i={begin:"`[\\s\\S]",relevance:0},u={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},_={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[i,u,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},w={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},x=s.inherit(s.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),C={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},j={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[s.TITLE_MODE]},L={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[u]}]},B={begin:/using\s/,end:/$/,returnBegin:!0,contains:[_,w,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},$={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},V={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(o.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},s.inherit(s.TITLE_MODE,{endsParent:!0})]},U=[V,x,i,s.NUMBER_MODE,_,w,C,u,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/@\B/,relevance:0}],z={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",U,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return V.contains.unshift(z),{name:"PowerShell",aliases:["ps","ps1"],case_insensitive:!0,keywords:o,contains:U.concat(j,L,B,$,z)}}},17285:s=>{function source(s){return s?"string"==typeof s?s:s.source:null}function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>source(s))).join("")}function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}s.exports=function xml(s){const o=concat(/[A-Z_]/,function optional(s){return concat("(",s,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},u={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},_=s.inherit(u,{begin:/\(/,end:/\)/}),w=s.inherit(s.APOS_STRING_MODE,{className:"meta-string"}),x=s.inherit(s.QUOTE_STRING_MODE,{className:"meta-string"}),C={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[u,x,w,_,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[u,_,x,w]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[C],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[C],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:o,relevance:0,starts:C}]},{className:"tag",begin:concat(/<\//,lookahead(concat(o,/>/))),contains:[{className:"name",begin:o,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17533:s=>{s.exports=function yaml(s){var o="true false yes no null",i="[\\w#;/?:@&=+$,.~*'()[\\]]+",u={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[s.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},_=s.inherit(u,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),w={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},x={end:",",endsWithParent:!0,excludeEnd:!0,keywords:o,relevance:0},C={begin:/\{/,end:/\}/,contains:[x],illegal:"\\n",relevance:0},j={begin:"\\[",end:"\\]",contains:[x],illegal:"\\n",relevance:0},L=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+i},{className:"type",begin:"!<"+i+">"},{className:"type",begin:"!"+i},{className:"type",begin:"!!"+i},{className:"meta",begin:"&"+s.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+s.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},s.HASH_COMMENT_MODE,{beginKeywords:o,keywords:{literal:o}},w,{className:"number",begin:s.C_NUMBER_RE+"\\b",relevance:0},C,j,u],B=[...L];return B.pop(),B.push(_),x.contains=B,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:L}}},251:(s,o)=>{o.read=function(s,o,i,u,_){var w,x,C=8*_-u-1,j=(1<>1,B=-7,$=i?_-1:0,V=i?-1:1,U=s[o+$];for($+=V,w=U&(1<<-B)-1,U>>=-B,B+=C;B>0;w=256*w+s[o+$],$+=V,B-=8);for(x=w&(1<<-B)-1,w>>=-B,B+=u;B>0;x=256*x+s[o+$],$+=V,B-=8);if(0===w)w=1-L;else{if(w===j)return x?NaN:1/0*(U?-1:1);x+=Math.pow(2,u),w-=L}return(U?-1:1)*x*Math.pow(2,w-u)},o.write=function(s,o,i,u,_,w){var x,C,j,L=8*w-_-1,B=(1<>1,V=23===_?Math.pow(2,-24)-Math.pow(2,-77):0,U=u?0:w-1,z=u?1:-1,Y=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(C=isNaN(o)?1:0,x=B):(x=Math.floor(Math.log(o)/Math.LN2),o*(j=Math.pow(2,-x))<1&&(x--,j*=2),(o+=x+$>=1?V/j:V*Math.pow(2,1-$))*j>=2&&(x++,j/=2),x+$>=B?(C=0,x=B):x+$>=1?(C=(o*j-1)*Math.pow(2,_),x+=$):(C=o*Math.pow(2,$-1)*Math.pow(2,_),x=0));_>=8;s[i+U]=255&C,U+=z,C/=256,_-=8);for(x=x<<_|C,L+=_;L>0;s[i+U]=255&x,U+=z,x/=256,L-=8);s[i+U-z]|=128*Y}},9404:function(s){s.exports=function(){"use strict";var s=Array.prototype.slice;function createClass(s,o){o&&(s.prototype=Object.create(o.prototype)),s.prototype.constructor=s}function Iterable(s){return isIterable(s)?s:Seq(s)}function KeyedIterable(s){return isKeyed(s)?s:KeyedSeq(s)}function IndexedIterable(s){return isIndexed(s)?s:IndexedSeq(s)}function SetIterable(s){return isIterable(s)&&!isAssociative(s)?s:SetSeq(s)}function isIterable(s){return!(!s||!s[o])}function isKeyed(s){return!(!s||!s[i])}function isIndexed(s){return!(!s||!s[u])}function isAssociative(s){return isKeyed(s)||isIndexed(s)}function isOrdered(s){return!(!s||!s[_])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var o="@@__IMMUTABLE_ITERABLE__@@",i="@@__IMMUTABLE_KEYED__@@",u="@@__IMMUTABLE_INDEXED__@@",_="@@__IMMUTABLE_ORDERED__@@",w="delete",x=5,C=1<>>0;if(""+i!==o||4294967295===i)return NaN;o=i}return o<0?ensureSize(s)+o:o}function returnTrue(){return!0}function wholeSlice(s,o,i){return(0===s||void 0!==i&&s<=-i)&&(void 0===o||void 0!==i&&o>=i)}function resolveBegin(s,o){return resolveIndex(s,o,0)}function resolveEnd(s,o){return resolveIndex(s,o,o)}function resolveIndex(s,o,i){return void 0===s?i:s<0?Math.max(0,o+s):void 0===o?s:Math.min(o,s)}var V=0,U=1,z=2,Y="function"==typeof Symbol&&Symbol.iterator,Z="@@iterator",ee=Y||Z;function Iterator(s){this.next=s}function iteratorValue(s,o,i,u){var _=0===s?o:1===s?i:[o,i];return u?u.value=_:u={value:_,done:!1},u}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(s){return!!getIteratorFn(s)}function isIterator(s){return s&&"function"==typeof s.next}function getIterator(s){var o=getIteratorFn(s);return o&&o.call(s)}function getIteratorFn(s){var o=s&&(Y&&s[Y]||s[Z]);if("function"==typeof o)return o}function isArrayLike(s){return s&&"number"==typeof s.length}function Seq(s){return null==s?emptySequence():isIterable(s)?s.toSeq():seqFromValue(s)}function KeyedSeq(s){return null==s?emptySequence().toKeyedSeq():isIterable(s)?isKeyed(s)?s.toSeq():s.fromEntrySeq():keyedSeqFromValue(s)}function IndexedSeq(s){return null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s.toIndexedSeq():indexedSeqFromValue(s)}function SetSeq(s){return(null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s:indexedSeqFromValue(s)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=V,Iterator.VALUES=U,Iterator.ENTRIES=z,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[ee]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!0)},Seq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!1)},IndexedSeq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ie,ae,le,ce="@@__IMMUTABLE_SEQ__@@";function ArraySeq(s){this._array=s,this.size=s.length}function ObjectSeq(s){var o=Object.keys(s);this._object=s,this._keys=o,this.size=o.length}function IterableSeq(s){this._iterable=s,this.size=s.length||s.size}function IteratorSeq(s){this._iterator=s,this._iteratorCache=[]}function isSeq(s){return!(!s||!s[ce])}function emptySequence(){return ie||(ie=new ArraySeq([]))}function keyedSeqFromValue(s){var o=Array.isArray(s)?new ArraySeq(s).fromEntrySeq():isIterator(s)?new IteratorSeq(s).fromEntrySeq():hasIterator(s)?new IterableSeq(s).fromEntrySeq():"object"==typeof s?new ObjectSeq(s):void 0;if(!o)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+s);return o}function indexedSeqFromValue(s){var o=maybeIndexedSeqFromValue(s);if(!o)throw new TypeError("Expected Array or iterable object of values: "+s);return o}function seqFromValue(s){var o=maybeIndexedSeqFromValue(s)||"object"==typeof s&&new ObjectSeq(s);if(!o)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+s);return o}function maybeIndexedSeqFromValue(s){return isArrayLike(s)?new ArraySeq(s):isIterator(s)?new IteratorSeq(s):hasIterator(s)?new IterableSeq(s):void 0}function seqIterate(s,o,i,u){var _=s._cache;if(_){for(var w=_.length-1,x=0;x<=w;x++){var C=_[i?w-x:x];if(!1===o(C[1],u?C[0]:x,s))return x+1}return x}return s.__iterateUncached(o,i)}function seqIterator(s,o,i,u){var _=s._cache;if(_){var w=_.length-1,x=0;return new Iterator((function(){var s=_[i?w-x:x];return x++>w?iteratorDone():iteratorValue(o,u?s[0]:x-1,s[1])}))}return s.__iteratorUncached(o,i)}function fromJS(s,o){return o?fromJSWith(o,s,"",{"":s}):fromJSDefault(s)}function fromJSWith(s,o,i,u){return Array.isArray(o)?s.call(u,i,IndexedSeq(o).map((function(i,u){return fromJSWith(s,i,u,o)}))):isPlainObj(o)?s.call(u,i,KeyedSeq(o).map((function(i,u){return fromJSWith(s,i,u,o)}))):o}function fromJSDefault(s){return Array.isArray(s)?IndexedSeq(s).map(fromJSDefault).toList():isPlainObj(s)?KeyedSeq(s).map(fromJSDefault).toMap():s}function isPlainObj(s){return s&&(s.constructor===Object||void 0===s.constructor)}function is(s,o){if(s===o||s!=s&&o!=o)return!0;if(!s||!o)return!1;if("function"==typeof s.valueOf&&"function"==typeof o.valueOf){if((s=s.valueOf())===(o=o.valueOf())||s!=s&&o!=o)return!0;if(!s||!o)return!1}return!("function"!=typeof s.equals||"function"!=typeof o.equals||!s.equals(o))}function deepEqual(s,o){if(s===o)return!0;if(!isIterable(o)||void 0!==s.size&&void 0!==o.size&&s.size!==o.size||void 0!==s.__hash&&void 0!==o.__hash&&s.__hash!==o.__hash||isKeyed(s)!==isKeyed(o)||isIndexed(s)!==isIndexed(o)||isOrdered(s)!==isOrdered(o))return!1;if(0===s.size&&0===o.size)return!0;var i=!isAssociative(s);if(isOrdered(s)){var u=s.entries();return o.every((function(s,o){var _=u.next().value;return _&&is(_[1],s)&&(i||is(_[0],o))}))&&u.next().done}var _=!1;if(void 0===s.size)if(void 0===o.size)"function"==typeof s.cacheResult&&s.cacheResult();else{_=!0;var w=s;s=o,o=w}var x=!0,C=o.__iterate((function(o,u){if(i?!s.has(o):_?!is(o,s.get(u,L)):!is(s.get(u,L),o))return x=!1,!1}));return x&&s.size===C}function Repeat(s,o){if(!(this instanceof Repeat))return new Repeat(s,o);if(this._value=s,this.size=void 0===o?1/0:Math.max(0,o),0===this.size){if(ae)return ae;ae=this}}function invariant(s,o){if(!s)throw new Error(o)}function Range(s,o,i){if(!(this instanceof Range))return new Range(s,o,i);if(invariant(0!==i,"Cannot step a Range by 0"),s=s||0,void 0===o&&(o=1/0),i=void 0===i?1:Math.abs(i),ou?iteratorDone():iteratorValue(s,_,i[o?u-_++:_++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(s,o){return void 0===o||this.has(s)?this._object[s]:o},ObjectSeq.prototype.has=function(s){return this._object.hasOwnProperty(s)},ObjectSeq.prototype.__iterate=function(s,o){for(var i=this._object,u=this._keys,_=u.length-1,w=0;w<=_;w++){var x=u[o?_-w:w];if(!1===s(i[x],x,this))return w+1}return w},ObjectSeq.prototype.__iterator=function(s,o){var i=this._object,u=this._keys,_=u.length-1,w=0;return new Iterator((function(){var x=u[o?_-w:w];return w++>_?iteratorDone():iteratorValue(s,x,i[x])}))},ObjectSeq.prototype[_]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);var i=getIterator(this._iterable),u=0;if(isIterator(i))for(var _;!(_=i.next()).done&&!1!==s(_.value,u++,this););return u},IterableSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var u=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,u++,o.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);for(var i,u=this._iterator,_=this._iteratorCache,w=0;w<_.length;)if(!1===s(_[w],w++,this))return w;for(;!(i=u.next()).done;){var x=i.value;if(_[w]=x,!1===s(x,w++,this))break}return w},IteratorSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=this._iterator,u=this._iteratorCache,_=0;return new Iterator((function(){if(_>=u.length){var o=i.next();if(o.done)return o;u[_]=o.value}return iteratorValue(s,_,u[_++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(s,o){return this.has(s)?this._value:o},Repeat.prototype.includes=function(s){return is(this._value,s)},Repeat.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:new Repeat(this._value,resolveEnd(o,i)-resolveBegin(s,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(s){return is(this._value,s)?0:-1},Repeat.prototype.lastIndexOf=function(s){return is(this._value,s)?this.size:-1},Repeat.prototype.__iterate=function(s,o){for(var i=0;i=0&&o=0&&ii?iteratorDone():iteratorValue(s,w++,x)}))},Range.prototype.equals=function(s){return s instanceof Range?this._start===s._start&&this._end===s._end&&this._step===s._step:deepEqual(this,s)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var pe="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(s,o){var i=65535&(s|=0),u=65535&(o|=0);return i*u+((s>>>16)*u+i*(o>>>16)<<16>>>0)|0};function smi(s){return s>>>1&1073741824|3221225471&s}function hash(s){if(!1===s||null==s)return 0;if("function"==typeof s.valueOf&&(!1===(s=s.valueOf())||null==s))return 0;if(!0===s)return 1;var o=typeof s;if("number"===o){if(s!=s||s===1/0)return 0;var i=0|s;for(i!==s&&(i^=4294967295*s);s>4294967295;)i^=s/=4294967295;return smi(i)}if("string"===o)return s.length>Se?cachedHashString(s):hashString(s);if("function"==typeof s.hashCode)return s.hashCode();if("object"===o)return hashJSObj(s);if("function"==typeof s.toString)return hashString(s.toString());throw new Error("Value type "+o+" cannot be hashed.")}function cachedHashString(s){var o=Te[s];return void 0===o&&(o=hashString(s),Pe===xe&&(Pe=0,Te={}),Pe++,Te[s]=o),o}function hashString(s){for(var o=0,i=0;i0)switch(s.nodeType){case 1:return s.uniqueID;case 9:return s.documentElement&&s.documentElement.uniqueID}}var ye,be="function"==typeof WeakMap;be&&(ye=new WeakMap);var _e=0,we="__immutablehash__";"function"==typeof Symbol&&(we=Symbol(we));var Se=16,xe=255,Pe=0,Te={};function assertNotInfinite(s){invariant(s!==1/0,"Cannot perform this action with an infinite size.")}function Map(s){return null==s?emptyMap():isMap(s)&&!isOrdered(s)?s:emptyMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isMap(s){return!(!s||!s[qe])}createClass(Map,KeyedCollection),Map.of=function(){var o=s.call(arguments,0);return emptyMap().withMutations((function(s){for(var i=0;i=o.length)throw new Error("Missing value for key: "+o[i]);s.set(o[i],o[i+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(s,o){return this._root?this._root.get(0,void 0,s,o):o},Map.prototype.set=function(s,o){return updateMap(this,s,o)},Map.prototype.setIn=function(s,o){return this.updateIn(s,L,(function(){return o}))},Map.prototype.remove=function(s){return updateMap(this,s,L)},Map.prototype.deleteIn=function(s){return this.updateIn(s,(function(){return L}))},Map.prototype.update=function(s,o,i){return 1===arguments.length?s(this):this.updateIn([s],o,i)},Map.prototype.updateIn=function(s,o,i){i||(i=o,o=void 0);var u=updateInDeepMap(this,forceIterator(s),o,i);return u===L?void 0:u},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(o){return mergeIntoMapWith(this,o,s.call(arguments,1))},Map.prototype.mergeIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.merge?s.merge.apply(s,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(o),i)},Map.prototype.mergeDeepIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.mergeDeep?s.mergeDeep.apply(s,i):i[i.length-1]}))},Map.prototype.sort=function(s){return OrderedMap(sortFactory(this,s))},Map.prototype.sortBy=function(s,o){return OrderedMap(sortFactory(this,o,s))},Map.prototype.withMutations=function(s){var o=this.asMutable();return s(o),o.wasAltered()?o.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(s,o){return new MapIterator(this,s,o)},Map.prototype.__iterate=function(s,o){var i=this,u=0;return this._root&&this._root.iterate((function(o){return u++,s(o[1],o[0],i)}),o),u},Map.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeMap(this.size,this._root,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Map.isMap=isMap;var Re,qe="@@__IMMUTABLE_MAP__@@",$e=Map.prototype;function ArrayMapNode(s,o){this.ownerID=s,this.entries=o}function BitmapIndexedNode(s,o,i){this.ownerID=s,this.bitmap=o,this.nodes=i}function HashArrayMapNode(s,o,i){this.ownerID=s,this.count=o,this.nodes=i}function HashCollisionNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entries=i}function ValueNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entry=i}function MapIterator(s,o,i){this._type=o,this._reverse=i,this._stack=s._root&&mapIteratorFrame(s._root)}function mapIteratorValue(s,o){return iteratorValue(s,o[0],o[1])}function mapIteratorFrame(s,o){return{node:s,index:0,__prev:o}}function makeMap(s,o,i,u){var _=Object.create($e);return _.size=s,_._root=o,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyMap(){return Re||(Re=makeMap(0))}function updateMap(s,o,i){var u,_;if(s._root){var w=MakeRef(B),x=MakeRef($);if(u=updateNode(s._root,s.__ownerID,0,void 0,o,i,w,x),!x.value)return s;_=s.size+(w.value?i===L?-1:1:0)}else{if(i===L)return s;_=1,u=new ArrayMapNode(s.__ownerID,[[o,i]])}return s.__ownerID?(s.size=_,s._root=u,s.__hash=void 0,s.__altered=!0,s):u?makeMap(_,u):emptyMap()}function updateNode(s,o,i,u,_,w,x,C){return s?s.update(o,i,u,_,w,x,C):w===L?s:(SetRef(C),SetRef(x),new ValueNode(o,u,[_,w]))}function isLeafNode(s){return s.constructor===ValueNode||s.constructor===HashCollisionNode}function mergeIntoNode(s,o,i,u,_){if(s.keyHash===u)return new HashCollisionNode(o,u,[s.entry,_]);var w,C=(0===i?s.keyHash:s.keyHash>>>i)&j,L=(0===i?u:u>>>i)&j;return new BitmapIndexedNode(o,1<>>=1)x[j]=1&i?o[w++]:void 0;return x[u]=_,new HashArrayMapNode(s,w+1,x)}function mergeIntoMapWith(s,o,i){for(var u=[],_=0;_>1&1431655765))+(s>>2&858993459))+(s>>4)&252645135,s+=s>>8,127&(s+=s>>16)}function setIn(s,o,i,u){var _=u?s:arrCopy(s);return _[o]=i,_}function spliceIn(s,o,i,u){var _=s.length+1;if(u&&o+1===_)return s[o]=i,s;for(var w=new Array(_),x=0,C=0;C<_;C++)C===o?(w[C]=i,x=-1):w[C]=s[C+x];return w}function spliceOut(s,o,i){var u=s.length-1;if(i&&o===u)return s.pop(),s;for(var _=new Array(u),w=0,x=0;x=ze)return createNodes(s,j,u,_);var U=s&&s===this.ownerID,z=U?j:arrCopy(j);return V?C?B===$-1?z.pop():z[B]=z.pop():z[B]=[u,_]:z.push([u,_]),U?(this.entries=z,this):new ArrayMapNode(s,z)}},BitmapIndexedNode.prototype.get=function(s,o,i,u){void 0===o&&(o=hash(i));var _=1<<((0===s?o:o>>>s)&j),w=this.bitmap;return w&_?this.nodes[popCount(w&_-1)].get(s+x,o,i,u):u},BitmapIndexedNode.prototype.update=function(s,o,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===o?i:i>>>o)&j,$=1<=We)return expandNodes(s,Y,V,B,ee);if(U&&!ee&&2===Y.length&&isLeafNode(Y[1^z]))return Y[1^z];if(U&&ee&&1===Y.length&&isLeafNode(ee))return ee;var ie=s&&s===this.ownerID,ae=U?ee?V:V^$:V|$,le=U?ee?setIn(Y,z,ee,ie):spliceOut(Y,z,ie):spliceIn(Y,z,ee,ie);return ie?(this.bitmap=ae,this.nodes=le,this):new BitmapIndexedNode(s,ae,le)},HashArrayMapNode.prototype.get=function(s,o,i,u){void 0===o&&(o=hash(i));var _=(0===s?o:o>>>s)&j,w=this.nodes[_];return w?w.get(s+x,o,i,u):u},HashArrayMapNode.prototype.update=function(s,o,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===o?i:i>>>o)&j,$=_===L,V=this.nodes,U=V[B];if($&&!U)return this;var z=updateNode(U,s,o+x,i,u,_,w,C);if(z===U)return this;var Y=this.count;if(U){if(!z&&--Y0&&u=0&&s>>o&j;if(u>=this.array.length)return new VNode([],s);var _,w=0===u;if(o>0){var C=this.array[u];if((_=C&&C.removeBefore(s,o-x,i))===C&&w)return this}if(w&&!_)return this;var L=editableVNode(this,s);if(!w)for(var B=0;B>>o&j;if(_>=this.array.length)return this;if(o>0){var w=this.array[_];if((u=w&&w.removeAfter(s,o-x,i))===w&&_===this.array.length-1)return this}var C=editableVNode(this,s);return C.array.splice(_+1),u&&(C.array[_]=u),C};var Qe,et,tt={};function iterateList(s,o){var i=s._origin,u=s._capacity,_=getTailOffset(u),w=s._tail;return iterateNodeOrLeaf(s._root,s._level,0);function iterateNodeOrLeaf(s,o,i){return 0===o?iterateLeaf(s,i):iterateNode(s,o,i)}function iterateLeaf(s,x){var j=x===_?w&&w.array:s&&s.array,L=x>i?0:i-x,B=u-x;return B>C&&(B=C),function(){if(L===B)return tt;var s=o?--B:L++;return j&&j[s]}}function iterateNode(s,_,w){var j,L=s&&s.array,B=w>i?0:i-w>>_,$=1+(u-w>>_);return $>C&&($=C),function(){for(;;){if(j){var s=j();if(s!==tt)return s;j=null}if(B===$)return tt;var i=o?--$:B++;j=iterateNodeOrLeaf(L&&L[i],_-x,w+(i<<_))}}}}function makeList(s,o,i,u,_,w,x){var C=Object.create(Xe);return C.size=o-s,C._origin=s,C._capacity=o,C._level=i,C._root=u,C._tail=_,C.__ownerID=w,C.__hash=x,C.__altered=!1,C}function emptyList(){return Qe||(Qe=makeList(0,0,x))}function updateList(s,o,i){if((o=wrapIndex(s,o))!=o)return s;if(o>=s.size||o<0)return s.withMutations((function(s){o<0?setListBounds(s,o).set(0,i):setListBounds(s,0,o+1).set(o,i)}));o+=s._origin;var u=s._tail,_=s._root,w=MakeRef($);return o>=getTailOffset(s._capacity)?u=updateVNode(u,s.__ownerID,0,o,i,w):_=updateVNode(_,s.__ownerID,s._level,o,i,w),w.value?s.__ownerID?(s._root=_,s._tail=u,s.__hash=void 0,s.__altered=!0,s):makeList(s._origin,s._capacity,s._level,_,u):s}function updateVNode(s,o,i,u,_,w){var C,L=u>>>i&j,B=s&&L0){var $=s&&s.array[L],V=updateVNode($,o,i-x,u,_,w);return V===$?s:((C=editableVNode(s,o)).array[L]=V,C)}return B&&s.array[L]===_?s:(SetRef(w),C=editableVNode(s,o),void 0===_&&L===C.array.length-1?C.array.pop():C.array[L]=_,C)}function editableVNode(s,o){return o&&s&&o===s.ownerID?s:new VNode(s?s.array.slice():[],o)}function listNodeFor(s,o){if(o>=getTailOffset(s._capacity))return s._tail;if(o<1<0;)i=i.array[o>>>u&j],u-=x;return i}}function setListBounds(s,o,i){void 0!==o&&(o|=0),void 0!==i&&(i|=0);var u=s.__ownerID||new OwnerID,_=s._origin,w=s._capacity,C=_+o,L=void 0===i?w:i<0?w+i:_+i;if(C===_&&L===w)return s;if(C>=L)return s.clear();for(var B=s._level,$=s._root,V=0;C+V<0;)$=new VNode($&&$.array.length?[void 0,$]:[],u),V+=1<<(B+=x);V&&(C+=V,_+=V,L+=V,w+=V);for(var U=getTailOffset(w),z=getTailOffset(L);z>=1<U?new VNode([],u):Y;if(Y&&z>U&&Cx;ie-=x){var ae=U>>>ie&j;ee=ee.array[ae]=editableVNode(ee.array[ae],u)}ee.array[U>>>x&j]=Y}if(L=z)C-=z,L-=z,B=x,$=null,Z=Z&&Z.removeBefore(u,0,C);else if(C>_||z>>B&j;if(le!==z>>>B&j)break;le&&(V+=(1<_&&($=$.removeBefore(u,B,C-V)),$&&z_&&(_=C.size),isIterable(x)||(C=C.map((function(s){return fromJS(s)}))),u.push(C)}return _>s.size&&(s=s.setSize(_)),mergeIntoCollectionWith(s,o,u)}function getTailOffset(s){return s>>x<=C&&x.size>=2*w.size?(u=(_=x.filter((function(s,o){return void 0!==s&&j!==o}))).toKeyedSeq().map((function(s){return s[0]})).flip().toMap(),s.__ownerID&&(u.__ownerID=_.__ownerID=s.__ownerID)):(u=w.remove(o),_=j===x.size-1?x.pop():x.set(j,void 0))}else if(B){if(i===x.get(j)[1])return s;u=w,_=x.set(j,[o,i])}else u=w.set(o,x.size),_=x.set(x.size,[o,i]);return s.__ownerID?(s.size=u.size,s._map=u,s._list=_,s.__hash=void 0,s):makeOrderedMap(u,_)}function ToKeyedSequence(s,o){this._iter=s,this._useKeys=o,this.size=s.size}function ToIndexedSequence(s){this._iter=s,this.size=s.size}function ToSetSequence(s){this._iter=s,this.size=s.size}function FromEntriesSequence(s){this._iter=s,this.size=s.size}function flipFactory(s){var o=makeSequence(s);return o._iter=s,o.size=s.size,o.flip=function(){return s},o.reverse=function(){var o=s.reverse.apply(this);return o.flip=function(){return s.reverse()},o},o.has=function(o){return s.includes(o)},o.includes=function(o){return s.has(o)},o.cacheResult=cacheResultThrough,o.__iterateUncached=function(o,i){var u=this;return s.__iterate((function(s,i){return!1!==o(i,s,u)}),i)},o.__iteratorUncached=function(o,i){if(o===z){var u=s.__iterator(o,i);return new Iterator((function(){var s=u.next();if(!s.done){var o=s.value[0];s.value[0]=s.value[1],s.value[1]=o}return s}))}return s.__iterator(o===U?V:U,i)},o}function mapFactory(s,o,i){var u=makeSequence(s);return u.size=s.size,u.has=function(o){return s.has(o)},u.get=function(u,_){var w=s.get(u,L);return w===L?_:o.call(i,w,u,s)},u.__iterateUncached=function(u,_){var w=this;return s.__iterate((function(s,_,x){return!1!==u(o.call(i,s,_,x),_,w)}),_)},u.__iteratorUncached=function(u,_){var w=s.__iterator(z,_);return new Iterator((function(){var _=w.next();if(_.done)return _;var x=_.value,C=x[0];return iteratorValue(u,C,o.call(i,x[1],C,s),_)}))},u}function reverseFactory(s,o){var i=makeSequence(s);return i._iter=s,i.size=s.size,i.reverse=function(){return s},s.flip&&(i.flip=function(){var o=flipFactory(s);return o.reverse=function(){return s.flip()},o}),i.get=function(i,u){return s.get(o?i:-1-i,u)},i.has=function(i){return s.has(o?i:-1-i)},i.includes=function(o){return s.includes(o)},i.cacheResult=cacheResultThrough,i.__iterate=function(o,i){var u=this;return s.__iterate((function(s,i){return o(s,i,u)}),!i)},i.__iterator=function(o,i){return s.__iterator(o,!i)},i}function filterFactory(s,o,i,u){var _=makeSequence(s);return u&&(_.has=function(u){var _=s.get(u,L);return _!==L&&!!o.call(i,_,u,s)},_.get=function(u,_){var w=s.get(u,L);return w!==L&&o.call(i,w,u,s)?w:_}),_.__iterateUncached=function(_,w){var x=this,C=0;return s.__iterate((function(s,w,j){if(o.call(i,s,w,j))return C++,_(s,u?w:C-1,x)}),w),C},_.__iteratorUncached=function(_,w){var x=s.__iterator(z,w),C=0;return new Iterator((function(){for(;;){var w=x.next();if(w.done)return w;var j=w.value,L=j[0],B=j[1];if(o.call(i,B,L,s))return iteratorValue(_,u?L:C++,B,w)}}))},_}function countByFactory(s,o,i){var u=Map().asMutable();return s.__iterate((function(_,w){u.update(o.call(i,_,w,s),0,(function(s){return s+1}))})),u.asImmutable()}function groupByFactory(s,o,i){var u=isKeyed(s),_=(isOrdered(s)?OrderedMap():Map()).asMutable();s.__iterate((function(w,x){_.update(o.call(i,w,x,s),(function(s){return(s=s||[]).push(u?[x,w]:w),s}))}));var w=iterableClass(s);return _.map((function(o){return reify(s,w(o))}))}function sliceFactory(s,o,i,u){var _=s.size;if(void 0!==o&&(o|=0),void 0!==i&&(i===1/0?i=_:i|=0),wholeSlice(o,i,_))return s;var w=resolveBegin(o,_),x=resolveEnd(i,_);if(w!=w||x!=x)return sliceFactory(s.toSeq().cacheResult(),o,i,u);var C,j=x-w;j==j&&(C=j<0?0:j);var L=makeSequence(s);return L.size=0===C?C:s.size&&C||void 0,!u&&isSeq(s)&&C>=0&&(L.get=function(o,i){return(o=wrapIndex(this,o))>=0&&oC)return iteratorDone();var s=_.next();return u||o===U?s:iteratorValue(o,j-1,o===V?void 0:s.value[1],s)}))},L}function takeWhileFactory(s,o,i){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=0;return s.__iterate((function(s,_,C){return o.call(i,s,_,C)&&++x&&u(s,_,w)})),x},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=s.__iterator(z,_),C=!0;return new Iterator((function(){if(!C)return iteratorDone();var s=x.next();if(s.done)return s;var _=s.value,j=_[0],L=_[1];return o.call(i,L,j,w)?u===z?s:iteratorValue(u,j,L,s):(C=!1,iteratorDone())}))},u}function skipWhileFactory(s,o,i,u){var _=makeSequence(s);return _.__iterateUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterate(_,w);var C=!0,j=0;return s.__iterate((function(s,w,L){if(!C||!(C=o.call(i,s,w,L)))return j++,_(s,u?w:j-1,x)})),j},_.__iteratorUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterator(_,w);var C=s.__iterator(z,w),j=!0,L=0;return new Iterator((function(){var s,w,B;do{if((s=C.next()).done)return u||_===U?s:iteratorValue(_,L++,_===V?void 0:s.value[1],s);var $=s.value;w=$[0],B=$[1],j&&(j=o.call(i,B,w,x))}while(j);return _===z?s:iteratorValue(_,w,B,s)}))},_}function concatFactory(s,o){var i=isKeyed(s),u=[s].concat(o).map((function(s){return isIterable(s)?i&&(s=KeyedIterable(s)):s=i?keyedSeqFromValue(s):indexedSeqFromValue(Array.isArray(s)?s:[s]),s})).filter((function(s){return 0!==s.size}));if(0===u.length)return s;if(1===u.length){var _=u[0];if(_===s||i&&isKeyed(_)||isIndexed(s)&&isIndexed(_))return _}var w=new ArraySeq(u);return i?w=w.toKeyedSeq():isIndexed(s)||(w=w.toSetSeq()),(w=w.flatten(!0)).size=u.reduce((function(s,o){if(void 0!==s){var i=o.size;if(void 0!==i)return s+i}}),0),w}function flattenFactory(s,o,i){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=0,x=!1;function flatDeep(s,C){var j=this;s.__iterate((function(s,_){return(!o||C0}function zipWithFactory(s,o,i){var u=makeSequence(s);return u.size=new ArraySeq(i).map((function(s){return s.size})).min(),u.__iterate=function(s,o){for(var i,u=this.__iterator(U,o),_=0;!(i=u.next()).done&&!1!==s(i.value,_++,this););return _},u.__iteratorUncached=function(s,u){var _=i.map((function(s){return s=Iterable(s),getIterator(u?s.reverse():s)})),w=0,x=!1;return new Iterator((function(){var i;return x||(i=_.map((function(s){return s.next()})),x=i.some((function(s){return s.done}))),x?iteratorDone():iteratorValue(s,w++,o.apply(null,i.map((function(s){return s.value}))))}))},u}function reify(s,o){return isSeq(s)?o:s.constructor(o)}function validateEntry(s){if(s!==Object(s))throw new TypeError("Expected [K, V] tuple: "+s)}function resolveSize(s){return assertNotInfinite(s.size),ensureSize(s)}function iterableClass(s){return isKeyed(s)?KeyedIterable:isIndexed(s)?IndexedIterable:SetIterable}function makeSequence(s){return Object.create((isKeyed(s)?KeyedSeq:isIndexed(s)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(s,o){return s>o?1:s=0;i--)o={value:arguments[i],next:o};return this.__ownerID?(this.size=s,this._head=o,this.__hash=void 0,this.__altered=!0,this):makeStack(s,o)},Stack.prototype.pushAll=function(s){if(0===(s=IndexedIterable(s)).size)return this;assertNotInfinite(s.size);var o=this.size,i=this._head;return s.reverse().forEach((function(s){o++,i={value:s,next:i}})),this.__ownerID?(this.size=o,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(o,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(s){return this.pushAll(s)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(s,o){if(wholeSlice(s,o,this.size))return this;var i=resolveBegin(s,this.size);if(resolveEnd(o,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,s,o);for(var u=this.size-i,_=this._head;i--;)_=_.next;return this.__ownerID?(this.size=u,this._head=_,this.__hash=void 0,this.__altered=!0,this):makeStack(u,_)},Stack.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeStack(this.size,this._head,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Stack.prototype.__iterate=function(s,o){if(o)return this.reverse().__iterate(s);for(var i=0,u=this._head;u&&!1!==s(u.value,i++,this);)u=u.next;return i},Stack.prototype.__iterator=function(s,o){if(o)return this.reverse().__iterator(s);var i=0,u=this._head;return new Iterator((function(){if(u){var o=u.value;return u=u.next,iteratorValue(s,i++,o)}return iteratorDone()}))},Stack.isStack=isStack;var lt,ct="@@__IMMUTABLE_STACK__@@",ut=Stack.prototype;function makeStack(s,o,i,u){var _=Object.create(ut);return _.size=s,_._head=o,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyStack(){return lt||(lt=makeStack(0))}function mixin(s,o){var keyCopier=function(i){s.prototype[i]=o[i]};return Object.keys(o).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(o).forEach(keyCopier),s}ut[ct]=!0,ut.withMutations=$e.withMutations,ut.asMutable=$e.asMutable,ut.asImmutable=$e.asImmutable,ut.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var s=new Array(this.size||0);return this.valueSeq().__iterate((function(o,i){s[i]=o})),s},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJS?s.toJS():s})).__toJS()},toJSON:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJSON?s.toJSON():s})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var s={};return this.__iterate((function(o,i){s[i]=o})),s},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(s,o){return 0===this.size?s+o:s+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+o},concat:function(){return reify(this,concatFactory(this,s.call(arguments,0)))},includes:function(s){return this.some((function(o){return is(o,s)}))},entries:function(){return this.__iterator(z)},every:function(s,o){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(u,_,w){if(!s.call(o,u,_,w))return i=!1,!1})),i},filter:function(s,o){return reify(this,filterFactory(this,s,o,!0))},find:function(s,o,i){var u=this.findEntry(s,o);return u?u[1]:i},forEach:function(s,o){return assertNotInfinite(this.size),this.__iterate(o?s.bind(o):s)},join:function(s){assertNotInfinite(this.size),s=void 0!==s?""+s:",";var o="",i=!0;return this.__iterate((function(u){i?i=!1:o+=s,o+=null!=u?u.toString():""})),o},keys:function(){return this.__iterator(V)},map:function(s,o){return reify(this,mapFactory(this,s,o))},reduce:function(s,o,i){var u,_;return assertNotInfinite(this.size),arguments.length<2?_=!0:u=o,this.__iterate((function(o,w,x){_?(_=!1,u=o):u=s.call(i,u,o,w,x)})),u},reduceRight:function(s,o,i){var u=this.toKeyedSeq().reverse();return u.reduce.apply(u,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!0))},some:function(s,o){return!this.every(not(s),o)},sort:function(s){return reify(this,sortFactory(this,s))},values:function(){return this.__iterator(U)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(s,o){return ensureSize(s?this.toSeq().filter(s,o):this)},countBy:function(s,o){return countByFactory(this,s,o)},equals:function(s){return deepEqual(this,s)},entrySeq:function(){var s=this;if(s._cache)return new ArraySeq(s._cache);var o=s.toSeq().map(entryMapper).toIndexedSeq();return o.fromEntrySeq=function(){return s.toSeq()},o},filterNot:function(s,o){return this.filter(not(s),o)},findEntry:function(s,o,i){var u=i;return this.__iterate((function(i,_,w){if(s.call(o,i,_,w))return u=[_,i],!1})),u},findKey:function(s,o){var i=this.findEntry(s,o);return i&&i[0]},findLast:function(s,o,i){return this.toKeyedSeq().reverse().find(s,o,i)},findLastEntry:function(s,o,i){return this.toKeyedSeq().reverse().findEntry(s,o,i)},findLastKey:function(s,o){return this.toKeyedSeq().reverse().findKey(s,o)},first:function(){return this.find(returnTrue)},flatMap:function(s,o){return reify(this,flatMapFactory(this,s,o))},flatten:function(s){return reify(this,flattenFactory(this,s,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(s,o){return this.find((function(o,i){return is(i,s)}),void 0,o)},getIn:function(s,o){for(var i,u=this,_=forceIterator(s);!(i=_.next()).done;){var w=i.value;if((u=u&&u.get?u.get(w,L):L)===L)return o}return u},groupBy:function(s,o){return groupByFactory(this,s,o)},has:function(s){return this.get(s,L)!==L},hasIn:function(s){return this.getIn(s,L)!==L},isSubset:function(s){return s="function"==typeof s.includes?s:Iterable(s),this.every((function(o){return s.includes(o)}))},isSuperset:function(s){return(s="function"==typeof s.isSubset?s:Iterable(s)).isSubset(this)},keyOf:function(s){return this.findKey((function(o){return is(o,s)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(s){return this.toKeyedSeq().reverse().keyOf(s)},max:function(s){return maxFactory(this,s)},maxBy:function(s,o){return maxFactory(this,o,s)},min:function(s){return maxFactory(this,s?neg(s):defaultNegComparator)},minBy:function(s,o){return maxFactory(this,o?neg(o):defaultNegComparator,s)},rest:function(){return this.slice(1)},skip:function(s){return this.slice(Math.max(0,s))},skipLast:function(s){return reify(this,this.toSeq().reverse().skip(s).reverse())},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!0))},skipUntil:function(s,o){return this.skipWhile(not(s),o)},sortBy:function(s,o){return reify(this,sortFactory(this,o,s))},take:function(s){return this.slice(0,Math.max(0,s))},takeLast:function(s){return reify(this,this.toSeq().reverse().take(s).reverse())},takeWhile:function(s,o){return reify(this,takeWhileFactory(this,s,o))},takeUntil:function(s,o){return this.takeWhile(not(s),o)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var pt=Iterable.prototype;pt[o]=!0,pt[ee]=pt.values,pt.__toJS=pt.toArray,pt.__toStringMapper=quoteString,pt.inspect=pt.toSource=function(){return this.toString()},pt.chain=pt.flatMap,pt.contains=pt.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(s,o){var i=this,u=0;return reify(this,this.toSeq().map((function(_,w){return s.call(o,[w,_],u++,i)})).fromEntrySeq())},mapKeys:function(s,o){var i=this;return reify(this,this.toSeq().flip().map((function(u,_){return s.call(o,u,_,i)})).flip())}});var ht=KeyedIterable.prototype;function keyMapper(s,o){return o}function entryMapper(s,o){return[o,s]}function not(s){return function(){return!s.apply(this,arguments)}}function neg(s){return function(){return-s.apply(this,arguments)}}function quoteString(s){return"string"==typeof s?JSON.stringify(s):String(s)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(s,o){return so?-1:0}function hashIterable(s){if(s.size===1/0)return 0;var o=isOrdered(s),i=isKeyed(s),u=o?1:0;return murmurHashOfSize(s.__iterate(i?o?function(s,o){u=31*u+hashMerge(hash(s),hash(o))|0}:function(s,o){u=u+hashMerge(hash(s),hash(o))|0}:o?function(s){u=31*u+hash(s)|0}:function(s){u=u+hash(s)|0}),u)}function murmurHashOfSize(s,o){return o=pe(o,3432918353),o=pe(o<<15|o>>>-15,461845907),o=pe(o<<13|o>>>-13,5),o=pe((o=o+3864292196^s)^o>>>16,2246822507),o=smi((o=pe(o^o>>>13,3266489909))^o>>>16)}function hashMerge(s,o){return s^o+2654435769+(s<<6)+(s>>2)}return ht[i]=!0,ht[ee]=pt.entries,ht.__toJS=pt.toObject,ht.__toStringMapper=function(s,o){return JSON.stringify(o)+": "+quoteString(s)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(s,o){return reify(this,filterFactory(this,s,o,!1))},findIndex:function(s,o){var i=this.findEntry(s,o);return i?i[0]:-1},indexOf:function(s){var o=this.keyOf(s);return void 0===o?-1:o},lastIndexOf:function(s){var o=this.lastKeyOf(s);return void 0===o?-1:o},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!1))},splice:function(s,o){var i=arguments.length;if(o=Math.max(0|o,0),0===i||2===i&&!o)return this;s=resolveBegin(s,s<0?this.count():this.size);var u=this.slice(0,s);return reify(this,1===i?u:u.concat(arrCopy(arguments,2),this.slice(s+o)))},findLastIndex:function(s,o){var i=this.findLastEntry(s,o);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(s){return reify(this,flattenFactory(this,s,!1))},get:function(s,o){return(s=wrapIndex(this,s))<0||this.size===1/0||void 0!==this.size&&s>this.size?o:this.find((function(o,i){return i===s}),void 0,o)},has:function(s){return(s=wrapIndex(this,s))>=0&&(void 0!==this.size?this.size===1/0||s{"function"==typeof Object.create?s.exports=function inherits(s,o){o&&(s.super_=o,s.prototype=Object.create(o.prototype,{constructor:{value:s,enumerable:!1,writable:!0,configurable:!0}}))}:s.exports=function inherits(s,o){if(o){s.super_=o;var TempCtor=function(){};TempCtor.prototype=o.prototype,s.prototype=new TempCtor,s.prototype.constructor=s}}},5419:s=>{s.exports=function(s,o,i,u){var _=new Blob(void 0!==u?[u,s]:[s],{type:i||"application/octet-stream"});if(void 0!==window.navigator.msSaveBlob)window.navigator.msSaveBlob(_,o);else{var w=window.URL&&window.URL.createObjectURL?window.URL.createObjectURL(_):window.webkitURL.createObjectURL(_),x=document.createElement("a");x.style.display="none",x.href=w,x.setAttribute("download",o),void 0===x.download&&x.setAttribute("target","_blank"),document.body.appendChild(x),x.click(),setTimeout((function(){document.body.removeChild(x),window.URL.revokeObjectURL(w)}),200)}}},20181:(s,o,i)=>{var u=/^\s+|\s+$/g,_=/^[-+]0x[0-9a-f]+$/i,w=/^0b[01]+$/i,x=/^0o[0-7]+$/i,C=parseInt,j="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g,L="object"==typeof self&&self&&self.Object===Object&&self,B=j||L||Function("return this")(),$=Object.prototype.toString,V=Math.max,U=Math.min,now=function(){return B.Date.now()};function isObject(s){var o=typeof s;return!!s&&("object"==o||"function"==o)}function toNumber(s){if("number"==typeof s)return s;if(function isSymbol(s){return"symbol"==typeof s||function isObjectLike(s){return!!s&&"object"==typeof s}(s)&&"[object Symbol]"==$.call(s)}(s))return NaN;if(isObject(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=isObject(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=s.replace(u,"");var i=w.test(s);return i||x.test(s)?C(s.slice(2),i?2:8):_.test(s)?NaN:+s}s.exports=function debounce(s,o,i){var u,_,w,x,C,j,L=0,B=!1,$=!1,z=!0;if("function"!=typeof s)throw new TypeError("Expected a function");function invokeFunc(o){var i=u,w=_;return u=_=void 0,L=o,x=s.apply(w,i)}function shouldInvoke(s){var i=s-j;return void 0===j||i>=o||i<0||$&&s-L>=w}function timerExpired(){var s=now();if(shouldInvoke(s))return trailingEdge(s);C=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-j);return $?U(i,w-(s-L)):i}(s))}function trailingEdge(s){return C=void 0,z&&u?invokeFunc(s):(u=_=void 0,x)}function debounced(){var s=now(),i=shouldInvoke(s);if(u=arguments,_=this,j=s,i){if(void 0===C)return function leadingEdge(s){return L=s,C=setTimeout(timerExpired,o),B?invokeFunc(s):x}(j);if($)return C=setTimeout(timerExpired,o),invokeFunc(j)}return void 0===C&&(C=setTimeout(timerExpired,o)),x}return o=toNumber(o)||0,isObject(i)&&(B=!!i.leading,w=($="maxWait"in i)?V(toNumber(i.maxWait)||0,o):w,z="trailing"in i?!!i.trailing:z),debounced.cancel=function cancel(){void 0!==C&&clearTimeout(C),L=0,u=j=_=C=void 0},debounced.flush=function flush(){return void 0===C?x:trailingEdge(now())},debounced}},55580:(s,o,i)=>{var u=i(56110)(i(9325),"DataView");s.exports=u},21549:(s,o,i)=>{var u=i(22032),_=i(63862),w=i(66721),x=i(12749),C=i(35749);function Hash(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(39344),_=i(94033);function LazyWrapper(s){this.__wrapped__=s,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=4294967295,this.__views__=[]}LazyWrapper.prototype=u(_.prototype),LazyWrapper.prototype.constructor=LazyWrapper,s.exports=LazyWrapper},80079:(s,o,i)=>{var u=i(63702),_=i(70080),w=i(24739),x=i(48655),C=i(31175);function ListCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(39344),_=i(94033);function LodashWrapper(s,o){this.__wrapped__=s,this.__actions__=[],this.__chain__=!!o,this.__index__=0,this.__values__=void 0}LodashWrapper.prototype=u(_.prototype),LodashWrapper.prototype.constructor=LodashWrapper,s.exports=LodashWrapper},68223:(s,o,i)=>{var u=i(56110)(i(9325),"Map");s.exports=u},53661:(s,o,i)=>{var u=i(63040),_=i(17670),w=i(90289),x=i(4509),C=i(72949);function MapCache(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var u=i(56110)(i(9325),"Promise");s.exports=u},76545:(s,o,i)=>{var u=i(56110)(i(9325),"Set");s.exports=u},38859:(s,o,i)=>{var u=i(53661),_=i(31380),w=i(51459);function SetCache(s){var o=-1,i=null==s?0:s.length;for(this.__data__=new u;++o{var u=i(80079),_=i(51420),w=i(90938),x=i(63605),C=i(29817),j=i(80945);function Stack(s){var o=this.__data__=new u(s);this.size=o.size}Stack.prototype.clear=_,Stack.prototype.delete=w,Stack.prototype.get=x,Stack.prototype.has=C,Stack.prototype.set=j,s.exports=Stack},51873:(s,o,i)=>{var u=i(9325).Symbol;s.exports=u},37828:(s,o,i)=>{var u=i(9325).Uint8Array;s.exports=u},28303:(s,o,i)=>{var u=i(56110)(i(9325),"WeakMap");s.exports=u},91033:s=>{s.exports=function apply(s,o,i){switch(i.length){case 0:return s.call(o);case 1:return s.call(o,i[0]);case 2:return s.call(o,i[0],i[1]);case 3:return s.call(o,i[0],i[1],i[2])}return s.apply(o,i)}},83729:s=>{s.exports=function arrayEach(s,o){for(var i=-1,u=null==s?0:s.length;++i{s.exports=function arrayFilter(s,o){for(var i=-1,u=null==s?0:s.length,_=0,w=[];++i{var u=i(96131);s.exports=function arrayIncludes(s,o){return!!(null==s?0:s.length)&&u(s,o,0)>-1}},70695:(s,o,i)=>{var u=i(78096),_=i(72428),w=i(56449),x=i(3656),C=i(30361),j=i(37167),L=Object.prototype.hasOwnProperty;s.exports=function arrayLikeKeys(s,o){var i=w(s),B=!i&&_(s),$=!i&&!B&&x(s),V=!i&&!B&&!$&&j(s),U=i||B||$||V,z=U?u(s.length,String):[],Y=z.length;for(var Z in s)!o&&!L.call(s,Z)||U&&("length"==Z||$&&("offset"==Z||"parent"==Z)||V&&("buffer"==Z||"byteLength"==Z||"byteOffset"==Z)||C(Z,Y))||z.push(Z);return z}},34932:s=>{s.exports=function arrayMap(s,o){for(var i=-1,u=null==s?0:s.length,_=Array(u);++i{s.exports=function arrayPush(s,o){for(var i=-1,u=o.length,_=s.length;++i{s.exports=function arrayReduce(s,o,i,u){var _=-1,w=null==s?0:s.length;for(u&&w&&(i=s[++_]);++_{s.exports=function arraySome(s,o){for(var i=-1,u=null==s?0:s.length;++i{s.exports=function asciiToArray(s){return s.split("")}},1733:s=>{var o=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;s.exports=function asciiWords(s){return s.match(o)||[]}},87805:(s,o,i)=>{var u=i(43360),_=i(75288);s.exports=function assignMergeValue(s,o,i){(void 0!==i&&!_(s[o],i)||void 0===i&&!(o in s))&&u(s,o,i)}},16547:(s,o,i)=>{var u=i(43360),_=i(75288),w=Object.prototype.hasOwnProperty;s.exports=function assignValue(s,o,i){var x=s[o];w.call(s,o)&&_(x,i)&&(void 0!==i||o in s)||u(s,o,i)}},26025:(s,o,i)=>{var u=i(75288);s.exports=function assocIndexOf(s,o){for(var i=s.length;i--;)if(u(s[i][0],o))return i;return-1}},74733:(s,o,i)=>{var u=i(21791),_=i(95950);s.exports=function baseAssign(s,o){return s&&u(o,_(o),s)}},43838:(s,o,i)=>{var u=i(21791),_=i(37241);s.exports=function baseAssignIn(s,o){return s&&u(o,_(o),s)}},43360:(s,o,i)=>{var u=i(93243);s.exports=function baseAssignValue(s,o,i){"__proto__"==o&&u?u(s,o,{configurable:!0,enumerable:!0,value:i,writable:!0}):s[o]=i}},9999:(s,o,i)=>{var u=i(37217),_=i(83729),w=i(16547),x=i(74733),C=i(43838),j=i(93290),L=i(23007),B=i(92271),$=i(48948),V=i(50002),U=i(83349),z=i(5861),Y=i(76189),Z=i(77199),ee=i(35529),ie=i(56449),ae=i(3656),le=i(87730),ce=i(23805),pe=i(38440),de=i(95950),fe=i(37241),ye="[object Arguments]",be="[object Function]",_e="[object Object]",we={};we[ye]=we["[object Array]"]=we["[object ArrayBuffer]"]=we["[object DataView]"]=we["[object Boolean]"]=we["[object Date]"]=we["[object Float32Array]"]=we["[object Float64Array]"]=we["[object Int8Array]"]=we["[object Int16Array]"]=we["[object Int32Array]"]=we["[object Map]"]=we["[object Number]"]=we[_e]=we["[object RegExp]"]=we["[object Set]"]=we["[object String]"]=we["[object Symbol]"]=we["[object Uint8Array]"]=we["[object Uint8ClampedArray]"]=we["[object Uint16Array]"]=we["[object Uint32Array]"]=!0,we["[object Error]"]=we[be]=we["[object WeakMap]"]=!1,s.exports=function baseClone(s,o,i,Se,xe,Pe){var Te,Re=1&o,qe=2&o,$e=4&o;if(i&&(Te=xe?i(s,Se,xe,Pe):i(s)),void 0!==Te)return Te;if(!ce(s))return s;var ze=ie(s);if(ze){if(Te=Y(s),!Re)return L(s,Te)}else{var We=z(s),He=We==be||"[object GeneratorFunction]"==We;if(ae(s))return j(s,Re);if(We==_e||We==ye||He&&!xe){if(Te=qe||He?{}:ee(s),!Re)return qe?$(s,C(Te,s)):B(s,x(Te,s))}else{if(!we[We])return xe?s:{};Te=Z(s,We,Re)}}Pe||(Pe=new u);var Ye=Pe.get(s);if(Ye)return Ye;Pe.set(s,Te),pe(s)?s.forEach((function(u){Te.add(baseClone(u,o,i,u,s,Pe))})):le(s)&&s.forEach((function(u,_){Te.set(_,baseClone(u,o,i,_,s,Pe))}));var Xe=ze?void 0:($e?qe?U:V:qe?fe:de)(s);return _(Xe||s,(function(u,_){Xe&&(u=s[_=u]),w(Te,_,baseClone(u,o,i,_,s,Pe))})),Te}},39344:(s,o,i)=>{var u=i(23805),_=Object.create,w=function(){function object(){}return function(s){if(!u(s))return{};if(_)return _(s);object.prototype=s;var o=new object;return object.prototype=void 0,o}}();s.exports=w},80909:(s,o,i)=>{var u=i(30641),_=i(38329)(u);s.exports=_},2523:s=>{s.exports=function baseFindIndex(s,o,i,u){for(var _=s.length,w=i+(u?1:-1);u?w--:++w<_;)if(o(s[w],w,s))return w;return-1}},83120:(s,o,i)=>{var u=i(14528),_=i(45891);s.exports=function baseFlatten(s,o,i,w,x){var C=-1,j=s.length;for(i||(i=_),x||(x=[]);++C0&&i(L)?o>1?baseFlatten(L,o-1,i,w,x):u(x,L):w||(x[x.length]=L)}return x}},86649:(s,o,i)=>{var u=i(83221)();s.exports=u},30641:(s,o,i)=>{var u=i(86649),_=i(95950);s.exports=function baseForOwn(s,o){return s&&u(s,o,_)}},47422:(s,o,i)=>{var u=i(31769),_=i(77797);s.exports=function baseGet(s,o){for(var i=0,w=(o=u(o,s)).length;null!=s&&i{var u=i(14528),_=i(56449);s.exports=function baseGetAllKeys(s,o,i){var w=o(s);return _(s)?w:u(w,i(s))}},72552:(s,o,i)=>{var u=i(51873),_=i(659),w=i(59350),x=u?u.toStringTag:void 0;s.exports=function baseGetTag(s){return null==s?void 0===s?"[object Undefined]":"[object Null]":x&&x in Object(s)?_(s):w(s)}},20426:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function baseHas(s,i){return null!=s&&o.call(s,i)}},28077:s=>{s.exports=function baseHasIn(s,o){return null!=s&&o in Object(s)}},96131:(s,o,i)=>{var u=i(2523),_=i(85463),w=i(76959);s.exports=function baseIndexOf(s,o,i){return o==o?w(s,o,i):u(s,_,i)}},27534:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function baseIsArguments(s){return _(s)&&"[object Arguments]"==u(s)}},60270:(s,o,i)=>{var u=i(87068),_=i(40346);s.exports=function baseIsEqual(s,o,i,w,x){return s===o||(null==s||null==o||!_(s)&&!_(o)?s!=s&&o!=o:u(s,o,i,w,baseIsEqual,x))}},87068:(s,o,i)=>{var u=i(37217),_=i(25911),w=i(21986),x=i(50689),C=i(5861),j=i(56449),L=i(3656),B=i(37167),$="[object Arguments]",V="[object Array]",U="[object Object]",z=Object.prototype.hasOwnProperty;s.exports=function baseIsEqualDeep(s,o,i,Y,Z,ee){var ie=j(s),ae=j(o),le=ie?V:C(s),ce=ae?V:C(o),pe=(le=le==$?U:le)==U,de=(ce=ce==$?U:ce)==U,fe=le==ce;if(fe&&L(s)){if(!L(o))return!1;ie=!0,pe=!1}if(fe&&!pe)return ee||(ee=new u),ie||B(s)?_(s,o,i,Y,Z,ee):w(s,o,le,i,Y,Z,ee);if(!(1&i)){var ye=pe&&z.call(s,"__wrapped__"),be=de&&z.call(o,"__wrapped__");if(ye||be){var _e=ye?s.value():s,we=be?o.value():o;return ee||(ee=new u),Z(_e,we,i,Y,ee)}}return!!fe&&(ee||(ee=new u),x(s,o,i,Y,Z,ee))}},29172:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function baseIsMap(s){return _(s)&&"[object Map]"==u(s)}},41799:(s,o,i)=>{var u=i(37217),_=i(60270);s.exports=function baseIsMatch(s,o,i,w){var x=i.length,C=x,j=!w;if(null==s)return!C;for(s=Object(s);x--;){var L=i[x];if(j&&L[2]?L[1]!==s[L[0]]:!(L[0]in s))return!1}for(;++x{s.exports=function baseIsNaN(s){return s!=s}},45083:(s,o,i)=>{var u=i(1882),_=i(87296),w=i(23805),x=i(47473),C=/^\[object .+?Constructor\]$/,j=Function.prototype,L=Object.prototype,B=j.toString,$=L.hasOwnProperty,V=RegExp("^"+B.call($).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");s.exports=function baseIsNative(s){return!(!w(s)||_(s))&&(u(s)?V:C).test(x(s))}},16038:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function baseIsSet(s){return _(s)&&"[object Set]"==u(s)}},4901:(s,o,i)=>{var u=i(72552),_=i(30294),w=i(40346),x={};x["[object Float32Array]"]=x["[object Float64Array]"]=x["[object Int8Array]"]=x["[object Int16Array]"]=x["[object Int32Array]"]=x["[object Uint8Array]"]=x["[object Uint8ClampedArray]"]=x["[object Uint16Array]"]=x["[object Uint32Array]"]=!0,x["[object Arguments]"]=x["[object Array]"]=x["[object ArrayBuffer]"]=x["[object Boolean]"]=x["[object DataView]"]=x["[object Date]"]=x["[object Error]"]=x["[object Function]"]=x["[object Map]"]=x["[object Number]"]=x["[object Object]"]=x["[object RegExp]"]=x["[object Set]"]=x["[object String]"]=x["[object WeakMap]"]=!1,s.exports=function baseIsTypedArray(s){return w(s)&&_(s.length)&&!!x[u(s)]}},15389:(s,o,i)=>{var u=i(93663),_=i(87978),w=i(83488),x=i(56449),C=i(50583);s.exports=function baseIteratee(s){return"function"==typeof s?s:null==s?w:"object"==typeof s?x(s)?_(s[0],s[1]):u(s):C(s)}},88984:(s,o,i)=>{var u=i(55527),_=i(3650),w=Object.prototype.hasOwnProperty;s.exports=function baseKeys(s){if(!u(s))return _(s);var o=[];for(var i in Object(s))w.call(s,i)&&"constructor"!=i&&o.push(i);return o}},72903:(s,o,i)=>{var u=i(23805),_=i(55527),w=i(90181),x=Object.prototype.hasOwnProperty;s.exports=function baseKeysIn(s){if(!u(s))return w(s);var o=_(s),i=[];for(var C in s)("constructor"!=C||!o&&x.call(s,C))&&i.push(C);return i}},94033:s=>{s.exports=function baseLodash(){}},93663:(s,o,i)=>{var u=i(41799),_=i(10776),w=i(67197);s.exports=function baseMatches(s){var o=_(s);return 1==o.length&&o[0][2]?w(o[0][0],o[0][1]):function(i){return i===s||u(i,s,o)}}},87978:(s,o,i)=>{var u=i(60270),_=i(58156),w=i(80631),x=i(28586),C=i(30756),j=i(67197),L=i(77797);s.exports=function baseMatchesProperty(s,o){return x(s)&&C(o)?j(L(s),o):function(i){var x=_(i,s);return void 0===x&&x===o?w(i,s):u(o,x,3)}}},85250:(s,o,i)=>{var u=i(37217),_=i(87805),w=i(86649),x=i(42824),C=i(23805),j=i(37241),L=i(14974);s.exports=function baseMerge(s,o,i,B,$){s!==o&&w(o,(function(w,j){if($||($=new u),C(w))x(s,o,j,i,baseMerge,B,$);else{var V=B?B(L(s,j),w,j+"",s,o,$):void 0;void 0===V&&(V=w),_(s,j,V)}}),j)}},42824:(s,o,i)=>{var u=i(87805),_=i(93290),w=i(71961),x=i(23007),C=i(35529),j=i(72428),L=i(56449),B=i(83693),$=i(3656),V=i(1882),U=i(23805),z=i(11331),Y=i(37167),Z=i(14974),ee=i(69884);s.exports=function baseMergeDeep(s,o,i,ie,ae,le,ce){var pe=Z(s,i),de=Z(o,i),fe=ce.get(de);if(fe)u(s,i,fe);else{var ye=le?le(pe,de,i+"",s,o,ce):void 0,be=void 0===ye;if(be){var _e=L(de),we=!_e&&$(de),Se=!_e&&!we&&Y(de);ye=de,_e||we||Se?L(pe)?ye=pe:B(pe)?ye=x(pe):we?(be=!1,ye=_(de,!0)):Se?(be=!1,ye=w(de,!0)):ye=[]:z(de)||j(de)?(ye=pe,j(pe)?ye=ee(pe):U(pe)&&!V(pe)||(ye=C(de))):be=!1}be&&(ce.set(de,ye),ae(ye,de,ie,le,ce),ce.delete(de)),u(s,i,ye)}}},47237:s=>{s.exports=function baseProperty(s){return function(o){return null==o?void 0:o[s]}}},17255:(s,o,i)=>{var u=i(47422);s.exports=function basePropertyDeep(s){return function(o){return u(o,s)}}},54552:s=>{s.exports=function basePropertyOf(s){return function(o){return null==s?void 0:s[o]}}},85558:s=>{s.exports=function baseReduce(s,o,i,u,_){return _(s,(function(s,_,w){i=u?(u=!1,s):o(i,s,_,w)})),i}},69302:(s,o,i)=>{var u=i(83488),_=i(56757),w=i(32865);s.exports=function baseRest(s,o){return w(_(s,o,u),s+"")}},73170:(s,o,i)=>{var u=i(16547),_=i(31769),w=i(30361),x=i(23805),C=i(77797);s.exports=function baseSet(s,o,i,j){if(!x(s))return s;for(var L=-1,B=(o=_(o,s)).length,$=B-1,V=s;null!=V&&++L{var u=i(83488),_=i(48152),w=_?function(s,o){return _.set(s,o),s}:u;s.exports=w},19570:(s,o,i)=>{var u=i(37334),_=i(93243),w=i(83488),x=_?function(s,o){return _(s,"toString",{configurable:!0,enumerable:!1,value:u(o),writable:!0})}:w;s.exports=x},25160:s=>{s.exports=function baseSlice(s,o,i){var u=-1,_=s.length;o<0&&(o=-o>_?0:_+o),(i=i>_?_:i)<0&&(i+=_),_=o>i?0:i-o>>>0,o>>>=0;for(var w=Array(_);++u<_;)w[u]=s[u+o];return w}},90916:(s,o,i)=>{var u=i(80909);s.exports=function baseSome(s,o){var i;return u(s,(function(s,u,_){return!(i=o(s,u,_))})),!!i}},78096:s=>{s.exports=function baseTimes(s,o){for(var i=-1,u=Array(s);++i{var u=i(51873),_=i(34932),w=i(56449),x=i(44394),C=u?u.prototype:void 0,j=C?C.toString:void 0;s.exports=function baseToString(s){if("string"==typeof s)return s;if(w(s))return _(s,baseToString)+"";if(x(s))return j?j.call(s):"";var o=s+"";return"0"==o&&1/s==-1/0?"-0":o}},54128:(s,o,i)=>{var u=i(31800),_=/^\s+/;s.exports=function baseTrim(s){return s?s.slice(0,u(s)+1).replace(_,""):s}},27301:s=>{s.exports=function baseUnary(s){return function(o){return s(o)}}},19931:(s,o,i)=>{var u=i(31769),_=i(68090),w=i(68969),x=i(77797);s.exports=function baseUnset(s,o){return o=u(o,s),null==(s=w(s,o))||delete s[x(_(o))]}},51234:s=>{s.exports=function baseZipObject(s,o,i){for(var u=-1,_=s.length,w=o.length,x={};++u<_;){var C=u{s.exports=function cacheHas(s,o){return s.has(o)}},31769:(s,o,i)=>{var u=i(56449),_=i(28586),w=i(61802),x=i(13222);s.exports=function castPath(s,o){return u(s)?s:_(s,o)?[s]:w(x(s))}},28754:(s,o,i)=>{var u=i(25160);s.exports=function castSlice(s,o,i){var _=s.length;return i=void 0===i?_:i,!o&&i>=_?s:u(s,o,i)}},49653:(s,o,i)=>{var u=i(37828);s.exports=function cloneArrayBuffer(s){var o=new s.constructor(s.byteLength);return new u(o).set(new u(s)),o}},93290:(s,o,i)=>{s=i.nmd(s);var u=i(9325),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_?u.Buffer:void 0,C=x?x.allocUnsafe:void 0;s.exports=function cloneBuffer(s,o){if(o)return s.slice();var i=s.length,u=C?C(i):new s.constructor(i);return s.copy(u),u}},76169:(s,o,i)=>{var u=i(49653);s.exports=function cloneDataView(s,o){var i=o?u(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.byteLength)}},73201:s=>{var o=/\w*$/;s.exports=function cloneRegExp(s){var i=new s.constructor(s.source,o.exec(s));return i.lastIndex=s.lastIndex,i}},93736:(s,o,i)=>{var u=i(51873),_=u?u.prototype:void 0,w=_?_.valueOf:void 0;s.exports=function cloneSymbol(s){return w?Object(w.call(s)):{}}},71961:(s,o,i)=>{var u=i(49653);s.exports=function cloneTypedArray(s,o){var i=o?u(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.length)}},91596:s=>{var o=Math.max;s.exports=function composeArgs(s,i,u,_){for(var w=-1,x=s.length,C=u.length,j=-1,L=i.length,B=o(x-C,0),$=Array(L+B),V=!_;++j{var o=Math.max;s.exports=function composeArgsRight(s,i,u,_){for(var w=-1,x=s.length,C=-1,j=u.length,L=-1,B=i.length,$=o(x-j,0),V=Array($+B),U=!_;++w<$;)V[w]=s[w];for(var z=w;++L{s.exports=function copyArray(s,o){var i=-1,u=s.length;for(o||(o=Array(u));++i{var u=i(16547),_=i(43360);s.exports=function copyObject(s,o,i,w){var x=!i;i||(i={});for(var C=-1,j=o.length;++C{var u=i(21791),_=i(4664);s.exports=function copySymbols(s,o){return u(s,_(s),o)}},48948:(s,o,i)=>{var u=i(21791),_=i(86375);s.exports=function copySymbolsIn(s,o){return u(s,_(s),o)}},55481:(s,o,i)=>{var u=i(9325)["__core-js_shared__"];s.exports=u},58523:s=>{s.exports=function countHolders(s,o){for(var i=s.length,u=0;i--;)s[i]===o&&++u;return u}},20999:(s,o,i)=>{var u=i(69302),_=i(36800);s.exports=function createAssigner(s){return u((function(o,i){var u=-1,w=i.length,x=w>1?i[w-1]:void 0,C=w>2?i[2]:void 0;for(x=s.length>3&&"function"==typeof x?(w--,x):void 0,C&&_(i[0],i[1],C)&&(x=w<3?void 0:x,w=1),o=Object(o);++u{var u=i(64894);s.exports=function createBaseEach(s,o){return function(i,_){if(null==i)return i;if(!u(i))return s(i,_);for(var w=i.length,x=o?w:-1,C=Object(i);(o?x--:++x{s.exports=function createBaseFor(s){return function(o,i,u){for(var _=-1,w=Object(o),x=u(o),C=x.length;C--;){var j=x[s?C:++_];if(!1===i(w[j],j,w))break}return o}}},11842:(s,o,i)=>{var u=i(82819),_=i(9325);s.exports=function createBind(s,o,i){var w=1&o,x=u(s);return function wrapper(){return(this&&this!==_&&this instanceof wrapper?x:s).apply(w?i:this,arguments)}}},12507:(s,o,i)=>{var u=i(28754),_=i(49698),w=i(63912),x=i(13222);s.exports=function createCaseFirst(s){return function(o){o=x(o);var i=_(o)?w(o):void 0,C=i?i[0]:o.charAt(0),j=i?u(i,1).join(""):o.slice(1);return C[s]()+j}}},45539:(s,o,i)=>{var u=i(40882),_=i(50828),w=i(66645),x=RegExp("['’]","g");s.exports=function createCompounder(s){return function(o){return u(w(_(o).replace(x,"")),s,"")}}},82819:(s,o,i)=>{var u=i(39344),_=i(23805);s.exports=function createCtor(s){return function(){var o=arguments;switch(o.length){case 0:return new s;case 1:return new s(o[0]);case 2:return new s(o[0],o[1]);case 3:return new s(o[0],o[1],o[2]);case 4:return new s(o[0],o[1],o[2],o[3]);case 5:return new s(o[0],o[1],o[2],o[3],o[4]);case 6:return new s(o[0],o[1],o[2],o[3],o[4],o[5]);case 7:return new s(o[0],o[1],o[2],o[3],o[4],o[5],o[6])}var i=u(s.prototype),w=s.apply(i,o);return _(w)?w:i}}},77078:(s,o,i)=>{var u=i(91033),_=i(82819),w=i(37471),x=i(18073),C=i(11287),j=i(36306),L=i(9325);s.exports=function createCurry(s,o,i){var B=_(s);return function wrapper(){for(var _=arguments.length,$=Array(_),V=_,U=C(wrapper);V--;)$[V]=arguments[V];var z=_<3&&$[0]!==U&&$[_-1]!==U?[]:j($,U);return(_-=z.length){var u=i(15389),_=i(64894),w=i(95950);s.exports=function createFind(s){return function(o,i,x){var C=Object(o);if(!_(o)){var j=u(i,3);o=w(o),i=function(s){return j(C[s],s,C)}}var L=s(o,i,x);return L>-1?C[j?o[L]:L]:void 0}}},37471:(s,o,i)=>{var u=i(91596),_=i(53320),w=i(58523),x=i(82819),C=i(18073),j=i(11287),L=i(68294),B=i(36306),$=i(9325);s.exports=function createHybrid(s,o,i,V,U,z,Y,Z,ee,ie){var ae=128&o,le=1&o,ce=2&o,pe=24&o,de=512&o,fe=ce?void 0:x(s);return function wrapper(){for(var ye=arguments.length,be=Array(ye),_e=ye;_e--;)be[_e]=arguments[_e];if(pe)var we=j(wrapper),Se=w(be,we);if(V&&(be=u(be,V,U,pe)),z&&(be=_(be,z,Y,pe)),ye-=Se,pe&&ye1&&be.reverse(),ae&&ee{var u=i(91033),_=i(82819),w=i(9325);s.exports=function createPartial(s,o,i,x){var C=1&o,j=_(s);return function wrapper(){for(var o=-1,_=arguments.length,L=-1,B=x.length,$=Array(B+_),V=this&&this!==w&&this instanceof wrapper?j:s;++L{var u=i(85087),_=i(54641),w=i(70981);s.exports=function createRecurry(s,o,i,x,C,j,L,B,$,V){var U=8&o;o|=U?32:64,4&(o&=~(U?64:32))||(o&=-4);var z=[s,o,C,U?j:void 0,U?L:void 0,U?void 0:j,U?void 0:L,B,$,V],Y=i.apply(void 0,z);return u(s)&&_(Y,z),Y.placeholder=x,w(Y,s,o)}},66977:(s,o,i)=>{var u=i(68882),_=i(11842),w=i(77078),x=i(37471),C=i(24168),j=i(37381),L=i(3209),B=i(54641),$=i(70981),V=i(61489),U=Math.max;s.exports=function createWrap(s,o,i,z,Y,Z,ee,ie){var ae=2&o;if(!ae&&"function"!=typeof s)throw new TypeError("Expected a function");var le=z?z.length:0;if(le||(o&=-97,z=Y=void 0),ee=void 0===ee?ee:U(V(ee),0),ie=void 0===ie?ie:V(ie),le-=Y?Y.length:0,64&o){var ce=z,pe=Y;z=Y=void 0}var de=ae?void 0:j(s),fe=[s,o,i,z,Y,ce,pe,Z,ee,ie];if(de&&L(fe,de),s=fe[0],o=fe[1],i=fe[2],z=fe[3],Y=fe[4],!(ie=fe[9]=void 0===fe[9]?ae?0:s.length:U(fe[9]-le,0))&&24&o&&(o&=-25),o&&1!=o)ye=8==o||16==o?w(s,o,ie):32!=o&&33!=o||Y.length?x.apply(void 0,fe):C(s,o,i,z);else var ye=_(s,o,i);return $((de?u:B)(ye,fe),s,o)}},53138:(s,o,i)=>{var u=i(11331);s.exports=function customOmitClone(s){return u(s)?void 0:s}},24647:(s,o,i)=>{var u=i(54552)({À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",â:"a",ã:"a",ä:"a",å:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ê:"E",Ë:"E",è:"e",é:"e",ê:"e",ë:"e",Ì:"I",Í:"I",Î:"I",Ï:"I",ì:"i",í:"i",î:"i",ï:"i",Ñ:"N",ñ:"n",Ò:"O",Ó:"O",Ô:"O",Õ:"O",Ö:"O",Ø:"O",ò:"o",ó:"o",ô:"o",õ:"o",ö:"o",ø:"o",Ù:"U",Ú:"U",Û:"U",Ü:"U",ù:"u",ú:"u",û:"u",ü:"u",Ý:"Y",ý:"y",ÿ:"y",Æ:"Ae",æ:"ae",Þ:"Th",þ:"th",ß:"ss",Ā:"A",Ă:"A",Ą:"A",ā:"a",ă:"a",ą:"a",Ć:"C",Ĉ:"C",Ċ:"C",Č:"C",ć:"c",ĉ:"c",ċ:"c",č:"c",Ď:"D",Đ:"D",ď:"d",đ:"d",Ē:"E",Ĕ:"E",Ė:"E",Ę:"E",Ě:"E",ē:"e",ĕ:"e",ė:"e",ę:"e",ě:"e",Ĝ:"G",Ğ:"G",Ġ:"G",Ģ:"G",ĝ:"g",ğ:"g",ġ:"g",ģ:"g",Ĥ:"H",Ħ:"H",ĥ:"h",ħ:"h",Ĩ:"I",Ī:"I",Ĭ:"I",Į:"I",İ:"I",ĩ:"i",ī:"i",ĭ:"i",į:"i",ı:"i",Ĵ:"J",ĵ:"j",Ķ:"K",ķ:"k",ĸ:"k",Ĺ:"L",Ļ:"L",Ľ:"L",Ŀ:"L",Ł:"L",ĺ:"l",ļ:"l",ľ:"l",ŀ:"l",ł:"l",Ń:"N",Ņ:"N",Ň:"N",Ŋ:"N",ń:"n",ņ:"n",ň:"n",ŋ:"n",Ō:"O",Ŏ:"O",Ő:"O",ō:"o",ŏ:"o",ő:"o",Ŕ:"R",Ŗ:"R",Ř:"R",ŕ:"r",ŗ:"r",ř:"r",Ś:"S",Ŝ:"S",Ş:"S",Š:"S",ś:"s",ŝ:"s",ş:"s",š:"s",Ţ:"T",Ť:"T",Ŧ:"T",ţ:"t",ť:"t",ŧ:"t",Ũ:"U",Ū:"U",Ŭ:"U",Ů:"U",Ű:"U",Ų:"U",ũ:"u",ū:"u",ŭ:"u",ů:"u",ű:"u",ų:"u",Ŵ:"W",ŵ:"w",Ŷ:"Y",ŷ:"y",Ÿ:"Y",Ź:"Z",Ż:"Z",Ž:"Z",ź:"z",ż:"z",ž:"z",IJ:"IJ",ij:"ij",Œ:"Oe",œ:"oe",ʼn:"'n",ſ:"s"});s.exports=u},93243:(s,o,i)=>{var u=i(56110),_=function(){try{var s=u(Object,"defineProperty");return s({},"",{}),s}catch(s){}}();s.exports=_},25911:(s,o,i)=>{var u=i(38859),_=i(14248),w=i(19219);s.exports=function equalArrays(s,o,i,x,C,j){var L=1&i,B=s.length,$=o.length;if(B!=$&&!(L&&$>B))return!1;var V=j.get(s),U=j.get(o);if(V&&U)return V==o&&U==s;var z=-1,Y=!0,Z=2&i?new u:void 0;for(j.set(s,o),j.set(o,s);++z{var u=i(51873),_=i(37828),w=i(75288),x=i(25911),C=i(20317),j=i(84247),L=u?u.prototype:void 0,B=L?L.valueOf:void 0;s.exports=function equalByTag(s,o,i,u,L,$,V){switch(i){case"[object DataView]":if(s.byteLength!=o.byteLength||s.byteOffset!=o.byteOffset)return!1;s=s.buffer,o=o.buffer;case"[object ArrayBuffer]":return!(s.byteLength!=o.byteLength||!$(new _(s),new _(o)));case"[object Boolean]":case"[object Date]":case"[object Number]":return w(+s,+o);case"[object Error]":return s.name==o.name&&s.message==o.message;case"[object RegExp]":case"[object String]":return s==o+"";case"[object Map]":var U=C;case"[object Set]":var z=1&u;if(U||(U=j),s.size!=o.size&&!z)return!1;var Y=V.get(s);if(Y)return Y==o;u|=2,V.set(s,o);var Z=x(U(s),U(o),u,L,$,V);return V.delete(s),Z;case"[object Symbol]":if(B)return B.call(s)==B.call(o)}return!1}},50689:(s,o,i)=>{var u=i(50002),_=Object.prototype.hasOwnProperty;s.exports=function equalObjects(s,o,i,w,x,C){var j=1&i,L=u(s),B=L.length;if(B!=u(o).length&&!j)return!1;for(var $=B;$--;){var V=L[$];if(!(j?V in o:_.call(o,V)))return!1}var U=C.get(s),z=C.get(o);if(U&&z)return U==o&&z==s;var Y=!0;C.set(s,o),C.set(o,s);for(var Z=j;++${var u=i(35970),_=i(56757),w=i(32865);s.exports=function flatRest(s){return w(_(s,void 0,u),s+"")}},34840:(s,o,i)=>{var u="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g;s.exports=u},50002:(s,o,i)=>{var u=i(82199),_=i(4664),w=i(95950);s.exports=function getAllKeys(s){return u(s,w,_)}},83349:(s,o,i)=>{var u=i(82199),_=i(86375),w=i(37241);s.exports=function getAllKeysIn(s){return u(s,w,_)}},37381:(s,o,i)=>{var u=i(48152),_=i(63950),w=u?function(s){return u.get(s)}:_;s.exports=w},62284:(s,o,i)=>{var u=i(84629),_=Object.prototype.hasOwnProperty;s.exports=function getFuncName(s){for(var o=s.name+"",i=u[o],w=_.call(u,o)?i.length:0;w--;){var x=i[w],C=x.func;if(null==C||C==s)return x.name}return o}},11287:s=>{s.exports=function getHolder(s){return s.placeholder}},12651:(s,o,i)=>{var u=i(74218);s.exports=function getMapData(s,o){var i=s.__data__;return u(o)?i["string"==typeof o?"string":"hash"]:i.map}},10776:(s,o,i)=>{var u=i(30756),_=i(95950);s.exports=function getMatchData(s){for(var o=_(s),i=o.length;i--;){var w=o[i],x=s[w];o[i]=[w,x,u(x)]}return o}},56110:(s,o,i)=>{var u=i(45083),_=i(10392);s.exports=function getNative(s,o){var i=_(s,o);return u(i)?i:void 0}},28879:(s,o,i)=>{var u=i(74335)(Object.getPrototypeOf,Object);s.exports=u},659:(s,o,i)=>{var u=i(51873),_=Object.prototype,w=_.hasOwnProperty,x=_.toString,C=u?u.toStringTag:void 0;s.exports=function getRawTag(s){var o=w.call(s,C),i=s[C];try{s[C]=void 0;var u=!0}catch(s){}var _=x.call(s);return u&&(o?s[C]=i:delete s[C]),_}},4664:(s,o,i)=>{var u=i(79770),_=i(63345),w=Object.prototype.propertyIsEnumerable,x=Object.getOwnPropertySymbols,C=x?function(s){return null==s?[]:(s=Object(s),u(x(s),(function(o){return w.call(s,o)})))}:_;s.exports=C},86375:(s,o,i)=>{var u=i(14528),_=i(28879),w=i(4664),x=i(63345),C=Object.getOwnPropertySymbols?function(s){for(var o=[];s;)u(o,w(s)),s=_(s);return o}:x;s.exports=C},5861:(s,o,i)=>{var u=i(55580),_=i(68223),w=i(32804),x=i(76545),C=i(28303),j=i(72552),L=i(47473),B="[object Map]",$="[object Promise]",V="[object Set]",U="[object WeakMap]",z="[object DataView]",Y=L(u),Z=L(_),ee=L(w),ie=L(x),ae=L(C),le=j;(u&&le(new u(new ArrayBuffer(1)))!=z||_&&le(new _)!=B||w&&le(w.resolve())!=$||x&&le(new x)!=V||C&&le(new C)!=U)&&(le=function(s){var o=j(s),i="[object Object]"==o?s.constructor:void 0,u=i?L(i):"";if(u)switch(u){case Y:return z;case Z:return B;case ee:return $;case ie:return V;case ae:return U}return o}),s.exports=le},10392:s=>{s.exports=function getValue(s,o){return null==s?void 0:s[o]}},75251:s=>{var o=/\{\n\/\* \[wrapped with (.+)\] \*/,i=/,? & /;s.exports=function getWrapDetails(s){var u=s.match(o);return u?u[1].split(i):[]}},49326:(s,o,i)=>{var u=i(31769),_=i(72428),w=i(56449),x=i(30361),C=i(30294),j=i(77797);s.exports=function hasPath(s,o,i){for(var L=-1,B=(o=u(o,s)).length,$=!1;++L{var o=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");s.exports=function hasUnicode(s){return o.test(s)}},45434:s=>{var o=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;s.exports=function hasUnicodeWord(s){return o.test(s)}},22032:(s,o,i)=>{var u=i(81042);s.exports=function hashClear(){this.__data__=u?u(null):{},this.size=0}},63862:s=>{s.exports=function hashDelete(s){var o=this.has(s)&&delete this.__data__[s];return this.size-=o?1:0,o}},66721:(s,o,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;s.exports=function hashGet(s){var o=this.__data__;if(u){var i=o[s];return"__lodash_hash_undefined__"===i?void 0:i}return _.call(o,s)?o[s]:void 0}},12749:(s,o,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;s.exports=function hashHas(s){var o=this.__data__;return u?void 0!==o[s]:_.call(o,s)}},35749:(s,o,i)=>{var u=i(81042);s.exports=function hashSet(s,o){var i=this.__data__;return this.size+=this.has(s)?0:1,i[s]=u&&void 0===o?"__lodash_hash_undefined__":o,this}},76189:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function initCloneArray(s){var i=s.length,u=new s.constructor(i);return i&&"string"==typeof s[0]&&o.call(s,"index")&&(u.index=s.index,u.input=s.input),u}},77199:(s,o,i)=>{var u=i(49653),_=i(76169),w=i(73201),x=i(93736),C=i(71961);s.exports=function initCloneByTag(s,o,i){var j=s.constructor;switch(o){case"[object ArrayBuffer]":return u(s);case"[object Boolean]":case"[object Date]":return new j(+s);case"[object DataView]":return _(s,i);case"[object Float32Array]":case"[object Float64Array]":case"[object Int8Array]":case"[object Int16Array]":case"[object Int32Array]":case"[object Uint8Array]":case"[object Uint8ClampedArray]":case"[object Uint16Array]":case"[object Uint32Array]":return C(s,i);case"[object Map]":case"[object Set]":return new j;case"[object Number]":case"[object String]":return new j(s);case"[object RegExp]":return w(s);case"[object Symbol]":return x(s)}}},35529:(s,o,i)=>{var u=i(39344),_=i(28879),w=i(55527);s.exports=function initCloneObject(s){return"function"!=typeof s.constructor||w(s)?{}:u(_(s))}},62060:s=>{var o=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/;s.exports=function insertWrapDetails(s,i){var u=i.length;if(!u)return s;var _=u-1;return i[_]=(u>1?"& ":"")+i[_],i=i.join(u>2?", ":" "),s.replace(o,"{\n/* [wrapped with "+i+"] */\n")}},45891:(s,o,i)=>{var u=i(51873),_=i(72428),w=i(56449),x=u?u.isConcatSpreadable:void 0;s.exports=function isFlattenable(s){return w(s)||_(s)||!!(x&&s&&s[x])}},30361:s=>{var o=/^(?:0|[1-9]\d*)$/;s.exports=function isIndex(s,i){var u=typeof s;return!!(i=null==i?9007199254740991:i)&&("number"==u||"symbol"!=u&&o.test(s))&&s>-1&&s%1==0&&s{var u=i(75288),_=i(64894),w=i(30361),x=i(23805);s.exports=function isIterateeCall(s,o,i){if(!x(i))return!1;var C=typeof o;return!!("number"==C?_(i)&&w(o,i.length):"string"==C&&o in i)&&u(i[o],s)}},28586:(s,o,i)=>{var u=i(56449),_=i(44394),w=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,x=/^\w*$/;s.exports=function isKey(s,o){if(u(s))return!1;var i=typeof s;return!("number"!=i&&"symbol"!=i&&"boolean"!=i&&null!=s&&!_(s))||(x.test(s)||!w.test(s)||null!=o&&s in Object(o))}},74218:s=>{s.exports=function isKeyable(s){var o=typeof s;return"string"==o||"number"==o||"symbol"==o||"boolean"==o?"__proto__"!==s:null===s}},85087:(s,o,i)=>{var u=i(30980),_=i(37381),w=i(62284),x=i(53758);s.exports=function isLaziable(s){var o=w(s),i=x[o];if("function"!=typeof i||!(o in u.prototype))return!1;if(s===i)return!0;var C=_(i);return!!C&&s===C[0]}},87296:(s,o,i)=>{var u,_=i(55481),w=(u=/[^.]+$/.exec(_&&_.keys&&_.keys.IE_PROTO||""))?"Symbol(src)_1."+u:"";s.exports=function isMasked(s){return!!w&&w in s}},55527:s=>{var o=Object.prototype;s.exports=function isPrototype(s){var i=s&&s.constructor;return s===("function"==typeof i&&i.prototype||o)}},30756:(s,o,i)=>{var u=i(23805);s.exports=function isStrictComparable(s){return s==s&&!u(s)}},63702:s=>{s.exports=function listCacheClear(){this.__data__=[],this.size=0}},70080:(s,o,i)=>{var u=i(26025),_=Array.prototype.splice;s.exports=function listCacheDelete(s){var o=this.__data__,i=u(o,s);return!(i<0)&&(i==o.length-1?o.pop():_.call(o,i,1),--this.size,!0)}},24739:(s,o,i)=>{var u=i(26025);s.exports=function listCacheGet(s){var o=this.__data__,i=u(o,s);return i<0?void 0:o[i][1]}},48655:(s,o,i)=>{var u=i(26025);s.exports=function listCacheHas(s){return u(this.__data__,s)>-1}},31175:(s,o,i)=>{var u=i(26025);s.exports=function listCacheSet(s,o){var i=this.__data__,_=u(i,s);return _<0?(++this.size,i.push([s,o])):i[_][1]=o,this}},63040:(s,o,i)=>{var u=i(21549),_=i(80079),w=i(68223);s.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new u,map:new(w||_),string:new u}}},17670:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheDelete(s){var o=u(this,s).delete(s);return this.size-=o?1:0,o}},90289:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheGet(s){return u(this,s).get(s)}},4509:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheHas(s){return u(this,s).has(s)}},72949:(s,o,i)=>{var u=i(12651);s.exports=function mapCacheSet(s,o){var i=u(this,s),_=i.size;return i.set(s,o),this.size+=i.size==_?0:1,this}},20317:s=>{s.exports=function mapToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s,u){i[++o]=[u,s]})),i}},67197:s=>{s.exports=function matchesStrictComparable(s,o){return function(i){return null!=i&&(i[s]===o&&(void 0!==o||s in Object(i)))}}},62224:(s,o,i)=>{var u=i(50104);s.exports=function memoizeCapped(s){var o=u(s,(function(s){return 500===i.size&&i.clear(),s})),i=o.cache;return o}},3209:(s,o,i)=>{var u=i(91596),_=i(53320),w=i(36306),x="__lodash_placeholder__",C=128,j=Math.min;s.exports=function mergeData(s,o){var i=s[1],L=o[1],B=i|L,$=B<131,V=L==C&&8==i||L==C&&256==i&&s[7].length<=o[8]||384==L&&o[7].length<=o[8]&&8==i;if(!$&&!V)return s;1&L&&(s[2]=o[2],B|=1&i?0:4);var U=o[3];if(U){var z=s[3];s[3]=z?u(z,U,o[4]):U,s[4]=z?w(s[3],x):o[4]}return(U=o[5])&&(z=s[5],s[5]=z?_(z,U,o[6]):U,s[6]=z?w(s[5],x):o[6]),(U=o[7])&&(s[7]=U),L&C&&(s[8]=null==s[8]?o[8]:j(s[8],o[8])),null==s[9]&&(s[9]=o[9]),s[0]=o[0],s[1]=B,s}},48152:(s,o,i)=>{var u=i(28303),_=u&&new u;s.exports=_},81042:(s,o,i)=>{var u=i(56110)(Object,"create");s.exports=u},3650:(s,o,i)=>{var u=i(74335)(Object.keys,Object);s.exports=u},90181:s=>{s.exports=function nativeKeysIn(s){var o=[];if(null!=s)for(var i in Object(s))o.push(i);return o}},86009:(s,o,i)=>{s=i.nmd(s);var u=i(34840),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_&&u.process,C=function(){try{var s=w&&w.require&&w.require("util").types;return s||x&&x.binding&&x.binding("util")}catch(s){}}();s.exports=C},59350:s=>{var o=Object.prototype.toString;s.exports=function objectToString(s){return o.call(s)}},74335:s=>{s.exports=function overArg(s,o){return function(i){return s(o(i))}}},56757:(s,o,i)=>{var u=i(91033),_=Math.max;s.exports=function overRest(s,o,i){return o=_(void 0===o?s.length-1:o,0),function(){for(var w=arguments,x=-1,C=_(w.length-o,0),j=Array(C);++x{var u=i(47422),_=i(25160);s.exports=function parent(s,o){return o.length<2?s:u(s,_(o,0,-1))}},84629:s=>{s.exports={}},68294:(s,o,i)=>{var u=i(23007),_=i(30361),w=Math.min;s.exports=function reorder(s,o){for(var i=s.length,x=w(o.length,i),C=u(s);x--;){var j=o[x];s[x]=_(j,i)?C[j]:void 0}return s}},36306:s=>{var o="__lodash_placeholder__";s.exports=function replaceHolders(s,i){for(var u=-1,_=s.length,w=0,x=[];++u<_;){var C=s[u];C!==i&&C!==o||(s[u]=o,x[w++]=u)}return x}},9325:(s,o,i)=>{var u=i(34840),_="object"==typeof self&&self&&self.Object===Object&&self,w=u||_||Function("return this")();s.exports=w},14974:s=>{s.exports=function safeGet(s,o){if(("constructor"!==o||"function"!=typeof s[o])&&"__proto__"!=o)return s[o]}},31380:s=>{s.exports=function setCacheAdd(s){return this.__data__.set(s,"__lodash_hash_undefined__"),this}},51459:s=>{s.exports=function setCacheHas(s){return this.__data__.has(s)}},54641:(s,o,i)=>{var u=i(68882),_=i(51811)(u);s.exports=_},84247:s=>{s.exports=function setToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s){i[++o]=s})),i}},32865:(s,o,i)=>{var u=i(19570),_=i(51811)(u);s.exports=_},70981:(s,o,i)=>{var u=i(75251),_=i(62060),w=i(32865),x=i(75948);s.exports=function setWrapToString(s,o,i){var C=o+"";return w(s,_(C,x(u(C),i)))}},51811:s=>{var o=Date.now;s.exports=function shortOut(s){var i=0,u=0;return function(){var _=o(),w=16-(_-u);if(u=_,w>0){if(++i>=800)return arguments[0]}else i=0;return s.apply(void 0,arguments)}}},51420:(s,o,i)=>{var u=i(80079);s.exports=function stackClear(){this.__data__=new u,this.size=0}},90938:s=>{s.exports=function stackDelete(s){var o=this.__data__,i=o.delete(s);return this.size=o.size,i}},63605:s=>{s.exports=function stackGet(s){return this.__data__.get(s)}},29817:s=>{s.exports=function stackHas(s){return this.__data__.has(s)}},80945:(s,o,i)=>{var u=i(80079),_=i(68223),w=i(53661);s.exports=function stackSet(s,o){var i=this.__data__;if(i instanceof u){var x=i.__data__;if(!_||x.length<199)return x.push([s,o]),this.size=++i.size,this;i=this.__data__=new w(x)}return i.set(s,o),this.size=i.size,this}},76959:s=>{s.exports=function strictIndexOf(s,o,i){for(var u=i-1,_=s.length;++u<_;)if(s[u]===o)return u;return-1}},63912:(s,o,i)=>{var u=i(61074),_=i(49698),w=i(42054);s.exports=function stringToArray(s){return _(s)?w(s):u(s)}},61802:(s,o,i)=>{var u=i(62224),_=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,w=/\\(\\)?/g,x=u((function(s){var o=[];return 46===s.charCodeAt(0)&&o.push(""),s.replace(_,(function(s,i,u,_){o.push(u?_.replace(w,"$1"):i||s)})),o}));s.exports=x},77797:(s,o,i)=>{var u=i(44394);s.exports=function toKey(s){if("string"==typeof s||u(s))return s;var o=s+"";return"0"==o&&1/s==-1/0?"-0":o}},47473:s=>{var o=Function.prototype.toString;s.exports=function toSource(s){if(null!=s){try{return o.call(s)}catch(s){}try{return s+""}catch(s){}}return""}},31800:s=>{var o=/\s/;s.exports=function trimmedEndIndex(s){for(var i=s.length;i--&&o.test(s.charAt(i)););return i}},42054:s=>{var o="\\ud800-\\udfff",i="["+o+"]",u="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",_="\\ud83c[\\udffb-\\udfff]",w="[^"+o+"]",x="(?:\\ud83c[\\udde6-\\uddff]){2}",C="[\\ud800-\\udbff][\\udc00-\\udfff]",j="(?:"+u+"|"+_+")"+"?",L="[\\ufe0e\\ufe0f]?",B=L+j+("(?:\\u200d(?:"+[w,x,C].join("|")+")"+L+j+")*"),$="(?:"+[w+u+"?",u,x,C,i].join("|")+")",V=RegExp(_+"(?="+_+")|"+$+B,"g");s.exports=function unicodeToArray(s){return s.match(V)||[]}},22225:s=>{var o="\\ud800-\\udfff",i="\\u2700-\\u27bf",u="a-z\\xdf-\\xf6\\xf8-\\xff",_="A-Z\\xc0-\\xd6\\xd8-\\xde",w="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",x="["+w+"]",C="\\d+",j="["+i+"]",L="["+u+"]",B="[^"+o+w+C+i+u+_+"]",$="(?:\\ud83c[\\udde6-\\uddff]){2}",V="[\\ud800-\\udbff][\\udc00-\\udfff]",U="["+_+"]",z="(?:"+L+"|"+B+")",Y="(?:"+U+"|"+B+")",Z="(?:['’](?:d|ll|m|re|s|t|ve))?",ee="(?:['’](?:D|LL|M|RE|S|T|VE))?",ie="(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?",ae="[\\ufe0e\\ufe0f]?",le=ae+ie+("(?:\\u200d(?:"+["[^"+o+"]",$,V].join("|")+")"+ae+ie+")*"),ce="(?:"+[j,$,V].join("|")+")"+le,pe=RegExp([U+"?"+L+"+"+Z+"(?="+[x,U,"$"].join("|")+")",Y+"+"+ee+"(?="+[x,U+z,"$"].join("|")+")",U+"?"+z+"+"+Z,U+"+"+ee,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",C,ce].join("|"),"g");s.exports=function unicodeWords(s){return s.match(pe)||[]}},75948:(s,o,i)=>{var u=i(83729),_=i(15325),w=[["ary",128],["bind",1],["bindKey",2],["curry",8],["curryRight",16],["flip",512],["partial",32],["partialRight",64],["rearg",256]];s.exports=function updateWrapDetails(s,o){return u(w,(function(i){var u="_."+i[0];o&i[1]&&!_(s,u)&&s.push(u)})),s.sort()}},80257:(s,o,i)=>{var u=i(30980),_=i(56017),w=i(23007);s.exports=function wrapperClone(s){if(s instanceof u)return s.clone();var o=new _(s.__wrapped__,s.__chain__);return o.__actions__=w(s.__actions__),o.__index__=s.__index__,o.__values__=s.__values__,o}},64626:(s,o,i)=>{var u=i(66977);s.exports=function ary(s,o,i){return o=i?void 0:o,o=s&&null==o?s.length:o,u(s,128,void 0,void 0,void 0,void 0,o)}},84058:(s,o,i)=>{var u=i(14792),_=i(45539)((function(s,o,i){return o=o.toLowerCase(),s+(i?u(o):o)}));s.exports=_},14792:(s,o,i)=>{var u=i(13222),_=i(55808);s.exports=function capitalize(s){return _(u(s).toLowerCase())}},32629:(s,o,i)=>{var u=i(9999);s.exports=function clone(s){return u(s,4)}},37334:s=>{s.exports=function constant(s){return function(){return s}}},49747:(s,o,i)=>{var u=i(66977);function curry(s,o,i){var _=u(s,8,void 0,void 0,void 0,void 0,void 0,o=i?void 0:o);return _.placeholder=curry.placeholder,_}curry.placeholder={},s.exports=curry},38221:(s,o,i)=>{var u=i(23805),_=i(10124),w=i(99374),x=Math.max,C=Math.min;s.exports=function debounce(s,o,i){var j,L,B,$,V,U,z=0,Y=!1,Z=!1,ee=!0;if("function"!=typeof s)throw new TypeError("Expected a function");function invokeFunc(o){var i=j,u=L;return j=L=void 0,z=o,$=s.apply(u,i)}function shouldInvoke(s){var i=s-U;return void 0===U||i>=o||i<0||Z&&s-z>=B}function timerExpired(){var s=_();if(shouldInvoke(s))return trailingEdge(s);V=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-U);return Z?C(i,B-(s-z)):i}(s))}function trailingEdge(s){return V=void 0,ee&&j?invokeFunc(s):(j=L=void 0,$)}function debounced(){var s=_(),i=shouldInvoke(s);if(j=arguments,L=this,U=s,i){if(void 0===V)return function leadingEdge(s){return z=s,V=setTimeout(timerExpired,o),Y?invokeFunc(s):$}(U);if(Z)return clearTimeout(V),V=setTimeout(timerExpired,o),invokeFunc(U)}return void 0===V&&(V=setTimeout(timerExpired,o)),$}return o=w(o)||0,u(i)&&(Y=!!i.leading,B=(Z="maxWait"in i)?x(w(i.maxWait)||0,o):B,ee="trailing"in i?!!i.trailing:ee),debounced.cancel=function cancel(){void 0!==V&&clearTimeout(V),z=0,j=U=L=V=void 0},debounced.flush=function flush(){return void 0===V?$:trailingEdge(_())},debounced}},50828:(s,o,i)=>{var u=i(24647),_=i(13222),w=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,x=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]","g");s.exports=function deburr(s){return(s=_(s))&&s.replace(w,u).replace(x,"")}},75288:s=>{s.exports=function eq(s,o){return s===o||s!=s&&o!=o}},60680:(s,o,i)=>{var u=i(13222),_=/[\\^$.*+?()[\]{}|]/g,w=RegExp(_.source);s.exports=function escapeRegExp(s){return(s=u(s))&&w.test(s)?s.replace(_,"\\$&"):s}},7309:(s,o,i)=>{var u=i(62006)(i(24713));s.exports=u},24713:(s,o,i)=>{var u=i(2523),_=i(15389),w=i(61489),x=Math.max;s.exports=function findIndex(s,o,i){var C=null==s?0:s.length;if(!C)return-1;var j=null==i?0:w(i);return j<0&&(j=x(C+j,0)),u(s,_(o,3),j)}},35970:(s,o,i)=>{var u=i(83120);s.exports=function flatten(s){return(null==s?0:s.length)?u(s,1):[]}},73424:(s,o,i)=>{var u=i(16962),_=i(2874),w=Array.prototype.push;function baseAry(s,o){return 2==o?function(o,i){return s(o,i)}:function(o){return s(o)}}function cloneArray(s){for(var o=s?s.length:0,i=Array(o);o--;)i[o]=s[o];return i}function wrapImmutable(s,o){return function(){var i=arguments.length;if(i){for(var u=Array(i);i--;)u[i]=arguments[i];var _=u[0]=o.apply(void 0,u);return s.apply(void 0,u),_}}}s.exports=function baseConvert(s,o,i,x){var C="function"==typeof o,j=o===Object(o);if(j&&(x=i,i=o,o=void 0),null==i)throw new TypeError;x||(x={});var L=!("cap"in x)||x.cap,B=!("curry"in x)||x.curry,$=!("fixed"in x)||x.fixed,V=!("immutable"in x)||x.immutable,U=!("rearg"in x)||x.rearg,z=C?i:_,Y="curry"in x&&x.curry,Z="fixed"in x&&x.fixed,ee="rearg"in x&&x.rearg,ie=C?i.runInContext():void 0,ae=C?i:{ary:s.ary,assign:s.assign,clone:s.clone,curry:s.curry,forEach:s.forEach,isArray:s.isArray,isError:s.isError,isFunction:s.isFunction,isWeakMap:s.isWeakMap,iteratee:s.iteratee,keys:s.keys,rearg:s.rearg,toInteger:s.toInteger,toPath:s.toPath},le=ae.ary,ce=ae.assign,pe=ae.clone,de=ae.curry,fe=ae.forEach,ye=ae.isArray,be=ae.isError,_e=ae.isFunction,we=ae.isWeakMap,Se=ae.keys,xe=ae.rearg,Pe=ae.toInteger,Te=ae.toPath,Re=Se(u.aryMethod),qe={castArray:function(s){return function(){var o=arguments[0];return ye(o)?s(cloneArray(o)):s.apply(void 0,arguments)}},iteratee:function(s){return function(){var o=arguments[1],i=s(arguments[0],o),u=i.length;return L&&"number"==typeof o?(o=o>2?o-2:1,u&&u<=o?i:baseAry(i,o)):i}},mixin:function(s){return function(o){var i=this;if(!_e(i))return s(i,Object(o));var u=[];return fe(Se(o),(function(s){_e(o[s])&&u.push([s,i.prototype[s]])})),s(i,Object(o)),fe(u,(function(s){var o=s[1];_e(o)?i.prototype[s[0]]=o:delete i.prototype[s[0]]})),i}},nthArg:function(s){return function(o){var i=o<0?1:Pe(o)+1;return de(s(o),i)}},rearg:function(s){return function(o,i){var u=i?i.length:0;return de(s(o,i),u)}},runInContext:function(o){return function(i){return baseConvert(s,o(i),x)}}};function castCap(s,o){if(L){var i=u.iterateeRearg[s];if(i)return function iterateeRearg(s,o){return overArg(s,(function(s){var i=o.length;return function baseArity(s,o){return 2==o?function(o,i){return s.apply(void 0,arguments)}:function(o){return s.apply(void 0,arguments)}}(xe(baseAry(s,i),o),i)}))}(o,i);var _=!C&&u.iterateeAry[s];if(_)return function iterateeAry(s,o){return overArg(s,(function(s){return"function"==typeof s?baseAry(s,o):s}))}(o,_)}return o}function castFixed(s,o,i){if($&&(Z||!u.skipFixed[s])){var _=u.methodSpread[s],x=_&&_.start;return void 0===x?le(o,i):function flatSpread(s,o){return function(){for(var i=arguments.length,u=i-1,_=Array(i);i--;)_[i]=arguments[i];var x=_[o],C=_.slice(0,o);return x&&w.apply(C,x),o!=u&&w.apply(C,_.slice(o+1)),s.apply(this,C)}}(o,x)}return o}function castRearg(s,o,i){return U&&i>1&&(ee||!u.skipRearg[s])?xe(o,u.methodRearg[s]||u.aryRearg[i]):o}function cloneByPath(s,o){for(var i=-1,u=(o=Te(o)).length,_=u-1,w=pe(Object(s)),x=w;null!=x&&++i1?de(o,i):o}(0,_=castCap(w,_),s),!1}})),!_})),_||(_=x),_==o&&(_=Y?de(_,1):function(){return o.apply(this,arguments)}),_.convert=createConverter(w,o),_.placeholder=o.placeholder=i,_}if(!j)return wrap(o,i,z);var $e=i,ze=[];return fe(Re,(function(s){fe(u.aryMethod[s],(function(s){var o=$e[u.remap[s]||s];o&&ze.push([s,wrap(s,o,$e)])}))})),fe(Se($e),(function(s){var o=$e[s];if("function"==typeof o){for(var i=ze.length;i--;)if(ze[i][0]==s)return;o.convert=createConverter(s,o),ze.push([s,o])}})),fe(ze,(function(s){$e[s[0]]=s[1]})),$e.convert=function convertLib(s){return $e.runInContext.convert(s)(void 0)},$e.placeholder=$e,fe(Se($e),(function(s){fe(u.realToAlias[s]||[],(function(o){$e[o]=$e[s]}))})),$e}},16962:(s,o)=>{o.aliasToReal={each:"forEach",eachRight:"forEachRight",entries:"toPairs",entriesIn:"toPairsIn",extend:"assignIn",extendAll:"assignInAll",extendAllWith:"assignInAllWith",extendWith:"assignInWith",first:"head",conforms:"conformsTo",matches:"isMatch",property:"get",__:"placeholder",F:"stubFalse",T:"stubTrue",all:"every",allPass:"overEvery",always:"constant",any:"some",anyPass:"overSome",apply:"spread",assoc:"set",assocPath:"set",complement:"negate",compose:"flowRight",contains:"includes",dissoc:"unset",dissocPath:"unset",dropLast:"dropRight",dropLastWhile:"dropRightWhile",equals:"isEqual",identical:"eq",indexBy:"keyBy",init:"initial",invertObj:"invert",juxt:"over",omitAll:"omit",nAry:"ary",path:"get",pathEq:"matchesProperty",pathOr:"getOr",paths:"at",pickAll:"pick",pipe:"flow",pluck:"map",prop:"get",propEq:"matchesProperty",propOr:"getOr",props:"at",symmetricDifference:"xor",symmetricDifferenceBy:"xorBy",symmetricDifferenceWith:"xorWith",takeLast:"takeRight",takeLastWhile:"takeRightWhile",unapply:"rest",unnest:"flatten",useWith:"overArgs",where:"conformsTo",whereEq:"isMatch",zipObj:"zipObject"},o.aryMethod={1:["assignAll","assignInAll","attempt","castArray","ceil","create","curry","curryRight","defaultsAll","defaultsDeepAll","floor","flow","flowRight","fromPairs","invert","iteratee","memoize","method","mergeAll","methodOf","mixin","nthArg","over","overEvery","overSome","rest","reverse","round","runInContext","spread","template","trim","trimEnd","trimStart","uniqueId","words","zipAll"],2:["add","after","ary","assign","assignAllWith","assignIn","assignInAllWith","at","before","bind","bindAll","bindKey","chunk","cloneDeepWith","cloneWith","concat","conformsTo","countBy","curryN","curryRightN","debounce","defaults","defaultsDeep","defaultTo","delay","difference","divide","drop","dropRight","dropRightWhile","dropWhile","endsWith","eq","every","filter","find","findIndex","findKey","findLast","findLastIndex","findLastKey","flatMap","flatMapDeep","flattenDepth","forEach","forEachRight","forIn","forInRight","forOwn","forOwnRight","get","groupBy","gt","gte","has","hasIn","includes","indexOf","intersection","invertBy","invoke","invokeMap","isEqual","isMatch","join","keyBy","lastIndexOf","lt","lte","map","mapKeys","mapValues","matchesProperty","maxBy","meanBy","merge","mergeAllWith","minBy","multiply","nth","omit","omitBy","overArgs","pad","padEnd","padStart","parseInt","partial","partialRight","partition","pick","pickBy","propertyOf","pull","pullAll","pullAt","random","range","rangeRight","rearg","reject","remove","repeat","restFrom","result","sampleSize","some","sortBy","sortedIndex","sortedIndexOf","sortedLastIndex","sortedLastIndexOf","sortedUniqBy","split","spreadFrom","startsWith","subtract","sumBy","take","takeRight","takeRightWhile","takeWhile","tap","throttle","thru","times","trimChars","trimCharsEnd","trimCharsStart","truncate","union","uniqBy","uniqWith","unset","unzipWith","without","wrap","xor","zip","zipObject","zipObjectDeep"],3:["assignInWith","assignWith","clamp","differenceBy","differenceWith","findFrom","findIndexFrom","findLastFrom","findLastIndexFrom","getOr","includesFrom","indexOfFrom","inRange","intersectionBy","intersectionWith","invokeArgs","invokeArgsMap","isEqualWith","isMatchWith","flatMapDepth","lastIndexOfFrom","mergeWith","orderBy","padChars","padCharsEnd","padCharsStart","pullAllBy","pullAllWith","rangeStep","rangeStepRight","reduce","reduceRight","replace","set","slice","sortedIndexBy","sortedLastIndexBy","transform","unionBy","unionWith","update","xorBy","xorWith","zipWith"],4:["fill","setWith","updateWith"]},o.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},o.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},o.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},o.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},o.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},o.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},o.realToAlias=function(){var s=Object.prototype.hasOwnProperty,i=o.aliasToReal,u={};for(var _ in i){var w=i[_];s.call(u,w)?u[w].push(_):u[w]=[_]}return u}(),o.remap={assignAll:"assign",assignAllWith:"assignWith",assignInAll:"assignIn",assignInAllWith:"assignInWith",curryN:"curry",curryRightN:"curryRight",defaultsAll:"defaults",defaultsDeepAll:"defaultsDeep",findFrom:"find",findIndexFrom:"findIndex",findLastFrom:"findLast",findLastIndexFrom:"findLastIndex",getOr:"get",includesFrom:"includes",indexOfFrom:"indexOf",invokeArgs:"invoke",invokeArgsMap:"invokeMap",lastIndexOfFrom:"lastIndexOf",mergeAll:"merge",mergeAllWith:"mergeWith",padChars:"pad",padCharsEnd:"padEnd",padCharsStart:"padStart",propertyOf:"get",rangeStep:"range",rangeStepRight:"rangeRight",restFrom:"rest",spreadFrom:"spread",trimChars:"trim",trimCharsEnd:"trimEnd",trimCharsStart:"trimStart",zipAll:"zip"},o.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},o.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},47934:(s,o,i)=>{s.exports={ary:i(64626),assign:i(74733),clone:i(32629),curry:i(49747),forEach:i(83729),isArray:i(56449),isError:i(23546),isFunction:i(1882),isWeakMap:i(47886),iteratee:i(33855),keys:i(88984),rearg:i(84195),toInteger:i(61489),toPath:i(42072)}},56367:(s,o,i)=>{s.exports=i(77731)},79920:(s,o,i)=>{var u=i(73424),_=i(47934);s.exports=function convert(s,o,i){return u(_,s,o,i)}},2874:s=>{s.exports={}},77731:(s,o,i)=>{var u=i(79920)("set",i(63560));u.placeholder=i(2874),s.exports=u},58156:(s,o,i)=>{var u=i(47422);s.exports=function get(s,o,i){var _=null==s?void 0:u(s,o);return void 0===_?i:_}},61448:(s,o,i)=>{var u=i(20426),_=i(49326);s.exports=function has(s,o){return null!=s&&_(s,o,u)}},80631:(s,o,i)=>{var u=i(28077),_=i(49326);s.exports=function hasIn(s,o){return null!=s&&_(s,o,u)}},83488:s=>{s.exports=function identity(s){return s}},72428:(s,o,i)=>{var u=i(27534),_=i(40346),w=Object.prototype,x=w.hasOwnProperty,C=w.propertyIsEnumerable,j=u(function(){return arguments}())?u:function(s){return _(s)&&x.call(s,"callee")&&!C.call(s,"callee")};s.exports=j},56449:s=>{var o=Array.isArray;s.exports=o},64894:(s,o,i)=>{var u=i(1882),_=i(30294);s.exports=function isArrayLike(s){return null!=s&&_(s.length)&&!u(s)}},83693:(s,o,i)=>{var u=i(64894),_=i(40346);s.exports=function isArrayLikeObject(s){return _(s)&&u(s)}},53812:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isBoolean(s){return!0===s||!1===s||_(s)&&"[object Boolean]"==u(s)}},3656:(s,o,i)=>{s=i.nmd(s);var u=i(9325),_=i(89935),w=o&&!o.nodeType&&o,x=w&&s&&!s.nodeType&&s,C=x&&x.exports===w?u.Buffer:void 0,j=(C?C.isBuffer:void 0)||_;s.exports=j},62193:(s,o,i)=>{var u=i(88984),_=i(5861),w=i(72428),x=i(56449),C=i(64894),j=i(3656),L=i(55527),B=i(37167),$=Object.prototype.hasOwnProperty;s.exports=function isEmpty(s){if(null==s)return!0;if(C(s)&&(x(s)||"string"==typeof s||"function"==typeof s.splice||j(s)||B(s)||w(s)))return!s.length;var o=_(s);if("[object Map]"==o||"[object Set]"==o)return!s.size;if(L(s))return!u(s).length;for(var i in s)if($.call(s,i))return!1;return!0}},2404:(s,o,i)=>{var u=i(60270);s.exports=function isEqual(s,o){return u(s,o)}},23546:(s,o,i)=>{var u=i(72552),_=i(40346),w=i(11331);s.exports=function isError(s){if(!_(s))return!1;var o=u(s);return"[object Error]"==o||"[object DOMException]"==o||"string"==typeof s.message&&"string"==typeof s.name&&!w(s)}},1882:(s,o,i)=>{var u=i(72552),_=i(23805);s.exports=function isFunction(s){if(!_(s))return!1;var o=u(s);return"[object Function]"==o||"[object GeneratorFunction]"==o||"[object AsyncFunction]"==o||"[object Proxy]"==o}},30294:s=>{s.exports=function isLength(s){return"number"==typeof s&&s>-1&&s%1==0&&s<=9007199254740991}},87730:(s,o,i)=>{var u=i(29172),_=i(27301),w=i(86009),x=w&&w.isMap,C=x?_(x):u;s.exports=C},5187:s=>{s.exports=function isNull(s){return null===s}},98023:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isNumber(s){return"number"==typeof s||_(s)&&"[object Number]"==u(s)}},23805:s=>{s.exports=function isObject(s){var o=typeof s;return null!=s&&("object"==o||"function"==o)}},40346:s=>{s.exports=function isObjectLike(s){return null!=s&&"object"==typeof s}},11331:(s,o,i)=>{var u=i(72552),_=i(28879),w=i(40346),x=Function.prototype,C=Object.prototype,j=x.toString,L=C.hasOwnProperty,B=j.call(Object);s.exports=function isPlainObject(s){if(!w(s)||"[object Object]"!=u(s))return!1;var o=_(s);if(null===o)return!0;var i=L.call(o,"constructor")&&o.constructor;return"function"==typeof i&&i instanceof i&&j.call(i)==B}},38440:(s,o,i)=>{var u=i(16038),_=i(27301),w=i(86009),x=w&&w.isSet,C=x?_(x):u;s.exports=C},85015:(s,o,i)=>{var u=i(72552),_=i(56449),w=i(40346);s.exports=function isString(s){return"string"==typeof s||!_(s)&&w(s)&&"[object String]"==u(s)}},44394:(s,o,i)=>{var u=i(72552),_=i(40346);s.exports=function isSymbol(s){return"symbol"==typeof s||_(s)&&"[object Symbol]"==u(s)}},37167:(s,o,i)=>{var u=i(4901),_=i(27301),w=i(86009),x=w&&w.isTypedArray,C=x?_(x):u;s.exports=C},47886:(s,o,i)=>{var u=i(5861),_=i(40346);s.exports=function isWeakMap(s){return _(s)&&"[object WeakMap]"==u(s)}},33855:(s,o,i)=>{var u=i(9999),_=i(15389);s.exports=function iteratee(s){return _("function"==typeof s?s:u(s,1))}},95950:(s,o,i)=>{var u=i(70695),_=i(88984),w=i(64894);s.exports=function keys(s){return w(s)?u(s):_(s)}},37241:(s,o,i)=>{var u=i(70695),_=i(72903),w=i(64894);s.exports=function keysIn(s){return w(s)?u(s,!0):_(s)}},68090:s=>{s.exports=function last(s){var o=null==s?0:s.length;return o?s[o-1]:void 0}},50104:(s,o,i)=>{var u=i(53661);function memoize(s,o){if("function"!=typeof s||null!=o&&"function"!=typeof o)throw new TypeError("Expected a function");var memoized=function(){var i=arguments,u=o?o.apply(this,i):i[0],_=memoized.cache;if(_.has(u))return _.get(u);var w=s.apply(this,i);return memoized.cache=_.set(u,w)||_,w};return memoized.cache=new(memoize.Cache||u),memoized}memoize.Cache=u,s.exports=memoize},55364:(s,o,i)=>{var u=i(85250),_=i(20999)((function(s,o,i){u(s,o,i)}));s.exports=_},6048:s=>{s.exports=function negate(s){if("function"!=typeof s)throw new TypeError("Expected a function");return function(){var o=arguments;switch(o.length){case 0:return!s.call(this);case 1:return!s.call(this,o[0]);case 2:return!s.call(this,o[0],o[1]);case 3:return!s.call(this,o[0],o[1],o[2])}return!s.apply(this,o)}}},63950:s=>{s.exports=function noop(){}},10124:(s,o,i)=>{var u=i(9325);s.exports=function(){return u.Date.now()}},90179:(s,o,i)=>{var u=i(34932),_=i(9999),w=i(19931),x=i(31769),C=i(21791),j=i(53138),L=i(38816),B=i(83349),$=L((function(s,o){var i={};if(null==s)return i;var L=!1;o=u(o,(function(o){return o=x(o,s),L||(L=o.length>1),o})),C(s,B(s),i),L&&(i=_(i,7,j));for(var $=o.length;$--;)w(i,o[$]);return i}));s.exports=$},50583:(s,o,i)=>{var u=i(47237),_=i(17255),w=i(28586),x=i(77797);s.exports=function property(s){return w(s)?u(x(s)):_(s)}},84195:(s,o,i)=>{var u=i(66977),_=i(38816),w=_((function(s,o){return u(s,256,void 0,void 0,void 0,o)}));s.exports=w},40860:(s,o,i)=>{var u=i(40882),_=i(80909),w=i(15389),x=i(85558),C=i(56449);s.exports=function reduce(s,o,i){var j=C(s)?u:x,L=arguments.length<3;return j(s,w(o,4),i,L,_)}},63560:(s,o,i)=>{var u=i(73170);s.exports=function set(s,o,i){return null==s?s:u(s,o,i)}},42426:(s,o,i)=>{var u=i(14248),_=i(15389),w=i(90916),x=i(56449),C=i(36800);s.exports=function some(s,o,i){var j=x(s)?u:w;return i&&C(s,o,i)&&(o=void 0),j(s,_(o,3))}},63345:s=>{s.exports=function stubArray(){return[]}},89935:s=>{s.exports=function stubFalse(){return!1}},17400:(s,o,i)=>{var u=i(99374),_=1/0;s.exports=function toFinite(s){return s?(s=u(s))===_||s===-1/0?17976931348623157e292*(s<0?-1:1):s==s?s:0:0===s?s:0}},61489:(s,o,i)=>{var u=i(17400);s.exports=function toInteger(s){var o=u(s),i=o%1;return o==o?i?o-i:o:0}},80218:(s,o,i)=>{var u=i(13222);s.exports=function toLower(s){return u(s).toLowerCase()}},99374:(s,o,i)=>{var u=i(54128),_=i(23805),w=i(44394),x=/^[-+]0x[0-9a-f]+$/i,C=/^0b[01]+$/i,j=/^0o[0-7]+$/i,L=parseInt;s.exports=function toNumber(s){if("number"==typeof s)return s;if(w(s))return NaN;if(_(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=_(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=u(s);var i=C.test(s);return i||j.test(s)?L(s.slice(2),i?2:8):x.test(s)?NaN:+s}},42072:(s,o,i)=>{var u=i(34932),_=i(23007),w=i(56449),x=i(44394),C=i(61802),j=i(77797),L=i(13222);s.exports=function toPath(s){return w(s)?u(s,j):x(s)?[s]:_(C(L(s)))}},69884:(s,o,i)=>{var u=i(21791),_=i(37241);s.exports=function toPlainObject(s){return u(s,_(s))}},13222:(s,o,i)=>{var u=i(77556);s.exports=function toString(s){return null==s?"":u(s)}},55808:(s,o,i)=>{var u=i(12507)("toUpperCase");s.exports=u},66645:(s,o,i)=>{var u=i(1733),_=i(45434),w=i(13222),x=i(22225);s.exports=function words(s,o,i){return s=w(s),void 0===(o=i?void 0:o)?_(s)?x(s):u(s):s.match(o)||[]}},53758:(s,o,i)=>{var u=i(30980),_=i(56017),w=i(94033),x=i(56449),C=i(40346),j=i(80257),L=Object.prototype.hasOwnProperty;function lodash(s){if(C(s)&&!x(s)&&!(s instanceof u)){if(s instanceof _)return s;if(L.call(s,"__wrapped__"))return j(s)}return new _(s)}lodash.prototype=w.prototype,lodash.prototype.constructor=lodash,s.exports=lodash},47248:(s,o,i)=>{var u=i(16547),_=i(51234);s.exports=function zipObject(s,o){return _(s||[],o||[],u)}},43768:(s,o,i)=>{"use strict";var u=i(45981),_=i(85587);o.highlight=highlight,o.highlightAuto=function highlightAuto(s,o){var i,x,C,j,L=o||{},B=L.subset||u.listLanguages(),$=L.prefix,V=B.length,U=-1;null==$&&($=w);if("string"!=typeof s)throw _("Expected `string` for value, got `%s`",s);x={relevance:0,language:null,value:[]},i={relevance:0,language:null,value:[]};for(;++Ux.relevance&&(x=C),C.relevance>i.relevance&&(x=i,i=C));x.language&&(i.secondBest=x);return i},o.registerLanguage=function registerLanguage(s,o){u.registerLanguage(s,o)},o.listLanguages=function listLanguages(){return u.listLanguages()},o.registerAlias=function registerAlias(s,o){var i,_=s;o&&((_={})[s]=o);for(i in _)u.registerAliases(_[i],{languageName:i})},Emitter.prototype.addText=function text(s){var o,i,u=this.stack;if(""===s)return;o=u[u.length-1],(i=o.children[o.children.length-1])&&"text"===i.type?i.value+=s:o.children.push({type:"text",value:s})},Emitter.prototype.addKeyword=function addKeyword(s,o){this.openNode(o),this.addText(s),this.closeNode()},Emitter.prototype.addSublanguage=function addSublanguage(s,o){var i=this.stack,u=i[i.length-1],_=s.rootNode.children,w=o?{type:"element",tagName:"span",properties:{className:[o]},children:_}:_;u.children=u.children.concat(w)},Emitter.prototype.openNode=function open(s){var o=this.stack,i=this.options.classPrefix+s,u=o[o.length-1],_={type:"element",tagName:"span",properties:{className:[i]},children:[]};u.children.push(_),o.push(_)},Emitter.prototype.closeNode=function close(){this.stack.pop()},Emitter.prototype.closeAllNodes=noop,Emitter.prototype.finalize=noop,Emitter.prototype.toHTML=function toHtmlNoop(){return""};var w="hljs-";function highlight(s,o,i){var x,C=u.configure({}),j=(i||{}).prefix;if("string"!=typeof s)throw _("Expected `string` for name, got `%s`",s);if(!u.getLanguage(s))throw _("Unknown language: `%s` is not registered",s);if("string"!=typeof o)throw _("Expected `string` for value, got `%s`",o);if(null==j&&(j=w),u.configure({__emitter:Emitter,classPrefix:j}),x=u.highlight(o,{language:s,ignoreIllegals:!0}),u.configure(C||{}),x.errorRaised)throw x.errorRaised;return{relevance:x.relevance,language:x.language,value:x.emitter.rootNode.children}}function Emitter(s){this.options=s,this.rootNode={children:[]},this.stack=[this.rootNode]}function noop(){}},92340:(s,o,i)=>{const u=i(6048);function coerceElementMatchingCallback(s){return"string"==typeof s?o=>o.element===s:s.constructor&&s.extend?o=>o instanceof s:s}class ArraySlice{constructor(s){this.elements=s||[]}toValue(){return this.elements.map((s=>s.toValue()))}map(s,o){return this.elements.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((u=>{const _=s.bind(o)(u);_&&i.push(_)})),i}filter(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(s,o))}reject(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(u(s),o))}find(s,o){return s=coerceElementMatchingCallback(s),this.elements.find(s,o)}forEach(s,o){this.elements.forEach(s,o)}reduce(s,o){return this.elements.reduce(s,o)}includes(s){return this.elements.some((o=>o.equals(s)))}shift(){return this.elements.shift()}unshift(s){this.elements.unshift(this.refract(s))}push(s){return this.elements.push(this.refract(s)),this}add(s){this.push(s)}get(s){return this.elements[s]}getValue(s){const o=this.elements[s];if(o)return o.toValue()}get length(){return this.elements.length}get isEmpty(){return 0===this.elements.length}get first(){return this.elements[0]}}"undefined"!=typeof Symbol&&(ArraySlice.prototype[Symbol.iterator]=function symbol(){return this.elements[Symbol.iterator]()}),s.exports=ArraySlice},55973:s=>{class KeyValuePair{constructor(s,o){this.key=s,this.value=o}clone(){const s=new KeyValuePair;return this.key&&(s.key=this.key.clone()),this.value&&(s.value=this.value.clone()),s}}s.exports=KeyValuePair},3110:(s,o,i)=>{const u=i(5187),_=i(85015),w=i(98023),x=i(53812),C=i(23805),j=i(85105),L=i(86804);class Namespace{constructor(s){this.elementMap={},this.elementDetection=[],this.Element=L.Element,this.KeyValuePair=L.KeyValuePair,s&&s.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(s){return s.namespace&&s.namespace({base:this}),s.load&&s.load({base:this}),this}useDefault(){return this.register("null",L.NullElement).register("string",L.StringElement).register("number",L.NumberElement).register("boolean",L.BooleanElement).register("array",L.ArrayElement).register("object",L.ObjectElement).register("member",L.MemberElement).register("ref",L.RefElement).register("link",L.LinkElement),this.detect(u,L.NullElement,!1).detect(_,L.StringElement,!1).detect(w,L.NumberElement,!1).detect(x,L.BooleanElement,!1).detect(Array.isArray,L.ArrayElement,!1).detect(C,L.ObjectElement,!1),this}register(s,o){return this._elements=void 0,this.elementMap[s]=o,this}unregister(s){return this._elements=void 0,delete this.elementMap[s],this}detect(s,o,i){return void 0===i||i?this.elementDetection.unshift([s,o]):this.elementDetection.push([s,o]),this}toElement(s){if(s instanceof this.Element)return s;let o;for(let i=0;i{const o=s[0].toUpperCase()+s.substr(1);this._elements[o]=this.elementMap[s]}))),this._elements}get serialiser(){return new j(this)}}j.prototype.Namespace=Namespace,s.exports=Namespace},10866:(s,o,i)=>{const u=i(6048),_=i(92340);class ObjectSlice extends _{map(s,o){return this.elements.map((i=>s.bind(o)(i.value,i.key,i)))}filter(s,o){return new ObjectSlice(this.elements.filter((i=>s.bind(o)(i.value,i.key,i))))}reject(s,o){return this.filter(u(s.bind(o)))}forEach(s,o){return this.elements.forEach(((i,u)=>{s.bind(o)(i.value,i.key,i,u)}))}keys(){return this.map(((s,o)=>o.toValue()))}values(){return this.map((s=>s.toValue()))}}s.exports=ObjectSlice},86804:(s,o,i)=>{const u=i(10316),_=i(41067),w=i(71167),x=i(40239),C=i(12242),j=i(6233),L=i(87726),B=i(61045),$=i(86303),V=i(14540),U=i(92340),z=i(10866),Y=i(55973);function refract(s){if(s instanceof u)return s;if("string"==typeof s)return new w(s);if("number"==typeof s)return new x(s);if("boolean"==typeof s)return new C(s);if(null===s)return new _;if(Array.isArray(s))return new j(s.map(refract));if("object"==typeof s){return new B(s)}return s}u.prototype.ObjectElement=B,u.prototype.RefElement=V,u.prototype.MemberElement=L,u.prototype.refract=refract,U.prototype.refract=refract,s.exports={Element:u,NullElement:_,StringElement:w,NumberElement:x,BooleanElement:C,ArrayElement:j,MemberElement:L,ObjectElement:B,LinkElement:$,RefElement:V,refract,ArraySlice:U,ObjectSlice:z,KeyValuePair:Y}},86303:(s,o,i)=>{const u=i(10316);s.exports=class LinkElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="link"}get relation(){return this.attributes.get("relation")}set relation(s){this.attributes.set("relation",s)}get href(){return this.attributes.get("href")}set href(s){this.attributes.set("href",s)}}},14540:(s,o,i)=>{const u=i(10316);s.exports=class RefElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="ref",this.path||(this.path="element")}get path(){return this.attributes.get("path")}set path(s){this.attributes.set("path",s)}}},34035:(s,o,i)=>{const u=i(3110),_=i(86804);o.g$=u,o.KeyValuePair=i(55973),o.G6=_.ArraySlice,o.ot=_.ObjectSlice,o.Hg=_.Element,o.Om=_.StringElement,o.kT=_.NumberElement,o.bd=_.BooleanElement,o.Os=_.NullElement,o.wE=_.ArrayElement,o.Sh=_.ObjectElement,o.Pr=_.MemberElement,o.sI=_.RefElement,o.Ft=_.LinkElement,o.e=_.refract,i(85105),i(75147)},6233:(s,o,i)=>{const u=i(6048),_=i(10316),w=i(92340);class ArrayElement extends _{constructor(s,o,i){super(s||[],o,i),this.element="array"}primitive(){return"array"}get(s){return this.content[s]}getValue(s){const o=this.get(s);if(o)return o.toValue()}getIndex(s){return this.content[s]}set(s,o){return this.content[s]=this.refract(o),this}remove(s){const o=this.content.splice(s,1);return o.length?o[0]:null}map(s,o){return this.content.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((u=>{const _=s.bind(o)(u);_&&i.push(_)})),i}filter(s,o){return new w(this.content.filter(s,o))}reject(s,o){return this.filter(u(s),o)}reduce(s,o){let i,u;void 0!==o?(i=0,u=this.refract(o)):(i=1,u="object"===this.primitive()?this.first.value:this.first);for(let o=i;o{s.bind(o)(i,this.refract(u))}))}shift(){return this.content.shift()}unshift(s){this.content.unshift(this.refract(s))}push(s){return this.content.push(this.refract(s)),this}add(s){this.push(s)}findElements(s,o){const i=o||{},u=!!i.recursive,_=void 0===i.results?[]:i.results;return this.forEach(((o,i,w)=>{u&&void 0!==o.findElements&&o.findElements(s,{results:_,recursive:u}),s(o,i,w)&&_.push(o)})),_}find(s){return new w(this.findElements(s,{recursive:!0}))}findByElement(s){return this.find((o=>o.element===s))}findByClass(s){return this.find((o=>o.classes.includes(s)))}getById(s){return this.find((o=>o.id.toValue()===s)).first}includes(s){return this.content.some((o=>o.equals(s)))}contains(s){return this.includes(s)}empty(){return new this.constructor([])}"fantasy-land/empty"(){return this.empty()}concat(s){return new this.constructor(this.content.concat(s.content))}"fantasy-land/concat"(s){return this.concat(s)}"fantasy-land/map"(s){return new this.constructor(this.map(s))}"fantasy-land/chain"(s){return this.map((o=>s(o)),this).reduce(((s,o)=>s.concat(o)),this.empty())}"fantasy-land/filter"(s){return new this.constructor(this.content.filter(s))}"fantasy-land/reduce"(s,o){return this.content.reduce(s,o)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement["fantasy-land/empty"]=ArrayElement.empty,"undefined"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),s.exports=ArrayElement},12242:(s,o,i)=>{const u=i(10316);s.exports=class BooleanElement extends u{constructor(s,o,i){super(s,o,i),this.element="boolean"}primitive(){return"boolean"}}},10316:(s,o,i)=>{const u=i(2404),_=i(55973),w=i(92340);class Element{constructor(s,o,i){o&&(this.meta=o),i&&(this.attributes=i),this.content=s}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((s=>{s.parent=this,s.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const s=new this.constructor;return s.element=this.element,this.meta.length&&(s._meta=this.meta.clone()),this.attributes.length&&(s._attributes=this.attributes.clone()),this.content?this.content.clone?s.content=this.content.clone():Array.isArray(this.content)?s.content=this.content.map((s=>s.clone())):s.content=this.content:s.content=this.content,s}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof _?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((s=>s.toValue()),this):this.content}toRef(s){if(""===this.id.toValue())throw Error("Cannot create reference to an element that does not contain an ID");const o=new this.RefElement(this.id.toValue());return s&&(o.path=s),o}findRecursive(...s){if(arguments.length>1&&!this.isFrozen)throw new Error("Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`");const o=s.pop();let i=new w;const append=(s,o)=>(s.push(o),s),checkElement=(s,i)=>{i.element===o&&s.push(i);const u=i.findRecursive(o);return u&&u.reduce(append,s),i.content instanceof _&&(i.content.key&&checkElement(s,i.content.key),i.content.value&&checkElement(s,i.content.value)),s};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),s.isEmpty||(i=i.filter((o=>{let i=o.parents.map((s=>s.element));for(const o in s){const u=s[o],_=i.indexOf(u);if(-1===_)return!1;i=i.splice(0,_)}return!0}))),i}set(s){return this.content=s,this}equals(s){return u(this.toValue(),s)}getMetaProperty(s,o){if(!this.meta.hasKey(s)){if(this.isFrozen){const s=this.refract(o);return s.freeze(),s}this.meta.set(s,o)}return this.meta.get(s)}setMetaProperty(s,o){this.meta.set(s,o)}get element(){return this._storedElement||"element"}set element(s){this._storedElement=s}get content(){return this._content}set content(s){if(s instanceof Element)this._content=s;else if(s instanceof w)this.content=s.elements;else if("string"==typeof s||"number"==typeof s||"boolean"==typeof s||"null"===s||null==s)this._content=s;else if(s instanceof _)this._content=s;else if(Array.isArray(s))this._content=s.map(this.refract);else{if("object"!=typeof s)throw new Error("Cannot set content to given value");this._content=Object.keys(s).map((o=>new this.MemberElement(o,s[o])))}}get meta(){if(!this._meta){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._meta=new this.ObjectElement}return this._meta}set meta(s){s instanceof this.ObjectElement?this._meta=s:this.meta.set(s||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._attributes=new this.ObjectElement}return this._attributes}set attributes(s){s instanceof this.ObjectElement?this._attributes=s:this.attributes.set(s||{})}get id(){return this.getMetaProperty("id","")}set id(s){this.setMetaProperty("id",s)}get classes(){return this.getMetaProperty("classes",[])}set classes(s){this.setMetaProperty("classes",s)}get title(){return this.getMetaProperty("title","")}set title(s){this.setMetaProperty("title",s)}get description(){return this.getMetaProperty("description","")}set description(s){this.setMetaProperty("description",s)}get links(){return this.getMetaProperty("links",[])}set links(s){this.setMetaProperty("links",s)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:s}=this;const o=new w;for(;s;)o.push(s),s=s.parent;return o}get children(){if(Array.isArray(this.content))return new w(this.content);if(this.content instanceof _){const s=new w([this.content.key]);return this.content.value&&s.push(this.content.value),s}return this.content instanceof Element?new w([this.content]):new w}get recursiveChildren(){const s=new w;return this.children.forEach((o=>{s.push(o),o.recursiveChildren.forEach((o=>{s.push(o)}))})),s}}s.exports=Element},87726:(s,o,i)=>{const u=i(55973),_=i(10316);s.exports=class MemberElement extends _{constructor(s,o,i,_){super(new u,i,_),this.element="member",this.key=s,this.value=o}get key(){return this.content.key}set key(s){this.content.key=this.refract(s)}get value(){return this.content.value}set value(s){this.content.value=this.refract(s)}}},41067:(s,o,i)=>{const u=i(10316);s.exports=class NullElement extends u{constructor(s,o,i){super(s||null,o,i),this.element="null"}primitive(){return"null"}set(){return new Error("Cannot set the value of null")}}},40239:(s,o,i)=>{const u=i(10316);s.exports=class NumberElement extends u{constructor(s,o,i){super(s,o,i),this.element="number"}primitive(){return"number"}}},61045:(s,o,i)=>{const u=i(6048),_=i(23805),w=i(6233),x=i(87726),C=i(10866);s.exports=class ObjectElement extends w{constructor(s,o,i){super(s||[],o,i),this.element="object"}primitive(){return"object"}toValue(){return this.content.reduce(((s,o)=>(s[o.key.toValue()]=o.value?o.value.toValue():void 0,s)),{})}get(s){const o=this.getMember(s);if(o)return o.value}getMember(s){if(void 0!==s)return this.content.find((o=>o.key.toValue()===s))}remove(s){let o=null;return this.content=this.content.filter((i=>i.key.toValue()!==s||(o=i,!1))),o}getKey(s){const o=this.getMember(s);if(o)return o.key}set(s,o){if(_(s))return Object.keys(s).forEach((o=>{this.set(o,s[o])})),this;const i=s,u=this.getMember(i);return u?u.value=o:this.content.push(new x(i,o)),this}keys(){return this.content.map((s=>s.key.toValue()))}values(){return this.content.map((s=>s.value.toValue()))}hasKey(s){return this.content.some((o=>o.key.equals(s)))}items(){return this.content.map((s=>[s.key.toValue(),s.value.toValue()]))}map(s,o){return this.content.map((i=>s.bind(o)(i.value,i.key,i)))}compactMap(s,o){const i=[];return this.forEach(((u,_,w)=>{const x=s.bind(o)(u,_,w);x&&i.push(x)})),i}filter(s,o){return new C(this.content).filter(s,o)}reject(s,o){return this.filter(u(s),o)}forEach(s,o){return this.content.forEach((i=>s.bind(o)(i.value,i.key,i)))}}},71167:(s,o,i)=>{const u=i(10316);s.exports=class StringElement extends u{constructor(s,o,i){super(s,o,i),this.element="string"}primitive(){return"string"}get length(){return this.content.length}}},75147:(s,o,i)=>{const u=i(85105);s.exports=class JSON06Serialiser extends u{serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);let o;s._attributes&&s.attributes.get("variable")&&(o=s.attributes.get("variable"));const i={element:s.element};s._meta&&s._meta.length>0&&(i.meta=this.serialiseObject(s.meta));const u="enum"===s.element||-1!==s.attributes.keys().indexOf("enumerations");if(u){const o=this.enumSerialiseAttributes(s);o&&(i.attributes=o)}else if(s._attributes&&s._attributes.length>0){let{attributes:u}=s;u.get("metadata")&&(u=u.clone(),u.set("meta",u.get("metadata")),u.remove("metadata")),"member"===s.element&&o&&(u=u.clone(),u.remove("variable")),u.length>0&&(i.attributes=this.serialiseObject(u))}if(u)i.content=this.enumSerialiseContent(s,i);else if(this[`${s.element}SerialiseContent`])i.content=this[`${s.element}SerialiseContent`](s,i);else if(void 0!==s.content){let u;o&&s.content.key?(u=s.content.clone(),u.key.attributes.set("variable",o),u=this.serialiseContent(u)):u=this.serialiseContent(s.content),this.shouldSerialiseContent(s,u)&&(i.content=u)}else this.shouldSerialiseContent(s,s.content)&&s instanceof this.namespace.elements.Array&&(i.content=[]);return i}shouldSerialiseContent(s,o){return"parseResult"===s.element||"httpRequest"===s.element||"httpResponse"===s.element||"category"===s.element||"link"===s.element||void 0!==o&&(!Array.isArray(o)||0!==o.length)}refSerialiseContent(s,o){return delete o.attributes,{href:s.toValue(),path:s.path.toValue()}}sourceMapSerialiseContent(s){return s.toValue()}dataStructureSerialiseContent(s){return[this.serialiseContent(s.content)]}enumSerialiseAttributes(s){const o=s.attributes.clone(),i=o.remove("enumerations")||new this.namespace.elements.Array([]),u=o.get("default");let _=o.get("samples")||new this.namespace.elements.Array([]);if(u&&u.content&&(u.content.attributes&&u.content.attributes.remove("typeAttributes"),o.set("default",new this.namespace.elements.Array([u.content]))),_.forEach((s=>{s.content&&s.content.element&&s.content.attributes.remove("typeAttributes")})),s.content&&0!==i.length&&_.unshift(s.content),_=_.map((s=>s instanceof this.namespace.elements.Array?[s]:new this.namespace.elements.Array([s.content]))),_.length&&o.set("samples",_),o.length>0)return this.serialiseObject(o)}enumSerialiseContent(s){if(s._attributes){const o=s.attributes.get("enumerations");if(o&&o.length>0)return o.content.map((s=>{const o=s.clone();return o.attributes.remove("typeAttributes"),this.serialise(o)}))}if(s.content){const o=s.content.clone();return o.attributes.remove("typeAttributes"),[this.serialise(o)]}return[]}deserialise(s){if("string"==typeof s)return new this.namespace.elements.String(s);if("number"==typeof s)return new this.namespace.elements.Number(s);if("boolean"==typeof s)return new this.namespace.elements.Boolean(s);if(null===s)return new this.namespace.elements.Null;if(Array.isArray(s))return new this.namespace.elements.Array(s.map(this.deserialise,this));const o=this.namespace.getElementClass(s.element),i=new o;i.element!==s.element&&(i.element=s.element),s.meta&&this.deserialiseObject(s.meta,i.meta),s.attributes&&this.deserialiseObject(s.attributes,i.attributes);const u=this.deserialiseContent(s.content);if(void 0===u&&null!==i.content||(i.content=u),"enum"===i.element){i.content&&i.attributes.set("enumerations",i.content);let s=i.attributes.get("samples");if(i.attributes.remove("samples"),s){const u=s;s=new this.namespace.elements.Array,u.forEach((u=>{u.forEach((u=>{const _=new o(u);_.element=i.element,s.push(_)}))}));const _=s.shift();i.content=_?_.content:void 0,i.attributes.set("samples",s)}else i.content=void 0;let u=i.attributes.get("default");if(u&&u.length>0){u=u.get(0);const s=new o(u);s.element=i.element,i.attributes.set("default",s)}}else if("dataStructure"===i.element&&Array.isArray(i.content))[i.content]=i.content;else if("category"===i.element){const s=i.attributes.get("meta");s&&(i.attributes.set("metadata",s),i.attributes.remove("meta"))}else"member"===i.element&&i.key&&i.key._attributes&&i.key._attributes.getValue("variable")&&(i.attributes.set("variable",i.key.attributes.get("variable")),i.key.attributes.remove("variable"));return i}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}return s&&s.map?s.map(this.serialise,this):s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}shouldRefract(s){return!!(s._attributes&&s.attributes.keys().length||s._meta&&s.meta.keys().length)||"enum"!==s.element&&(s.element!==s.primitive()||"member"===s.element)}convertKeyToRefract(s,o){return this.shouldRefract(o)?this.serialise(o):"enum"===o.element?this.serialiseEnum(o):"array"===o.element?o.map((o=>this.shouldRefract(o)||"default"===s?this.serialise(o):"array"===o.element||"object"===o.element||"enum"===o.element?o.children.map((s=>this.serialise(s))):o.toValue())):"object"===o.element?(o.content||[]).map(this.serialise,this):o.toValue()}serialiseEnum(s){return s.children.map((s=>this.serialise(s)))}serialiseObject(s){const o={};return s.forEach(((s,i)=>{if(s){const u=i.toValue();o[u]=this.convertKeyToRefract(u,s)}})),o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},85105:s=>{s.exports=class JSONSerialiser{constructor(s){this.namespace=s||new this.Namespace}serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);const o={element:s.element};s._meta&&s._meta.length>0&&(o.meta=this.serialiseObject(s.meta)),s._attributes&&s._attributes.length>0&&(o.attributes=this.serialiseObject(s.attributes));const i=this.serialiseContent(s.content);return void 0!==i&&(o.content=i),o}deserialise(s){if(!s.element)throw new Error("Given value is not an object containing an element name");const o=new(this.namespace.getElementClass(s.element));o.element!==s.element&&(o.element=s.element),s.meta&&this.deserialiseObject(s.meta,o.meta),s.attributes&&this.deserialiseObject(s.attributes,o.attributes);const i=this.deserialiseContent(s.content);return void 0===i&&null!==o.content||(o.content=i),o}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}if(s&&s.map){if(0===s.length)return;return s.map(this.serialise,this)}return s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}serialiseObject(s){const o={};if(s.forEach(((s,i)=>{s&&(o[i.toValue()]=this.serialise(s))})),0!==Object.keys(o).length)return o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},65606:s=>{var o,i,u=s.exports={};function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(s){if(o===setTimeout)return setTimeout(s,0);if((o===defaultSetTimout||!o)&&setTimeout)return o=setTimeout,setTimeout(s,0);try{return o(s,0)}catch(i){try{return o.call(null,s,0)}catch(i){return o.call(this,s,0)}}}!function(){try{o="function"==typeof setTimeout?setTimeout:defaultSetTimout}catch(s){o=defaultSetTimout}try{i="function"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(s){i=defaultClearTimeout}}();var _,w=[],x=!1,C=-1;function cleanUpNextTick(){x&&_&&(x=!1,_.length?w=_.concat(w):C=-1,w.length&&drainQueue())}function drainQueue(){if(!x){var s=runTimeout(cleanUpNextTick);x=!0;for(var o=w.length;o;){for(_=w,w=[];++C1)for(var i=1;i{"use strict";var u=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,s.exports=function(){function shim(s,o,i,_,w,x){if(x!==u){var C=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw C.name="Invariant Violation",C}}function getShim(){return shim}shim.isRequired=shim;var s={array:shim,bigint:shim,bool:shim,func:shim,number:shim,object:shim,string:shim,symbol:shim,any:shim,arrayOf:getShim,element:shim,elementType:shim,instanceOf:getShim,node:shim,objectOf:getShim,oneOf:getShim,oneOfType:getShim,shape:getShim,exact:getShim,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return s.PropTypes=s,s}},5556:(s,o,i)=>{s.exports=i(2694)()},6925:s=>{"use strict";s.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},73992:(s,o)=>{"use strict";var i=Object.prototype.hasOwnProperty;function decode(s){try{return decodeURIComponent(s.replace(/\+/g," "))}catch(s){return null}}function encode(s){try{return encodeURIComponent(s)}catch(s){return null}}o.stringify=function querystringify(s,o){o=o||"";var u,_,w=[];for(_ in"string"!=typeof o&&(o="?"),s)if(i.call(s,_)){if((u=s[_])||null!=u&&!isNaN(u)||(u=""),_=encode(_),u=encode(u),null===_||null===u)continue;w.push(_+"="+u)}return w.length?o+w.join("&"):""},o.parse=function querystring(s){for(var o,i=/([^=?#&]+)=?([^&]*)/g,u={};o=i.exec(s);){var _=decode(o[1]),w=decode(o[2]);null===_||null===w||_ in u||(u[_]=w)}return u}},41859:(s,o,i)=>{const u=i(27096),_=i(78004),w=u.types;s.exports=class RandExp{constructor(s,o){if(this._setDefaults(s),s instanceof RegExp)this.ignoreCase=s.ignoreCase,this.multiline=s.multiline,s=s.source;else{if("string"!=typeof s)throw new Error("Expected a regexp or string");this.ignoreCase=o&&-1!==o.indexOf("i"),this.multiline=o&&-1!==o.indexOf("m")}this.tokens=u(s)}_setDefaults(s){this.max=null!=s.max?s.max:null!=RandExp.prototype.max?RandExp.prototype.max:100,this.defaultRange=s.defaultRange?s.defaultRange:this.defaultRange.clone(),s.randInt&&(this.randInt=s.randInt)}gen(){return this._gen(this.tokens,[])}_gen(s,o){var i,u,_,x,C;switch(s.type){case w.ROOT:case w.GROUP:if(s.followedBy||s.notFollowedBy)return"";for(s.remember&&void 0===s.groupNumber&&(s.groupNumber=o.push(null)-1),u="",x=0,C=(i=s.options?this._randSelect(s.options):s.stack).length;x{"use strict";var u=i(65606),_=65536,w=4294967295;var x=i(92861).Buffer,C=i.g.crypto||i.g.msCrypto;C&&C.getRandomValues?s.exports=function randomBytes(s,o){if(s>w)throw new RangeError("requested too many random bytes");var i=x.allocUnsafe(s);if(s>0)if(s>_)for(var j=0;j{"use strict";function _typeof(s){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&"function"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?"symbol":typeof s},_typeof(s)}Object.defineProperty(o,"__esModule",{value:!0}),o.CopyToClipboard=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(17965)),w=["text","onCopy","options","children"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(s);o&&(u=u.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(s){for(var o=1;o=0||(_[i]=s[i]);return _}(s,o);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(s);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(_[i]=s[i])}return _}function _defineProperties(s,o){for(var i=0;i{"use strict";var u=i(25264).CopyToClipboard;u.CopyToClipboard=u,s.exports=u},81214:(s,o,i)=>{"use strict";function _typeof(s){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(s){return typeof s}:function(s){return s&&"function"==typeof Symbol&&s.constructor===Symbol&&s!==Symbol.prototype?"symbol":typeof s},_typeof(s)}Object.defineProperty(o,"__esModule",{value:!0}),o.DebounceInput=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(20181)),w=["element","onChange","value","minLength","debounceTimeout","forceNotifyByEnter","forceNotifyOnBlur","onKeyDown","onBlur","inputRef"];function _interopRequireDefault(s){return s&&s.__esModule?s:{default:s}}function _objectWithoutProperties(s,o){if(null==s)return{};var i,u,_=function _objectWithoutPropertiesLoose(s,o){if(null==s)return{};var i,u,_={},w=Object.keys(s);for(u=0;u=0||(_[i]=s[i]);return _}(s,o);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(s);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(_[i]=s[i])}return _}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(s);o&&(u=u.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(s){for(var o=1;o=u?i.notify(s):o.length>_.length&&i.notify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:""})}))}))})),_defineProperty(_assertThisInitialized(i),"onKeyDown",(function(s){"Enter"===s.key&&i.forceNotify(s);var o=i.props.onKeyDown;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"onBlur",(function(s){i.forceNotify(s);var o=i.props.onBlur;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"createNotifier",(function(s){if(s<0)i.notify=function(){return null};else if(0===s)i.notify=i.doNotify;else{var o=(0,_.default)((function(s){i.isDebouncing=!1,i.doNotify(s)}),s);i.notify=function(s){i.isDebouncing=!0,o(s)},i.flush=function(){return o.flush()},i.cancel=function(){i.isDebouncing=!1,o.cancel()}}})),_defineProperty(_assertThisInitialized(i),"doNotify",(function(){i.props.onChange.apply(void 0,arguments)})),_defineProperty(_assertThisInitialized(i),"forceNotify",(function(s){var o=i.props.debounceTimeout;if(i.isDebouncing||!(o>0)){i.cancel&&i.cancel();var u=i.state.value,_=i.props.minLength;u.length>=_?i.doNotify(s):i.doNotify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:u})}))}})),i.isDebouncing=!1,i.state={value:void 0===s.value||null===s.value?"":s.value};var u=i.props.debounceTimeout;return i.createNotifier(u),i}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,"prototype",{writable:!1}),s}(DebounceInput,[{key:"componentDidUpdate",value:function componentDidUpdate(s){if(!this.isDebouncing){var o=this.props,i=o.value,u=o.debounceTimeout,_=s.debounceTimeout,w=s.value,x=this.state.value;void 0!==i&&w!==i&&x!==i&&this.setState({value:i}),u!==_&&this.createNotifier(u)}}},{key:"componentWillUnmount",value:function componentWillUnmount(){this.flush&&this.flush()}},{key:"render",value:function render(){var s,o,i=this.props,_=i.element,x=(i.onChange,i.value,i.minLength,i.debounceTimeout,i.forceNotifyByEnter),C=i.forceNotifyOnBlur,j=i.onKeyDown,L=i.onBlur,B=i.inputRef,$=_objectWithoutProperties(i,w),V=this.state.value;s=x?{onKeyDown:this.onKeyDown}:j?{onKeyDown:j}:{},o=C?{onBlur:this.onBlur}:L?{onBlur:L}:{};var U=B?{ref:B}:{};return u.default.createElement(_,_objectSpread(_objectSpread(_objectSpread(_objectSpread({},$),{},{onChange:this.onChange,value:V},s),o),U))}}]),DebounceInput}(u.default.PureComponent);o.DebounceInput=x,_defineProperty(x,"defaultProps",{element:"input",type:"text",onKeyDown:void 0,onBlur:void 0,value:void 0,minLength:0,debounceTimeout:100,forceNotifyByEnter:!0,forceNotifyOnBlur:!0,inputRef:void 0})},24677:(s,o,i)=>{"use strict";var u=i(81214).DebounceInput;u.DebounceInput=u,s.exports=u},22551:(s,o,i)=>{"use strict";var u=i(96540),_=i(69982);function p(s){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+s,i=1;i
")}value(){return this.buffer}span(s){this.buffer+=``}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(s){this.top.children.push(s)}openNode(s){const o={kind:s,children:[]};this.add(o),this.stack.push(o)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(s){return this.constructor._walk(s,this.rootNode)}static _walk(s,o){return"string"==typeof o?s.addText(o):o.children&&(s.openNode(o),o.children.forEach((o=>this._walk(s,o))),s.closeNode(o)),s}static _collapse(s){"string"!=typeof s&&s.children&&(s.children.every((s=>"string"==typeof s))?s.children=[s.children.join("")]:s.children.forEach((s=>{TokenTree._collapse(s)})))}}class TokenTreeEmitter extends TokenTree{constructor(s){super(),this.options=s}addKeyword(s,o){""!==s&&(this.openNode(o),this.addText(s),this.closeNode())}addText(s){""!==s&&this.add(s)}addSublanguage(s,o){const i=s.root;i.kind=o,i.sublanguage=!0,this.add(i)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(s){return s?"string"==typeof s?s:s.source:null}const a=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;const u="[a-zA-Z]\\w*",_="[a-zA-Z_]\\w*",w="\\b\\d+(\\.\\d+)?",x="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",C="\\b(0b[01]+)",j={begin:"\\\\[\\s\\S]",relevance:0},L={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[j]},B={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[j]},$={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT=function(s,o,i={}){const a=inherit({className:"comment",begin:s,end:o,contains:[]},i);return a.contains.push($),a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),a},U=COMMENT("//","$"),V=COMMENT("/\\*","\\*/"),z=COMMENT("#","$"),Y={className:"number",begin:w,relevance:0},Z={className:"number",begin:x,relevance:0},ee={className:"number",begin:C,relevance:0},ie={className:"number",begin:w+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},ae={begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[j,{begin:/\[/,end:/\]/,relevance:0,contains:[j]}]}]},ce={className:"title",begin:u,relevance:0},le={className:"title",begin:_,relevance:0},pe={begin:"\\.\\s*"+_,relevance:0};var de=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:u,UNDERSCORE_IDENT_RE:_,NUMBER_RE:w,C_NUMBER_RE:x,BINARY_NUMBER_RE:C,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(s={})=>{const o=/^#![ ]*\//;return s.binary&&(s.begin=function concat(...s){return s.map((s=>source(s))).join("")}(o,/.*\b/,s.binary,/\b.*/)),inherit({className:"meta",begin:o,end:/$/,relevance:0,"on:begin":(s,o)=>{0!==s.index&&o.ignoreMatch()}},s)},BACKSLASH_ESCAPE:j,APOS_STRING_MODE:L,QUOTE_STRING_MODE:B,PHRASAL_WORDS_MODE:$,COMMENT,C_LINE_COMMENT_MODE:U,C_BLOCK_COMMENT_MODE:V,HASH_COMMENT_MODE:z,NUMBER_MODE:Y,C_NUMBER_MODE:Z,BINARY_NUMBER_MODE:ee,CSS_NUMBER_MODE:ie,REGEXP_MODE:ae,TITLE_MODE:ce,UNDERSCORE_TITLE_MODE:le,METHOD_GUARD:pe,END_SAME_AS_BEGIN:function(s){return Object.assign(s,{"on:begin":(s,o)=>{o.data._beginMatch=s[1]},"on:end":(s,o)=>{o.data._beginMatch!==s[1]&&o.ignoreMatch()}})}});function skipIfhasPrecedingDot(s,o){"."===s.input[s.index-1]&&o.ignoreMatch()}function beginKeywords(s,o){o&&s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",s.__beforeBegin=skipIfhasPrecedingDot,s.keywords=s.keywords||s.beginKeywords,delete s.beginKeywords,void 0===s.relevance&&(s.relevance=0))}function compileIllegal(s,o){Array.isArray(s.illegal)&&(s.illegal=function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}(...s.illegal))}function compileMatch(s,o){if(s.match){if(s.begin||s.end)throw new Error("begin & end are not supported with match");s.begin=s.match,delete s.match}}function compileRelevance(s,o){void 0===s.relevance&&(s.relevance=1)}const fe=["of","and","for","in","not","or","if","then","parent","list","value"];function compileKeywords(s,o,i="keyword"){const a={};return"string"==typeof s?compileList(i,s.split(" ")):Array.isArray(s)?compileList(i,s):Object.keys(s).forEach((function(i){Object.assign(a,compileKeywords(s[i],o,i))})),a;function compileList(s,i){o&&(i=i.map((s=>s.toLowerCase()))),i.forEach((function(o){const i=o.split("|");a[i[0]]=[s,scoreForKeyword(i[0],i[1])]}))}}function scoreForKeyword(s,o){return o?Number(o):function commonKeyword(s){return fe.includes(s.toLowerCase())}(s)?0:1}function compileLanguage(s,{plugins:o}){function langRe(o,i){return new RegExp(source(o),"m"+(s.case_insensitive?"i":"")+(i?"g":""))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(s,o){o.position=this.position++,this.matchIndexes[this.matchAt]=o,this.regexes.push([o,s]),this.matchAt+=function countMatchGroups(s){return new RegExp(s.toString()+"|").exec("").length-1}(s)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const s=this.regexes.map((s=>s[1]));this.matcherRe=langRe(function join(s,o="|"){let i=0;return s.map((s=>{i+=1;const o=i;let u=source(s),_="";for(;u.length>0;){const s=a.exec(u);if(!s){_+=u;break}_+=u.substring(0,s.index),u=u.substring(s.index+s[0].length),"\\"===s[0][0]&&s[1]?_+="\\"+String(Number(s[1])+o):(_+=s[0],"("===s[0]&&i++)}return _})).map((s=>`(${s})`)).join(o)}(s),!0),this.lastIndex=0}exec(s){this.matcherRe.lastIndex=this.lastIndex;const o=this.matcherRe.exec(s);if(!o)return null;const i=o.findIndex(((s,o)=>o>0&&void 0!==s)),a=this.matchIndexes[i];return o.splice(0,i),Object.assign(o,a)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(s){if(this.multiRegexes[s])return this.multiRegexes[s];const o=new MultiRegex;return this.rules.slice(s).forEach((([s,i])=>o.addRule(s,i))),o.compile(),this.multiRegexes[s]=o,o}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(s,o){this.rules.push([s,o]),"begin"===o.type&&this.count++}exec(s){const o=this.getMatcher(this.regexIndex);o.lastIndex=this.lastIndex;let i=o.exec(s);if(this.resumingScanAtSamePosition())if(i&&i.index===this.lastIndex);else{const o=this.getMatcher(0);o.lastIndex=this.lastIndex+1,i=o.exec(s)}return i&&(this.regexIndex+=i.position+1,this.regexIndex===this.count&&this.considerAll()),i}}if(s.compilerExtensions||(s.compilerExtensions=[]),s.contains&&s.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return s.classNameAliases=inherit(s.classNameAliases||{}),function compileMode(o,i){const a=o;if(o.isCompiled)return a;[compileMatch].forEach((s=>s(o,i))),s.compilerExtensions.forEach((s=>s(o,i))),o.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((s=>s(o,i))),o.isCompiled=!0;let u=null;if("object"==typeof o.keywords&&(u=o.keywords.$pattern,delete o.keywords.$pattern),o.keywords&&(o.keywords=compileKeywords(o.keywords,s.case_insensitive)),o.lexemes&&u)throw new Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return u=u||o.lexemes||/\w+/,a.keywordPatternRe=langRe(u,!0),i&&(o.begin||(o.begin=/\B|\b/),a.beginRe=langRe(o.begin),o.endSameAsBegin&&(o.end=o.begin),o.end||o.endsWithParent||(o.end=/\B|\b/),o.end&&(a.endRe=langRe(o.end)),a.terminatorEnd=source(o.end)||"",o.endsWithParent&&i.terminatorEnd&&(a.terminatorEnd+=(o.end?"|":"")+i.terminatorEnd)),o.illegal&&(a.illegalRe=langRe(o.illegal)),o.contains||(o.contains=[]),o.contains=[].concat(...o.contains.map((function(s){return function expandOrCloneMode(s){s.variants&&!s.cachedVariants&&(s.cachedVariants=s.variants.map((function(o){return inherit(s,{variants:null},o)})));if(s.cachedVariants)return s.cachedVariants;if(dependencyOnParent(s))return inherit(s,{starts:s.starts?inherit(s.starts):null});if(Object.isFrozen(s))return inherit(s);return s}("self"===s?o:s)}))),o.contains.forEach((function(s){compileMode(s,a)})),o.starts&&compileMode(o.starts,i),a.matcher=function buildModeRegex(s){const o=new ResumableMultiRegex;return s.contains.forEach((s=>o.addRule(s.begin,{rule:s,type:"begin"}))),s.terminatorEnd&&o.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&o.addRule(s.illegal,{type:"illegal"}),o}(a),a}(s)}function dependencyOnParent(s){return!!s&&(s.endsWithParent||dependencyOnParent(s.starts))}function BuildVuePlugin(s){const o={props:["language","code","autodetect"],data:function(){return{detectedLanguage:"",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!s.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let o={};return this.autoDetect?(o=s.highlightAuto(this.code),this.detectedLanguage=o.language):(o=s.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),o.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(s){return Boolean(s||""===s)}(this.autodetect)},ignoreIllegals:()=>!0},render(s){return s("pre",{},[s("code",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:o,VuePlugin:{install(s){s.component("highlightjs",o)}}}}const ye={"after:highlightElement":({el:s,result:o,text:i})=>{const a=nodeStream(s);if(!a.length)return;const u=document.createElement("div");u.innerHTML=o.value,o.value=function mergeStreams(s,o,i){let a=0,u="";const _=[];function selectStream(){return s.length&&o.length?s[0].offset!==o[0].offset?s[0].offset"}function close(s){u+=""}function render(s){("start"===s.event?open:close)(s.node)}for(;s.length||o.length;){let o=selectStream();if(u+=escapeHTML(i.substring(a,o[0].offset)),a=o[0].offset,o===s){_.reverse().forEach(close);do{render(o.splice(0,1)[0]),o=selectStream()}while(o===s&&o.length&&o[0].offset===a);_.reverse().forEach(open)}else"start"===o[0].event?_.push(o[0].node):_.pop(),render(o.splice(0,1)[0])}return u+escapeHTML(i.substr(a))}(a,nodeStream(u),i)}};function tag(s){return s.nodeName.toLowerCase()}function nodeStream(s){const o=[];return function _nodeStream(s,i){for(let a=s.firstChild;a;a=a.nextSibling)3===a.nodeType?i+=a.nodeValue.length:1===a.nodeType&&(o.push({event:"start",offset:i,node:a}),i=_nodeStream(a,i),tag(a).match(/br|hr|img|input/)||o.push({event:"stop",offset:i,node:a}));return i}(s,0),o}const be={},error=s=>{console.error(s)},warn=(s,...o)=>{console.log(`WARN: ${s}`,...o)},deprecated=(s,o)=>{be[`${s}/${o}`]||(console.log(`Deprecated as of ${s}. ${o}`),be[`${s}/${o}`]=!0)},Se=escapeHTML,_e=inherit,we=Symbol("nomatch");var xe=function(s){const i=Object.create(null),a=Object.create(null),u=[];let _=!0;const w=/(^(<[^>]+>|\t|)+|\n)/gm,x="Could not find the language '{}', did you forget to load/include a language module?",C={disableAutodetect:!0,name:"Plain text",contains:[]};let j={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(s){return j.noHighlightRe.test(s)}function highlight(s,o,i,a){let u="",_="";"object"==typeof o?(u=s,i=o.ignoreIllegals,_=o.language,a=void 0):(deprecated("10.7.0","highlight(lang, code, ...args) has been deprecated."),deprecated("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),_=s,u=o);const w={code:u,language:_};fire("before:highlight",w);const x=w.result?w.result:_highlight(w.language,w.code,i,a);return x.code=w.code,fire("after:highlight",x),x}function _highlight(s,o,a,w){function keywordData(s,o){const i=L.case_insensitive?o[0].toLowerCase():o[0];return Object.prototype.hasOwnProperty.call(s.keywords,i)&&s.keywords[i]}function processBuffer(){null!=U.subLanguage?function processSubLanguage(){if(""===Y)return;let s=null;if("string"==typeof U.subLanguage){if(!i[U.subLanguage])return void z.addText(Y);s=_highlight(U.subLanguage,Y,!0,V[U.subLanguage]),V[U.subLanguage]=s.top}else s=highlightAuto(Y,U.subLanguage.length?U.subLanguage:null);U.relevance>0&&(Z+=s.relevance),z.addSublanguage(s.emitter,s.language)}():function processKeywords(){if(!U.keywords)return void z.addText(Y);let s=0;U.keywordPatternRe.lastIndex=0;let o=U.keywordPatternRe.exec(Y),i="";for(;o;){i+=Y.substring(s,o.index);const a=keywordData(U,o);if(a){const[s,u]=a;if(z.addText(i),i="",Z+=u,s.startsWith("_"))i+=o[0];else{const i=L.classNameAliases[s]||s;z.addKeyword(o[0],i)}}else i+=o[0];s=U.keywordPatternRe.lastIndex,o=U.keywordPatternRe.exec(Y)}i+=Y.substr(s),z.addText(i)}(),Y=""}function startNewMode(s){return s.className&&z.openNode(L.classNameAliases[s.className]||s.className),U=Object.create(s,{parent:{value:U}}),U}function endOfMode(s,o,i){let a=function startsWith(s,o){const i=s&&s.exec(o);return i&&0===i.index}(s.endRe,i);if(a){if(s["on:end"]){const i=new Response(s);s["on:end"](o,i),i.isMatchIgnored&&(a=!1)}if(a){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return endOfMode(s.parent,o,i)}function doIgnore(s){return 0===U.matcher.regexIndex?(Y+=s[0],1):(ae=!0,0)}function doBeginMatch(s){const o=s[0],i=s.rule,a=new Response(i),u=[i.__beforeBegin,i["on:begin"]];for(const i of u)if(i&&(i(s,a),a.isMatchIgnored))return doIgnore(o);return i&&i.endSameAsBegin&&(i.endRe=function escape(s){return new RegExp(s.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}(o)),i.skip?Y+=o:(i.excludeBegin&&(Y+=o),processBuffer(),i.returnBegin||i.excludeBegin||(Y=o)),startNewMode(i),i.returnBegin?0:o.length}function doEndMatch(s){const i=s[0],a=o.substr(s.index),u=endOfMode(U,s,a);if(!u)return we;const _=U;_.skip?Y+=i:(_.returnEnd||_.excludeEnd||(Y+=i),processBuffer(),_.excludeEnd&&(Y=i));do{U.className&&z.closeNode(),U.skip||U.subLanguage||(Z+=U.relevance),U=U.parent}while(U!==u.parent);return u.starts&&(u.endSameAsBegin&&(u.starts.endRe=u.endRe),startNewMode(u.starts)),_.returnEnd?0:i.length}let C={};function processLexeme(i,u){const w=u&&u[0];if(Y+=i,null==w)return processBuffer(),0;if("begin"===C.type&&"end"===u.type&&C.index===u.index&&""===w){if(Y+=o.slice(u.index,u.index+1),!_){const o=new Error("0 width match regex");throw o.languageName=s,o.badRule=C.rule,o}return 1}if(C=u,"begin"===u.type)return doBeginMatch(u);if("illegal"===u.type&&!a){const s=new Error('Illegal lexeme "'+w+'" for mode "'+(U.className||"")+'"');throw s.mode=U,s}if("end"===u.type){const s=doEndMatch(u);if(s!==we)return s}if("illegal"===u.type&&""===w)return 1;if(ie>1e5&&ie>3*u.index){throw new Error("potential infinite loop, way more iterations than matches")}return Y+=w,w.length}const L=getLanguage(s);if(!L)throw error(x.replace("{}",s)),new Error('Unknown language: "'+s+'"');const B=compileLanguage(L,{plugins:u});let $="",U=w||B;const V={},z=new j.__emitter(j);!function processContinuations(){const s=[];for(let o=U;o!==L;o=o.parent)o.className&&s.unshift(o.className);s.forEach((s=>z.openNode(s)))}();let Y="",Z=0,ee=0,ie=0,ae=!1;try{for(U.matcher.considerAll();;){ie++,ae?ae=!1:U.matcher.considerAll(),U.matcher.lastIndex=ee;const s=U.matcher.exec(o);if(!s)break;const i=processLexeme(o.substring(ee,s.index),s);ee=s.index+i}return processLexeme(o.substr(ee)),z.closeAllNodes(),z.finalize(),$=z.toHTML(),{relevance:Math.floor(Z),value:$,language:s,illegal:!1,emitter:z,top:U}}catch(i){if(i.message&&i.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:i.message,context:o.slice(ee-100,ee+100),mode:i.mode},sofar:$,relevance:0,value:Se(o),emitter:z};if(_)return{illegal:!1,relevance:0,value:Se(o),emitter:z,language:s,top:U,errorRaised:i};throw i}}function highlightAuto(s,o){o=o||j.languages||Object.keys(i);const a=function justTextHighlightResult(s){const o={relevance:0,emitter:new j.__emitter(j),value:Se(s),illegal:!1,top:C};return o.emitter.addText(s),o}(s),u=o.filter(getLanguage).filter(autoDetection).map((o=>_highlight(o,s,!1)));u.unshift(a);const _=u.sort(((s,o)=>{if(s.relevance!==o.relevance)return o.relevance-s.relevance;if(s.language&&o.language){if(getLanguage(s.language).supersetOf===o.language)return 1;if(getLanguage(o.language).supersetOf===s.language)return-1}return 0})),[w,x]=_,L=w;return L.second_best=x,L}const L={"before:highlightElement":({el:s})=>{j.useBR&&(s.innerHTML=s.innerHTML.replace(/\n/g,"").replace(//g,"\n"))},"after:highlightElement":({result:s})=>{j.useBR&&(s.value=s.value.replace(/\n/g,"
"))}},B=/^(<[^>]+>|\t)+/gm,$={"after:highlightElement":({result:s})=>{j.tabReplace&&(s.value=s.value.replace(B,(s=>s.replace(/\t/g,j.tabReplace))))}};function highlightElement(s){let o=null;const i=function blockLanguage(s){let o=s.className+" ";o+=s.parentNode?s.parentNode.className:"";const i=j.languageDetectRe.exec(o);if(i){const o=getLanguage(i[1]);return o||(warn(x.replace("{}",i[1])),warn("Falling back to no-highlight mode for this block.",s)),o?i[1]:"no-highlight"}return o.split(/\s+/).find((s=>shouldNotHighlight(s)||getLanguage(s)))}(s);if(shouldNotHighlight(i))return;fire("before:highlightElement",{el:s,language:i}),o=s;const u=o.textContent,_=i?highlight(u,{language:i,ignoreIllegals:!0}):highlightAuto(u);fire("after:highlightElement",{el:s,result:_,text:u}),s.innerHTML=_.value,function updateClassName(s,o,i){const u=o?a[o]:i;s.classList.add("hljs"),u&&s.classList.add(u)}(s,i,_.language),s.result={language:_.language,re:_.relevance,relavance:_.relevance},_.second_best&&(s.second_best={language:_.second_best.language,re:_.second_best.relevance,relavance:_.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead.");document.querySelectorAll("pre code").forEach(highlightElement)};let U=!1;function highlightAll(){if("loading"===document.readyState)return void(U=!0);document.querySelectorAll("pre code").forEach(highlightElement)}function getLanguage(s){return s=(s||"").toLowerCase(),i[s]||i[a[s]]}function registerAliases(s,{languageName:o}){"string"==typeof s&&(s=[s]),s.forEach((s=>{a[s.toLowerCase()]=o}))}function autoDetection(s){const o=getLanguage(s);return o&&!o.disableAutodetect}function fire(s,o){const i=s;u.forEach((function(s){s[i]&&s[i](o)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function boot(){U&&highlightAll()}),!1),Object.assign(s,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(s){return deprecated("10.2.0","fixMarkup will be removed entirely in v11.0"),deprecated("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),function fixMarkup(s){return j.tabReplace||j.useBR?s.replace(w,(s=>"\n"===s?j.useBR?"
":s:j.tabReplace?s.replace(/\t/g,j.tabReplace):s)):s}(s)},highlightElement,highlightBlock:function deprecateHighlightBlock(s){return deprecated("10.7.0","highlightBlock will be removed entirely in v12.0"),deprecated("10.7.0","Please use highlightElement now."),highlightElement(s)},configure:function configure(s){s.useBR&&(deprecated("10.3.0","'useBR' will be removed entirely in v11.0"),deprecated("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),j=_e(j,s)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."),U=!0},registerLanguage:function registerLanguage(o,a){let u=null;try{u=a(s)}catch(s){if(error("Language definition for '{}' could not be registered.".replace("{}",o)),!_)throw s;error(s),u=C}u.name||(u.name=o),i[o]=u,u.rawDefinition=a.bind(null,s),u.aliases&®isterAliases(u.aliases,{languageName:o})},unregisterLanguage:function unregisterLanguage(s){delete i[s];for(const o of Object.keys(a))a[o]===s&&delete a[o]},listLanguages:function listLanguages(){return Object.keys(i)},getLanguage,registerAliases,requireLanguage:function requireLanguage(s){deprecated("10.4.0","requireLanguage will be removed entirely in v11."),deprecated("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844");const o=getLanguage(s);if(o)return o;throw new Error("The '{}' language is required, but not loaded.".replace("{}",s))},autoDetection,inherit:_e,addPlugin:function addPlugin(s){!function upgradePluginAPI(s){s["before:highlightBlock"]&&!s["before:highlightElement"]&&(s["before:highlightElement"]=o=>{s["before:highlightBlock"](Object.assign({block:o.el},o))}),s["after:highlightBlock"]&&!s["after:highlightElement"]&&(s["after:highlightElement"]=o=>{s["after:highlightBlock"](Object.assign({block:o.el},o))})}(s),u.push(s)},vuePlugin:BuildVuePlugin(s).VuePlugin}),s.debugMode=function(){_=!1},s.safeMode=function(){_=!0},s.versionString="10.7.3";for(const s in de)"object"==typeof de[s]&&o(de[s]);return Object.assign(s,de),s.addPlugin(L),s.addPlugin(ye),s.addPlugin($),s}({});s.exports=xe},35344(s){function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function bash(s){const o={},i={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[o]}]};Object.assign(o,{className:"variable",variants:[{begin:concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},i]});const a={className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},u={begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},_={className:"string",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE,o,a]};a.contains.push(_);const w={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,o]},x=s.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),C={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"},contains:[x,s.SHEBANG(),C,w,s.HASH_COMMENT_MODE,u,_,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},o]}}},73402(s){function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function http(s){const o="HTTP/(2|1\\.[01])",i={className:"attribute",begin:concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}},a=[i,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+o+" \\d{3})",end:/$/,contains:[{className:"meta",begin:o},{className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:a}},{begin:"(?=^[A-Z]+ (.*?) "+o+"$)",end:/$/,contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:o},{className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:a}},s.inherit(i,{relevance:0})]}}},95089(s){const o="[A-Za-z$_][0-9A-Za-z$_]*",i=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],u=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function javascript(s){const _=o,w="<>",x="",C={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(s,o)=>{const i=s[0].length+s.index,a=s.input[i];"<"!==a?">"===a&&(((s,{after:o})=>{const i="",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:s.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:j,contains:ce}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:w,end:x},{begin:C.begin,"on:begin":C.isTrulyOpeningTag,end:C.end}],subLanguage:"xml",contains:[{begin:C.begin,end:C.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:j,contains:["self",s.inherit(s.TITLE_MODE,{begin:_}),le],illegal:/%/},{beginKeywords:"while if switch catch for"},{className:"function",begin:s.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,contains:[le,s.inherit(s.TITLE_MODE,{begin:_})]},{variants:[{begin:"\\."+_},{begin:"\\$"+_}],relevance:0},{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{beginKeywords:"extends"},s.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[s.inherit(s.TITLE_MODE,{begin:_}),"self",le]},{begin:"(get|set)\\s+(?="+_+"\\()",end:/\{/,keywords:"get set",contains:[s.inherit(s.TITLE_MODE,{begin:_}),{begin:/\(\)/},le]},{begin:/\$[(.]/}]}}},65772(s){s.exports=function json(s){const o={literal:"true false null"},i=[s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE],a=[s.QUOTE_STRING_MODE,s.C_NUMBER_MODE],u={end:",",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:o},_={begin:/\{/,end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE],illegal:"\\n"},s.inherit(u,{begin:/:/})].concat(i),illegal:"\\S"},w={begin:"\\[",end:"\\]",contains:[s.inherit(u)],illegal:"\\S"};return a.push(_,w),i.forEach((function(s){a.push(s)})),{name:"JSON",contains:a,keywords:o,illegal:"\\S"}}},26571(s){s.exports=function powershell(s){const o={$pattern:/-?[A-z\.\-]+\b/,keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},i={begin:"`[\\s\\S]",relevance:0},a={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},u={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[i,a,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},_={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},w=s.inherit(s.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),x={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},C={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[s.TITLE_MODE]},j={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[a]}]},L={begin:/using\s/,end:/$/,returnBegin:!0,contains:[u,_,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},B={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},$={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(o.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},s.inherit(s.TITLE_MODE,{endsParent:!0})]},U=[$,w,i,s.NUMBER_MODE,u,_,x,a,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/@\B/,relevance:0}],V={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",U,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return $.contains.unshift(V),{name:"PowerShell",aliases:["ps","ps1"],case_insensitive:!0,keywords:o,contains:U.concat(C,j,L,B,V)}}},17285(s){function source(s){return s?"string"==typeof s?s:s.source:null}function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>source(s))).join("")}function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}s.exports=function xml(s){const o=concat(/[A-Z_]/,function optional(s){return concat("(",s,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},a={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},u=s.inherit(a,{begin:/\(/,end:/\)/}),_=s.inherit(s.APOS_STRING_MODE,{className:"meta-string"}),w=s.inherit(s.QUOTE_STRING_MODE,{className:"meta-string"}),x={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[a,w,_,u,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[a,u,w,_]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[x],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[x],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:o,relevance:0,starts:x}]},{className:"tag",begin:concat(/<\//,lookahead(concat(o,/>/))),contains:[{className:"name",begin:o,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17533(s){s.exports=function yaml(s){var o="true false yes no null",i="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[s.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},u=s.inherit(a,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),_={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},w={end:",",endsWithParent:!0,excludeEnd:!0,keywords:o,relevance:0},x={begin:/\{/,end:/\}/,contains:[w],illegal:"\\n",relevance:0},C={begin:"\\[",end:"\\]",contains:[w],illegal:"\\n",relevance:0},j=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+i},{className:"type",begin:"!<"+i+">"},{className:"type",begin:"!"+i},{className:"type",begin:"!!"+i},{className:"meta",begin:"&"+s.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+s.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},s.HASH_COMMENT_MODE,{beginKeywords:o,keywords:{literal:o}},_,{className:"number",begin:s.C_NUMBER_RE+"\\b",relevance:0},x,C,a],L=[...j];return L.pop(),L.push(u),w.contains=L,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:j}}},251(s,o){o.read=function(s,o,i,a,u){var _,w,x=8*u-a-1,C=(1<>1,L=-7,B=i?u-1:0,$=i?-1:1,U=s[o+B];for(B+=$,_=U&(1<<-L)-1,U>>=-L,L+=x;L>0;_=256*_+s[o+B],B+=$,L-=8);for(w=_&(1<<-L)-1,_>>=-L,L+=a;L>0;w=256*w+s[o+B],B+=$,L-=8);if(0===_)_=1-j;else{if(_===C)return w?NaN:1/0*(U?-1:1);w+=Math.pow(2,a),_-=j}return(U?-1:1)*w*Math.pow(2,_-a)},o.write=function(s,o,i,a,u,_){var w,x,C,j=8*_-u-1,L=(1<>1,$=23===u?Math.pow(2,-24)-Math.pow(2,-77):0,U=a?0:_-1,V=a?1:-1,z=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(x=isNaN(o)?1:0,w=L):(w=Math.floor(Math.log(o)/Math.LN2),o*(C=Math.pow(2,-w))<1&&(w--,C*=2),(o+=w+B>=1?$/C:$*Math.pow(2,1-B))*C>=2&&(w++,C/=2),w+B>=L?(x=0,w=L):w+B>=1?(x=(o*C-1)*Math.pow(2,u),w+=B):(x=o*Math.pow(2,B-1)*Math.pow(2,u),w=0));u>=8;s[i+U]=255&x,U+=V,x/=256,u-=8);for(w=w<0;s[i+U]=255&w,U+=V,w/=256,j-=8);s[i+U-V]|=128*z}},9404(s){s.exports=function(){"use strict";var s=Array.prototype.slice;function createClass(s,o){o&&(s.prototype=Object.create(o.prototype)),s.prototype.constructor=s}function Iterable(s){return isIterable(s)?s:Seq(s)}function KeyedIterable(s){return isKeyed(s)?s:KeyedSeq(s)}function IndexedIterable(s){return isIndexed(s)?s:IndexedSeq(s)}function SetIterable(s){return isIterable(s)&&!isAssociative(s)?s:SetSeq(s)}function isIterable(s){return!(!s||!s[o])}function isKeyed(s){return!(!s||!s[i])}function isIndexed(s){return!(!s||!s[a])}function isAssociative(s){return isKeyed(s)||isIndexed(s)}function isOrdered(s){return!(!s||!s[u])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var o="@@__IMMUTABLE_ITERABLE__@@",i="@@__IMMUTABLE_KEYED__@@",a="@@__IMMUTABLE_INDEXED__@@",u="@@__IMMUTABLE_ORDERED__@@",_="delete",w=5,x=1<>>0;if(""+i!==o||4294967295===i)return NaN;o=i}return o<0?ensureSize(s)+o:o}function returnTrue(){return!0}function wholeSlice(s,o,i){return(0===s||void 0!==i&&s<=-i)&&(void 0===o||void 0!==i&&o>=i)}function resolveBegin(s,o){return resolveIndex(s,o,0)}function resolveEnd(s,o){return resolveIndex(s,o,o)}function resolveIndex(s,o,i){return void 0===s?i:s<0?Math.max(0,o+s):void 0===o?s:Math.min(o,s)}var $=0,U=1,V=2,z="function"==typeof Symbol&&Symbol.iterator,Y="@@iterator",Z=z||Y;function Iterator(s){this.next=s}function iteratorValue(s,o,i,a){var u=0===s?o:1===s?i:[o,i];return a?a.value=u:a={value:u,done:!1},a}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(s){return!!getIteratorFn(s)}function isIterator(s){return s&&"function"==typeof s.next}function getIterator(s){var o=getIteratorFn(s);return o&&o.call(s)}function getIteratorFn(s){var o=s&&(z&&s[z]||s[Y]);if("function"==typeof o)return o}function isArrayLike(s){return s&&"number"==typeof s.length}function Seq(s){return null==s?emptySequence():isIterable(s)?s.toSeq():seqFromValue(s)}function KeyedSeq(s){return null==s?emptySequence().toKeyedSeq():isIterable(s)?isKeyed(s)?s.toSeq():s.fromEntrySeq():keyedSeqFromValue(s)}function IndexedSeq(s){return null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s.toIndexedSeq():indexedSeqFromValue(s)}function SetSeq(s){return(null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s:indexedSeqFromValue(s)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=$,Iterator.VALUES=U,Iterator.ENTRIES=V,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[Z]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!0)},Seq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!1)},IndexedSeq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ee,ie,ae,ce="@@__IMMUTABLE_SEQ__@@";function ArraySeq(s){this._array=s,this.size=s.length}function ObjectSeq(s){var o=Object.keys(s);this._object=s,this._keys=o,this.size=o.length}function IterableSeq(s){this._iterable=s,this.size=s.length||s.size}function IteratorSeq(s){this._iterator=s,this._iteratorCache=[]}function isSeq(s){return!(!s||!s[ce])}function emptySequence(){return ee||(ee=new ArraySeq([]))}function keyedSeqFromValue(s){var o=Array.isArray(s)?new ArraySeq(s).fromEntrySeq():isIterator(s)?new IteratorSeq(s).fromEntrySeq():hasIterator(s)?new IterableSeq(s).fromEntrySeq():"object"==typeof s?new ObjectSeq(s):void 0;if(!o)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+s);return o}function indexedSeqFromValue(s){var o=maybeIndexedSeqFromValue(s);if(!o)throw new TypeError("Expected Array or iterable object of values: "+s);return o}function seqFromValue(s){var o=maybeIndexedSeqFromValue(s)||"object"==typeof s&&new ObjectSeq(s);if(!o)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+s);return o}function maybeIndexedSeqFromValue(s){return isArrayLike(s)?new ArraySeq(s):isIterator(s)?new IteratorSeq(s):hasIterator(s)?new IterableSeq(s):void 0}function seqIterate(s,o,i,a){var u=s._cache;if(u){for(var _=u.length-1,w=0;w<=_;w++){var x=u[i?_-w:w];if(!1===o(x[1],a?x[0]:w,s))return w+1}return w}return s.__iterateUncached(o,i)}function seqIterator(s,o,i,a){var u=s._cache;if(u){var _=u.length-1,w=0;return new Iterator((function(){var s=u[i?_-w:w];return w++>_?iteratorDone():iteratorValue(o,a?s[0]:w-1,s[1])}))}return s.__iteratorUncached(o,i)}function fromJS(s,o){return o?fromJSWith(o,s,"",{"":s}):fromJSDefault(s)}function fromJSWith(s,o,i,a){return Array.isArray(o)?s.call(a,i,IndexedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):isPlainObj(o)?s.call(a,i,KeyedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):o}function fromJSDefault(s){return Array.isArray(s)?IndexedSeq(s).map(fromJSDefault).toList():isPlainObj(s)?KeyedSeq(s).map(fromJSDefault).toMap():s}function isPlainObj(s){return s&&(s.constructor===Object||void 0===s.constructor)}function is(s,o){if(s===o||s!=s&&o!=o)return!0;if(!s||!o)return!1;if("function"==typeof s.valueOf&&"function"==typeof o.valueOf){if((s=s.valueOf())===(o=o.valueOf())||s!=s&&o!=o)return!0;if(!s||!o)return!1}return!("function"!=typeof s.equals||"function"!=typeof o.equals||!s.equals(o))}function deepEqual(s,o){if(s===o)return!0;if(!isIterable(o)||void 0!==s.size&&void 0!==o.size&&s.size!==o.size||void 0!==s.__hash&&void 0!==o.__hash&&s.__hash!==o.__hash||isKeyed(s)!==isKeyed(o)||isIndexed(s)!==isIndexed(o)||isOrdered(s)!==isOrdered(o))return!1;if(0===s.size&&0===o.size)return!0;var i=!isAssociative(s);if(isOrdered(s)){var a=s.entries();return o.every((function(s,o){var u=a.next().value;return u&&is(u[1],s)&&(i||is(u[0],o))}))&&a.next().done}var u=!1;if(void 0===s.size)if(void 0===o.size)"function"==typeof s.cacheResult&&s.cacheResult();else{u=!0;var _=s;s=o,o=_}var w=!0,x=o.__iterate((function(o,a){if(i?!s.has(o):u?!is(o,s.get(a,j)):!is(s.get(a,j),o))return w=!1,!1}));return w&&s.size===x}function Repeat(s,o){if(!(this instanceof Repeat))return new Repeat(s,o);if(this._value=s,this.size=void 0===o?1/0:Math.max(0,o),0===this.size){if(ie)return ie;ie=this}}function invariant(s,o){if(!s)throw new Error(o)}function Range(s,o,i){if(!(this instanceof Range))return new Range(s,o,i);if(invariant(0!==i,"Cannot step a Range by 0"),s=s||0,void 0===o&&(o=1/0),i=void 0===i?1:Math.abs(i),oa?iteratorDone():iteratorValue(s,u,i[o?a-u++:u++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(s,o){return void 0===o||this.has(s)?this._object[s]:o},ObjectSeq.prototype.has=function(s){return this._object.hasOwnProperty(s)},ObjectSeq.prototype.__iterate=function(s,o){for(var i=this._object,a=this._keys,u=a.length-1,_=0;_<=u;_++){var w=a[o?u-_:_];if(!1===s(i[w],w,this))return _+1}return _},ObjectSeq.prototype.__iterator=function(s,o){var i=this._object,a=this._keys,u=a.length-1,_=0;return new Iterator((function(){var w=a[o?u-_:_];return _++>u?iteratorDone():iteratorValue(s,w,i[w])}))},ObjectSeq.prototype[u]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);var i=getIterator(this._iterable),a=0;if(isIterator(i))for(var u;!(u=i.next()).done&&!1!==s(u.value,a++,this););return a},IterableSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var a=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,a++,o.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);for(var i,a=this._iterator,u=this._iteratorCache,_=0;_=a.length){var o=i.next();if(o.done)return o;a[u]=o.value}return iteratorValue(s,u,a[u++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(s,o){return this.has(s)?this._value:o},Repeat.prototype.includes=function(s){return is(this._value,s)},Repeat.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:new Repeat(this._value,resolveEnd(o,i)-resolveBegin(s,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(s){return is(this._value,s)?0:-1},Repeat.prototype.lastIndexOf=function(s){return is(this._value,s)?this.size:-1},Repeat.prototype.__iterate=function(s,o){for(var i=0;i=0&&o=0&&ii?iteratorDone():iteratorValue(s,_++,w)}))},Range.prototype.equals=function(s){return s instanceof Range?this._start===s._start&&this._end===s._end&&this._step===s._step:deepEqual(this,s)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var le="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(s,o){var i=65535&(s|=0),a=65535&(o|=0);return i*a+((s>>>16)*a+i*(o>>>16)<<16>>>0)|0};function smi(s){return s>>>1&1073741824|3221225471&s}function hash(s){if(!1===s||null==s)return 0;if("function"==typeof s.valueOf&&(!1===(s=s.valueOf())||null==s))return 0;if(!0===s)return 1;var o=typeof s;if("number"===o){if(s!=s||s===1/0)return 0;var i=0|s;for(i!==s&&(i^=4294967295*s);s>4294967295;)i^=s/=4294967295;return smi(i)}if("string"===o)return s.length>_e?cachedHashString(s):hashString(s);if("function"==typeof s.hashCode)return s.hashCode();if("object"===o)return hashJSObj(s);if("function"==typeof s.toString)return hashString(s.toString());throw new Error("Value type "+o+" cannot be hashed.")}function cachedHashString(s){var o=Pe[s];return void 0===o&&(o=hashString(s),xe===we&&(xe=0,Pe={}),xe++,Pe[s]=o),o}function hashString(s){for(var o=0,i=0;i0)switch(s.nodeType){case 1:return s.uniqueID;case 9:return s.documentElement&&s.documentElement.uniqueID}}var fe,ye="function"==typeof WeakMap;ye&&(fe=new WeakMap);var be=0,Se="__immutablehash__";"function"==typeof Symbol&&(Se=Symbol(Se));var _e=16,we=255,xe=0,Pe={};function assertNotInfinite(s){invariant(s!==1/0,"Cannot perform this action with an infinite size.")}function Map(s){return null==s?emptyMap():isMap(s)&&!isOrdered(s)?s:emptyMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isMap(s){return!(!s||!s[Re])}createClass(Map,KeyedCollection),Map.of=function(){var o=s.call(arguments,0);return emptyMap().withMutations((function(s){for(var i=0;i=o.length)throw new Error("Missing value for key: "+o[i]);s.set(o[i],o[i+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(s,o){return this._root?this._root.get(0,void 0,s,o):o},Map.prototype.set=function(s,o){return updateMap(this,s,o)},Map.prototype.setIn=function(s,o){return this.updateIn(s,j,(function(){return o}))},Map.prototype.remove=function(s){return updateMap(this,s,j)},Map.prototype.deleteIn=function(s){return this.updateIn(s,(function(){return j}))},Map.prototype.update=function(s,o,i){return 1===arguments.length?s(this):this.updateIn([s],o,i)},Map.prototype.updateIn=function(s,o,i){i||(i=o,o=void 0);var a=updateInDeepMap(this,forceIterator(s),o,i);return a===j?void 0:a},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(o){return mergeIntoMapWith(this,o,s.call(arguments,1))},Map.prototype.mergeIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.merge?s.merge.apply(s,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(o),i)},Map.prototype.mergeDeepIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.mergeDeep?s.mergeDeep.apply(s,i):i[i.length-1]}))},Map.prototype.sort=function(s){return OrderedMap(sortFactory(this,s))},Map.prototype.sortBy=function(s,o){return OrderedMap(sortFactory(this,o,s))},Map.prototype.withMutations=function(s){var o=this.asMutable();return s(o),o.wasAltered()?o.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(s,o){return new MapIterator(this,s,o)},Map.prototype.__iterate=function(s,o){var i=this,a=0;return this._root&&this._root.iterate((function(o){return a++,s(o[1],o[0],i)}),o),a},Map.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeMap(this.size,this._root,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Map.isMap=isMap;var Te,Re="@@__IMMUTABLE_MAP__@@",$e=Map.prototype;function ArrayMapNode(s,o){this.ownerID=s,this.entries=o}function BitmapIndexedNode(s,o,i){this.ownerID=s,this.bitmap=o,this.nodes=i}function HashArrayMapNode(s,o,i){this.ownerID=s,this.count=o,this.nodes=i}function HashCollisionNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entries=i}function ValueNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entry=i}function MapIterator(s,o,i){this._type=o,this._reverse=i,this._stack=s._root&&mapIteratorFrame(s._root)}function mapIteratorValue(s,o){return iteratorValue(s,o[0],o[1])}function mapIteratorFrame(s,o){return{node:s,index:0,__prev:o}}function makeMap(s,o,i,a){var u=Object.create($e);return u.size=s,u._root=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyMap(){return Te||(Te=makeMap(0))}function updateMap(s,o,i){var a,u;if(s._root){var _=MakeRef(L),w=MakeRef(B);if(a=updateNode(s._root,s.__ownerID,0,void 0,o,i,_,w),!w.value)return s;u=s.size+(_.value?i===j?-1:1:0)}else{if(i===j)return s;u=1,a=new ArrayMapNode(s.__ownerID,[[o,i]])}return s.__ownerID?(s.size=u,s._root=a,s.__hash=void 0,s.__altered=!0,s):a?makeMap(u,a):emptyMap()}function updateNode(s,o,i,a,u,_,w,x){return s?s.update(o,i,a,u,_,w,x):_===j?s:(SetRef(x),SetRef(w),new ValueNode(o,a,[u,_]))}function isLeafNode(s){return s.constructor===ValueNode||s.constructor===HashCollisionNode}function mergeIntoNode(s,o,i,a,u){if(s.keyHash===a)return new HashCollisionNode(o,a,[s.entry,u]);var _,x=(0===i?s.keyHash:s.keyHash>>>i)&C,j=(0===i?a:a>>>i)&C;return new BitmapIndexedNode(o,1<>>=1)w[C]=1&i?o[_++]:void 0;return w[a]=u,new HashArrayMapNode(s,_+1,w)}function mergeIntoMapWith(s,o,i){for(var a=[],u=0;u>1&1431655765))+(s>>2&858993459))+(s>>4)&252645135,s+=s>>8,127&(s+=s>>16)}function setIn(s,o,i,a){var u=a?s:arrCopy(s);return u[o]=i,u}function spliceIn(s,o,i,a){var u=s.length+1;if(a&&o+1===u)return s[o]=i,s;for(var _=new Array(u),w=0,x=0;x=qe)return createNodes(s,C,a,u);var U=s&&s===this.ownerID,V=U?C:arrCopy(C);return $?x?L===B-1?V.pop():V[L]=V.pop():V[L]=[a,u]:V.push([a,u]),U?(this.entries=V,this):new ArrayMapNode(s,V)}},BitmapIndexedNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=1<<((0===s?o:o>>>s)&C),_=this.bitmap;return _&u?this.nodes[popCount(_&u-1)].get(s+w,o,i,a):a},BitmapIndexedNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=1<=ze)return expandNodes(s,z,$,L,Z);if(U&&!Z&&2===z.length&&isLeafNode(z[1^V]))return z[1^V];if(U&&Z&&1===z.length&&isLeafNode(Z))return Z;var ee=s&&s===this.ownerID,ie=U?Z?$:$^B:$|B,ae=U?Z?setIn(z,V,Z,ee):spliceOut(z,V,ee):spliceIn(z,V,Z,ee);return ee?(this.bitmap=ie,this.nodes=ae,this):new BitmapIndexedNode(s,ie,ae)},HashArrayMapNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=(0===s?o:o>>>s)&C,_=this.nodes[u];return _?_.get(s+w,o,i,a):a},HashArrayMapNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=u===j,$=this.nodes,U=$[L];if(B&&!U)return this;var V=updateNode(U,s,o+w,i,a,u,_,x);if(V===U)return this;var z=this.count;if(U){if(!V&&--z0&&a=0&&s>>o&C;if(a>=this.array.length)return new VNode([],s);var u,_=0===a;if(o>0){var x=this.array[a];if((u=x&&x.removeBefore(s,o-w,i))===x&&_)return this}if(_&&!u)return this;var j=editableVNode(this,s);if(!_)for(var L=0;L>>o&C;if(u>=this.array.length)return this;if(o>0){var _=this.array[u];if((a=_&&_.removeAfter(s,o-w,i))===_&&u===this.array.length-1)return this}var x=editableVNode(this,s);return x.array.splice(u+1),a&&(x.array[u]=a),x};var Xe,Qe,et={};function iterateList(s,o){var i=s._origin,a=s._capacity,u=getTailOffset(a),_=s._tail;return iterateNodeOrLeaf(s._root,s._level,0);function iterateNodeOrLeaf(s,o,i){return 0===o?iterateLeaf(s,i):iterateNode(s,o,i)}function iterateLeaf(s,w){var C=w===u?_&&_.array:s&&s.array,j=w>i?0:i-w,L=a-w;return L>x&&(L=x),function(){if(j===L)return et;var s=o?--L:j++;return C&&C[s]}}function iterateNode(s,u,_){var C,j=s&&s.array,L=_>i?0:i-_>>u,B=1+(a-_>>u);return B>x&&(B=x),function(){for(;;){if(C){var s=C();if(s!==et)return s;C=null}if(L===B)return et;var i=o?--B:L++;C=iterateNodeOrLeaf(j&&j[i],u-w,_+(i<=s.size||o<0)return s.withMutations((function(s){o<0?setListBounds(s,o).set(0,i):setListBounds(s,0,o+1).set(o,i)}));o+=s._origin;var a=s._tail,u=s._root,_=MakeRef(B);return o>=getTailOffset(s._capacity)?a=updateVNode(a,s.__ownerID,0,o,i,_):u=updateVNode(u,s.__ownerID,s._level,o,i,_),_.value?s.__ownerID?(s._root=u,s._tail=a,s.__hash=void 0,s.__altered=!0,s):makeList(s._origin,s._capacity,s._level,u,a):s}function updateVNode(s,o,i,a,u,_){var x,j=a>>>i&C,L=s&&j0){var B=s&&s.array[j],$=updateVNode(B,o,i-w,a,u,_);return $===B?s:((x=editableVNode(s,o)).array[j]=$,x)}return L&&s.array[j]===u?s:(SetRef(_),x=editableVNode(s,o),void 0===u&&j===x.array.length-1?x.array.pop():x.array[j]=u,x)}function editableVNode(s,o){return o&&s&&o===s.ownerID?s:new VNode(s?s.array.slice():[],o)}function listNodeFor(s,o){if(o>=getTailOffset(s._capacity))return s._tail;if(o<1<0;)i=i.array[o>>>a&C],a-=w;return i}}function setListBounds(s,o,i){void 0!==o&&(o|=0),void 0!==i&&(i|=0);var a=s.__ownerID||new OwnerID,u=s._origin,_=s._capacity,x=u+o,j=void 0===i?_:i<0?_+i:u+i;if(x===u&&j===_)return s;if(x>=j)return s.clear();for(var L=s._level,B=s._root,$=0;x+$<0;)B=new VNode(B&&B.array.length?[void 0,B]:[],a),$+=1<<(L+=w);$&&(x+=$,u+=$,j+=$,_+=$);for(var U=getTailOffset(_),V=getTailOffset(j);V>=1<U?new VNode([],a):z;if(z&&V>U&&x<_&&z.array.length){for(var Z=B=editableVNode(B,a),ee=L;ee>w;ee-=w){var ie=U>>>ee&C;Z=Z.array[ie]=editableVNode(Z.array[ie],a)}Z.array[U>>>w&C]=z}if(j<_&&(Y=Y&&Y.removeAfter(a,0,j)),x>=V)x-=V,j-=V,L=w,B=null,Y=Y&&Y.removeBefore(a,0,x);else if(x>u||V>>L&C;if(ae!==V>>>L&C)break;ae&&($+=(1<u&&(B=B.removeBefore(a,L,x-$)),B&&Vu&&(u=x.size),isIterable(w)||(x=x.map((function(s){return fromJS(s)}))),a.push(x)}return u>s.size&&(s=s.setSize(u)),mergeIntoCollectionWith(s,o,a)}function getTailOffset(s){return s>>w<=x&&w.size>=2*_.size?(a=(u=w.filter((function(s,o){return void 0!==s&&C!==o}))).toKeyedSeq().map((function(s){return s[0]})).flip().toMap(),s.__ownerID&&(a.__ownerID=u.__ownerID=s.__ownerID)):(a=_.remove(o),u=C===w.size-1?w.pop():w.set(C,void 0))}else if(L){if(i===w.get(C)[1])return s;a=_,u=w.set(C,[o,i])}else a=_.set(o,w.size),u=w.set(w.size,[o,i]);return s.__ownerID?(s.size=a.size,s._map=a,s._list=u,s.__hash=void 0,s):makeOrderedMap(a,u)}function ToKeyedSequence(s,o){this._iter=s,this._useKeys=o,this.size=s.size}function ToIndexedSequence(s){this._iter=s,this.size=s.size}function ToSetSequence(s){this._iter=s,this.size=s.size}function FromEntriesSequence(s){this._iter=s,this.size=s.size}function flipFactory(s){var o=makeSequence(s);return o._iter=s,o.size=s.size,o.flip=function(){return s},o.reverse=function(){var o=s.reverse.apply(this);return o.flip=function(){return s.reverse()},o},o.has=function(o){return s.includes(o)},o.includes=function(o){return s.has(o)},o.cacheResult=cacheResultThrough,o.__iterateUncached=function(o,i){var a=this;return s.__iterate((function(s,i){return!1!==o(i,s,a)}),i)},o.__iteratorUncached=function(o,i){if(o===V){var a=s.__iterator(o,i);return new Iterator((function(){var s=a.next();if(!s.done){var o=s.value[0];s.value[0]=s.value[1],s.value[1]=o}return s}))}return s.__iterator(o===U?$:U,i)},o}function mapFactory(s,o,i){var a=makeSequence(s);return a.size=s.size,a.has=function(o){return s.has(o)},a.get=function(a,u){var _=s.get(a,j);return _===j?u:o.call(i,_,a,s)},a.__iterateUncached=function(a,u){var _=this;return s.__iterate((function(s,u,w){return!1!==a(o.call(i,s,u,w),u,_)}),u)},a.__iteratorUncached=function(a,u){var _=s.__iterator(V,u);return new Iterator((function(){var u=_.next();if(u.done)return u;var w=u.value,x=w[0];return iteratorValue(a,x,o.call(i,w[1],x,s),u)}))},a}function reverseFactory(s,o){var i=makeSequence(s);return i._iter=s,i.size=s.size,i.reverse=function(){return s},s.flip&&(i.flip=function(){var o=flipFactory(s);return o.reverse=function(){return s.flip()},o}),i.get=function(i,a){return s.get(o?i:-1-i,a)},i.has=function(i){return s.has(o?i:-1-i)},i.includes=function(o){return s.includes(o)},i.cacheResult=cacheResultThrough,i.__iterate=function(o,i){var a=this;return s.__iterate((function(s,i){return o(s,i,a)}),!i)},i.__iterator=function(o,i){return s.__iterator(o,!i)},i}function filterFactory(s,o,i,a){var u=makeSequence(s);return a&&(u.has=function(a){var u=s.get(a,j);return u!==j&&!!o.call(i,u,a,s)},u.get=function(a,u){var _=s.get(a,j);return _!==j&&o.call(i,_,a,s)?_:u}),u.__iterateUncached=function(u,_){var w=this,x=0;return s.__iterate((function(s,_,C){if(o.call(i,s,_,C))return x++,u(s,a?_:x-1,w)}),_),x},u.__iteratorUncached=function(u,_){var w=s.__iterator(V,_),x=0;return new Iterator((function(){for(;;){var _=w.next();if(_.done)return _;var C=_.value,j=C[0],L=C[1];if(o.call(i,L,j,s))return iteratorValue(u,a?j:x++,L,_)}}))},u}function countByFactory(s,o,i){var a=Map().asMutable();return s.__iterate((function(u,_){a.update(o.call(i,u,_,s),0,(function(s){return s+1}))})),a.asImmutable()}function groupByFactory(s,o,i){var a=isKeyed(s),u=(isOrdered(s)?OrderedMap():Map()).asMutable();s.__iterate((function(_,w){u.update(o.call(i,_,w,s),(function(s){return(s=s||[]).push(a?[w,_]:_),s}))}));var _=iterableClass(s);return u.map((function(o){return reify(s,_(o))}))}function sliceFactory(s,o,i,a){var u=s.size;if(void 0!==o&&(o|=0),void 0!==i&&(i===1/0?i=u:i|=0),wholeSlice(o,i,u))return s;var _=resolveBegin(o,u),w=resolveEnd(i,u);if(_!=_||w!=w)return sliceFactory(s.toSeq().cacheResult(),o,i,a);var x,C=w-_;C==C&&(x=C<0?0:C);var j=makeSequence(s);return j.size=0===x?x:s.size&&x||void 0,!a&&isSeq(s)&&x>=0&&(j.get=function(o,i){return(o=wrapIndex(this,o))>=0&&ox)return iteratorDone();var s=u.next();return a||o===U?s:iteratorValue(o,C-1,o===$?void 0:s.value[1],s)}))},j}function takeWhileFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterate(a,u);var w=0;return s.__iterate((function(s,u,x){return o.call(i,s,u,x)&&++w&&a(s,u,_)})),w},a.__iteratorUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterator(a,u);var w=s.__iterator(V,u),x=!0;return new Iterator((function(){if(!x)return iteratorDone();var s=w.next();if(s.done)return s;var u=s.value,C=u[0],j=u[1];return o.call(i,j,C,_)?a===V?s:iteratorValue(a,C,j,s):(x=!1,iteratorDone())}))},a}function skipWhileFactory(s,o,i,a){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=!0,C=0;return s.__iterate((function(s,_,j){if(!x||!(x=o.call(i,s,_,j)))return C++,u(s,a?_:C-1,w)})),C},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=s.__iterator(V,_),C=!0,j=0;return new Iterator((function(){var s,_,L;do{if((s=x.next()).done)return a||u===U?s:iteratorValue(u,j++,u===$?void 0:s.value[1],s);var B=s.value;_=B[0],L=B[1],C&&(C=o.call(i,L,_,w))}while(C);return u===V?s:iteratorValue(u,_,L,s)}))},u}function concatFactory(s,o){var i=isKeyed(s),a=[s].concat(o).map((function(s){return isIterable(s)?i&&(s=KeyedIterable(s)):s=i?keyedSeqFromValue(s):indexedSeqFromValue(Array.isArray(s)?s:[s]),s})).filter((function(s){return 0!==s.size}));if(0===a.length)return s;if(1===a.length){var u=a[0];if(u===s||i&&isKeyed(u)||isIndexed(s)&&isIndexed(u))return u}var _=new ArraySeq(a);return i?_=_.toKeyedSeq():isIndexed(s)||(_=_.toSetSeq()),(_=_.flatten(!0)).size=a.reduce((function(s,o){if(void 0!==s){var i=o.size;if(void 0!==i)return s+i}}),0),_}function flattenFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=0,w=!1;function flatDeep(s,x){var C=this;s.__iterate((function(s,u){return(!o||x0}function zipWithFactory(s,o,i){var a=makeSequence(s);return a.size=new ArraySeq(i).map((function(s){return s.size})).min(),a.__iterate=function(s,o){for(var i,a=this.__iterator(U,o),u=0;!(i=a.next()).done&&!1!==s(i.value,u++,this););return u},a.__iteratorUncached=function(s,a){var u=i.map((function(s){return s=Iterable(s),getIterator(a?s.reverse():s)})),_=0,w=!1;return new Iterator((function(){var i;return w||(i=u.map((function(s){return s.next()})),w=i.some((function(s){return s.done}))),w?iteratorDone():iteratorValue(s,_++,o.apply(null,i.map((function(s){return s.value}))))}))},a}function reify(s,o){return isSeq(s)?o:s.constructor(o)}function validateEntry(s){if(s!==Object(s))throw new TypeError("Expected [K, V] tuple: "+s)}function resolveSize(s){return assertNotInfinite(s.size),ensureSize(s)}function iterableClass(s){return isKeyed(s)?KeyedIterable:isIndexed(s)?IndexedIterable:SetIterable}function makeSequence(s){return Object.create((isKeyed(s)?KeyedSeq:isIndexed(s)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(s,o){return s>o?1:s=0;i--)o={value:arguments[i],next:o};return this.__ownerID?(this.size=s,this._head=o,this.__hash=void 0,this.__altered=!0,this):makeStack(s,o)},Stack.prototype.pushAll=function(s){if(0===(s=IndexedIterable(s)).size)return this;assertNotInfinite(s.size);var o=this.size,i=this._head;return s.reverse().forEach((function(s){o++,i={value:s,next:i}})),this.__ownerID?(this.size=o,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(o,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(s){return this.pushAll(s)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(s,o){if(wholeSlice(s,o,this.size))return this;var i=resolveBegin(s,this.size);if(resolveEnd(o,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,s,o);for(var a=this.size-i,u=this._head;i--;)u=u.next;return this.__ownerID?(this.size=a,this._head=u,this.__hash=void 0,this.__altered=!0,this):makeStack(a,u)},Stack.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeStack(this.size,this._head,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Stack.prototype.__iterate=function(s,o){if(o)return this.reverse().__iterate(s);for(var i=0,a=this._head;a&&!1!==s(a.value,i++,this);)a=a.next;return i},Stack.prototype.__iterator=function(s,o){if(o)return this.reverse().__iterator(s);var i=0,a=this._head;return new Iterator((function(){if(a){var o=a.value;return a=a.next,iteratorValue(s,i++,o)}return iteratorDone()}))},Stack.isStack=isStack;var at,ct="@@__IMMUTABLE_STACK__@@",lt=Stack.prototype;function makeStack(s,o,i,a){var u=Object.create(lt);return u.size=s,u._head=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyStack(){return at||(at=makeStack(0))}function mixin(s,o){var keyCopier=function(i){s.prototype[i]=o[i]};return Object.keys(o).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(o).forEach(keyCopier),s}function isProtoKey(s){return"string"==typeof s&&("__proto__"===s||"constructor"===s)}lt[ct]=!0,lt.withMutations=$e.withMutations,lt.asMutable=$e.asMutable,lt.asImmutable=$e.asImmutable,lt.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var s=new Array(this.size||0);return this.valueSeq().__iterate((function(o,i){s[i]=o})),s},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJS?s.toJS():s})).__toJS()},toJSON:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJSON?s.toJSON():s})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var s={};return this.__iterate((function(o,i){isProtoKey(i)||(s[i]=o)})),s},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(s,o){return 0===this.size?s+o:s+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+o},concat:function(){return reify(this,concatFactory(this,s.call(arguments,0)))},includes:function(s){return this.some((function(o){return is(o,s)}))},entries:function(){return this.__iterator(V)},every:function(s,o){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(a,u,_){if(!s.call(o,a,u,_))return i=!1,!1})),i},filter:function(s,o){return reify(this,filterFactory(this,s,o,!0))},find:function(s,o,i){var a=this.findEntry(s,o);return a?a[1]:i},forEach:function(s,o){return assertNotInfinite(this.size),this.__iterate(o?s.bind(o):s)},join:function(s){assertNotInfinite(this.size),s=void 0!==s?""+s:",";var o="",i=!0;return this.__iterate((function(a){i?i=!1:o+=s,o+=null!=a?a.toString():""})),o},keys:function(){return this.__iterator($)},map:function(s,o){return reify(this,mapFactory(this,s,o))},reduce:function(s,o,i){var a,u;return assertNotInfinite(this.size),arguments.length<2?u=!0:a=o,this.__iterate((function(o,_,w){u?(u=!1,a=o):a=s.call(i,a,o,_,w)})),a},reduceRight:function(s,o,i){var a=this.toKeyedSeq().reverse();return a.reduce.apply(a,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!0))},some:function(s,o){return!this.every(not(s),o)},sort:function(s){return reify(this,sortFactory(this,s))},values:function(){return this.__iterator(U)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(s,o){return ensureSize(s?this.toSeq().filter(s,o):this)},countBy:function(s,o){return countByFactory(this,s,o)},equals:function(s){return deepEqual(this,s)},entrySeq:function(){var s=this;if(s._cache)return new ArraySeq(s._cache);var o=s.toSeq().map(entryMapper).toIndexedSeq();return o.fromEntrySeq=function(){return s.toSeq()},o},filterNot:function(s,o){return this.filter(not(s),o)},findEntry:function(s,o,i){var a=i;return this.__iterate((function(i,u,_){if(s.call(o,i,u,_))return a=[u,i],!1})),a},findKey:function(s,o){var i=this.findEntry(s,o);return i&&i[0]},findLast:function(s,o,i){return this.toKeyedSeq().reverse().find(s,o,i)},findLastEntry:function(s,o,i){return this.toKeyedSeq().reverse().findEntry(s,o,i)},findLastKey:function(s,o){return this.toKeyedSeq().reverse().findKey(s,o)},first:function(){return this.find(returnTrue)},flatMap:function(s,o){return reify(this,flatMapFactory(this,s,o))},flatten:function(s){return reify(this,flattenFactory(this,s,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(s,o){return this.find((function(o,i){return is(i,s)}),void 0,o)},getIn:function(s,o){for(var i,a=this,u=forceIterator(s);!(i=u.next()).done;){var _=i.value;if((a=a&&a.get?a.get(_,j):j)===j)return o}return a},groupBy:function(s,o){return groupByFactory(this,s,o)},has:function(s){return this.get(s,j)!==j},hasIn:function(s){return this.getIn(s,j)!==j},isSubset:function(s){return s="function"==typeof s.includes?s:Iterable(s),this.every((function(o){return s.includes(o)}))},isSuperset:function(s){return(s="function"==typeof s.isSubset?s:Iterable(s)).isSubset(this)},keyOf:function(s){return this.findKey((function(o){return is(o,s)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(s){return this.toKeyedSeq().reverse().keyOf(s)},max:function(s){return maxFactory(this,s)},maxBy:function(s,o){return maxFactory(this,o,s)},min:function(s){return maxFactory(this,s?neg(s):defaultNegComparator)},minBy:function(s,o){return maxFactory(this,o?neg(o):defaultNegComparator,s)},rest:function(){return this.slice(1)},skip:function(s){return this.slice(Math.max(0,s))},skipLast:function(s){return reify(this,this.toSeq().reverse().skip(s).reverse())},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!0))},skipUntil:function(s,o){return this.skipWhile(not(s),o)},sortBy:function(s,o){return reify(this,sortFactory(this,o,s))},take:function(s){return this.slice(0,Math.max(0,s))},takeLast:function(s){return reify(this,this.toSeq().reverse().take(s).reverse())},takeWhile:function(s,o){return reify(this,takeWhileFactory(this,s,o))},takeUntil:function(s,o){return this.takeWhile(not(s),o)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var ut=Iterable.prototype;ut[o]=!0,ut[Z]=ut.values,ut.__toJS=ut.toArray,ut.__toStringMapper=quoteString,ut.inspect=ut.toSource=function(){return this.toString()},ut.chain=ut.flatMap,ut.contains=ut.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(s,o){var i=this,a=0;return reify(this,this.toSeq().map((function(u,_){return s.call(o,[_,u],a++,i)})).fromEntrySeq())},mapKeys:function(s,o){var i=this;return reify(this,this.toSeq().flip().map((function(a,u){return s.call(o,a,u,i)})).flip())}});var pt=KeyedIterable.prototype;function keyMapper(s,o){return o}function entryMapper(s,o){return[o,s]}function not(s){return function(){return!s.apply(this,arguments)}}function neg(s){return function(){return-s.apply(this,arguments)}}function quoteString(s){return"string"==typeof s?JSON.stringify(s):String(s)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(s,o){return so?-1:0}function hashIterable(s){if(s.size===1/0)return 0;var o=isOrdered(s),i=isKeyed(s),a=o?1:0;return murmurHashOfSize(s.__iterate(i?o?function(s,o){a=31*a+hashMerge(hash(s),hash(o))|0}:function(s,o){a=a+hashMerge(hash(s),hash(o))|0}:o?function(s){a=31*a+hash(s)|0}:function(s){a=a+hash(s)|0}),a)}function murmurHashOfSize(s,o){return o=le(o,3432918353),o=le(o<<15|o>>>-15,461845907),o=le(o<<13|o>>>-13,5),o=le((o=o+3864292196^s)^o>>>16,2246822507),o=smi((o=le(o^o>>>13,3266489909))^o>>>16)}function hashMerge(s,o){return s^o+2654435769+(s<<6)+(s>>2)}return pt[i]=!0,pt[Z]=ut.entries,pt.__toJS=ut.toObject,pt.__toStringMapper=function(s,o){return JSON.stringify(o)+": "+quoteString(s)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(s,o){return reify(this,filterFactory(this,s,o,!1))},findIndex:function(s,o){var i=this.findEntry(s,o);return i?i[0]:-1},indexOf:function(s){var o=this.keyOf(s);return void 0===o?-1:o},lastIndexOf:function(s){var o=this.lastKeyOf(s);return void 0===o?-1:o},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!1))},splice:function(s,o){var i=arguments.length;if(o=Math.max(0|o,0),0===i||2===i&&!o)return this;s=resolveBegin(s,s<0?this.count():this.size);var a=this.slice(0,s);return reify(this,1===i?a:a.concat(arrCopy(arguments,2),this.slice(s+o)))},findLastIndex:function(s,o){var i=this.findLastEntry(s,o);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(s){return reify(this,flattenFactory(this,s,!1))},get:function(s,o){return(s=wrapIndex(this,s))<0||this.size===1/0||void 0!==this.size&&s>this.size?o:this.find((function(o,i){return i===s}),void 0,o)},has:function(s){return(s=wrapIndex(this,s))>=0&&(void 0!==this.size?this.size===1/0||s=o||i<0||B&&s-j>=_}function timerExpired(){var s=now();if(shouldInvoke(s))return trailingEdge(s);x=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-C);return B?U(i,_-(s-j)):i}(s))}function trailingEdge(s){return x=void 0,V&&a?invokeFunc(s):(a=u=void 0,w)}function debounced(){var s=now(),i=shouldInvoke(s);if(a=arguments,u=this,C=s,i){if(void 0===x)return function leadingEdge(s){return j=s,x=setTimeout(timerExpired,o),L?invokeFunc(s):w}(C);if(B)return x=setTimeout(timerExpired,o),invokeFunc(C)}return void 0===x&&(x=setTimeout(timerExpired,o)),w}return o=toNumber(o)||0,isObject(i)&&(L=!!i.leading,_=(B="maxWait"in i)?$(toNumber(i.maxWait)||0,o):_,V="trailing"in i?!!i.trailing:V),debounced.cancel=function cancel(){void 0!==x&&clearTimeout(x),j=0,a=C=u=x=void 0},debounced.flush=function flush(){return void 0===x?w:trailingEdge(now())},debounced}},55580(s,o,i){var a=i(56110)(i(9325),"DataView");s.exports=a},21549(s,o,i){var a=i(22032),u=i(63862),_=i(66721),w=i(12749),x=i(35749);function Hash(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o-1}},70695(s,o,i){var a=i(78096),u=i(72428),_=i(56449),w=i(3656),x=i(30361),C=i(37167),j=Object.prototype.hasOwnProperty;s.exports=function arrayLikeKeys(s,o){var i=_(s),L=!i&&u(s),B=!i&&!L&&w(s),$=!i&&!L&&!B&&C(s),U=i||L||B||$,V=U?a(s.length,String):[],z=V.length;for(var Y in s)!o&&!j.call(s,Y)||U&&("length"==Y||B&&("offset"==Y||"parent"==Y)||$&&("buffer"==Y||"byteLength"==Y||"byteOffset"==Y)||x(Y,z))||V.push(Y);return V}},34932(s){s.exports=function arrayMap(s,o){for(var i=-1,a=null==s?0:s.length,u=Array(a);++i0&&i(j)?o>1?baseFlatten(j,o-1,i,_,w):a(w,j):_||(w[w.length]=j)}return w}},86649(s,o,i){var a=i(83221)();s.exports=a},30641(s,o,i){var a=i(86649),u=i(95950);s.exports=function baseForOwn(s,o){return s&&a(s,o,u)}},47422(s,o,i){var a=i(31769),u=i(77797);s.exports=function baseGet(s,o){for(var i=0,_=(o=a(o,s)).length;null!=s&&i<_;)s=s[u(o[i++])];return i&&i==_?s:void 0}},82199(s,o,i){var a=i(14528),u=i(56449);s.exports=function baseGetAllKeys(s,o,i){var _=o(s);return u(s)?_:a(_,i(s))}},72552(s,o,i){var a=i(51873),u=i(659),_=i(59350),w=a?a.toStringTag:void 0;s.exports=function baseGetTag(s){return null==s?void 0===s?"[object Undefined]":"[object Null]":w&&w in Object(s)?u(s):_(s)}},20426(s){var o=Object.prototype.hasOwnProperty;s.exports=function baseHas(s,i){return null!=s&&o.call(s,i)}},28077(s){s.exports=function baseHasIn(s,o){return null!=s&&o in Object(s)}},96131(s,o,i){var a=i(2523),u=i(85463),_=i(76959);s.exports=function baseIndexOf(s,o,i){return o==o?_(s,o,i):a(s,u,i)}},27534(s,o,i){var a=i(72552),u=i(40346);s.exports=function baseIsArguments(s){return u(s)&&"[object Arguments]"==a(s)}},60270(s,o,i){var a=i(87068),u=i(40346);s.exports=function baseIsEqual(s,o,i,_,w){return s===o||(null==s||null==o||!u(s)&&!u(o)?s!=s&&o!=o:a(s,o,i,_,baseIsEqual,w))}},87068(s,o,i){var a=i(37217),u=i(25911),_=i(21986),w=i(50689),x=i(5861),C=i(56449),j=i(3656),L=i(37167),B="[object Arguments]",$="[object Array]",U="[object Object]",V=Object.prototype.hasOwnProperty;s.exports=function baseIsEqualDeep(s,o,i,z,Y,Z){var ee=C(s),ie=C(o),ae=ee?$:x(s),ce=ie?$:x(o),le=(ae=ae==B?U:ae)==U,pe=(ce=ce==B?U:ce)==U,de=ae==ce;if(de&&j(s)){if(!j(o))return!1;ee=!0,le=!1}if(de&&!le)return Z||(Z=new a),ee||L(s)?u(s,o,i,z,Y,Z):_(s,o,ae,i,z,Y,Z);if(!(1&i)){var fe=le&&V.call(s,"__wrapped__"),ye=pe&&V.call(o,"__wrapped__");if(fe||ye){var be=fe?s.value():s,Se=ye?o.value():o;return Z||(Z=new a),Y(be,Se,i,z,Z)}}return!!de&&(Z||(Z=new a),w(s,o,i,z,Y,Z))}},29172(s,o,i){var a=i(5861),u=i(40346);s.exports=function baseIsMap(s){return u(s)&&"[object Map]"==a(s)}},41799(s,o,i){var a=i(37217),u=i(60270);s.exports=function baseIsMatch(s,o,i,_){var w=i.length,x=w,C=!_;if(null==s)return!x;for(s=Object(s);w--;){var j=i[w];if(C&&j[2]?j[1]!==s[j[0]]:!(j[0]in s))return!1}for(;++wu?0:u+o),(i=i>u?u:i)<0&&(i+=u),u=o>i?0:i-o>>>0,o>>>=0;for(var _=Array(u);++a=u?s:a(s,o,i)}},49653(s,o,i){var a=i(37828);s.exports=function cloneArrayBuffer(s){var o=new s.constructor(s.byteLength);return new a(o).set(new a(s)),o}},93290(s,o,i){s=i.nmd(s);var a=i(9325),u=o&&!o.nodeType&&o,_=u&&s&&!s.nodeType&&s,w=_&&_.exports===u?a.Buffer:void 0,x=w?w.allocUnsafe:void 0;s.exports=function cloneBuffer(s,o){if(o)return s.slice();var i=s.length,a=x?x(i):new s.constructor(i);return s.copy(a),a}},76169(s,o,i){var a=i(49653);s.exports=function cloneDataView(s,o){var i=o?a(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.byteLength)}},73201(s){var o=/\w*$/;s.exports=function cloneRegExp(s){var i=new s.constructor(s.source,o.exec(s));return i.lastIndex=s.lastIndex,i}},93736(s,o,i){var a=i(51873),u=a?a.prototype:void 0,_=u?u.valueOf:void 0;s.exports=function cloneSymbol(s){return _?Object(_.call(s)):{}}},71961(s,o,i){var a=i(49653);s.exports=function cloneTypedArray(s,o){var i=o?a(s.buffer):s.buffer;return new s.constructor(i,s.byteOffset,s.length)}},91596(s){var o=Math.max;s.exports=function composeArgs(s,i,a,u){for(var _=-1,w=s.length,x=a.length,C=-1,j=i.length,L=o(w-x,0),B=Array(j+L),$=!u;++C1?i[_-1]:void 0,x=_>2?i[2]:void 0;for(w=s.length>3&&"function"==typeof w?(_--,w):void 0,x&&u(i[0],i[1],x)&&(w=_<3?void 0:w,_=1),o=Object(o);++a<_;){var C=i[a];C&&s(o,C,a,w)}return o}))}},38329(s,o,i){var a=i(64894);s.exports=function createBaseEach(s,o){return function(i,u){if(null==i)return i;if(!a(i))return s(i,u);for(var _=i.length,w=o?_:-1,x=Object(i);(o?w--:++w<_)&&!1!==u(x[w],w,x););return i}}},83221(s){s.exports=function createBaseFor(s){return function(o,i,a){for(var u=-1,_=Object(o),w=a(o),x=w.length;x--;){var C=w[s?x:++u];if(!1===i(_[C],C,_))break}return o}}},11842(s,o,i){var a=i(82819),u=i(9325);s.exports=function createBind(s,o,i){var _=1&o,w=a(s);return function wrapper(){return(this&&this!==u&&this instanceof wrapper?w:s).apply(_?i:this,arguments)}}},12507(s,o,i){var a=i(28754),u=i(49698),_=i(63912),w=i(13222);s.exports=function createCaseFirst(s){return function(o){o=w(o);var i=u(o)?_(o):void 0,x=i?i[0]:o.charAt(0),C=i?a(i,1).join(""):o.slice(1);return x[s]()+C}}},45539(s,o,i){var a=i(40882),u=i(50828),_=i(66645),w=RegExp("['’]","g");s.exports=function createCompounder(s){return function(o){return a(_(u(o).replace(w,"")),s,"")}}},82819(s,o,i){var a=i(39344),u=i(23805);s.exports=function createCtor(s){return function(){var o=arguments;switch(o.length){case 0:return new s;case 1:return new s(o[0]);case 2:return new s(o[0],o[1]);case 3:return new s(o[0],o[1],o[2]);case 4:return new s(o[0],o[1],o[2],o[3]);case 5:return new s(o[0],o[1],o[2],o[3],o[4]);case 6:return new s(o[0],o[1],o[2],o[3],o[4],o[5]);case 7:return new s(o[0],o[1],o[2],o[3],o[4],o[5],o[6])}var i=a(s.prototype),_=s.apply(i,o);return u(_)?_:i}}},77078(s,o,i){var a=i(91033),u=i(82819),_=i(37471),w=i(18073),x=i(11287),C=i(36306),j=i(9325);s.exports=function createCurry(s,o,i){var L=u(s);return function wrapper(){for(var u=arguments.length,B=Array(u),$=u,U=x(wrapper);$--;)B[$]=arguments[$];var V=u<3&&B[0]!==U&&B[u-1]!==U?[]:C(B,U);return(u-=V.length)-1?x[C?o[j]:j]:void 0}}},37471(s,o,i){var a=i(91596),u=i(53320),_=i(58523),w=i(82819),x=i(18073),C=i(11287),j=i(68294),L=i(36306),B=i(9325);s.exports=function createHybrid(s,o,i,$,U,V,z,Y,Z,ee){var ie=128&o,ae=1&o,ce=2&o,le=24&o,pe=512&o,de=ce?void 0:w(s);return function wrapper(){for(var fe=arguments.length,ye=Array(fe),be=fe;be--;)ye[be]=arguments[be];if(le)var Se=C(wrapper),_e=_(ye,Se);if($&&(ye=a(ye,$,U,le)),V&&(ye=u(ye,V,z,le)),fe-=_e,le&&fe1&&ye.reverse(),ie&&ZL))return!1;var $=C.get(s),U=C.get(o);if($&&U)return $==o&&U==s;var V=-1,z=!0,Y=2&i?new a:void 0;for(C.set(s,o),C.set(o,s);++V1?"& ":"")+i[u],i=i.join(a>2?", ":" "),s.replace(o,"{\n/* [wrapped with "+i+"] */\n")}},45891(s,o,i){var a=i(51873),u=i(72428),_=i(56449),w=a?a.isConcatSpreadable:void 0;s.exports=function isFlattenable(s){return _(s)||u(s)||!!(w&&s&&s[w])}},30361(s){var o=/^(?:0|[1-9]\d*)$/;s.exports=function isIndex(s,i){var a=typeof s;return!!(i=null==i?9007199254740991:i)&&("number"==a||"symbol"!=a&&o.test(s))&&s>-1&&s%1==0&&s-1}},31175(s,o,i){var a=i(26025);s.exports=function listCacheSet(s,o){var i=this.__data__,u=a(i,s);return u<0?(++this.size,i.push([s,o])):i[u][1]=o,this}},63040(s,o,i){var a=i(21549),u=i(80079),_=i(68223);s.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new a,map:new(_||u),string:new a}}},17670(s,o,i){var a=i(12651);s.exports=function mapCacheDelete(s){var o=a(this,s).delete(s);return this.size-=o?1:0,o}},90289(s,o,i){var a=i(12651);s.exports=function mapCacheGet(s){return a(this,s).get(s)}},4509(s,o,i){var a=i(12651);s.exports=function mapCacheHas(s){return a(this,s).has(s)}},72949(s,o,i){var a=i(12651);s.exports=function mapCacheSet(s,o){var i=a(this,s),u=i.size;return i.set(s,o),this.size+=i.size==u?0:1,this}},20317(s){s.exports=function mapToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s,a){i[++o]=[a,s]})),i}},67197(s){s.exports=function matchesStrictComparable(s,o){return function(i){return null!=i&&(i[s]===o&&(void 0!==o||s in Object(i)))}}},62224(s,o,i){var a=i(50104);s.exports=function memoizeCapped(s){var o=a(s,(function(s){return 500===i.size&&i.clear(),s})),i=o.cache;return o}},3209(s,o,i){var a=i(91596),u=i(53320),_=i(36306),w="__lodash_placeholder__",x=128,C=Math.min;s.exports=function mergeData(s,o){var i=s[1],j=o[1],L=i|j,B=L<131,$=j==x&&8==i||j==x&&256==i&&s[7].length<=o[8]||384==j&&o[7].length<=o[8]&&8==i;if(!B&&!$)return s;1&j&&(s[2]=o[2],L|=1&i?0:4);var U=o[3];if(U){var V=s[3];s[3]=V?a(V,U,o[4]):U,s[4]=V?_(s[3],w):o[4]}return(U=o[5])&&(V=s[5],s[5]=V?u(V,U,o[6]):U,s[6]=V?_(s[5],w):o[6]),(U=o[7])&&(s[7]=U),j&x&&(s[8]=null==s[8]?o[8]:C(s[8],o[8])),null==s[9]&&(s[9]=o[9]),s[0]=o[0],s[1]=L,s}},48152(s,o,i){var a=i(28303),u=a&&new a;s.exports=u},81042(s,o,i){var a=i(56110)(Object,"create");s.exports=a},3650(s,o,i){var a=i(74335)(Object.keys,Object);s.exports=a},90181(s){s.exports=function nativeKeysIn(s){var o=[];if(null!=s)for(var i in Object(s))o.push(i);return o}},86009(s,o,i){s=i.nmd(s);var a=i(34840),u=o&&!o.nodeType&&o,_=u&&s&&!s.nodeType&&s,w=_&&_.exports===u&&a.process,x=function(){try{var s=_&&_.require&&_.require("util").types;return s||w&&w.binding&&w.binding("util")}catch(s){}}();s.exports=x},59350(s){var o=Object.prototype.toString;s.exports=function objectToString(s){return o.call(s)}},74335(s){s.exports=function overArg(s,o){return function(i){return s(o(i))}}},56757(s,o,i){var a=i(91033),u=Math.max;s.exports=function overRest(s,o,i){return o=u(void 0===o?s.length-1:o,0),function(){for(var _=arguments,w=-1,x=u(_.length-o,0),C=Array(x);++w0){if(++i>=800)return arguments[0]}else i=0;return s.apply(void 0,arguments)}}},51420(s,o,i){var a=i(80079);s.exports=function stackClear(){this.__data__=new a,this.size=0}},90938(s){s.exports=function stackDelete(s){var o=this.__data__,i=o.delete(s);return this.size=o.size,i}},63605(s){s.exports=function stackGet(s){return this.__data__.get(s)}},29817(s){s.exports=function stackHas(s){return this.__data__.has(s)}},80945(s,o,i){var a=i(80079),u=i(68223),_=i(53661);s.exports=function stackSet(s,o){var i=this.__data__;if(i instanceof a){var w=i.__data__;if(!u||w.length<199)return w.push([s,o]),this.size=++i.size,this;i=this.__data__=new _(w)}return i.set(s,o),this.size=i.size,this}},76959(s){s.exports=function strictIndexOf(s,o,i){for(var a=i-1,u=s.length;++a=o||i<0||Y&&s-V>=L}function timerExpired(){var s=u();if(shouldInvoke(s))return trailingEdge(s);$=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-U);return Y?x(i,L-(s-V)):i}(s))}function trailingEdge(s){return $=void 0,Z&&C?invokeFunc(s):(C=j=void 0,B)}function debounced(){var s=u(),i=shouldInvoke(s);if(C=arguments,j=this,U=s,i){if(void 0===$)return function leadingEdge(s){return V=s,$=setTimeout(timerExpired,o),z?invokeFunc(s):B}(U);if(Y)return clearTimeout($),$=setTimeout(timerExpired,o),invokeFunc(U)}return void 0===$&&($=setTimeout(timerExpired,o)),B}return o=_(o)||0,a(i)&&(z=!!i.leading,L=(Y="maxWait"in i)?w(_(i.maxWait)||0,o):L,Z="trailing"in i?!!i.trailing:Z),debounced.cancel=function cancel(){void 0!==$&&clearTimeout($),V=0,C=U=j=$=void 0},debounced.flush=function flush(){return void 0===$?B:trailingEdge(u())},debounced}},50828(s,o,i){var a=i(24647),u=i(13222),_=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,w=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]","g");s.exports=function deburr(s){return(s=u(s))&&s.replace(_,a).replace(w,"")}},75288(s){s.exports=function eq(s,o){return s===o||s!=s&&o!=o}},60680(s,o,i){var a=i(13222),u=/[\\^$.*+?()[\]{}|]/g,_=RegExp(u.source);s.exports=function escapeRegExp(s){return(s=a(s))&&_.test(s)?s.replace(u,"\\$&"):s}},7309(s,o,i){var a=i(62006)(i(24713));s.exports=a},24713(s,o,i){var a=i(2523),u=i(15389),_=i(61489),w=Math.max;s.exports=function findIndex(s,o,i){var x=null==s?0:s.length;if(!x)return-1;var C=null==i?0:_(i);return C<0&&(C=w(x+C,0)),a(s,u(o,3),C)}},35970(s,o,i){var a=i(83120);s.exports=function flatten(s){return(null==s?0:s.length)?a(s,1):[]}},73424(s,o,i){var a=i(16962),u=i(2874),_=Array.prototype.push;function baseAry(s,o){return 2==o?function(o,i){return s(o,i)}:function(o){return s(o)}}function cloneArray(s){for(var o=s?s.length:0,i=Array(o);o--;)i[o]=s[o];return i}function wrapImmutable(s,o){return function(){var i=arguments.length;if(i){for(var a=Array(i);i--;)a[i]=arguments[i];var u=a[0]=o.apply(void 0,a);return s.apply(void 0,a),u}}}s.exports=function baseConvert(s,o,i,w){var x="function"==typeof o,C=o===Object(o);if(C&&(w=i,i=o,o=void 0),null==i)throw new TypeError;w||(w={});var j=!("cap"in w)||w.cap,L=!("curry"in w)||w.curry,B=!("fixed"in w)||w.fixed,$=!("immutable"in w)||w.immutable,U=!("rearg"in w)||w.rearg,V=x?i:u,z="curry"in w&&w.curry,Y="fixed"in w&&w.fixed,Z="rearg"in w&&w.rearg,ee=x?i.runInContext():void 0,ie=x?i:{ary:s.ary,assign:s.assign,clone:s.clone,curry:s.curry,forEach:s.forEach,isArray:s.isArray,isError:s.isError,isFunction:s.isFunction,isWeakMap:s.isWeakMap,iteratee:s.iteratee,keys:s.keys,rearg:s.rearg,toInteger:s.toInteger,toPath:s.toPath},ae=ie.ary,ce=ie.assign,le=ie.clone,pe=ie.curry,de=ie.forEach,fe=ie.isArray,ye=ie.isError,be=ie.isFunction,Se=ie.isWeakMap,_e=ie.keys,we=ie.rearg,xe=ie.toInteger,Pe=ie.toPath,Te=_e(a.aryMethod),Re={castArray:function(s){return function(){var o=arguments[0];return fe(o)?s(cloneArray(o)):s.apply(void 0,arguments)}},iteratee:function(s){return function(){var o=arguments[1],i=s(arguments[0],o),a=i.length;return j&&"number"==typeof o?(o=o>2?o-2:1,a&&a<=o?i:baseAry(i,o)):i}},mixin:function(s){return function(o){var i=this;if(!be(i))return s(i,Object(o));var a=[];return de(_e(o),(function(s){be(o[s])&&a.push([s,i.prototype[s]])})),s(i,Object(o)),de(a,(function(s){var o=s[1];be(o)?i.prototype[s[0]]=o:delete i.prototype[s[0]]})),i}},nthArg:function(s){return function(o){var i=o<0?1:xe(o)+1;return pe(s(o),i)}},rearg:function(s){return function(o,i){var a=i?i.length:0;return pe(s(o,i),a)}},runInContext:function(o){return function(i){return baseConvert(s,o(i),w)}}};function castCap(s,o){if(j){var i=a.iterateeRearg[s];if(i)return function iterateeRearg(s,o){return overArg(s,(function(s){var i=o.length;return function baseArity(s,o){return 2==o?function(o,i){return s.apply(void 0,arguments)}:function(o){return s.apply(void 0,arguments)}}(we(baseAry(s,i),o),i)}))}(o,i);var u=!x&&a.iterateeAry[s];if(u)return function iterateeAry(s,o){return overArg(s,(function(s){return"function"==typeof s?baseAry(s,o):s}))}(o,u)}return o}function castFixed(s,o,i){if(B&&(Y||!a.skipFixed[s])){var u=a.methodSpread[s],w=u&&u.start;return void 0===w?ae(o,i):function flatSpread(s,o){return function(){for(var i=arguments.length,a=i-1,u=Array(i);i--;)u[i]=arguments[i];var w=u[o],x=u.slice(0,o);return w&&_.apply(x,w),o!=a&&_.apply(x,u.slice(o+1)),s.apply(this,x)}}(o,w)}return o}function castRearg(s,o,i){return U&&i>1&&(Z||!a.skipRearg[s])?we(o,a.methodRearg[s]||a.aryRearg[i]):o}function cloneByPath(s,o){for(var i=-1,a=(o=Pe(o)).length,u=a-1,_=le(Object(s)),w=_;null!=w&&++i1?pe(o,i):o}(0,u=castCap(_,u),s),!1}})),!u})),u||(u=w),u==o&&(u=z?pe(u,1):function(){return o.apply(this,arguments)}),u.convert=createConverter(_,o),u.placeholder=o.placeholder=i,u}if(!C)return wrap(o,i,V);var $e=i,qe=[];return de(Te,(function(s){de(a.aryMethod[s],(function(s){var o=$e[a.remap[s]||s];o&&qe.push([s,wrap(s,o,$e)])}))})),de(_e($e),(function(s){var o=$e[s];if("function"==typeof o){for(var i=qe.length;i--;)if(qe[i][0]==s)return;o.convert=createConverter(s,o),qe.push([s,o])}})),de(qe,(function(s){$e[s[0]]=s[1]})),$e.convert=function convertLib(s){return $e.runInContext.convert(s)(void 0)},$e.placeholder=$e,de(_e($e),(function(s){de(a.realToAlias[s]||[],(function(o){$e[o]=$e[s]}))})),$e}},16962(s,o){o.aliasToReal={each:"forEach",eachRight:"forEachRight",entries:"toPairs",entriesIn:"toPairsIn",extend:"assignIn",extendAll:"assignInAll",extendAllWith:"assignInAllWith",extendWith:"assignInWith",first:"head",conforms:"conformsTo",matches:"isMatch",property:"get",__:"placeholder",F:"stubFalse",T:"stubTrue",all:"every",allPass:"overEvery",always:"constant",any:"some",anyPass:"overSome",apply:"spread",assoc:"set",assocPath:"set",complement:"negate",compose:"flowRight",contains:"includes",dissoc:"unset",dissocPath:"unset",dropLast:"dropRight",dropLastWhile:"dropRightWhile",equals:"isEqual",identical:"eq",indexBy:"keyBy",init:"initial",invertObj:"invert",juxt:"over",omitAll:"omit",nAry:"ary",path:"get",pathEq:"matchesProperty",pathOr:"getOr",paths:"at",pickAll:"pick",pipe:"flow",pluck:"map",prop:"get",propEq:"matchesProperty",propOr:"getOr",props:"at",symmetricDifference:"xor",symmetricDifferenceBy:"xorBy",symmetricDifferenceWith:"xorWith",takeLast:"takeRight",takeLastWhile:"takeRightWhile",unapply:"rest",unnest:"flatten",useWith:"overArgs",where:"conformsTo",whereEq:"isMatch",zipObj:"zipObject"},o.aryMethod={1:["assignAll","assignInAll","attempt","castArray","ceil","create","curry","curryRight","defaultsAll","defaultsDeepAll","floor","flow","flowRight","fromPairs","invert","iteratee","memoize","method","mergeAll","methodOf","mixin","nthArg","over","overEvery","overSome","rest","reverse","round","runInContext","spread","template","trim","trimEnd","trimStart","uniqueId","words","zipAll"],2:["add","after","ary","assign","assignAllWith","assignIn","assignInAllWith","at","before","bind","bindAll","bindKey","chunk","cloneDeepWith","cloneWith","concat","conformsTo","countBy","curryN","curryRightN","debounce","defaults","defaultsDeep","defaultTo","delay","difference","divide","drop","dropRight","dropRightWhile","dropWhile","endsWith","eq","every","filter","find","findIndex","findKey","findLast","findLastIndex","findLastKey","flatMap","flatMapDeep","flattenDepth","forEach","forEachRight","forIn","forInRight","forOwn","forOwnRight","get","groupBy","gt","gte","has","hasIn","includes","indexOf","intersection","invertBy","invoke","invokeMap","isEqual","isMatch","join","keyBy","lastIndexOf","lt","lte","map","mapKeys","mapValues","matchesProperty","maxBy","meanBy","merge","mergeAllWith","minBy","multiply","nth","omit","omitBy","overArgs","pad","padEnd","padStart","parseInt","partial","partialRight","partition","pick","pickBy","propertyOf","pull","pullAll","pullAt","random","range","rangeRight","rearg","reject","remove","repeat","restFrom","result","sampleSize","some","sortBy","sortedIndex","sortedIndexOf","sortedLastIndex","sortedLastIndexOf","sortedUniqBy","split","spreadFrom","startsWith","subtract","sumBy","take","takeRight","takeRightWhile","takeWhile","tap","throttle","thru","times","trimChars","trimCharsEnd","trimCharsStart","truncate","union","uniqBy","uniqWith","unset","unzipWith","without","wrap","xor","zip","zipObject","zipObjectDeep"],3:["assignInWith","assignWith","clamp","differenceBy","differenceWith","findFrom","findIndexFrom","findLastFrom","findLastIndexFrom","getOr","includesFrom","indexOfFrom","inRange","intersectionBy","intersectionWith","invokeArgs","invokeArgsMap","isEqualWith","isMatchWith","flatMapDepth","lastIndexOfFrom","mergeWith","orderBy","padChars","padCharsEnd","padCharsStart","pullAllBy","pullAllWith","rangeStep","rangeStepRight","reduce","reduceRight","replace","set","slice","sortedIndexBy","sortedLastIndexBy","transform","unionBy","unionWith","update","xorBy","xorWith","zipWith"],4:["fill","setWith","updateWith"]},o.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},o.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},o.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},o.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},o.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},o.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},o.realToAlias=function(){var s=Object.prototype.hasOwnProperty,i=o.aliasToReal,a={};for(var u in i){var _=i[u];s.call(a,_)?a[_].push(u):a[_]=[u]}return a}(),o.remap={assignAll:"assign",assignAllWith:"assignWith",assignInAll:"assignIn",assignInAllWith:"assignInWith",curryN:"curry",curryRightN:"curryRight",defaultsAll:"defaults",defaultsDeepAll:"defaultsDeep",findFrom:"find",findIndexFrom:"findIndex",findLastFrom:"findLast",findLastIndexFrom:"findLastIndex",getOr:"get",includesFrom:"includes",indexOfFrom:"indexOf",invokeArgs:"invoke",invokeArgsMap:"invokeMap",lastIndexOfFrom:"lastIndexOf",mergeAll:"merge",mergeAllWith:"mergeWith",padChars:"pad",padCharsEnd:"padEnd",padCharsStart:"padStart",propertyOf:"get",rangeStep:"range",rangeStepRight:"rangeRight",restFrom:"rest",spreadFrom:"spread",trimChars:"trim",trimCharsEnd:"trimEnd",trimCharsStart:"trimStart",zipAll:"zip"},o.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},o.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},47934(s,o,i){s.exports={ary:i(64626),assign:i(74733),clone:i(32629),curry:i(49747),forEach:i(83729),isArray:i(56449),isError:i(23546),isFunction:i(1882),isWeakMap:i(47886),iteratee:i(33855),keys:i(88984),rearg:i(84195),toInteger:i(61489),toPath:i(42072)}},56367(s,o,i){s.exports=i(77731)},79920(s,o,i){var a=i(73424),u=i(47934);s.exports=function convert(s,o,i){return a(u,s,o,i)}},2874(s){s.exports={}},77731(s,o,i){var a=i(79920)("set",i(63560));a.placeholder=i(2874),s.exports=a},58156(s,o,i){var a=i(47422);s.exports=function get(s,o,i){var u=null==s?void 0:a(s,o);return void 0===u?i:u}},61448(s,o,i){var a=i(20426),u=i(49326);s.exports=function has(s,o){return null!=s&&u(s,o,a)}},80631(s,o,i){var a=i(28077),u=i(49326);s.exports=function hasIn(s,o){return null!=s&&u(s,o,a)}},83488(s){s.exports=function identity(s){return s}},72428(s,o,i){var a=i(27534),u=i(40346),_=Object.prototype,w=_.hasOwnProperty,x=_.propertyIsEnumerable,C=a(function(){return arguments}())?a:function(s){return u(s)&&w.call(s,"callee")&&!x.call(s,"callee")};s.exports=C},56449(s){var o=Array.isArray;s.exports=o},64894(s,o,i){var a=i(1882),u=i(30294);s.exports=function isArrayLike(s){return null!=s&&u(s.length)&&!a(s)}},83693(s,o,i){var a=i(64894),u=i(40346);s.exports=function isArrayLikeObject(s){return u(s)&&a(s)}},53812(s,o,i){var a=i(72552),u=i(40346);s.exports=function isBoolean(s){return!0===s||!1===s||u(s)&&"[object Boolean]"==a(s)}},3656(s,o,i){s=i.nmd(s);var a=i(9325),u=i(89935),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_?a.Buffer:void 0,C=(x?x.isBuffer:void 0)||u;s.exports=C},62193(s,o,i){var a=i(88984),u=i(5861),_=i(72428),w=i(56449),x=i(64894),C=i(3656),j=i(55527),L=i(37167),B=Object.prototype.hasOwnProperty;s.exports=function isEmpty(s){if(null==s)return!0;if(x(s)&&(w(s)||"string"==typeof s||"function"==typeof s.splice||C(s)||L(s)||_(s)))return!s.length;var o=u(s);if("[object Map]"==o||"[object Set]"==o)return!s.size;if(j(s))return!a(s).length;for(var i in s)if(B.call(s,i))return!1;return!0}},2404(s,o,i){var a=i(60270);s.exports=function isEqual(s,o){return a(s,o)}},23546(s,o,i){var a=i(72552),u=i(40346),_=i(11331);s.exports=function isError(s){if(!u(s))return!1;var o=a(s);return"[object Error]"==o||"[object DOMException]"==o||"string"==typeof s.message&&"string"==typeof s.name&&!_(s)}},1882(s,o,i){var a=i(72552),u=i(23805);s.exports=function isFunction(s){if(!u(s))return!1;var o=a(s);return"[object Function]"==o||"[object GeneratorFunction]"==o||"[object AsyncFunction]"==o||"[object Proxy]"==o}},30294(s){s.exports=function isLength(s){return"number"==typeof s&&s>-1&&s%1==0&&s<=9007199254740991}},87730(s,o,i){var a=i(29172),u=i(27301),_=i(86009),w=_&&_.isMap,x=w?u(w):a;s.exports=x},5187(s){s.exports=function isNull(s){return null===s}},98023(s,o,i){var a=i(72552),u=i(40346);s.exports=function isNumber(s){return"number"==typeof s||u(s)&&"[object Number]"==a(s)}},23805(s){s.exports=function isObject(s){var o=typeof s;return null!=s&&("object"==o||"function"==o)}},40346(s){s.exports=function isObjectLike(s){return null!=s&&"object"==typeof s}},11331(s,o,i){var a=i(72552),u=i(28879),_=i(40346),w=Function.prototype,x=Object.prototype,C=w.toString,j=x.hasOwnProperty,L=C.call(Object);s.exports=function isPlainObject(s){if(!_(s)||"[object Object]"!=a(s))return!1;var o=u(s);if(null===o)return!0;var i=j.call(o,"constructor")&&o.constructor;return"function"==typeof i&&i instanceof i&&C.call(i)==L}},38440(s,o,i){var a=i(16038),u=i(27301),_=i(86009),w=_&&_.isSet,x=w?u(w):a;s.exports=x},85015(s,o,i){var a=i(72552),u=i(56449),_=i(40346);s.exports=function isString(s){return"string"==typeof s||!u(s)&&_(s)&&"[object String]"==a(s)}},44394(s,o,i){var a=i(72552),u=i(40346);s.exports=function isSymbol(s){return"symbol"==typeof s||u(s)&&"[object Symbol]"==a(s)}},37167(s,o,i){var a=i(4901),u=i(27301),_=i(86009),w=_&&_.isTypedArray,x=w?u(w):a;s.exports=x},47886(s,o,i){var a=i(5861),u=i(40346);s.exports=function isWeakMap(s){return u(s)&&"[object WeakMap]"==a(s)}},33855(s,o,i){var a=i(9999),u=i(15389);s.exports=function iteratee(s){return u("function"==typeof s?s:a(s,1))}},95950(s,o,i){var a=i(70695),u=i(88984),_=i(64894);s.exports=function keys(s){return _(s)?a(s):u(s)}},37241(s,o,i){var a=i(70695),u=i(72903),_=i(64894);s.exports=function keysIn(s){return _(s)?a(s,!0):u(s)}},68090(s){s.exports=function last(s){var o=null==s?0:s.length;return o?s[o-1]:void 0}},50104(s,o,i){var a=i(53661);function memoize(s,o){if("function"!=typeof s||null!=o&&"function"!=typeof o)throw new TypeError("Expected a function");var memoized=function(){var i=arguments,a=o?o.apply(this,i):i[0],u=memoized.cache;if(u.has(a))return u.get(a);var _=s.apply(this,i);return memoized.cache=u.set(a,_)||u,_};return memoized.cache=new(memoize.Cache||a),memoized}memoize.Cache=a,s.exports=memoize},55364(s,o,i){var a=i(85250),u=i(20999)((function(s,o,i){a(s,o,i)}));s.exports=u},6048(s){s.exports=function negate(s){if("function"!=typeof s)throw new TypeError("Expected a function");return function(){var o=arguments;switch(o.length){case 0:return!s.call(this);case 1:return!s.call(this,o[0]);case 2:return!s.call(this,o[0],o[1]);case 3:return!s.call(this,o[0],o[1],o[2])}return!s.apply(this,o)}}},63950(s){s.exports=function noop(){}},10124(s,o,i){var a=i(9325);s.exports=function(){return a.Date.now()}},90179(s,o,i){var a=i(34932),u=i(9999),_=i(19931),w=i(31769),x=i(21791),C=i(53138),j=i(38816),L=i(83349),B=j((function(s,o){var i={};if(null==s)return i;var j=!1;o=a(o,(function(o){return o=w(o,s),j||(j=o.length>1),o})),x(s,L(s),i),j&&(i=u(i,7,C));for(var B=o.length;B--;)_(i,o[B]);return i}));s.exports=B},50583(s,o,i){var a=i(47237),u=i(17255),_=i(28586),w=i(77797);s.exports=function property(s){return _(s)?a(w(s)):u(s)}},84195(s,o,i){var a=i(66977),u=i(38816),_=u((function(s,o){return a(s,256,void 0,void 0,void 0,o)}));s.exports=_},40860(s,o,i){var a=i(40882),u=i(80909),_=i(15389),w=i(85558),x=i(56449);s.exports=function reduce(s,o,i){var C=x(s)?a:w,j=arguments.length<3;return C(s,_(o,4),i,j,u)}},63560(s,o,i){var a=i(73170);s.exports=function set(s,o,i){return null==s?s:a(s,o,i)}},42426(s,o,i){var a=i(14248),u=i(15389),_=i(90916),w=i(56449),x=i(36800);s.exports=function some(s,o,i){var C=w(s)?a:_;return i&&x(s,o,i)&&(o=void 0),C(s,u(o,3))}},63345(s){s.exports=function stubArray(){return[]}},89935(s){s.exports=function stubFalse(){return!1}},17400(s,o,i){var a=i(99374),u=1/0;s.exports=function toFinite(s){return s?(s=a(s))===u||s===-1/0?17976931348623157e292*(s<0?-1:1):s==s?s:0:0===s?s:0}},61489(s,o,i){var a=i(17400);s.exports=function toInteger(s){var o=a(s),i=o%1;return o==o?i?o-i:o:0}},80218(s,o,i){var a=i(13222);s.exports=function toLower(s){return a(s).toLowerCase()}},99374(s,o,i){var a=i(54128),u=i(23805),_=i(44394),w=/^[-+]0x[0-9a-f]+$/i,x=/^0b[01]+$/i,C=/^0o[0-7]+$/i,j=parseInt;s.exports=function toNumber(s){if("number"==typeof s)return s;if(_(s))return NaN;if(u(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=u(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=a(s);var i=x.test(s);return i||C.test(s)?j(s.slice(2),i?2:8):w.test(s)?NaN:+s}},42072(s,o,i){var a=i(34932),u=i(23007),_=i(56449),w=i(44394),x=i(61802),C=i(77797),j=i(13222);s.exports=function toPath(s){return _(s)?a(s,C):w(s)?[s]:u(x(j(s)))}},69884(s,o,i){var a=i(21791),u=i(37241);s.exports=function toPlainObject(s){return a(s,u(s))}},13222(s,o,i){var a=i(77556);s.exports=function toString(s){return null==s?"":a(s)}},55808(s,o,i){var a=i(12507)("toUpperCase");s.exports=a},66645(s,o,i){var a=i(1733),u=i(45434),_=i(13222),w=i(22225);s.exports=function words(s,o,i){return s=_(s),void 0===(o=i?void 0:o)?u(s)?w(s):a(s):s.match(o)||[]}},53758(s,o,i){var a=i(30980),u=i(56017),_=i(94033),w=i(56449),x=i(40346),C=i(80257),j=Object.prototype.hasOwnProperty;function lodash(s){if(x(s)&&!w(s)&&!(s instanceof a)){if(s instanceof u)return s;if(j.call(s,"__wrapped__"))return C(s)}return new u(s)}lodash.prototype=_.prototype,lodash.prototype.constructor=lodash,s.exports=lodash},47248(s,o,i){var a=i(16547),u=i(51234);s.exports=function zipObject(s,o){return u(s||[],o||[],a)}},43768(s,o,i){"use strict";var a=i(45981),u=i(85587);o.highlight=highlight,o.highlightAuto=function highlightAuto(s,o){var i,w,x,C,j=o||{},L=j.subset||a.listLanguages(),B=j.prefix,$=L.length,U=-1;null==B&&(B=_);if("string"!=typeof s)throw u("Expected `string` for value, got `%s`",s);w={relevance:0,language:null,value:[]},i={relevance:0,language:null,value:[]};for(;++U<$;)C=L[U],a.getLanguage(C)&&((x=highlight(C,s,o)).language=C,x.relevance>w.relevance&&(w=x),x.relevance>i.relevance&&(w=i,i=x));w.language&&(i.secondBest=w);return i},o.registerLanguage=function registerLanguage(s,o){a.registerLanguage(s,o)},o.listLanguages=function listLanguages(){return a.listLanguages()},o.registerAlias=function registerAlias(s,o){var i,u=s;o&&((u={})[s]=o);for(i in u)a.registerAliases(u[i],{languageName:i})},Emitter.prototype.addText=function text(s){var o,i,a=this.stack;if(""===s)return;o=a[a.length-1],(i=o.children[o.children.length-1])&&"text"===i.type?i.value+=s:o.children.push({type:"text",value:s})},Emitter.prototype.addKeyword=function addKeyword(s,o){this.openNode(o),this.addText(s),this.closeNode()},Emitter.prototype.addSublanguage=function addSublanguage(s,o){var i=this.stack,a=i[i.length-1],u=s.rootNode.children,_=o?{type:"element",tagName:"span",properties:{className:[o]},children:u}:u;a.children=a.children.concat(_)},Emitter.prototype.openNode=function open(s){var o=this.stack,i=this.options.classPrefix+s,a=o[o.length-1],u={type:"element",tagName:"span",properties:{className:[i]},children:[]};a.children.push(u),o.push(u)},Emitter.prototype.closeNode=function close(){this.stack.pop()},Emitter.prototype.closeAllNodes=noop,Emitter.prototype.finalize=noop,Emitter.prototype.toHTML=function toHtmlNoop(){return""};var _="hljs-";function highlight(s,o,i){var w,x=a.configure({}),C=(i||{}).prefix;if("string"!=typeof s)throw u("Expected `string` for name, got `%s`",s);if(!a.getLanguage(s))throw u("Unknown language: `%s` is not registered",s);if("string"!=typeof o)throw u("Expected `string` for value, got `%s`",o);if(null==C&&(C=_),a.configure({__emitter:Emitter,classPrefix:C}),w=a.highlight(o,{language:s,ignoreIllegals:!0}),a.configure(x||{}),w.errorRaised)throw w.errorRaised;return{relevance:w.relevance,language:w.language,value:w.emitter.rootNode.children}}function Emitter(s){this.options=s,this.rootNode={children:[]},this.stack=[this.rootNode]}function noop(){}},71514(s){"use strict";s.exports=Math.abs},58968(s){"use strict";s.exports=Math.floor},94459(s){"use strict";s.exports=Number.isNaN||function isNaN(s){return s!=s}},6188(s){"use strict";s.exports=Math.max},68002(s){"use strict";s.exports=Math.min},75880(s){"use strict";s.exports=Math.pow},70414(s){"use strict";s.exports=Math.round},73093(s,o,i){"use strict";var a=i(94459);s.exports=function sign(s){return a(s)||0===s?s:s<0?-1:1}},92340(s,o,i){const a=i(6048);function coerceElementMatchingCallback(s){return"string"==typeof s?o=>o.element===s:s.constructor&&s.extend?o=>o instanceof s:s}class ArraySlice{constructor(s){this.elements=s||[]}toValue(){return this.elements.map((s=>s.toValue()))}map(s,o){return this.elements.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((a=>{const u=s.bind(o)(a);u&&i.push(u)})),i}filter(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(s,o))}reject(s,o){return s=coerceElementMatchingCallback(s),new ArraySlice(this.elements.filter(a(s),o))}find(s,o){return s=coerceElementMatchingCallback(s),this.elements.find(s,o)}forEach(s,o){this.elements.forEach(s,o)}reduce(s,o){return this.elements.reduce(s,o)}includes(s){return this.elements.some((o=>o.equals(s)))}shift(){return this.elements.shift()}unshift(s){this.elements.unshift(this.refract(s))}push(s){return this.elements.push(this.refract(s)),this}add(s){this.push(s)}get(s){return this.elements[s]}getValue(s){const o=this.elements[s];if(o)return o.toValue()}get length(){return this.elements.length}get isEmpty(){return 0===this.elements.length}get first(){return this.elements[0]}}"undefined"!=typeof Symbol&&(ArraySlice.prototype[Symbol.iterator]=function symbol(){return this.elements[Symbol.iterator]()}),s.exports=ArraySlice},55973(s){class KeyValuePair{constructor(s,o){this.key=s,this.value=o}clone(){const s=new KeyValuePair;return this.key&&(s.key=this.key.clone()),this.value&&(s.value=this.value.clone()),s}}s.exports=KeyValuePair},3110(s,o,i){const a=i(5187),u=i(85015),_=i(98023),w=i(53812),x=i(23805),C=i(85105),j=i(86804);class Namespace{constructor(s){this.elementMap={},this.elementDetection=[],this.Element=j.Element,this.KeyValuePair=j.KeyValuePair,s&&s.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(s){return s.namespace&&s.namespace({base:this}),s.load&&s.load({base:this}),this}useDefault(){return this.register("null",j.NullElement).register("string",j.StringElement).register("number",j.NumberElement).register("boolean",j.BooleanElement).register("array",j.ArrayElement).register("object",j.ObjectElement).register("member",j.MemberElement).register("ref",j.RefElement).register("link",j.LinkElement),this.detect(a,j.NullElement,!1).detect(u,j.StringElement,!1).detect(_,j.NumberElement,!1).detect(w,j.BooleanElement,!1).detect(Array.isArray,j.ArrayElement,!1).detect(x,j.ObjectElement,!1),this}register(s,o){return this._elements=void 0,this.elementMap[s]=o,this}unregister(s){return this._elements=void 0,delete this.elementMap[s],this}detect(s,o,i){return void 0===i||i?this.elementDetection.unshift([s,o]):this.elementDetection.push([s,o]),this}toElement(s){if(s instanceof this.Element)return s;let o;for(let i=0;i{const o=s[0].toUpperCase()+s.substr(1);this._elements[o]=this.elementMap[s]}))),this._elements}get serialiser(){return new C(this)}}C.prototype.Namespace=Namespace,s.exports=Namespace},10866(s,o,i){const a=i(6048),u=i(92340);class ObjectSlice extends u{map(s,o){return this.elements.map((i=>s.bind(o)(i.value,i.key,i)))}filter(s,o){return new ObjectSlice(this.elements.filter((i=>s.bind(o)(i.value,i.key,i))))}reject(s,o){return this.filter(a(s.bind(o)))}forEach(s,o){return this.elements.forEach(((i,a)=>{s.bind(o)(i.value,i.key,i,a)}))}keys(){return this.map(((s,o)=>o.toValue()))}values(){return this.map((s=>s.toValue()))}}s.exports=ObjectSlice},86804(s,o,i){const a=i(10316),u=i(41067),_=i(71167),w=i(40239),x=i(12242),C=i(6233),j=i(87726),L=i(61045),B=i(86303),$=i(14540),U=i(92340),V=i(10866),z=i(55973);function refract(s){if(s instanceof a)return s;if("string"==typeof s)return new _(s);if("number"==typeof s)return new w(s);if("boolean"==typeof s)return new x(s);if(null===s)return new u;if(Array.isArray(s))return new C(s.map(refract));if("object"==typeof s){return new L(s)}return s}a.prototype.ObjectElement=L,a.prototype.RefElement=$,a.prototype.MemberElement=j,a.prototype.refract=refract,U.prototype.refract=refract,s.exports={Element:a,NullElement:u,StringElement:_,NumberElement:w,BooleanElement:x,ArrayElement:C,MemberElement:j,ObjectElement:L,LinkElement:B,RefElement:$,refract,ArraySlice:U,ObjectSlice:V,KeyValuePair:z}},86303(s,o,i){const a=i(10316);s.exports=class LinkElement extends a{constructor(s,o,i){super(s||[],o,i),this.element="link"}get relation(){return this.attributes.get("relation")}set relation(s){this.attributes.set("relation",s)}get href(){return this.attributes.get("href")}set href(s){this.attributes.set("href",s)}}},14540(s,o,i){const a=i(10316);s.exports=class RefElement extends a{constructor(s,o,i){super(s||[],o,i),this.element="ref",this.path||(this.path="element")}get path(){return this.attributes.get("path")}set path(s){this.attributes.set("path",s)}}},34035(s,o,i){const a=i(3110),u=i(86804);o.g$=a,o.KeyValuePair=i(55973),o.G6=u.ArraySlice,o.ot=u.ObjectSlice,o.Hg=u.Element,o.Om=u.StringElement,o.kT=u.NumberElement,o.bd=u.BooleanElement,o.Os=u.NullElement,o.wE=u.ArrayElement,o.Sh=u.ObjectElement,o.Pr=u.MemberElement,o.sI=u.RefElement,o.Ft=u.LinkElement,o.e=u.refract,i(85105),i(75147)},6233(s,o,i){const a=i(6048),u=i(10316),_=i(92340);class ArrayElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="array"}primitive(){return"array"}get(s){return this.content[s]}getValue(s){const o=this.get(s);if(o)return o.toValue()}getIndex(s){return this.content[s]}set(s,o){return this.content[s]=this.refract(o),this}remove(s){const o=this.content.splice(s,1);return o.length?o[0]:null}map(s,o){return this.content.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((a=>{const u=s.bind(o)(a);u&&i.push(u)})),i}filter(s,o){return new _(this.content.filter(s,o))}reject(s,o){return this.filter(a(s),o)}reduce(s,o){let i,a;void 0!==o?(i=0,a=this.refract(o)):(i=1,a="object"===this.primitive()?this.first.value:this.first);for(let o=i;o{s.bind(o)(i,this.refract(a))}))}shift(){return this.content.shift()}unshift(s){this.content.unshift(this.refract(s))}push(s){return this.content.push(this.refract(s)),this}add(s){this.push(s)}findElements(s,o){const i=o||{},a=!!i.recursive,u=void 0===i.results?[]:i.results;return this.forEach(((o,i,_)=>{a&&void 0!==o.findElements&&o.findElements(s,{results:u,recursive:a}),s(o,i,_)&&u.push(o)})),u}find(s){return new _(this.findElements(s,{recursive:!0}))}findByElement(s){return this.find((o=>o.element===s))}findByClass(s){return this.find((o=>o.classes.includes(s)))}getById(s){return this.find((o=>o.id.toValue()===s)).first}includes(s){return this.content.some((o=>o.equals(s)))}contains(s){return this.includes(s)}empty(){return new this.constructor([])}"fantasy-land/empty"(){return this.empty()}concat(s){return new this.constructor(this.content.concat(s.content))}"fantasy-land/concat"(s){return this.concat(s)}"fantasy-land/map"(s){return new this.constructor(this.map(s))}"fantasy-land/chain"(s){return this.map((o=>s(o)),this).reduce(((s,o)=>s.concat(o)),this.empty())}"fantasy-land/filter"(s){return new this.constructor(this.content.filter(s))}"fantasy-land/reduce"(s,o){return this.content.reduce(s,o)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement["fantasy-land/empty"]=ArrayElement.empty,"undefined"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),s.exports=ArrayElement},12242(s,o,i){const a=i(10316);s.exports=class BooleanElement extends a{constructor(s,o,i){super(s,o,i),this.element="boolean"}primitive(){return"boolean"}}},10316(s,o,i){const a=i(2404),u=i(55973),_=i(92340);class Element{constructor(s,o,i){o&&(this.meta=o),i&&(this.attributes=i),this.content=s}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((s=>{s.parent=this,s.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const s=new this.constructor;return s.element=this.element,this.meta.length&&(s._meta=this.meta.clone()),this.attributes.length&&(s._attributes=this.attributes.clone()),this.content?this.content.clone?s.content=this.content.clone():Array.isArray(this.content)?s.content=this.content.map((s=>s.clone())):s.content=this.content:s.content=this.content,s}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof u?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((s=>s.toValue()),this):this.content}toRef(s){if(""===this.id.toValue())throw Error("Cannot create reference to an element that does not contain an ID");const o=new this.RefElement(this.id.toValue());return s&&(o.path=s),o}findRecursive(...s){if(arguments.length>1&&!this.isFrozen)throw new Error("Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`");const o=s.pop();let i=new _;const append=(s,o)=>(s.push(o),s),checkElement=(s,i)=>{i.element===o&&s.push(i);const a=i.findRecursive(o);return a&&a.reduce(append,s),i.content instanceof u&&(i.content.key&&checkElement(s,i.content.key),i.content.value&&checkElement(s,i.content.value)),s};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),s.isEmpty||(i=i.filter((o=>{let i=o.parents.map((s=>s.element));for(const o in s){const a=s[o],u=i.indexOf(a);if(-1===u)return!1;i=i.splice(0,u)}return!0}))),i}set(s){return this.content=s,this}equals(s){return a(this.toValue(),s)}getMetaProperty(s,o){if(!this.meta.hasKey(s)){if(this.isFrozen){const s=this.refract(o);return s.freeze(),s}this.meta.set(s,o)}return this.meta.get(s)}setMetaProperty(s,o){this.meta.set(s,o)}get element(){return this._storedElement||"element"}set element(s){this._storedElement=s}get content(){return this._content}set content(s){if(s instanceof Element)this._content=s;else if(s instanceof _)this.content=s.elements;else if("string"==typeof s||"number"==typeof s||"boolean"==typeof s||"null"===s||null==s)this._content=s;else if(s instanceof u)this._content=s;else if(Array.isArray(s))this._content=s.map(this.refract);else{if("object"!=typeof s)throw new Error("Cannot set content to given value");this._content=Object.keys(s).map((o=>new this.MemberElement(o,s[o])))}}get meta(){if(!this._meta){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._meta=new this.ObjectElement}return this._meta}set meta(s){s instanceof this.ObjectElement?this._meta=s:this.meta.set(s||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._attributes=new this.ObjectElement}return this._attributes}set attributes(s){s instanceof this.ObjectElement?this._attributes=s:this.attributes.set(s||{})}get id(){return this.getMetaProperty("id","")}set id(s){this.setMetaProperty("id",s)}get classes(){return this.getMetaProperty("classes",[])}set classes(s){this.setMetaProperty("classes",s)}get title(){return this.getMetaProperty("title","")}set title(s){this.setMetaProperty("title",s)}get description(){return this.getMetaProperty("description","")}set description(s){this.setMetaProperty("description",s)}get links(){return this.getMetaProperty("links",[])}set links(s){this.setMetaProperty("links",s)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:s}=this;const o=new _;for(;s;)o.push(s),s=s.parent;return o}get children(){if(Array.isArray(this.content))return new _(this.content);if(this.content instanceof u){const s=new _([this.content.key]);return this.content.value&&s.push(this.content.value),s}return this.content instanceof Element?new _([this.content]):new _}get recursiveChildren(){const s=new _;return this.children.forEach((o=>{s.push(o),o.recursiveChildren.forEach((o=>{s.push(o)}))})),s}}s.exports=Element},87726(s,o,i){const a=i(55973),u=i(10316);s.exports=class MemberElement extends u{constructor(s,o,i,u){super(new a,i,u),this.element="member",this.key=s,this.value=o}get key(){return this.content.key}set key(s){this.content.key=this.refract(s)}get value(){return this.content.value}set value(s){this.content.value=this.refract(s)}}},41067(s,o,i){const a=i(10316);s.exports=class NullElement extends a{constructor(s,o,i){super(s||null,o,i),this.element="null"}primitive(){return"null"}set(){return new Error("Cannot set the value of null")}}},40239(s,o,i){const a=i(10316);s.exports=class NumberElement extends a{constructor(s,o,i){super(s,o,i),this.element="number"}primitive(){return"number"}}},61045(s,o,i){const a=i(6048),u=i(23805),_=i(6233),w=i(87726),x=i(10866);s.exports=class ObjectElement extends _{constructor(s,o,i){super(s||[],o,i),this.element="object"}primitive(){return"object"}toValue(){return this.content.reduce(((s,o)=>(s[o.key.toValue()]=o.value?o.value.toValue():void 0,s)),{})}get(s){const o=this.getMember(s);if(o)return o.value}getMember(s){if(void 0!==s)return this.content.find((o=>o.key.toValue()===s))}remove(s){let o=null;return this.content=this.content.filter((i=>i.key.toValue()!==s||(o=i,!1))),o}getKey(s){const o=this.getMember(s);if(o)return o.key}set(s,o){if(u(s))return Object.keys(s).forEach((o=>{this.set(o,s[o])})),this;const i=s,a=this.getMember(i);return a?a.value=o:this.content.push(new w(i,o)),this}keys(){return this.content.map((s=>s.key.toValue()))}values(){return this.content.map((s=>s.value.toValue()))}hasKey(s){return this.content.some((o=>o.key.equals(s)))}items(){return this.content.map((s=>[s.key.toValue(),s.value.toValue()]))}map(s,o){return this.content.map((i=>s.bind(o)(i.value,i.key,i)))}compactMap(s,o){const i=[];return this.forEach(((a,u,_)=>{const w=s.bind(o)(a,u,_);w&&i.push(w)})),i}filter(s,o){return new x(this.content).filter(s,o)}reject(s,o){return this.filter(a(s),o)}forEach(s,o){return this.content.forEach((i=>s.bind(o)(i.value,i.key,i)))}}},71167(s,o,i){const a=i(10316);s.exports=class StringElement extends a{constructor(s,o,i){super(s,o,i),this.element="string"}primitive(){return"string"}get length(){return this.content.length}}},75147(s,o,i){const a=i(85105);s.exports=class JSON06Serialiser extends a{serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);let o;s._attributes&&s.attributes.get("variable")&&(o=s.attributes.get("variable"));const i={element:s.element};s._meta&&s._meta.length>0&&(i.meta=this.serialiseObject(s.meta));const a="enum"===s.element||-1!==s.attributes.keys().indexOf("enumerations");if(a){const o=this.enumSerialiseAttributes(s);o&&(i.attributes=o)}else if(s._attributes&&s._attributes.length>0){let{attributes:a}=s;a.get("metadata")&&(a=a.clone(),a.set("meta",a.get("metadata")),a.remove("metadata")),"member"===s.element&&o&&(a=a.clone(),a.remove("variable")),a.length>0&&(i.attributes=this.serialiseObject(a))}if(a)i.content=this.enumSerialiseContent(s,i);else if(this[`${s.element}SerialiseContent`])i.content=this[`${s.element}SerialiseContent`](s,i);else if(void 0!==s.content){let a;o&&s.content.key?(a=s.content.clone(),a.key.attributes.set("variable",o),a=this.serialiseContent(a)):a=this.serialiseContent(s.content),this.shouldSerialiseContent(s,a)&&(i.content=a)}else this.shouldSerialiseContent(s,s.content)&&s instanceof this.namespace.elements.Array&&(i.content=[]);return i}shouldSerialiseContent(s,o){return"parseResult"===s.element||"httpRequest"===s.element||"httpResponse"===s.element||"category"===s.element||"link"===s.element||void 0!==o&&(!Array.isArray(o)||0!==o.length)}refSerialiseContent(s,o){return delete o.attributes,{href:s.toValue(),path:s.path.toValue()}}sourceMapSerialiseContent(s){return s.toValue()}dataStructureSerialiseContent(s){return[this.serialiseContent(s.content)]}enumSerialiseAttributes(s){const o=s.attributes.clone(),i=o.remove("enumerations")||new this.namespace.elements.Array([]),a=o.get("default");let u=o.get("samples")||new this.namespace.elements.Array([]);if(a&&a.content&&(a.content.attributes&&a.content.attributes.remove("typeAttributes"),o.set("default",new this.namespace.elements.Array([a.content]))),u.forEach((s=>{s.content&&s.content.element&&s.content.attributes.remove("typeAttributes")})),s.content&&0!==i.length&&u.unshift(s.content),u=u.map((s=>s instanceof this.namespace.elements.Array?[s]:new this.namespace.elements.Array([s.content]))),u.length&&o.set("samples",u),o.length>0)return this.serialiseObject(o)}enumSerialiseContent(s){if(s._attributes){const o=s.attributes.get("enumerations");if(o&&o.length>0)return o.content.map((s=>{const o=s.clone();return o.attributes.remove("typeAttributes"),this.serialise(o)}))}if(s.content){const o=s.content.clone();return o.attributes.remove("typeAttributes"),[this.serialise(o)]}return[]}deserialise(s){if("string"==typeof s)return new this.namespace.elements.String(s);if("number"==typeof s)return new this.namespace.elements.Number(s);if("boolean"==typeof s)return new this.namespace.elements.Boolean(s);if(null===s)return new this.namespace.elements.Null;if(Array.isArray(s))return new this.namespace.elements.Array(s.map(this.deserialise,this));const o=this.namespace.getElementClass(s.element),i=new o;i.element!==s.element&&(i.element=s.element),s.meta&&this.deserialiseObject(s.meta,i.meta),s.attributes&&this.deserialiseObject(s.attributes,i.attributes);const a=this.deserialiseContent(s.content);if(void 0===a&&null!==i.content||(i.content=a),"enum"===i.element){i.content&&i.attributes.set("enumerations",i.content);let s=i.attributes.get("samples");if(i.attributes.remove("samples"),s){const a=s;s=new this.namespace.elements.Array,a.forEach((a=>{a.forEach((a=>{const u=new o(a);u.element=i.element,s.push(u)}))}));const u=s.shift();i.content=u?u.content:void 0,i.attributes.set("samples",s)}else i.content=void 0;let a=i.attributes.get("default");if(a&&a.length>0){a=a.get(0);const s=new o(a);s.element=i.element,i.attributes.set("default",s)}}else if("dataStructure"===i.element&&Array.isArray(i.content))[i.content]=i.content;else if("category"===i.element){const s=i.attributes.get("meta");s&&(i.attributes.set("metadata",s),i.attributes.remove("meta"))}else"member"===i.element&&i.key&&i.key._attributes&&i.key._attributes.getValue("variable")&&(i.attributes.set("variable",i.key.attributes.get("variable")),i.key.attributes.remove("variable"));return i}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}return s&&s.map?s.map(this.serialise,this):s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}shouldRefract(s){return!!(s._attributes&&s.attributes.keys().length||s._meta&&s.meta.keys().length)||"enum"!==s.element&&(s.element!==s.primitive()||"member"===s.element)}convertKeyToRefract(s,o){return this.shouldRefract(o)?this.serialise(o):"enum"===o.element?this.serialiseEnum(o):"array"===o.element?o.map((o=>this.shouldRefract(o)||"default"===s?this.serialise(o):"array"===o.element||"object"===o.element||"enum"===o.element?o.children.map((s=>this.serialise(s))):o.toValue())):"object"===o.element?(o.content||[]).map(this.serialise,this):o.toValue()}serialiseEnum(s){return s.children.map((s=>this.serialise(s)))}serialiseObject(s){const o={};return s.forEach(((s,i)=>{if(s){const a=i.toValue();o[a]=this.convertKeyToRefract(a,s)}})),o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},85105(s){s.exports=class JSONSerialiser{constructor(s){this.namespace=s||new this.Namespace}serialise(s){if(!(s instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${s}\` is not an Element instance`);const o={element:s.element};s._meta&&s._meta.length>0&&(o.meta=this.serialiseObject(s.meta)),s._attributes&&s._attributes.length>0&&(o.attributes=this.serialiseObject(s.attributes));const i=this.serialiseContent(s.content);return void 0!==i&&(o.content=i),o}deserialise(s){if(!s.element)throw new Error("Given value is not an object containing an element name");const o=new(this.namespace.getElementClass(s.element));o.element!==s.element&&(o.element=s.element),s.meta&&this.deserialiseObject(s.meta,o.meta),s.attributes&&this.deserialiseObject(s.attributes,o.attributes);const i=this.deserialiseContent(s.content);return void 0===i&&null!==o.content||(o.content=i),o}serialiseContent(s){if(s instanceof this.namespace.elements.Element)return this.serialise(s);if(s instanceof this.namespace.KeyValuePair){const o={key:this.serialise(s.key)};return s.value&&(o.value=this.serialise(s.value)),o}if(s&&s.map){if(0===s.length)return;return s.map(this.serialise,this)}return s}deserialiseContent(s){if(s){if(s.element)return this.deserialise(s);if(s.key){const o=new this.namespace.KeyValuePair(this.deserialise(s.key));return s.value&&(o.value=this.deserialise(s.value)),o}if(s.map)return s.map(this.deserialise,this)}return s}serialiseObject(s){const o={};if(s.forEach(((s,i)=>{s&&(o[i.toValue()]=this.serialise(s))})),0!==Object.keys(o).length)return o}deserialiseObject(s,o){Object.keys(s).forEach((i=>{o.set(i,this.deserialise(s[i]))}))}}},76578(s){"use strict";s.exports=["Float16Array","Float32Array","Float64Array","Int8Array","Int16Array","Int32Array","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array"]},65606(s){var o,i,a=s.exports={};function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(s){if(o===setTimeout)return setTimeout(s,0);if((o===defaultSetTimout||!o)&&setTimeout)return o=setTimeout,setTimeout(s,0);try{return o(s,0)}catch(i){try{return o.call(null,s,0)}catch(i){return o.call(this,s,0)}}}!function(){try{o="function"==typeof setTimeout?setTimeout:defaultSetTimout}catch(s){o=defaultSetTimout}try{i="function"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(s){i=defaultClearTimeout}}();var u,_=[],w=!1,x=-1;function cleanUpNextTick(){w&&u&&(w=!1,u.length?_=u.concat(_):x=-1,_.length&&drainQueue())}function drainQueue(){if(!w){var s=runTimeout(cleanUpNextTick);w=!0;for(var o=_.length;o;){for(u=_,_=[];++x1)for(var i=1;i_)throw new RangeError("requested too many random bytes");var i=w.allocUnsafe(s);if(s>0)if(s>u)for(var C=0;C=0||(u[i]=s[i]);return u}(s,o);if(Object.getOwnPropertySymbols){var _=Object.getOwnPropertySymbols(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(u[i]=s[i])}return u}function _defineProperties(s,o){for(var i=0;i=0||(u[i]=s[i]);return u}(s,o);if(Object.getOwnPropertySymbols){var _=Object.getOwnPropertySymbols(s);for(a=0;a<_.length;a++)i=_[a],o.indexOf(i)>=0||Object.prototype.propertyIsEnumerable.call(s,i)&&(u[i]=s[i])}return u}function ownKeys(s,o){var i=Object.keys(s);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(s);o&&(a=a.filter((function(o){return Object.getOwnPropertyDescriptor(s,o).enumerable}))),i.push.apply(i,a)}return i}function _objectSpread(s){for(var o=1;o=a?i.notify(s):o.length>u.length&&i.notify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:""})}))}))})),_defineProperty(_assertThisInitialized(i),"onKeyDown",(function(s){"Enter"===s.key&&i.forceNotify(s);var o=i.props.onKeyDown;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"onBlur",(function(s){i.forceNotify(s);var o=i.props.onBlur;o&&(s.persist(),o(s))})),_defineProperty(_assertThisInitialized(i),"createNotifier",(function(s){if(s<0)i.notify=function(){return null};else if(0===s)i.notify=i.doNotify;else{var o=(0,u.default)((function(s){i.isDebouncing=!1,i.doNotify(s)}),s);i.notify=function(s){i.isDebouncing=!0,o(s)},i.flush=function(){return o.flush()},i.cancel=function(){i.isDebouncing=!1,o.cancel()}}})),_defineProperty(_assertThisInitialized(i),"doNotify",(function(){i.props.onChange.apply(void 0,arguments)})),_defineProperty(_assertThisInitialized(i),"forceNotify",(function(s){var o=i.props.debounceTimeout;if(i.isDebouncing||!(o>0)){i.cancel&&i.cancel();var a=i.state.value,u=i.props.minLength;a.length>=u?i.doNotify(s):i.doNotify(_objectSpread(_objectSpread({},s),{},{target:_objectSpread(_objectSpread({},s.target),{},{value:a})}))}})),i.isDebouncing=!1,i.state={value:void 0===s.value||null===s.value?"":s.value};var a=i.props.debounceTimeout;return i.createNotifier(a),i}return function _createClass(s,o,i){return o&&_defineProperties(s.prototype,o),i&&_defineProperties(s,i),Object.defineProperty(s,"prototype",{writable:!1}),s}(DebounceInput,[{key:"componentDidUpdate",value:function componentDidUpdate(s){if(!this.isDebouncing){var o=this.props,i=o.value,a=o.debounceTimeout,u=s.debounceTimeout,_=s.value,w=this.state.value;void 0!==i&&_!==i&&w!==i&&this.setState({value:i}),a!==u&&this.createNotifier(a)}}},{key:"componentWillUnmount",value:function componentWillUnmount(){this.flush&&this.flush()}},{key:"render",value:function render(){var s,o,i=this.props,u=i.element,w=(i.onChange,i.value,i.minLength,i.debounceTimeout,i.forceNotifyByEnter),x=i.forceNotifyOnBlur,C=i.onKeyDown,j=i.onBlur,L=i.inputRef,B=_objectWithoutProperties(i,_),$=this.state.value;s=w?{onKeyDown:this.onKeyDown}:C?{onKeyDown:C}:{},o=x?{onBlur:this.onBlur}:j?{onBlur:j}:{};var U=L?{ref:L}:{};return a.default.createElement(u,_objectSpread(_objectSpread(_objectSpread(_objectSpread({},B),{},{onChange:this.onChange,value:$},s),o),U))}}]),DebounceInput}(a.default.PureComponent);o.DebounceInput=w,_defineProperty(w,"defaultProps",{element:"input",type:"text",onKeyDown:void 0,onBlur:void 0,value:void 0,minLength:0,debounceTimeout:100,forceNotifyByEnter:!0,forceNotifyOnBlur:!0,inputRef:void 0})},24677(s,o,i){"use strict";var a=i(81214).DebounceInput;a.DebounceInput=a,s.exports=a},22551(s,o,i){"use strict";var a=i(96540),u=i(69982);function p(s){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+s,i=1;i