mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-04 06:21:15 +02:00
Merge branch '3.1' into base-configs-3.0
This commit is contained in:
177 files changed
+16013
-1745
No files matched your search
@@ -15,14 +15,15 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Agent module with optional AI Copilot support.
|
||||
Agent module with optional AI Copilot and MCP support.
|
||||
|
||||
This module provides the AI Copilot functionality as an optional feature.
|
||||
If the AI dependencies are not installed, the module will be disabled but
|
||||
will not prevent the server from starting.
|
||||
This module provides the AI Copilot and MCP (Model Context Protocol)
|
||||
functionality as optional features. If the respective dependencies are
|
||||
not installed, the affected features will be disabled but will not
|
||||
prevent the server from starting.
|
||||
|
||||
Installation:
|
||||
pip install gns3-server[ai-copilot]
|
||||
pip install gns3-server[ai-features] # Install all AI features
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -49,7 +50,8 @@ except ImportError as e:
|
||||
# AI dependencies not installed, disable AI Copilot feature
|
||||
logging.warning(
|
||||
f"AI Copilot dependencies not installed: {e}. "
|
||||
"AI features will be disabled. Install with: pip install gns3-server[ai-copilot]"
|
||||
"AI features will be disabled. "
|
||||
"Install with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
AI_COPILOT_AVAILABLE = False
|
||||
|
||||
@@ -63,7 +65,7 @@ except ImportError as e:
|
||||
"""
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
class ProjectAgentManager:
|
||||
@@ -74,12 +76,30 @@ except ImportError as e:
|
||||
def __init__(self):
|
||||
raise RuntimeError(
|
||||
"AI Copilot is not available. "
|
||||
"Install AI dependencies with: pip install gns3-server[ai-copilot]"
|
||||
"Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
|
||||
# Feature flag: MCP (Model Context Protocol) is available
|
||||
MCP_AVAILABLE = False
|
||||
|
||||
# Try to import MCP dependencies
|
||||
try:
|
||||
import mcp.server.fastmcp # noqa: F401 — test import only
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
# MCP dependencies not installed, disable MCP feature
|
||||
logging.warning(
|
||||
"MCP dependencies not installed. "
|
||||
"MCP features will be disabled. "
|
||||
"Install with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
MCP_AVAILABLE = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AI_COPILOT_AVAILABLE",
|
||||
"MCP_AVAILABLE",
|
||||
"get_project_agent_manager",
|
||||
"ProjectAgentManager",
|
||||
]
|
||||
@@ -29,6 +29,10 @@ GNS3 Connector Factory Module
|
||||
This module provides factory functions for creating Gns3Connector instances
|
||||
with JWT token authentication and context-aware configuration management.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
The get_gns3_connector() function is used by both gns3-copilot and MCP.
|
||||
Modifications must be tested with BOTH.
|
||||
|
||||
Features:
|
||||
- Context variable based request-scoped data management (JWT tokens, LLM
|
||||
config)
|
||||
|
||||
@@ -29,6 +29,10 @@ Adapted gns3fy module for GNS3-Copilot
|
||||
This module is based on the upstream gns3fy project
|
||||
(https://github.com/davidban77/gns3fy).
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
The Gns3Connector class is used by MCP handlers to make HTTP calls.
|
||||
Modifications to this file must be tested with BOTH gns3-copilot AND MCP.
|
||||
|
||||
Modifications made for GNS3-Copilot:
|
||||
- Adjusted pydantic usages and dataclass configuration to reduce dependency
|
||||
conflicts with langchain (pydantic version/api differences)
|
||||
@@ -55,7 +59,7 @@ from typing import Any
|
||||
from typing import ParamSpec
|
||||
from typing import TypeVar
|
||||
from typing import cast
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
@@ -71,6 +75,7 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
config = ConfigDict(validate_assignment=True, extra="ignore")
|
||||
|
||||
|
||||
NODE_TYPES = [
|
||||
"cloud",
|
||||
"nat",
|
||||
@@ -187,6 +192,10 @@ class Gns3Connector:
|
||||
Creates the requests.Session object and applies the necessary parameters
|
||||
"""
|
||||
self.session = requests.Session() # pragma: no cover
|
||||
# Increase connection pool size to support concurrent MCP batch operations
|
||||
adapter = requests.adapters.HTTPAdapter(pool_connections=500, pool_maxsize=1000)
|
||||
self.session.mount("http://", adapter)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.headers["Accept"] = "application/json" # pragma: no cover
|
||||
|
||||
# Set authentication based on API version
|
||||
@@ -287,6 +296,7 @@ class Gns3Connector:
|
||||
"""
|
||||
Executes HTTP operations and handles GNS3-specific error logic.
|
||||
"""
|
||||
|
||||
# Handle JWT authentication
|
||||
if (
|
||||
self.auth_type == "jwt"
|
||||
@@ -304,7 +314,7 @@ class Gns3Connector:
|
||||
"headers": headers,
|
||||
"params": params,
|
||||
"verify": verify,
|
||||
"timeout": 10.0, # Fixed 10-second timeout for all GNS3 API requests
|
||||
"timeout": 30.0, # Main request timeout (auth call uses 10s)
|
||||
}
|
||||
if data is not None:
|
||||
kwargs["data"] = data
|
||||
@@ -316,6 +326,7 @@ class Gns3Connector:
|
||||
|
||||
self.api_calls += 1
|
||||
|
||||
|
||||
try:
|
||||
_response.raise_for_status()
|
||||
except HTTPError as e:
|
||||
@@ -708,6 +719,80 @@ class Gns3Connector:
|
||||
self.http_call("delete", _url)
|
||||
return None
|
||||
|
||||
def update_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Update a project's properties.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
|
||||
**Optional Attributes:**
|
||||
|
||||
- `name`, `auto_close`, `auto_open`, `auto_start`
|
||||
- `scene_height`, `scene_width`, `zoom`
|
||||
- `show_layers`, `snap_to_grid`, `show_grid`, `grid_size`, `drawing_grid_size`
|
||||
- `show_interface_labels`, `supplier`, `variables`
|
||||
|
||||
**Returns**
|
||||
|
||||
JSON project information
|
||||
"""
|
||||
_url = f"{self.base_url}/projects/{project_id}"
|
||||
_response = self.http_call("put", _url, json_data=kwargs)
|
||||
return cast(dict[str, Any], _response.json())
|
||||
|
||||
def duplicate_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Duplicate a project from a given project_id.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `name` (in kwargs)
|
||||
|
||||
**Returns**
|
||||
|
||||
JSON project information
|
||||
"""
|
||||
_url = f"{self.base_url}/projects/{project_id}/duplicate"
|
||||
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 get_project_file(self, project_id: str, file_path: str) -> str:
|
||||
"""
|
||||
Get the content of a file in a project.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `file_path`
|
||||
|
||||
**Returns**
|
||||
|
||||
File content as text string
|
||||
"""
|
||||
encoded_path = quote(file_path, safe="/")
|
||||
_url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}"
|
||||
_response = self.http_call("get", _url)
|
||||
return _response.text
|
||||
|
||||
def write_project_file(self, project_id: str, file_path: str, content: str) -> None:
|
||||
"""
|
||||
Write content to a file in a project. Creates the file if it doesn't exist.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `file_path`
|
||||
- `content`
|
||||
"""
|
||||
encoded_path = quote(file_path, safe="/")
|
||||
_url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}"
|
||||
self.http_call("post", _url, data=content, headers={"Content-Type": "text/plain"})
|
||||
|
||||
def get_computes(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Returns a list of computes.
|
||||
@@ -1102,6 +1187,96 @@ class Link:
|
||||
|
||||
return cast(list[dict[str, Any]], _response.json())
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset the link, clearing its state (counters, filters, etc.).
|
||||
|
||||
**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}/reset"
|
||||
_response = _conn.http_call("post", _url)
|
||||
self._update(_response.json())
|
||||
|
||||
def start_capture(
|
||||
self,
|
||||
data_link_type: str = "DLT_EN10MB",
|
||||
capture_file_name: str | None = None,
|
||||
wireshark: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Start packet capture on the link.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `connector`
|
||||
- `link_id`
|
||||
|
||||
**Optional Attributes:**
|
||||
|
||||
- `data_link_type` Data link type (default: DLT_EN10MB)
|
||||
- `capture_file_name` Name of the capture file (optional)
|
||||
- `wireshark` Open Wireshark automatically (default: False)
|
||||
"""
|
||||
_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")
|
||||
if not self.link_id:
|
||||
raise ValueError("Need to submit link_id")
|
||||
|
||||
_url = (
|
||||
f"{_conn.base_url}/projects/{_project_id}/links/"
|
||||
f"{self.link_id}/capture/start"
|
||||
)
|
||||
_data: dict[str, Any] = {
|
||||
"data_link_type": data_link_type,
|
||||
"wireshark": wireshark,
|
||||
}
|
||||
if capture_file_name:
|
||||
_data["capture_file_name"] = capture_file_name
|
||||
_response = _conn.http_call("post", _url, json_data=_data)
|
||||
self._update(_response.json())
|
||||
|
||||
def stop_capture(self) -> None:
|
||||
"""
|
||||
Stop packet capture on the link.
|
||||
|
||||
**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/"
|
||||
f"{self.link_id}/capture/stop"
|
||||
)
|
||||
_conn.http_call("post", _url)
|
||||
|
||||
|
||||
@dataclass(config=config)
|
||||
class Node:
|
||||
@@ -1614,6 +1789,64 @@ class Node:
|
||||
|
||||
return cast(str, _conn.http_call("get", _url).text)
|
||||
|
||||
@verify_connector_and_id
|
||||
def list_files(self, path: str = "", recursive: bool = False) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List files in the node directory with metadata (name, size, type, modified time).
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `connector`
|
||||
- `node_id`
|
||||
|
||||
**Optional Attributes:**
|
||||
|
||||
- `path`: Subdirectory path within node directory (default: "")
|
||||
- `recursive`: Recursively list all files (default: False)
|
||||
|
||||
**Returns:**
|
||||
|
||||
List of file objects with metadata.
|
||||
"""
|
||||
_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"
|
||||
_params: dict[str, str] = {}
|
||||
if path:
|
||||
_params["path"] = path
|
||||
if recursive:
|
||||
_params["recursive"] = "true"
|
||||
_response = _conn.http_call("get", _url, params=_params if _params else None)
|
||||
return cast(list[dict[str, Any]], _response.json())
|
||||
|
||||
@verify_connector_and_id
|
||||
def delete_file(self, path: str) -> None:
|
||||
"""
|
||||
Delete a file from the node directory.
|
||||
|
||||
**Required Attributes:**
|
||||
|
||||
- `project_id`
|
||||
- `connector`
|
||||
- `node_id`
|
||||
- `path`: Node's relative path of the file to delete
|
||||
"""
|
||||
_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("delete", _url)
|
||||
|
||||
@verify_connector_and_id
|
||||
def write_file(self, path: str, data: Any) -> None:
|
||||
"""
|
||||
|
||||
@@ -30,6 +30,10 @@ This module provides a LangChain BaseTool to retrieve the topology of a
|
||||
specific GNS3 project by project ID. Returns nodes, links, and project
|
||||
metadata.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
GNS3TopologyTool._run() is called by MCP device config handlers.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import copy
|
||||
@@ -70,6 +74,8 @@ class GNS3TopologyTool(BaseTool):
|
||||
tool_input: Any = None,
|
||||
run_manager: Any = None,
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Synchronous method to retrieve the topology of a specific GNS3 project.
|
||||
@@ -80,6 +86,8 @@ class GNS3TopologyTool(BaseTool):
|
||||
run_manager: Callback manager for tool run.
|
||||
project_id: The UUID of the specific GNS3 project to retrieve
|
||||
topology from.
|
||||
jwt_token: JWT token for authentication (used by MCP handlers).
|
||||
url: GNS3 server URL (used by MCP handlers).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the project ID, name, status, nodes,
|
||||
@@ -102,8 +110,10 @@ class GNS3TopologyTool(BaseTool):
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
||||
# or auto-detected (e.g. from gns3-copilot agent)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
server = get_gns3_connector(jwt_token=jwt_token, url=url)
|
||||
|
||||
if server is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
|
||||
@@ -98,7 +98,7 @@ class SkillsLoader:
|
||||
|
||||
def load_device_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all device/feature skills from YAML files.
|
||||
Load all device skills from YAML files.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
@@ -131,7 +131,48 @@ class SkillsLoader:
|
||||
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}")
|
||||
logger.debug(f"Loaded {len(skills)} device skills from device directory")
|
||||
return skills
|
||||
|
||||
def load_feature_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all feature skills from YAML files.
|
||||
|
||||
Feature skills are network planning and design functionalities
|
||||
(e.g., topology planner), not device-specific features.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
"""
|
||||
if yaml is None:
|
||||
logger.error("PyYAML is not installed. Cannot load skills from YAML.")
|
||||
return {}
|
||||
|
||||
skills = {}
|
||||
feature_dir = self.skills_dir / "feature"
|
||||
|
||||
if not feature_dir.exists():
|
||||
logger.warning(f"Feature skills directory not found: {feature_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in feature_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 feature skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load feature skill from {yaml_file}: {e}")
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} feature skills from feature directory")
|
||||
return skills
|
||||
|
||||
def load_prompt(self, prompt_name: str) -> str:
|
||||
|
||||
@@ -227,9 +227,17 @@ class SkillsManager:
|
||||
logger.error(f"Invalid skill format for {skill_key}, skipping")
|
||||
continue
|
||||
|
||||
# Load new device/feature skills from YAML files
|
||||
# Load new device skills from YAML files
|
||||
new_device_skills = self.loader.load_device_skills()
|
||||
|
||||
# Load new feature skills from YAML files
|
||||
new_feature_skills = self.loader.load_feature_skills()
|
||||
|
||||
# Merge device and feature skills into single registry
|
||||
all_skills = {}
|
||||
all_skills.update(new_device_skills)
|
||||
all_skills.update(new_feature_skills)
|
||||
|
||||
# Update registries (safe replace - never leaves dict empty)
|
||||
for k in list(INJECTION_SKILLS_REGISTRY):
|
||||
if k not in new_injection_skills:
|
||||
@@ -237,11 +245,11 @@ class SkillsManager:
|
||||
INJECTION_SKILLS_REGISTRY.update(new_injection_skills)
|
||||
|
||||
for k in list(SKILLS_REGISTRY):
|
||||
if k not in new_device_skills:
|
||||
if k not in all_skills:
|
||||
del SKILLS_REGISTRY[k]
|
||||
SKILLS_REGISTRY.update(new_device_skills)
|
||||
SKILLS_REGISTRY.update(all_skills)
|
||||
|
||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills and {len(new_device_skills)} device skills")
|
||||
logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload skills: {e}")
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
This module provides a tool to execute configuration commands on multiple
|
||||
devices in a GNS3 topology using Nornir.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
ExecuteMultipleDeviceConfigCommands._run() is called by the MCP device_config_send handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -169,6 +174,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str, # or Union[str, List[Any], Dict[str, Any]]
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -177,6 +184,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
Args:
|
||||
tool_input (str): A JSON string containing project_id and device
|
||||
configuration commands to execute.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: A list of dicts containing device names and
|
||||
@@ -214,7 +223,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
@@ -547,6 +556,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
@@ -556,7 +567,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
hosts_data = get_device_ports_from_topology(
|
||||
device_names, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
This module provides a tool to execute display commands on multiple devices
|
||||
in a GNS3 topology using Nornir.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
ExecuteMultipleDeviceCommands._run() is called by the MCP device_command_run handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -171,6 +176,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -181,6 +188,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and diagnostic commands.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List[Dict]: A list of dicts with device names and outputs.
|
||||
@@ -210,7 +219,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
@@ -490,6 +499,8 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
self,
|
||||
device_config_list: list[dict[str, Any]],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Prepare device hosts data from topology information."""
|
||||
# Extract device names list
|
||||
@@ -499,7 +510,9 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
]
|
||||
|
||||
# Get device port information with project_id
|
||||
hosts_data = get_device_ports_from_topology(device_names, project_id)
|
||||
hosts_data = get_device_ports_from_topology(
|
||||
device_names, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
if not hosts_data:
|
||||
error_msg = (
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
"""
|
||||
This module provides a tool to execute commands on VPCS devices
|
||||
in a GNS3 topology using Nornir with Netmiko.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
VPCSCommands._run() is called by the MCP vpcs_config_set handler.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -154,6 +159,8 @@ class VPCSCommands(BaseTool):
|
||||
self,
|
||||
tool_input: str | bytes | list[Any] | dict[str, Any],
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -161,6 +168,8 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
Args:
|
||||
tool_input: JSON string with project_id and VPCS commands.
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
List of dicts with device names and command outputs.
|
||||
@@ -183,7 +192,7 @@ class VPCSCommands(BaseTool):
|
||||
# Prepare device hosts data
|
||||
try:
|
||||
hosts_data = self._prepare_device_hosts_data(
|
||||
device_configs_list, project_id
|
||||
device_configs_list, project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
@@ -408,7 +417,11 @@ class VPCSCommands(BaseTool):
|
||||
}
|
||||
|
||||
def _prepare_device_hosts_data(
|
||||
self, device_configs_list: list[dict[str, Any]], project_id: str
|
||||
self,
|
||||
device_configs_list: list[dict[str, Any]],
|
||||
project_id: str,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Prepare Nornir inventory hosts data for VPCS devices.
|
||||
@@ -416,6 +429,8 @@ class VPCSCommands(BaseTool):
|
||||
Args:
|
||||
device_configs_list: List of device configurations
|
||||
project_id: GNS3 project ID
|
||||
jwt_token: JWT token for GNS3 API auth (MCP handlers).
|
||||
url: GNS3 server URL (MCP handlers).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their host data
|
||||
@@ -431,7 +446,7 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
# Get device port mappings from topology
|
||||
device_ports = get_device_ports_from_topology(
|
||||
device_names, project_id=project_id
|
||||
device_names, project_id=project_id, jwt_token=jwt_token, url=url
|
||||
)
|
||||
|
||||
# Build Nornir inventory hosts data
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"""
|
||||
|
||||
Public module for getting device port information from GNS3 topology
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service.
|
||||
get_device_ports_from_topology() is called by MCP device config handlers.
|
||||
The jwt_token/url parameters were added for MCP compatibility.
|
||||
Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,6 +41,8 @@ logger = logging.getLogger(__name__)
|
||||
def get_device_ports_from_topology(
|
||||
device_names: list[str],
|
||||
project_id: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Get device connection information from GNS3 topology
|
||||
@@ -43,6 +50,8 @@ def get_device_ports_from_topology(
|
||||
Args:
|
||||
device_names: List of device names to look up
|
||||
project_id: UUID of the specific GNS3 project to retrieve topology from
|
||||
jwt_token: JWT token for authentication (used by MCP handlers).
|
||||
url: GNS3 server URL (used by MCP handlers).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device names to their connection data:
|
||||
@@ -71,7 +80,7 @@ def get_device_ports_from_topology(
|
||||
|
||||
# Get topology information
|
||||
topo = GNS3TopologyTool()
|
||||
topology = topo._run(project_id=project_id)
|
||||
topology = topo._run(project_id=project_id, jwt_token=jwt_token, url=url)
|
||||
|
||||
# Dynamically build hosts_data from topology
|
||||
hosts_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -15,6 +15,7 @@ RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /
|
||||
&& sed -i 's|http://security.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Add xpra official repository
|
||||
COPY pin-xpra /etc/apt/preferences.d/
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
wget \
|
||||
@@ -24,11 +25,11 @@ RUN apt-get update && apt-get install -y \
|
||||
&& apt-get update
|
||||
|
||||
# Install xpra and dependencies
|
||||
# Lock xpra version for reproducible builds
|
||||
# Locking of xpra version is done in /etc/apt/preferences.d/pin-xpra
|
||||
RUN apt-get install -y \
|
||||
wireshark-common \
|
||||
wireshark \
|
||||
xpra=6.4.3* \
|
||||
xpra \
|
||||
xpra-x11 \
|
||||
xvfb \
|
||||
curl \
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Explanation: Lock Xpra packages to v6.4
|
||||
Package: xpra*
|
||||
Pin: version 6.4*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Explanation: xpra-html5 uses different version scheme, lock it to v19
|
||||
Package: xpra-html5
|
||||
Pin: version 19*
|
||||
Pin-Priority: 1000
|
||||
|
||||
Explanation: Block the installation of other xpra versions
|
||||
Package: xpra*
|
||||
Pin: version *
|
||||
Pin-Priority: -1
|
||||
@@ -184,6 +184,8 @@ async def update_cloud_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
|
||||
nio.markers = getattr(nio_data, "markers", None) or {}
|
||||
await node.update_nio(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -253,3 +255,83 @@ async def stream_pcap_file(
|
||||
nio = node.get_nio(port_number)
|
||||
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
|
||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}"
|
||||
)
|
||||
async def toggle_cloud_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_cloud_marker_capture(
|
||||
*,
|
||||
marker_name: str,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
||||
async def rebuild_cloud_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: Cloud = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -55,6 +55,27 @@ def allocate_udp_port(project_id: UUID) -> dict:
|
||||
return {"udp_port": udp_port}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/ports/udp/batch", status_code=status.HTTP_201_CREATED)
|
||||
def batch_allocate_udp_ports(project_id: UUID, body: dict) -> dict:
|
||||
"""
|
||||
Allocate multiple UDP ports on the compute in a single call.
|
||||
|
||||
Used during project loading to pre-allocate all required UDP ports
|
||||
before creating links, reducing HTTP round-trips.
|
||||
"""
|
||||
|
||||
count = body.get("count", 1)
|
||||
try:
|
||||
count = max(1, min(int(count), 10000))
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="count must be a positive integer")
|
||||
pm = ProjectManager.instance()
|
||||
project = pm.get_project(str(project_id))
|
||||
m = PortManager.instance()
|
||||
udp_ports = [m.get_free_udp_port(project) for _ in range(count)]
|
||||
return {"udp_ports": udp_ports}
|
||||
|
||||
|
||||
@router.get("/network/interfaces")
|
||||
def network_interfaces() -> List[dict]:
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@ API routes for Docker nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, status
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from uuid import UUID
|
||||
@@ -29,7 +29,6 @@ from typing import Union
|
||||
from gns3server import schemas
|
||||
from gns3server.compute.docker import Docker
|
||||
from gns3server.compute.docker.docker_vm import DockerVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}}
|
||||
@@ -293,6 +292,7 @@ async def update_docker_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -408,3 +408,89 @@ async def vnc_console_ws(
|
||||
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_docker_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_docker_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_docker_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: DockerVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -20,7 +20,7 @@ API routes for Dynamips nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Body, Depends, status
|
||||
from fastapi import APIRouter, WebSocket, Body, Depends, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Union
|
||||
@@ -235,6 +235,7 @@ async def update_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.slot_update_nio_binding(adapter_number, port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -366,3 +367,89 @@ async def console_ws(
|
||||
async def reset_console(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_dynamips_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: Router = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_dynamips_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: Router = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number, port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_dynamips_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: Router = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -16,6 +16,10 @@
|
||||
|
||||
"""
|
||||
API routes for Ethernet switch nodes.
|
||||
|
||||
The Ethernet switch is a builtin node backed by a Linux kernel bridge driven
|
||||
through uBridge's ``brctl`` module (see
|
||||
``gns3server.compute.builtin.nodes.ethernet_switch``).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -25,8 +29,8 @@ from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from uuid import UUID
|
||||
|
||||
from gns3server.compute.dynamips import Dynamips
|
||||
from gns3server.compute.dynamips.nodes.ethernet_switch import EthernetSwitch
|
||||
from gns3server.compute.builtin import Builtin
|
||||
from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch
|
||||
from gns3server import schemas
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}}
|
||||
@@ -39,8 +43,8 @@ def dep_node(project_id: UUID, node_id: UUID) -> EthernetSwitch:
|
||||
Dependency to retrieve a node.
|
||||
"""
|
||||
|
||||
dynamips_manager = Dynamips.instance()
|
||||
node = dynamips_manager.get_node(str(node_id), project_id=str(project_id))
|
||||
builtin_manager = Builtin.instance()
|
||||
node = builtin_manager.get_node(str(node_id), project_id=str(project_id))
|
||||
return node
|
||||
|
||||
|
||||
@@ -55,10 +59,9 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
|
||||
Create a new Ethernet switch.
|
||||
"""
|
||||
|
||||
# Use the Dynamips Ethernet switch to simulate this node
|
||||
dynamips_manager = Dynamips.instance()
|
||||
builtin_manager = Builtin.instance()
|
||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
node = await dynamips_manager.create_node(
|
||||
node = await builtin_manager.create_node(
|
||||
node_data.pop("name"),
|
||||
str(project_id),
|
||||
node_data.get("node_id"),
|
||||
@@ -67,7 +70,7 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
|
||||
node_type="ethernet_switch",
|
||||
ports=node_data.get("ports_mapping"),
|
||||
)
|
||||
|
||||
node.usage = node_data.get("usage", "")
|
||||
return node.asdict()
|
||||
|
||||
|
||||
@@ -86,7 +89,7 @@ async def duplicate_ethernet_switch(
|
||||
Duplicate an Ethernet switch.
|
||||
"""
|
||||
|
||||
new_node = await Dynamips.instance().duplicate_node(node.id, str(destination_node_id))
|
||||
new_node = await Builtin.instance().duplicate_node(node.id, str(destination_node_id))
|
||||
return new_node.asdict()
|
||||
|
||||
|
||||
@@ -101,7 +104,9 @@ async def update_ethernet_switch(
|
||||
|
||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
if "name" in node_data and node.name != node_data["name"]:
|
||||
await node.set_name(node_data["name"])
|
||||
node.name = node_data["name"]
|
||||
if "usage" in node_data:
|
||||
node.usage = node_data["usage"]
|
||||
if "ports_mapping" in node_data:
|
||||
node.ports_mapping = node_data["ports_mapping"]
|
||||
await node.update_port_settings()
|
||||
@@ -117,7 +122,7 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No
|
||||
Delete an Ethernet switch.
|
||||
"""
|
||||
|
||||
await Dynamips.instance().delete_node(node.id)
|
||||
await Builtin.instance().delete_node(node.id)
|
||||
|
||||
|
||||
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -182,7 +187,7 @@ async def create_ethernet_switch_nio(
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> schemas.UDPNIO:
|
||||
|
||||
nio = await Dynamips.instance().create_nio(node, jsonable_encoder(nio_data, exclude_unset=True))
|
||||
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
|
||||
await node.add_nio(nio, port_number)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -199,8 +204,7 @@ async def delete_ethernet_switch_nio(
|
||||
The adapter number on the switch is always 0.
|
||||
"""
|
||||
|
||||
nio = await node.remove_nio(port_number)
|
||||
await nio.delete()
|
||||
await node.remove_nio(port_number)
|
||||
|
||||
|
||||
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
|
||||
@@ -251,5 +255,5 @@ async def stream_pcap_file(
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
stream = Dynamips.instance().stream_pcap_file(nio, node.project.id)
|
||||
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
|
||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||
@@ -21,7 +21,7 @@ API routes for images.
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import APIRouter, Request, status, Response, HTTPException
|
||||
from fastapi import APIRouter, Body, Request, status, Response, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List
|
||||
|
||||
@@ -43,6 +43,16 @@ async def get_docker_images() -> List[dict]:
|
||||
return await docker_manager.list_images()
|
||||
|
||||
|
||||
@router.post("/docker/images/pull", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")) -> None:
|
||||
"""
|
||||
Pull or update a Docker image.
|
||||
"""
|
||||
|
||||
docker_manager = Docker.instance()
|
||||
await docker_manager.pull_image(image, force=True)
|
||||
|
||||
|
||||
@router.get("/dynamips/images")
|
||||
async def get_dynamips_images() -> List[dict]:
|
||||
"""
|
||||
|
||||
@@ -254,6 +254,8 @@ async def update_iou_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
|
||||
nio.markers = getattr(nio_data, "markers", None) or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -286,7 +288,7 @@ async def start_iou_node_capture(
|
||||
"""
|
||||
|
||||
pcap_file_path = os.path.join(node.project.capture_working_directory(), node_capture_data.capture_file_name)
|
||||
await node.start_capture(adapter_number, port_number, pcap_file_path)
|
||||
await node.start_capture(adapter_number, port_number, pcap_file_path, node_capture_data.data_link_type)
|
||||
return {"pcap_file_path": str(pcap_file_path)}
|
||||
|
||||
|
||||
@@ -344,3 +346,89 @@ async def console_ws(
|
||||
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_iou_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_iou_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number, port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_iou_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: IOUVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -19,13 +19,14 @@ API routes for projects.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import urllib.parse
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger()
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List
|
||||
@@ -129,59 +130,6 @@ async def delete_compute_project(project: Project = Depends(dep_project)) -> Non
|
||||
await project.delete()
|
||||
ProjectManager.instance().remove_project(project.id)
|
||||
|
||||
# @Route.get(
|
||||
# r"/projects/{project_id}/notifications",
|
||||
# description="Receive notifications about the project",
|
||||
# parameters={
|
||||
# "project_id": "Project UUID",
|
||||
# },
|
||||
# status_codes={
|
||||
# 200: "End of stream",
|
||||
# 404: "The project doesn't exist"
|
||||
# })
|
||||
# async def notification(request, response):
|
||||
#
|
||||
# pm = ProjectManager.instance()
|
||||
# project = pm.get_project(request.match_info["project_id"])
|
||||
#
|
||||
# response.content_type = "application/json"
|
||||
# response.set_status(200)
|
||||
# response.enable_chunked_encoding()
|
||||
#
|
||||
# response.start(request)
|
||||
# queue = project.get_listen_queue()
|
||||
# ProjectHandler._notifications_listening.setdefault(project.id, 0)
|
||||
# ProjectHandler._notifications_listening[project.id] += 1
|
||||
# await response.write("{}\n".format(json.dumps(ProjectHandler._getPingMessage())).encode("utf-8"))
|
||||
# while True:
|
||||
# try:
|
||||
# (action, msg) = await asyncio.wait_for(queue.get(), 5)
|
||||
# if hasattr(msg, "asdict"):
|
||||
# msg = json.dumps({"action": action, "event": msg.asdict()}, sort_keys=True)
|
||||
# else:
|
||||
# msg = json.dumps({"action": action, "event": msg}, sort_keys=True)
|
||||
# log.debug("Send notification: %s", msg)
|
||||
# await response.write(("{}\n".format(msg)).encode("utf-8"))
|
||||
# except asyncio.TimeoutError:
|
||||
# await response.write("{}\n".format(json.dumps(ProjectHandler._getPingMessage())).encode("utf-8"))
|
||||
# project.stop_listen_queue(queue)
|
||||
# if project.id in ProjectHandler._notifications_listening:
|
||||
# ProjectHandler._notifications_listening[project.id] -= 1
|
||||
|
||||
# def _getPingMessage(cls):
|
||||
# """
|
||||
# Ping messages are regularly sent to the client to
|
||||
# keep the connection open. We send with it some information about server load.
|
||||
#
|
||||
# :returns: hash
|
||||
# """
|
||||
# stats = {}
|
||||
# # Non blocking call in order to get cpu usage. First call will return 0
|
||||
# stats["cpu_usage_percent"] = CpuPercent.get(interval=None)
|
||||
# stats["memory_usage_percent"] = psutil.virtual_memory().percent
|
||||
# stats["disk_usage_percent"] = psutil.disk_usage(get_default_project_directory()).percent
|
||||
# return {"action": "ping", "event": stats}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/files", response_model=List[schemas.ProjectFile])
|
||||
async def get_compute_project_files(project: Project = Depends(dep_project)) -> List[schemas.ProjectFile]:
|
||||
@@ -196,14 +144,16 @@ async def get_compute_project_files(project: Project = Depends(dep_project)) ->
|
||||
async def get_compute_node_files(
|
||||
node_type: str,
|
||||
node_id: str,
|
||||
project: Project = Depends(dep_project)
|
||||
project: Project = Depends(dep_project),
|
||||
path: str = Query("", description="Subdirectory path within node directory"),
|
||||
recursive: bool = Query(False, description="Recursively list all files")
|
||||
) -> 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)
|
||||
return await project.list_node_files(node_path, subpath=path, recursive=recursive)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/files/{file_path:path}")
|
||||
@@ -238,20 +188,49 @@ async def write_compute_project_file(
|
||||
|
||||
# Raise error if user try to escape
|
||||
if not is_safe_path(path, project.path):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Path is outside the project directory")
|
||||
|
||||
path = os.path.join(project.path, path)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(path, "wb+") as f:
|
||||
async for chunk in request.stream():
|
||||
f.write(chunk)
|
||||
except (UnicodeEncodeError, OSError) as e:
|
||||
pass # FIXME
|
||||
with open(path, "wb+") as f:
|
||||
async for chunk in request.stream():
|
||||
f.write(chunk)
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Permission denied writing to '{path}'")
|
||||
except OSError as e:
|
||||
log.error(f"Error writing file '{path}': {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_compute_project_file(
|
||||
file_path: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
|
||||
file_path = urllib.parse.unquote(file_path)
|
||||
path = os.path.normpath(file_path)
|
||||
|
||||
if not is_safe_path(path, project.path):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Path is outside the project directory")
|
||||
|
||||
path = os.path.join(project.path, path)
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Permission denied deleting '{path}'")
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
@@ -20,7 +20,7 @@ API routes for Qemu nodes.
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, Path, status
|
||||
from fastapi import APIRouter, WebSocket, Depends, Body, Path, status, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Union
|
||||
@@ -30,7 +30,6 @@ from gns3server import schemas
|
||||
from gns3server.compute import qemu
|
||||
from gns3server.compute.qemu import Qemu
|
||||
from gns3server.compute.qemu.qemu_vm import QemuVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
import logging
|
||||
@@ -321,6 +320,7 @@ async def update_qemu_node_nio(
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.suspend = nio_data.suspend
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.adapter_update_nio_binding(adapter_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -438,3 +438,89 @@ async def vnc_console_ws(
|
||||
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_qemu_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_qemu_marker_capture(
|
||||
marker_name: str,
|
||||
adapter_number: int,
|
||||
port_number: int = Path(..., ge=0, le=0),
|
||||
link_id: str = "",
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(adapter_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_qemu_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: QemuVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -29,7 +29,6 @@ from uuid import UUID
|
||||
from gns3server import schemas
|
||||
from gns3server.compute.vpcs import VPCS
|
||||
from gns3server.compute.vpcs.vpcs_vm import VPCSVM
|
||||
|
||||
from .dependencies.authentication import compute_authentication, ws_compute_authentication
|
||||
|
||||
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or VMware node"}}
|
||||
@@ -240,6 +239,7 @@ async def update_vpcs_node_nio(
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.port_update_nio_binding(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
@@ -303,6 +303,7 @@ async def stop_vpcs_node_capture(
|
||||
await node.stop_capture(port_number)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
@@ -344,3 +345,90 @@ async def console_ws(
|
||||
async def reset_console(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node.reset_console()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def toggle_vpcs_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{node_id}/markers/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def delete_vpcs_marker_capture(
|
||||
*,
|
||||
marker_name: str,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the node stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a node restart won't reinstall
|
||||
it (and recreate an empty pcap).
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/markers/{marker_name}/rebuild",
|
||||
dependencies=[Depends(compute_authentication)]
|
||||
)
|
||||
async def rebuild_vpcs_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: VPCSVM = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
@@ -39,7 +39,7 @@ else:
|
||||
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]"
|
||||
detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
from . import controller
|
||||
@@ -60,6 +60,7 @@ from . import roles
|
||||
from . import acl
|
||||
from . import pools
|
||||
from . import privileges
|
||||
from . import api_keys
|
||||
|
||||
from .dependencies.authentication import get_current_active_user
|
||||
|
||||
@@ -192,3 +193,9 @@ router.include_router(
|
||||
dependencies=[Depends(get_current_active_user)],
|
||||
tags=["GNS3 Copilot"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
api_keys.router,
|
||||
dependencies=[Depends(get_current_active_user)],
|
||||
tags=["API Keys"]
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
API routes for API key management.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import bcrypt
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status, HTTPException
|
||||
|
||||
from gns3server import schemas
|
||||
from gns3server.db.repositories.api_keys import ApiKeysRepository
|
||||
from .dependencies.database import get_repository
|
||||
from .dependencies.authentication import get_current_active_user
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/access/api-keys", tags=["API Keys"])
|
||||
|
||||
API_KEY_PREFIX = "gns3_"
|
||||
API_KEY_BYTES = 32
|
||||
|
||||
|
||||
def _generate_api_key(api_key_id: UUID = None) -> tuple[str, str, str, UUID]:
|
||||
if api_key_id is None:
|
||||
api_key_id = uuid4()
|
||||
random_bytes = secrets.token_hex(API_KEY_BYTES)
|
||||
raw_key = f"gns3_{api_key_id}_{random_bytes}"
|
||||
# Only hash the random secret part, so auth can extract api_key_id and do O(1) lookup
|
||||
key_hash = bcrypt.hashpw(random_bytes.encode(), bcrypt.gensalt()).decode()
|
||||
key_prefix = raw_key[:8]
|
||||
return raw_key, key_hash, key_prefix, api_key_id
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_api_key(
|
||||
api_key_data: schemas.ApiKeyCreate,
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
) -> dict:
|
||||
"""Create a new API key. The full key is returned only once."""
|
||||
|
||||
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
|
||||
db_key = await api_keys_repo.create_api_key(
|
||||
api_key_id=new_key_id,
|
||||
user_id=current_user.user_id,
|
||||
name=api_key_data.name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
return {
|
||||
"api_key_id": str(db_key.api_key_id),
|
||||
"api_key": raw_key,
|
||||
"name": db_key.name,
|
||||
"key_prefix": db_key.key_prefix,
|
||||
"created_at": db_key.created_at.isoformat() if db_key.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_api_keys(
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
) -> list[dict]:
|
||||
"""List all API keys for the current user."""
|
||||
|
||||
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
|
||||
return [
|
||||
{
|
||||
"api_key_id": str(k.api_key_id),
|
||||
"name": k.name,
|
||||
"key_prefix": k.key_prefix,
|
||||
"created_at": k.created_at.isoformat() if k.created_at else None,
|
||||
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
|
||||
"revoked": k.revoked,
|
||||
}
|
||||
for k in keys
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK)
|
||||
async def revoke_api_key(
|
||||
api_key_id: UUID,
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
) -> dict:
|
||||
"""Revoke an API key. It will immediately stop working, but can be restored."""
|
||||
|
||||
key = await api_keys_repo.get_api_key(api_key_id)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
if key.user_id != current_user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
|
||||
|
||||
await api_keys_repo.revoke_api_key(api_key_id)
|
||||
return {"message": f"API key '{key.name}' revoked"}
|
||||
|
||||
|
||||
@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK)
|
||||
async def restore_api_key(
|
||||
api_key_id: UUID,
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
) -> dict:
|
||||
"""Restore a previously revoked API key."""
|
||||
|
||||
key = await api_keys_repo.get_api_key(api_key_id)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
if key.user_id != current_user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
|
||||
|
||||
await api_keys_repo.restore_api_key(api_key_id)
|
||||
return {"message": f"API key '{key.name}' restored"}
|
||||
|
||||
|
||||
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_api_key(
|
||||
api_key_id: UUID,
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
) -> None:
|
||||
"""Permanently delete an API key. Cannot be undone."""
|
||||
|
||||
key = await api_keys_repo.get_api_key(api_key_id)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
if key.user_id != current_user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete another user's API key")
|
||||
|
||||
await api_keys_repo.delete_api_key(api_key_id)
|
||||
@@ -18,7 +18,7 @@
|
||||
API routes for computes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Body, Depends, status
|
||||
from typing import Any, List, Union, Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -165,6 +165,25 @@ async def docker_get_images(compute_id: Union[str, UUID]) -> List[schemas.Comput
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{compute_id}/docker/images/pull",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Compute.Modify"))]
|
||||
)
|
||||
async def docker_pull_image(
|
||||
compute_id: Union[str, UUID],
|
||||
image: str = Body(..., embed=True, min_length=1, pattern=r"^\S+$")
|
||||
) -> None:
|
||||
"""
|
||||
Pull or update a Docker image on a compute.
|
||||
|
||||
Required privilege: Compute.Modify
|
||||
"""
|
||||
|
||||
compute = Controller.instance().get_compute(str(compute_id))
|
||||
await compute.forward("POST", "docker", "images/pull", data={"image": image})
|
||||
|
||||
|
||||
@router.get("/{compute_id}/virtualbox/vms", response_model=List[schemas.ComputeVirtualBoxVM])
|
||||
async def virtualbox_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVirtualBoxVM]:
|
||||
"""
|
||||
|
||||
@@ -14,13 +14,18 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import bcrypt
|
||||
|
||||
from fastapi import Request, Query, Depends, HTTPException, WebSocket, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from gns3server import schemas
|
||||
import gns3server.db.models as models
|
||||
from gns3server.db.repositories.api_keys import ApiKeysRepository
|
||||
from gns3server.db.repositories.users import UsersRepository
|
||||
from gns3server.db.repositories.rbac import RbacRepository
|
||||
from gns3server.services import auth_service
|
||||
@@ -30,12 +35,25 @@ log = logging.getLogger(__name__)
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/access/users/login", auto_error=False)
|
||||
|
||||
|
||||
def _reject_refresh_token(token_data) -> None:
|
||||
"""Reject tokens with type == 'refresh' — they must not grant API access."""
|
||||
|
||||
if token_data.token_use == "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh tokens cannot be used for API access",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_user_from_token(
|
||||
bearer_token: str = Depends(oauth2_scheme),
|
||||
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
|
||||
token: Optional[str] = Query(None, include_in_schema=False)
|
||||
) -> schemas.User:
|
||||
|
||||
|
||||
if bearer_token:
|
||||
# bearer token is used first, then any token passed as a URL parameter
|
||||
token = bearer_token
|
||||
@@ -47,7 +65,35 @@ async def get_user_from_token(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# API Key authentication — format: gns3_<api_key_id>_<random_secret>
|
||||
# Direct lookup by UUID avoids O(n) scan of all keys.
|
||||
if token.startswith("gns3_"):
|
||||
parts = token.split("_", 2)
|
||||
if len(parts) != 3:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
|
||||
try:
|
||||
key_id = UUID(parts[1])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key format")
|
||||
secret = parts[2]
|
||||
db_key = await api_keys_repo.get_api_key(key_id)
|
||||
if not db_key or db_key.revoked:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
if not await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
await api_keys_repo.update_last_used(db_key.api_key_id)
|
||||
user = await user_repo.get_user(db_key.user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not an active user",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
# JWT authentication
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_reject_refresh_token(token_data)
|
||||
user = await user_repo.get_user_by_username(token_data.username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
@@ -103,6 +149,7 @@ async def get_current_active_user_from_websocket(
|
||||
|
||||
try:
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_reject_refresh_token(token_data)
|
||||
user = await user_repo.get_user_by_username(token_data.username)
|
||||
|
||||
if user is None:
|
||||
|
||||
@@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from gns3server.db.repositories.base import BaseRepository
|
||||
|
||||
|
||||
|
||||
|
||||
async def get_db_session(request: HTTPConnection) -> AsyncSession:
|
||||
|
||||
async with AsyncSession(request.app.state._db_engine, expire_on_commit=False) as session:
|
||||
|
||||
@@ -52,6 +52,10 @@ def has_privilege_on_websocket(
|
||||
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
|
||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||
):
|
||||
# Authentication may have failed and closed the socket inside the auth
|
||||
# dependency, returning None — bail out before touching the user object.
|
||||
if current_user is None:
|
||||
return None
|
||||
if not current_user.is_superadmin:
|
||||
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
|
||||
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
|
||||
|
||||
@@ -27,12 +27,12 @@ from fastapi import APIRouter, Depends, Request, status, WebSocket
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from typing import List, Union
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
from gns3server.db.repositories.rbac import RbacRepository
|
||||
from gns3server.controller.link import Link
|
||||
from gns3server.controller.link import Link, _UNSET
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
from gns3server.utils.port_allocator import link_id_to_port
|
||||
from gns3server.utils.websocket_to_websocket import websocket_proxy
|
||||
@@ -424,6 +424,101 @@ async def web_wireshark_websocket(
|
||||
pass
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{link_id}/markers",
|
||||
dependencies=[Depends(has_privilege("Link.Audit"))]
|
||||
)
|
||||
async def get_markers(link: Link = Depends(dep_link)) -> dict:
|
||||
"""
|
||||
Return all traffic-insight markers configured on this link.
|
||||
|
||||
Required privilege: Link.Audit
|
||||
"""
|
||||
|
||||
return link.markers
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{link_id}/markers",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def create_marker(
|
||||
marker_data: schemas.MarkerCreate,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> dict:
|
||||
"""
|
||||
Attach a traffic-insight marker to the link.
|
||||
On BPF match uBridge emits MARK signals and appends packets to a pcap.
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
# Auto-generate a link-unique name when the caller omits one. The short
|
||||
# uuid suffix avoids the collision that `marker-{link.id[:8]}` alone would
|
||||
# cause on the second anonymous marker on the same link (start_marker
|
||||
# rejects duplicate names).
|
||||
if marker_data.name and marker_data.name.lower().startswith("global"):
|
||||
raise ControllerError('Names starting with "global" are reserved for inherited markers')
|
||||
name = marker_data.name or f"marker-{link.id[:8]}-{uuid4().hex[:4]}"
|
||||
await link.start_marker(
|
||||
name=name,
|
||||
bpf=marker_data.bpf,
|
||||
tag=marker_data.tag,
|
||||
direction=marker_data.direction,
|
||||
capture_node_id=marker_data.capture_node_id,
|
||||
color=marker_data.color,
|
||||
highlight_duration=marker_data.highlight_duration,
|
||||
data_link_type=marker_data.data_link_type,
|
||||
)
|
||||
return link.markers.get(name, {})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{link_id}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def delete_marker(
|
||||
marker_name: str,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> None:
|
||||
"""
|
||||
Remove a traffic-insight marker from the link.
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
await link.stop_marker(marker_name)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{link_id}/markers/{marker_name}",
|
||||
dependencies=[Depends(has_privilege("Link.Modify"))]
|
||||
)
|
||||
async def update_marker(
|
||||
marker_name: str,
|
||||
marker_data: schemas.MarkerUpdate,
|
||||
link: Link = Depends(dep_link)
|
||||
) -> dict:
|
||||
"""
|
||||
Update a traffic-insight marker (change BPF, tag, or enabled).
|
||||
|
||||
Required privilege: Link.Modify
|
||||
"""
|
||||
|
||||
await link.update_marker(
|
||||
name=marker_name,
|
||||
bpf=marker_data.bpf if marker_data.bpf else None,
|
||||
tag=marker_data.tag,
|
||||
direction=marker_data.direction if "direction" in marker_data.model_fields_set else _UNSET,
|
||||
color=marker_data.color,
|
||||
enabled=marker_data.enabled,
|
||||
highlight_duration=marker_data.highlight_duration,
|
||||
)
|
||||
return link.markers.get(marker_name, {})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{link_id}/iface",
|
||||
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
|
||||
|
||||
@@ -24,6 +24,7 @@ import ipaddress
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from typing import List, Callable, Optional
|
||||
from uuid import UUID
|
||||
@@ -242,14 +243,25 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
|
||||
raise
|
||||
|
||||
|
||||
# Node types that need live host interface data from compute
|
||||
_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"}
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
|
||||
def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
|
||||
async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
|
||||
"""
|
||||
Return a node from a given project.
|
||||
|
||||
Required privilege: Node.Audit
|
||||
"""
|
||||
|
||||
if node.node_type in _HOST_INTERFACE_NODE_TYPES:
|
||||
try:
|
||||
response = await node.get()
|
||||
await node.parse_node_response(response.json)
|
||||
except Exception:
|
||||
# If compute is unreachable, still return cached data
|
||||
log.warning(f"Could not refresh node {node.id} from compute, returning cached data")
|
||||
return node.asdict()
|
||||
|
||||
|
||||
@@ -523,17 +535,30 @@ async def delete_disk_image(
|
||||
|
||||
|
||||
@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]:
|
||||
async def list_node_files(
|
||||
node: Node = Depends(dep_node),
|
||||
path: str = Query("", description="Subdirectory path within node directory"),
|
||||
recursive: bool = Query(False, description="Recursively list all files")
|
||||
) -> List[schemas.NodeFile]:
|
||||
"""
|
||||
List files in a node directory with detailed metadata.
|
||||
|
||||
By default lists only the current directory level (non-recursive).
|
||||
Use recursive=true for a full recursive listing.
|
||||
|
||||
Required privilege: Node.Audit
|
||||
"""
|
||||
|
||||
node_type = node.node_type
|
||||
url = f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files"
|
||||
params = {}
|
||||
if path:
|
||||
params["path"] = path
|
||||
if recursive:
|
||||
params["recursive"] = "true"
|
||||
res = await node.compute.http_query(
|
||||
"GET",
|
||||
f"/projects/{node.project.id}/nodes/{node_type}/{node.id}/files",
|
||||
"GET", url,
|
||||
params=params if params else None,
|
||||
timeout=None
|
||||
)
|
||||
return res.json
|
||||
@@ -550,14 +575,32 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
|
||||
path = force_unix_path(file_path)
|
||||
|
||||
# Raise error if user try to escape
|
||||
if path[0] == ".":
|
||||
if path.startswith(".."):
|
||||
raise ControllerForbiddenError("It is forbidden to get a file outside the project directory")
|
||||
|
||||
node_type = node.node_type
|
||||
path = f"/project-files/{node_type}/{node.id}/{path}"
|
||||
|
||||
res = await node.compute.http_query("GET", f"/projects/{node.project.id}/files{path}", timeout=None, raw=True)
|
||||
return Response(res.body, media_type="application/octet-stream", status_code=res.status)
|
||||
compute_resp = await node.compute.http_query(
|
||||
"GET", f"/projects/{node.project.id}/files{path}",
|
||||
timeout=None, stream=True
|
||||
)
|
||||
|
||||
async def streamer():
|
||||
try:
|
||||
async for chunk in compute_resp.content.iter_chunked(65536):
|
||||
yield chunk
|
||||
except (IOError, OSError, asyncio.TimeoutError) as e:
|
||||
log.error(f"Error streaming file '{path}' from compute: {e}")
|
||||
raise
|
||||
finally:
|
||||
compute_resp.close()
|
||||
|
||||
return StreamingResponse(
|
||||
streamer(),
|
||||
media_type="application/octet-stream",
|
||||
status_code=compute_resp.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -575,15 +618,43 @@ async def post_file(file_path: str, request: Request, node: Node = Depends(dep_n
|
||||
path = force_unix_path(file_path)
|
||||
|
||||
# Raise error if user try to escape
|
||||
if path[0] == ".":
|
||||
if path.startswith(".."):
|
||||
raise ControllerForbiddenError("Cannot write outside the node directory")
|
||||
|
||||
node_type = node.node_type
|
||||
path = f"/project-files/{node_type}/{node.id}/{path}"
|
||||
|
||||
data = await request.body() # FIXME: are we handling timeout or large files correctly?
|
||||
await node.compute.http_query("POST", f"/projects/{node.project.id}/files{path}", data=data, timeout=None, raw=True)
|
||||
# FIXME: response with correct status code (from compute)
|
||||
# Stream request body directly to compute node
|
||||
await node.compute.http_query(
|
||||
"POST", f"/projects/{node.project.id}/files{path}",
|
||||
data=request.stream(), timeout=None
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/files/{file_path:path}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Node.Modify"))]
|
||||
)
|
||||
async def delete_node_file(file_path: str, node: Node = Depends(dep_node)) -> None:
|
||||
"""
|
||||
Delete a file from the node directory.
|
||||
|
||||
Required privilege: Node.Modify
|
||||
"""
|
||||
|
||||
path = force_unix_path(file_path)
|
||||
|
||||
if path.startswith(".."):
|
||||
raise ControllerForbiddenError("It is forbidden to delete a file outside the project directory")
|
||||
|
||||
node_type = node.node_type
|
||||
path = f"/project-files/{node_type}/{node.id}/{path}"
|
||||
|
||||
await node.compute.http_query(
|
||||
"DELETE", f"/projects/{node.project.id}/files{path}",
|
||||
timeout=None
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/{node_id}/console/ws")
|
||||
|
||||
@@ -40,6 +40,7 @@ from uuid import UUID
|
||||
from gns3server import schemas
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.controller.link import _UNSET
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
|
||||
from gns3server.controller.import_project import import_project as import_controller_project
|
||||
from gns3server.controller.export_project import export_project as export_controller_project
|
||||
@@ -203,6 +204,161 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
|
||||
return project.stats()
|
||||
|
||||
|
||||
@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
|
||||
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
|
||||
"""
|
||||
Return all traffic-insight markers across every link in the project.
|
||||
|
||||
Each entry is keyed ``"{link_id}/{marker_name}"`` and carries the
|
||||
marker's BPF, tag, color, enabled flag, plus its parent ``link_id``
|
||||
and capture-side ``node_id`` for frontend filtering / grouping.
|
||||
|
||||
Required privilege: Project.Audit
|
||||
"""
|
||||
|
||||
return project.markers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project-level marker definitions (global rules inherited by every link)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/marker-definitions",
|
||||
dependencies=[Depends(has_privilege("Project.Audit"))]
|
||||
)
|
||||
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
|
||||
"""
|
||||
Return all project-level marker definitions with their bound link IDs.
|
||||
|
||||
Required privilege: Project.Audit
|
||||
"""
|
||||
|
||||
result = {}
|
||||
for name, d in project.marker_definitions.items():
|
||||
# Collect which links currently carry an inherited copy.
|
||||
bound = [
|
||||
lid for lid, link in project.links.items()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
result[name] = {**d, "link_ids": bound}
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def create_marker_definition(
|
||||
def_data: schemas.MarkerDefinitionCreate,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> dict:
|
||||
"""
|
||||
Create a project-level marker definition and fan out to every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
if def_data.name and def_data.name.lower().startswith("global"):
|
||||
raise ControllerError('Names starting with "global" are reserved for inherited markers')
|
||||
name = def_data.name or f"def-{project.id[:8]}"
|
||||
await project.create_marker_definition(
|
||||
name=name,
|
||||
bpf=def_data.bpf,
|
||||
tag=def_data.tag,
|
||||
direction=def_data.direction,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
data_link_type=def_data.data_link_type,
|
||||
)
|
||||
return project.marker_definitions.get(name, {})
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}/marker-definitions/{def_name}",
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def update_marker_definition(
|
||||
def_name: str,
|
||||
def_data: schemas.MarkerDefinitionCreate,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> dict:
|
||||
"""
|
||||
Update a marker definition and sync all inherited copies on every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.update_marker_definition(
|
||||
name=def_name,
|
||||
bpf=def_data.bpf if def_data.bpf else None,
|
||||
tag=def_data.tag,
|
||||
direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET,
|
||||
color=def_data.color,
|
||||
highlight_duration=def_data.highlight_duration,
|
||||
data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET,
|
||||
)
|
||||
return project.marker_definitions.get(def_name, {})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions/{def_name}/pause",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def pause_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""
|
||||
Pause a definition: toggle off every inherited ``global-{def_name}`` copy
|
||||
on every link (uBridge ``enable_packet_filter off``, instant — no NIO
|
||||
rebuild). The definition's ``paused`` flag is persisted, so links created
|
||||
later inherit it already paused.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.pause_marker_definition(def_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/marker-definitions/{def_name}/resume",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def resume_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""Resume a paused definition (toggle on every inherited copy).
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.resume_marker_definition(def_name)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/marker-definitions/{def_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(has_privilege("Project.Modify"))]
|
||||
)
|
||||
async def delete_marker_definition(
|
||||
def_name: str,
|
||||
project: Project = Depends(dep_project)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker definition and remove all inherited copies from every link.
|
||||
|
||||
Required privilege: Project.Modify
|
||||
"""
|
||||
|
||||
await project.delete_marker_definition(def_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/close",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@@ -611,9 +767,11 @@ async def create_node_from_template(
|
||||
"""
|
||||
|
||||
template = await TemplatesService(templates_repo).get_template(template_id)
|
||||
|
||||
controller = Controller.instance()
|
||||
project = controller.get_project(str(project_id))
|
||||
|
||||
node = await project.add_node_from_template(
|
||||
template, x=template_usage.x, y=template_usage.y, compute_id=template_usage.compute_id
|
||||
template, x=template_usage.x, y=template_usage.y, name=template_usage.name, compute_id=template_usage.compute_id
|
||||
)
|
||||
return node.asdict()
|
||||
@@ -67,7 +67,8 @@ async def login(
|
||||
|
||||
token = schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -92,11 +93,55 @@ async def authenticate(
|
||||
|
||||
token = schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=schemas.Token)
|
||||
async def refresh_access_token(
|
||||
request: schemas.RefreshTokenRequest,
|
||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||
) -> schemas.Token:
|
||||
"""
|
||||
Exchange a refresh token for a new access token.
|
||||
|
||||
Public endpoint — the refresh token itself proves identity. Respects the
|
||||
user's token_version, so logout (which increments it) invalidates all
|
||||
outstanding refresh tokens. Refresh tokens are stateless JWTs with a
|
||||
longer expiry (default 30 days). Stolen tokens remain valid until their
|
||||
`exp` or until logout — no replay protection without a server-side table.
|
||||
"""
|
||||
|
||||
token_data = auth_service.get_token_data(request.refresh_token)
|
||||
if token_data.token_use != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = await users_repo.get_user_by_username(token_data.username)
|
||||
if user is None or not user.is_active:
|
||||
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 schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 appliance management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
VALID_APPLIANCE_FIELDS = {
|
||||
"appliance_id", "name", "category", "description", "vendor_name",
|
||||
"vendor_url", "product_name", "product_url", "documentation_url",
|
||||
"status", "availability", "maintainer", "usage", "symbol",
|
||||
"images", "versions", "tags", "builtin",
|
||||
"first_port_name", "port_name_format", "port_segment_size",
|
||||
"linked_clone", "docker", "iou", "dynamips", "qemu",
|
||||
}
|
||||
|
||||
|
||||
def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
appliances = conn.http_call("get", f"{conn.base_url}/appliances").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_APPLIANCE_FIELDS),
|
||||
}
|
||||
appliances = [{k: a[k] for k in fields if k in a} for a in appliances]
|
||||
return {"appliances": appliances, "count": len(appliances)}
|
||||
|
||||
|
||||
def get_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
appliance_id = params.get("appliance_id")
|
||||
if not appliance_id:
|
||||
return {"error": "appliance_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/appliances/{appliance_id}").json()
|
||||
|
||||
|
||||
def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
appliance_id = params.get("appliance_id")
|
||||
if not appliance_id:
|
||||
return {"error": "appliance_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/appliances/{appliance_id}/install"
|
||||
request_params = {}
|
||||
version = params.get("version")
|
||||
if version:
|
||||
request_params["version"] = version
|
||||
result = conn.http_call("post", url, params=request_params).json()
|
||||
return {"message": f"Appliance {appliance_id} installation requested", "result": result}
|
||||
@@ -0,0 +1,95 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 compute management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
computes = conn.http_call("get", f"{conn.base_url}/computes").json()
|
||||
return {"computes": computes, "count": len(computes)}
|
||||
|
||||
|
||||
def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
compute_id = params.get("compute_id")
|
||||
if not compute_id:
|
||||
return {"error": "compute_id is required (use compute_list to get the UUID)"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json()
|
||||
|
||||
|
||||
def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
emulator = params.get("emulator")
|
||||
compute_id = params.get("compute_id")
|
||||
if not emulator:
|
||||
return {"error": "emulator is required (e.g. qemu, iou, docker)"}
|
||||
if not compute_id:
|
||||
return {"error": "compute_id is required (use compute_list to get the UUID)"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
|
||||
return {"images": images, "count": len(images)}
|
||||
|
||||
|
||||
COMPUTE_TOOLS = [
|
||||
{
|
||||
"name": "list_computes",
|
||||
"description": "List all compute nodes available to the server",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
"handler": list_computes_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_compute",
|
||||
"description": "Get detailed information about a compute node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compute_id": {"type": "string", "description": "Compute ID (default: local)"},
|
||||
},
|
||||
},
|
||||
"handler": get_compute_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_compute_images",
|
||||
"description": "List available images for an emulator on a compute node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emulator": {"type": "string", "description": "Emulator type (e.g. qemu, iou, docker)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (default: local)"},
|
||||
},
|
||||
"required": ["emulator"],
|
||||
},
|
||||
"handler": get_compute_images_handler,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for device configuration via Nornir + Netmiko.
|
||||
|
||||
These tools connect to network device consoles via telnet/SSH and execute
|
||||
configuration or diagnostic commands. Device connection info is automatically
|
||||
discovered from the project topology using the device's tags for device_type.
|
||||
|
||||
Prerequisites:
|
||||
- Device must be started (use node_start / node_start_all)
|
||||
- Device must have a 'device_type:<type>' tag set in GNS3
|
||||
(right-click → Configure → Tags → add 'device_type:cisco_ios_telnet')
|
||||
- Device must have a console port assigned
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _render_template(template: str, device_configs: list[dict], commands_field: str = "config_commands") -> list[dict]:
|
||||
"""Render a Jinja2 template for each device's vars into the specified commands field.
|
||||
|
||||
Entries with the same device_name are merged into a single entry
|
||||
so they share one Nornir session and avoid output fragmentation.
|
||||
|
||||
Each device in device_configs can have:
|
||||
- "vars": dict of template variables (rendered into commands_field)
|
||||
- commands_field: existing commands merged after rendering if present
|
||||
|
||||
Args:
|
||||
commands_field: field name for the rendered commands, e.g. "config_commands", "commands"
|
||||
"""
|
||||
jinja = JinjaTemplate(template)
|
||||
merged: dict[str, dict] = {}
|
||||
for dev in device_configs:
|
||||
name = dev.get("device_name")
|
||||
if not name:
|
||||
continue
|
||||
vars_data = dev.get("vars", {})
|
||||
if name not in merged:
|
||||
merged[name] = {"device_name": name, commands_field: list(dev.get(commands_field, []))}
|
||||
entry = merged[name]
|
||||
if vars_data:
|
||||
try:
|
||||
output = jinja.render(**vars_data)
|
||||
lines = [l for l in output.splitlines() if l.strip()]
|
||||
entry[commands_field].extend(lines)
|
||||
except JinjaError as e:
|
||||
error_msg = f"Template rendering failed for '{name}': {e}"
|
||||
log.error(error_msg)
|
||||
return [{"error": error_msg}]
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Send configuration commands to network devices via console."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
template = params.get("template")
|
||||
if not project_id or not device_configs:
|
||||
return [{"error": "project_id and device_configs are required"}]
|
||||
|
||||
if template:
|
||||
device_configs = _render_template(template, device_configs, commands_field="config_commands")
|
||||
if len(device_configs) == 1 and "error" in device_configs[0]:
|
||||
return device_configs
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||
|
||||
tool = ExecuteMultipleDeviceConfigCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
|
||||
|
||||
def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Run read-only diagnostic (show) commands on network devices."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
template = params.get("template")
|
||||
if not project_id or not device_configs:
|
||||
return [{"error": "project_id and device_configs (list of {device_name, commands}) are required"}]
|
||||
|
||||
if template:
|
||||
device_configs = _render_template(template, device_configs, commands_field="commands")
|
||||
if len(device_configs) == 1 and "error" in device_configs[0]:
|
||||
return device_configs
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||
|
||||
tool = ExecuteMultipleDeviceCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
|
||||
|
||||
def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Configure VPCS devices (set IP, gateway, etc.)."""
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
if not project_id or not device_configs:
|
||||
return [{"error": "project_id and device_configs are required"}]
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
tool = VPCSCommands()
|
||||
input_data = json.dumps({
|
||||
"project_id": project_id,
|
||||
"device_configs": device_configs,
|
||||
})
|
||||
return tool._run(
|
||||
input_data,
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
url=gns3_ctx["server_url"],
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 drawing management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
drawings = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings").json()
|
||||
return {"drawings": drawings, "count": len(drawings)}
|
||||
|
||||
|
||||
def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
svg = params.get("svg")
|
||||
if not project_id or not svg:
|
||||
return {"error": "project_id and svg are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"svg": svg,
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"z": params.get("z", 0),
|
||||
"locked": params.get("locked", False),
|
||||
"rotation": params.get("rotation", 0),
|
||||
}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Drawing created", "drawing": result}
|
||||
|
||||
|
||||
def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}").json()
|
||||
|
||||
|
||||
def update_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {k: v for k, v in params.items() if k not in ("project_id", "drawing_id") and v is not None}
|
||||
return conn.http_call("put", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}", json_data=data).json()
|
||||
|
||||
|
||||
def delete_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
drawing_id = params.get("drawing_id")
|
||||
if not project_id or not drawing_id:
|
||||
return {"error": "project_id and drawing_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}")
|
||||
return {"message": f"Drawing {drawing_id} deleted", "drawing_id": drawing_id}
|
||||
@@ -0,0 +1,77 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 image management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
images = conn.http_call("get", f"{conn.base_url}/images").json()
|
||||
return {"images": images, "count": len(images)}
|
||||
|
||||
|
||||
def get_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
image_id = params.get("image_id")
|
||||
if not image_id:
|
||||
return {"error": "image_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/images/{image_id}").json()
|
||||
|
||||
|
||||
def delete_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
image_id = params.get("image_id")
|
||||
if not image_id:
|
||||
return {"error": "image_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/images/{image_id}")
|
||||
return {"message": f"Image {image_id} deleted", "image_id": image_id}
|
||||
|
||||
|
||||
def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
# Returns 204 No Content on success (empty body, no .json())
|
||||
conn.http_call("delete", f"{conn.base_url}/images/prune")
|
||||
return {"message": "Unused images pruned"}
|
||||
|
||||
|
||||
def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
# Returns 204 No Content on success (empty body, no .json())
|
||||
conn.http_call("post", f"{conn.base_url}/images/install")
|
||||
return {"message": "Image installation completed"}
|
||||
@@ -0,0 +1,633 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 link management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 100
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize link node entries, accepting both standard object format and
|
||||
compact array format to reduce token usage.
|
||||
|
||||
Standard: [{"node_id": "uuid", "adapter_number": 0, "port_number": 0}]
|
||||
Compact: ["uuid", 0, 0, "uuid", 0, 0]
|
||||
|
||||
Returns the normalized list, or raises ValueError with a clear message
|
||||
on format errors so the AI can self-correct.
|
||||
"""
|
||||
if not nodes:
|
||||
return nodes
|
||||
if not isinstance(nodes, list):
|
||||
raise ValueError(f"nodes must be a list, got {type(nodes).__name__}: {nodes}")
|
||||
# Standard object format: [{"node_id": "...", ...}]
|
||||
if isinstance(nodes[0], dict):
|
||||
return nodes
|
||||
# Compact array format: ["uuid", ad, pt, "uuid", ad, pt]
|
||||
if all(not isinstance(n, dict) for n in nodes):
|
||||
if len(nodes) != 6:
|
||||
raise ValueError(
|
||||
f"Compact link format requires exactly 6 elements "
|
||||
f"[node_id, adapter, port, node_id, adapter, port], "
|
||||
f"but got {len(nodes)} elements: {nodes}"
|
||||
)
|
||||
if not isinstance(nodes[0], str) or not isinstance(nodes[3], str):
|
||||
raise ValueError(
|
||||
f"Compact link format expects node_id (string) at positions 0 and 3, "
|
||||
f"got types {type(nodes[0]).__name__} and {type(nodes[3]).__name__}: {nodes}"
|
||||
)
|
||||
return [
|
||||
{"node_id": nodes[0], "adapter_number": nodes[1], "port_number": nodes[2]},
|
||||
{"node_id": nodes[3], "adapter_number": nodes[4], "port_number": nodes[5]},
|
||||
]
|
||||
raise ValueError(
|
||||
f"Unrecognized link nodes format. "
|
||||
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
|
||||
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
VALID_LINK_FIELDS = {
|
||||
"link_id", "project_id", "link_type", "nodes", "suspend",
|
||||
"link_style", "filters", "show_filters_icon",
|
||||
"capturing", "capture_file_name", "capture_file_path",
|
||||
"capture_compute_id", "wireshark",
|
||||
}
|
||||
|
||||
|
||||
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||
|
||||
|
||||
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter link response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = LINK_DEFAULT_FIELDS
|
||||
return {k: link[k] for k in fields if k in link}
|
||||
|
||||
|
||||
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_LINK_FIELDS),
|
||||
}
|
||||
links = [{k: l[k] for k in fields if k in l} for l in links]
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
|
||||
|
||||
|
||||
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
fields = params.get("fields")
|
||||
if fields is not None and not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
|
||||
|
||||
links = params.get("links")
|
||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||
if links is not None:
|
||||
if not isinstance(links, list) or not links:
|
||||
return {"error": "links must be a non-empty array"}
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(link_data):
|
||||
raw_nodes = link_data.get("nodes")
|
||||
if not raw_nodes:
|
||||
return {"status": "error", "error": "nodes is required for each link"}
|
||||
try:
|
||||
body = {"nodes": _normalize_link_nodes(raw_nodes)}
|
||||
if link_data.get("link_type"):
|
||||
body["link_type"] = link_data["link_type"]
|
||||
if link_data.get("filters"):
|
||||
body["filters"] = link_data["filters"]
|
||||
if link_data.get("suspend"):
|
||||
body["suspend"] = link_data["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
return {"status": "success", "link": _filter_link_response(resp, fields)}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
|
||||
futures = {pool.submit(_create_one, l): l for l in links}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
nodes = params.get("nodes")
|
||||
if not nodes:
|
||||
return {"error": "nodes is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"nodes": _normalize_link_nodes(nodes)}
|
||||
if "link_type" in params:
|
||||
data["link_type"] = params["link_type"]
|
||||
if "filters" in params:
|
||||
data["filters"] = params["filters"]
|
||||
if "suspend" in params:
|
||||
data["suspend"] = params["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_link_response(resp, fields)
|
||||
|
||||
|
||||
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _del(lid):
|
||||
try:
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
|
||||
return {"link_id": lid, "status": "deleted"}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_del, link_ids))
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{link_id}")
|
||||
return {"message": f"Link {link_id} deleted", "link_id": link_id}
|
||||
|
||||
|
||||
def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "link_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
# ── Link capture / reset handlers ──────────────────────────────────────
|
||||
|
||||
|
||||
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _rst(lid):
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
|
||||
r = conn.http_call("post", url).json()
|
||||
return {"link_id": lid, "status": "reset", "link": r}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_rst, link_ids))
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
|
||||
result = conn.http_call("post", url).json()
|
||||
return {"message": f"Link {link_id} reset", "link": result}
|
||||
|
||||
|
||||
def _batch_capture(project_id, link_ids, action, data_builder, conn):
|
||||
"""Helper for batch capture start/stop."""
|
||||
def _act(lid):
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
|
||||
kwargs = data_builder(lid) if data_builder else {}
|
||||
conn.http_call("post", url, **kwargs)
|
||||
return {"link_id": lid, "status": "success"}
|
||||
except Exception as e:
|
||||
return {"link_id": lid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_act, link_ids))
|
||||
|
||||
|
||||
def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
dlt = params.get("data_link_type", "DLT_EN10MB")
|
||||
ws = params.get("wireshark", False)
|
||||
fname = params.get("capture_file_name")
|
||||
def _build(lid):
|
||||
data = {"data_link_type": dlt, "wireshark": ws}
|
||||
if fname:
|
||||
data["capture_file_name"] = fname
|
||||
return {"json_data": data}
|
||||
return _batch_capture(project_id, link_ids, "start", _build, conn)
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"data_link_type": params.get("data_link_type", "DLT_EN10MB"),
|
||||
"wireshark": params.get("wireshark", False),
|
||||
}
|
||||
if params.get("capture_file_name"):
|
||||
data["capture_file_name"] = params["capture_file_name"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
|
||||
result = conn.http_call("post", url, json_data=data).json()
|
||||
return {"message": f"Capture started on link {link_id}", "link": result}
|
||||
|
||||
|
||||
def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_capture(project_id, link_ids, "stop", None, conn)
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
|
||||
conn.http_call("post", url)
|
||||
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
|
||||
|
||||
|
||||
def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
results = []
|
||||
for lid in link_ids:
|
||||
url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{lid}/capture/file"
|
||||
entry = {"link_id": lid, "download_url": url}
|
||||
if download_token:
|
||||
cmd = f"curl -L -o capture_{lid}.pcap -H 'Authorization: Bearer {download_token}' '{url}'"
|
||||
entry["curl_command"] = cmd
|
||||
results.append(entry)
|
||||
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. Links include a 10-minute token."}
|
||||
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
|
||||
result = {
|
||||
"link_id": link_id,
|
||||
"download_url": download_url,
|
||||
"note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
|
||||
}
|
||||
if download_token:
|
||||
result["curl_command"] = f"curl -L -o capture.pcap -H 'Authorization: Bearer {download_token}' '{download_url}'"
|
||||
result["note"] += " The download link includes a 10-minute token."
|
||||
return result
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) handlers ──────────────────────────────────
|
||||
|
||||
|
||||
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage traffic-insight markers on a specific link.
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/links/{lid}/markers
|
||||
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
|
||||
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, link_id, action]):
|
||||
return {"error": "project_id, link_id and action are required"}
|
||||
if action not in ("create", "update", "delete"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
|
||||
if params.get("direction") in ("tx", "rx"):
|
||||
body["direction"] = params["direction"]
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
marker_name = params.get("marker_name")
|
||||
if not marker_name:
|
||||
return {"error": "marker_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{marker_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
|
||||
direction = params.get("direction")
|
||||
if direction == "both":
|
||||
body["direction"] = None
|
||||
elif direction in ("tx", "rx"):
|
||||
body["direction"] = direction
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
|
||||
|
||||
|
||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage project-level marker definitions (auto-fanout to all links).
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/marker-definitions → fans out global-{name} to every link
|
||||
- update: PUT /projects/{pid}/marker-definitions/{name}
|
||||
- delete: DELETE /projects/{pid}/marker-definitions/{name}
|
||||
- list: GET /projects/{pid}/marker-definitions
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, action]):
|
||||
return {"error": "project_id and action are required"}
|
||||
if action not in ("create", "update", "delete", "list"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
|
||||
|
||||
if action == "list":
|
||||
return conn.http_call("get", base).json()
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# No direction: a definition fans out to every link and auto-selects its
|
||||
# capture node on each, so tx/rx (which is relative to that node) has no
|
||||
# consistent meaning. Encode direction in the BPF instead.
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
def_name = params.get("def_name")
|
||||
if not def_name:
|
||||
return {"error": "def_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{def_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
if not body:
|
||||
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
|
||||
return conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
LINK_TOOLS = [
|
||||
{
|
||||
"name": "get_links",
|
||||
"description": "List all links in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_links_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_link",
|
||||
"description": "Get detailed information about a specific link",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": get_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_link",
|
||||
"description": "Create a link between two nodes in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"description": "List of node connections, each with node_id, adapter_number, port_number",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string"},
|
||||
"adapter_number": {"type": "integer"},
|
||||
"port_number": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"link_type": {"type": "string", "description": "Link type: ethernet or serial (optional)"},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]"
|
||||
},
|
||||
},
|
||||
"required": ["project_id", "nodes"],
|
||||
},
|
||||
"handler": create_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_link",
|
||||
"description": "Delete a link from a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": delete_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_link",
|
||||
"description": "Update a link's properties (suspend, filters, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
"suspend": {"type": "boolean", "description": "Suspend the link (optional)"},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]. Example: {\"frequency_drop\": [10], \"packet_loss\": [5]}"
|
||||
},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": update_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "reset_link",
|
||||
"description": "Reset a link, clearing its state (counters, filters, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": reset_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "start_capture",
|
||||
"description": "Start packet capture on a link. The capture file can later be downloaded with download_capture_file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
"data_link_type": {"type": "string", "description": "Data link type (optional, default: DLT_EN10MB)"},
|
||||
"capture_file_name": {"type": "string", "description": "Capture file name (optional)"},
|
||||
"wireshark": {"type": "boolean", "description": "Open Wireshark automatically (optional, default: false)"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": start_capture_handler,
|
||||
},
|
||||
{
|
||||
"name": "stop_capture",
|
||||
"description": "Stop packet capture on a link. After stopping, the capture file can be downloaded.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": stop_capture_handler,
|
||||
},
|
||||
{
|
||||
"name": "download_capture_file",
|
||||
"description": "Get the download URL and instructions for a PCAP capture file from a link. "
|
||||
"Use the returned curl command to download the file. "
|
||||
"The PCAP file can be analyzed with Wireshark or tcpdump.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": download_capture_file_handler,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,671 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 node management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 100
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Maximum bytes to return from get_node_file (safety net).
|
||||
# Larger files are truncated with a truncated=True flag.
|
||||
MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
nodes = [{k: n[k] for k in fields if k in n} for n in nodes]
|
||||
return {"nodes": nodes, "count": len(nodes)}
|
||||
|
||||
|
||||
VALID_NODE_FIELDS = {
|
||||
# NodeBase
|
||||
"compute_id", "name", "node_type", "node_id",
|
||||
"console", "console_type", "console_auto_start",
|
||||
"aux", "aux_type", "properties", "label", "symbol",
|
||||
"x", "y", "z", "locked",
|
||||
"port_name_format", "port_segment_size", "first_port_name",
|
||||
"custom_adapters", "tags",
|
||||
# Node
|
||||
"template_id", "project_id", "node_directory", "status",
|
||||
"command_line", "width", "height", "ports", "console_host",
|
||||
}
|
||||
|
||||
|
||||
def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
|
||||
"""Helper to run a lifecycle action on multiple nodes in parallel."""
|
||||
def _act(nid):
|
||||
try:
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
|
||||
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
|
||||
except Exception as e:
|
||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_act, node_ids))
|
||||
|
||||
|
||||
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "start", conn, "started")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={})
|
||||
return {"message": f"Node {node_id} started", "node_id": node_id}
|
||||
|
||||
|
||||
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "stop", conn, "stopped")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={})
|
||||
return {"message": f"Node {node_id} stopped", "node_id": node_id}
|
||||
|
||||
|
||||
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "suspend", conn, "suspended")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend")
|
||||
return {"message": f"Node {node_id} suspended", "node_id": node_id}
|
||||
|
||||
|
||||
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter node response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = ["node_id", "name", "node_type", "status", "console"]
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
|
||||
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
fields = params.get("fields")
|
||||
if fields is not None and not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
|
||||
|
||||
nodes = params.get("nodes")
|
||||
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
||||
# When top-level template_id is set, it applies to all nodes as a default
|
||||
if nodes is not None:
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
return {"error": "nodes must be a non-empty array"}
|
||||
default_tid = params.get("template_id")
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(node_data):
|
||||
tid = node_data.get("template_id", default_tid)
|
||||
if not tid:
|
||||
return {"template_id": tid, "status": "error", "error": "template_id is required"}
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
|
||||
body = {
|
||||
"x": node_data.get("x", 0),
|
||||
"y": node_data.get("y", 0),
|
||||
"compute_id": node_data.get("compute_id", "local"),
|
||||
}
|
||||
node_name = node_data.get("name")
|
||||
if node_name:
|
||||
body["name"] = node_name
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
|
||||
except Exception as e:
|
||||
return {"template_id": tid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
|
||||
futures = {pool.submit(_create_one, n): n for n in nodes}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
template_id = params.get("template_id")
|
||||
if not template_id:
|
||||
return {"error": "template_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"compute_id": params.get("compute_id", "local"),
|
||||
}
|
||||
node_name = params.get("name")
|
||||
if node_name:
|
||||
data["name"] = node_name
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{template_id}"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_node_response(resp, fields)
|
||||
|
||||
|
||||
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _del(nid):
|
||||
try:
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
|
||||
return {"node_id": nid, "status": "deleted"}
|
||||
except Exception as e:
|
||||
return {"node_id": nid, "status": "error", "error": str(e)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
|
||||
return list(pool.map(_del, node_ids))
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}")
|
||||
return {"message": f"Node {node_id} deleted", "node_id": node_id}
|
||||
|
||||
|
||||
def update_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "node_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
console_type = node.get("console_type", "unknown")
|
||||
# Short-lived JWT for the WebSocket URL (10 min)
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||
if ws_token:
|
||||
raw_url += f"?token={ws_token}"
|
||||
# Convert http scheme to ws for direct websocat usage
|
||||
ws_url = raw_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
result = {
|
||||
"node_id": node_id,
|
||||
"node_name": node.get("name"),
|
||||
"console_type": console_type,
|
||||
"ws_url": ws_url,
|
||||
"command": f"websocat -t --no-close {ws_url}",
|
||||
}
|
||||
if console_type in ("vnc",):
|
||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||
return result
|
||||
|
||||
|
||||
# ── Node file handlers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_node_files_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
|
||||
query = {}
|
||||
if params.get("path"):
|
||||
query["path"] = params["path"]
|
||||
if params.get("recursive"):
|
||||
query["recursive"] = "true"
|
||||
files = conn.http_call("get", url, params=query if query else None).json()
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
|
||||
def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
|
||||
offset = params.get("offset", 0)
|
||||
limit = params.get("limit", 200)
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
raw = conn.http_call("get", url).text
|
||||
|
||||
total_bytes = len(raw.encode("utf-8"))
|
||||
truncated = False
|
||||
if total_bytes > MAX_NODE_FILE_BYTES:
|
||||
raw = raw[:MAX_NODE_FILE_BYTES]
|
||||
truncated = True
|
||||
|
||||
lines = raw.splitlines(keepends=False)
|
||||
total_lines = len(lines)
|
||||
|
||||
# Apply offset/limit
|
||||
selected = lines[offset: offset + limit] if offset < total_lines else []
|
||||
has_more = (offset + limit) < total_lines or truncated
|
||||
|
||||
return {
|
||||
"file_path": file_path,
|
||||
"content": "\n".join(selected),
|
||||
"metadata": {
|
||||
"total_lines": total_lines,
|
||||
"total_bytes": total_bytes,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"returned_lines": len(selected),
|
||||
"returned_bytes": len("\n".join(selected).encode("utf-8")),
|
||||
"truncated": truncated or has_more,
|
||||
"has_more": has_more,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
content = params.get("content")
|
||||
if not project_id or not node_id or not file_path or content is None:
|
||||
return {"error": "project_id, node_id, file_path and content are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
|
||||
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
# ── Node bulk / advanced handlers ────────────────────────────────────
|
||||
|
||||
|
||||
def start_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/start")
|
||||
return {"message": "All nodes started", "project_id": project_id}
|
||||
|
||||
|
||||
def stop_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/stop")
|
||||
return {"message": "All nodes stopped", "project_id": project_id}
|
||||
|
||||
|
||||
def suspend_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/suspend")
|
||||
return {"message": "All nodes suspended", "project_id": project_id}
|
||||
|
||||
|
||||
def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
|
||||
return {"message": f"Node {node_id} duplicated", "node": result}
|
||||
|
||||
|
||||
def isolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/isolate")
|
||||
return {"message": f"Node {node_id} isolated (all links suspended)", "node_id": node_id}
|
||||
|
||||
|
||||
def unisolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/unisolate")
|
||||
return {"message": f"Node {node_id} unisolated (links resumed)", "node_id": node_id}
|
||||
|
||||
|
||||
def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/links").json()
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
NODE_TOOLS = [
|
||||
{
|
||||
"name": "get_nodes",
|
||||
"description": "List all nodes in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_nodes_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node",
|
||||
"description": "Get detailed information about a specific node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": get_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "start_node",
|
||||
"description": "Start a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": start_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "stop_node",
|
||||
"description": "Stop a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": stop_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "suspend_node",
|
||||
"description": "Suspend a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": suspend_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_node",
|
||||
"description": "Create a new node from a template in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"x": {"type": "integer", "description": "X coordinate (optional)"},
|
||||
"y": {"type": "integer", "description": "Y coordinate (optional)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional, default: local)"},
|
||||
},
|
||||
"required": ["project_id", "template_id"],
|
||||
},
|
||||
"handler": create_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_node",
|
||||
"description": "Delete a node from a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": delete_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_node",
|
||||
"description": "Update a node's properties (name, position, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"name": {"type": "string", "description": "New node name (optional)"},
|
||||
"x": {"type": "integer", "description": "New X position (optional)"},
|
||||
"y": {"type": "integer", "description": "New Y position (optional)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional)"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": update_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node_console_info",
|
||||
"description": "Get console WebSocket URL for a node (use websocat to connect)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": get_node_console_info_handler,
|
||||
},
|
||||
{
|
||||
"name": "list_node_files",
|
||||
"description": "List files in a node directory with metadata (name, size, type, modified time). "
|
||||
"Use recursive=true for a full recursive listing. "
|
||||
"Check file sizes before reading large files with get_node_file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"path": {"type": "string", "description": "Subdirectory path within node directory (optional)"},
|
||||
"recursive": {"type": "boolean", "description": "Recursively list all files (optional, default: false)"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": list_node_files_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node_file",
|
||||
"description": "Read a text file from a node directory. Returns file content line-by-line with offset/limit support. "
|
||||
"Best practice: start with offset=0, limit=200 to preview, then increase offset to read more. "
|
||||
"Large files (>50KB) are auto-truncated; check the metadata.truncated flag. "
|
||||
"For binary files, check file type via list_node_files first.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
"offset": {"type": "integer", "description": "Line offset to start reading from (optional, default: 0)"},
|
||||
"limit": {"type": "integer", "description": "Maximum number of lines to return (optional, default: 200)"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path"],
|
||||
},
|
||||
"handler": get_node_file_handler,
|
||||
},
|
||||
{
|
||||
"name": "write_node_file",
|
||||
"description": "Write content to a file in a node directory. Creates the file if it doesn't exist. "
|
||||
"Overwrites existing content. Useful for updating configuration files on nodes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
"content": {"type": "string", "description": "Content to write to the file"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path", "content"],
|
||||
},
|
||||
"handler": write_node_file_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_node_file",
|
||||
"description": "Delete a file from a node directory. Cannot be undone. "
|
||||
"Use list_node_files to confirm the file path before deleting.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path"],
|
||||
},
|
||||
"handler": delete_node_file_handler,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,340 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tools for GNS3 project management.
|
||||
|
||||
Tool handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
"""Create a Gns3Connector from the GNS3 context dict."""
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
|
||||
return {"projects": projects, "count": len(projects)}
|
||||
|
||||
|
||||
def get_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
project = conn.http_call("get", f"{conn.base_url}/projects/{project_id}").json()
|
||||
if project is None:
|
||||
return {"error": f"Project '{project_id}' not found"}
|
||||
return project
|
||||
|
||||
|
||||
def create_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return {"error": "name is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("post", f"{conn.base_url}/projects", json_data={"name": name}).json()
|
||||
|
||||
|
||||
def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}")
|
||||
return {"message": f"Project '{project_id}' deleted", "project_id": project_id}
|
||||
|
||||
|
||||
def open_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/open"
|
||||
return conn.http_call("post", url).json()
|
||||
|
||||
|
||||
def close_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/close"
|
||||
conn.http_call("post", url)
|
||||
return {"message": f"Project '{project_id}' closed", "project_id": project_id}
|
||||
|
||||
|
||||
def get_project_stats_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/stats"
|
||||
return conn.http_call("get", url).json()
|
||||
|
||||
|
||||
def update_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
kwargs = {k: v for k, v in params.items() if k != "project_id" and v is not None}
|
||||
return conn.http_call("put", f"{conn.base_url}/projects/{project_id}", json_data=kwargs).json()
|
||||
|
||||
|
||||
def duplicate_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return {"error": "name is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
kwargs = {k: v for k, v in params.items() if k not in ("project_id",) and v is not None}
|
||||
return conn.http_call("post", f"{conn.base_url}/projects/{project_id}/duplicate", json_data=kwargs).json()
|
||||
|
||||
|
||||
def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
|
||||
content = conn.http_call("get", url).text
|
||||
return {"project_id": project_id, "file": "README.txt", "content": content}
|
||||
except Exception as e:
|
||||
if "404" in str(e):
|
||||
return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"}
|
||||
raise
|
||||
|
||||
|
||||
def update_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
content = params.get("content")
|
||||
if content is None:
|
||||
return {"error": "content is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/files/README.txt"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
|
||||
return {"message": "README.txt updated", "project_id": project_id}
|
||||
|
||||
|
||||
def lock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/lock")
|
||||
return {"message": f"Project {project_id} locked", "project_id": project_id}
|
||||
|
||||
|
||||
def unlock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/unlock")
|
||||
return {"message": f"Project {project_id} unlocked", "project_id": project_id}
|
||||
|
||||
|
||||
def load_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
path = params.get("path")
|
||||
if not path:
|
||||
return {"error": "path is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/load", json_data={"path": path}).json()
|
||||
return {"message": f"Project loaded from {path}", "project": result}
|
||||
|
||||
|
||||
def get_locked_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
locked = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/locked").json()
|
||||
return {"project_id": project_id, "locked": locked}
|
||||
|
||||
|
||||
# ── Tool definitions (consumed by mcp/__init__.py) ─────────────────────────
|
||||
|
||||
PROJECT_TOOLS = [
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "List all GNS3 projects accessible to the current user",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
"handler": list_projects_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project",
|
||||
"description": "Get detailed information about a specific project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_project",
|
||||
"description": "Create a new GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Project name"},
|
||||
"description": {"type": "string", "description": "Optional project description"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"handler": create_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_project",
|
||||
"description": "Delete a GNS3 project permanently",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project to delete"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": delete_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "open_project",
|
||||
"description": "Open a closed GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": open_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "close_project",
|
||||
"description": "Close an open GNS3 project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": close_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project_stats",
|
||||
"description": "Get statistics (nodes, links, snapshots, drawings) for a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_stats_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_project",
|
||||
"description": "Update a project's properties (name, auto_close, auto_open, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"name": {"type": "string", "description": "New project name"},
|
||||
"auto_close": {"type": "boolean", "description": "Close project when last client leaves"},
|
||||
"auto_open": {"type": "boolean", "description": "Project opens when GNS3 starts"},
|
||||
"auto_start": {"type": "boolean", "description": "Project starts when opened"},
|
||||
"scene_width": {"type": "integer", "description": "Width of the drawing area"},
|
||||
"scene_height": {"type": "integer", "description": "Height of the drawing area"},
|
||||
"zoom": {"type": "integer", "description": "Zoom of the drawing area"},
|
||||
"show_layers": {"type": "boolean", "description": "Show layers on the drawing area"},
|
||||
"snap_to_grid": {"type": "boolean", "description": "Snap to grid on the drawing area"},
|
||||
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
|
||||
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
|
||||
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
|
||||
"show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": update_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "duplicate_project",
|
||||
"description": "Duplicate a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "UUID of the project to duplicate"},
|
||||
"name": {"type": "string", "description": "New project name"},
|
||||
"reset_mac_addresses": {"type": "boolean", "description": "Reset MAC addresses for this project"},
|
||||
},
|
||||
"required": ["project_id", "name"],
|
||||
},
|
||||
"handler": duplicate_project_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_project_readme",
|
||||
"description": "Get the content of a project's README.md file (project documentation, Markdown format)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_project_readme_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_project_readme",
|
||||
"description": "Update or create a project's README.md file (project documentation, Markdown format)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"content": {"type": "string", "description": "Content to write to README.md (Markdown format)"},
|
||||
},
|
||||
"required": ["project_id", "content"],
|
||||
},
|
||||
"handler": update_project_readme_handler,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 server information.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/version").json()
|
||||
|
||||
|
||||
def get_statistics_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/statistics").json()
|
||||
@@ -0,0 +1,79 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 snapshot management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
snapshots = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/snapshots").json()
|
||||
return {"snapshots": snapshots, "count": len(snapshots)}
|
||||
|
||||
|
||||
def create_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
name = params.get("name")
|
||||
if not project_id or not name:
|
||||
return {"error": "project_id and name are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots", json_data={"name": name}).json()
|
||||
return {"message": f"Snapshot '{name}' created", "snapshot": result}
|
||||
|
||||
|
||||
def delete_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
snapshot_id = params.get("snapshot_id")
|
||||
if not project_id or not snapshot_id:
|
||||
return {"error": "project_id and snapshot_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}")
|
||||
return {"message": f"Snapshot {snapshot_id} deleted", "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def restore_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
snapshot_id = params.get("snapshot_id")
|
||||
if not project_id or not snapshot_id:
|
||||
return {"error": "project_id and snapshot_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}/restore").json()
|
||||
return {"message": f"Snapshot {snapshot_id} restored", "project": result}
|
||||
@@ -0,0 +1,101 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 symbol management.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
|
||||
return {"symbols": symbols, "count": len(symbols)}
|
||||
|
||||
|
||||
def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
result = {
|
||||
"symbol_id": symbol_id,
|
||||
"download_url": download_url,
|
||||
"note": "Symbol files are SVG images.",
|
||||
}
|
||||
if download_token:
|
||||
safe_name = symbol_id.replace(':', '').replace('/', '_')
|
||||
result["curl_command"] = f"curl -L -o '{safe_name}.svg' -H 'Authorization: Bearer {download_token}' '{download_url}'"
|
||||
result["note"] += " Download link includes a 10-minute token."
|
||||
return result
|
||||
|
||||
|
||||
def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/symbols/{symbol_id}/dimensions").json()
|
||||
|
||||
|
||||
def get_default_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
symbols = conn.http_call("get", f"{conn.base_url}/symbols/default_symbols").json()
|
||||
return {"default_symbols": symbols}
|
||||
|
||||
|
||||
def upload_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
content = params.get("content")
|
||||
if not symbol_id or content is None:
|
||||
return {"error": "symbol_id and content (SVG data) are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/symbols/{symbol_id}/raw"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "image/svg+xml"})
|
||||
return {"message": f"Symbol {symbol_id} uploaded", "symbol_id": symbol_id}
|
||||
|
||||
|
||||
def delete_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
symbol_id = params.get("symbol_id")
|
||||
if not symbol_id:
|
||||
return {"error": "symbol_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/symbols/{symbol_id}")
|
||||
return {"message": f"Symbol {symbol_id} deleted", "symbol_id": symbol_id}
|
||||
@@ -0,0 +1,229 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 template management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
VALID_TEMPLATE_FIELDS = {
|
||||
"template_id", "name", "version", "category", "default_name_format",
|
||||
"symbol", "template_type", "compute_id", "usage", "tags", "builtin",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
|
||||
|
||||
|
||||
def _filter_templates(templates, fields):
|
||||
"""Filter each template to only include requested fields."""
|
||||
if not fields:
|
||||
fields = TEMPLATE_DEFAULT_FIELDS
|
||||
if isinstance(templates, dict):
|
||||
return {k: templates[k] for k in fields if k in templates}
|
||||
return [{k: t[k] for k in fields if k in t} for t in templates]
|
||||
|
||||
|
||||
def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_TEMPLATE_FIELDS),
|
||||
}
|
||||
templates = _filter_templates(templates, fields)
|
||||
return {"templates": templates, "count": len(templates)}
|
||||
|
||||
|
||||
def get_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
if template_id:
|
||||
template = conn.http_call("get", f"{conn.base_url}/templates/{template_id}").json()
|
||||
else:
|
||||
# Find template by name
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template = matches[0]
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def create_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
name = params.get("name")
|
||||
template_type = params.get("template_type")
|
||||
if not name or not template_type:
|
||||
return {"error": "name and template_type are required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"name": name,
|
||||
"template_type": template_type,
|
||||
"compute_id": params.get("compute_id", "local"),
|
||||
}
|
||||
# Pass through optional template-type-specific fields (image, qemu_path, etc.)
|
||||
for key in ("image",):
|
||||
if key in params:
|
||||
data[key] = params[key]
|
||||
return conn.http_call("post", f"{conn.base_url}/templates", json_data=data).json()
|
||||
|
||||
|
||||
def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Resolve name to ID if needed
|
||||
if not template_id and name:
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template_id = matches[0]["template_id"]
|
||||
|
||||
update_data = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")}
|
||||
# Support nested kwargs from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
|
||||
return conn.http_call("put", f"{conn.base_url}/templates/{template_id}", json_data=update_data).json()
|
||||
|
||||
|
||||
def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
template_id = params.get("template_id")
|
||||
name = params.get("name")
|
||||
|
||||
if not template_id and not name:
|
||||
return {"error": "template_id or name is required"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
if not template_id and name:
|
||||
all_templates = conn.http_call("get", f"{conn.base_url}/templates").json()
|
||||
matches = [t for t in all_templates if t.get("name") == name]
|
||||
if not matches:
|
||||
return {"error": f"Template '{name}' not found"}
|
||||
template_id = matches[0]["template_id"]
|
||||
|
||||
conn.http_call("delete", f"{conn.base_url}/templates/{template_id}")
|
||||
return {"message": f"Template deleted"}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATE_TOOLS = [
|
||||
{
|
||||
"name": "list_templates",
|
||||
"description": "List all available templates on the server",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
"handler": list_templates_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_template",
|
||||
"description": "Get detailed information about a specific template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": get_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_template",
|
||||
"description": "Create a new template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
"template_type": {"type": "string", "description": "Template type (e.g. qemu, docker, dynamips)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional, default: local)"},
|
||||
},
|
||||
"required": ["name", "template_type"],
|
||||
},
|
||||
"handler": create_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_template",
|
||||
"description": "Update an existing template's properties",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": update_template_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_template",
|
||||
"description": "Delete a template",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
},
|
||||
},
|
||||
"handler": delete_template_handler,
|
||||
},
|
||||
]
|
||||
+25
-12
@@ -47,6 +47,24 @@ from gns3server.api.routes import controller, index
|
||||
from gns3server.api.routes.compute import compute_api
|
||||
from gns3server.core import tasks
|
||||
|
||||
# MCP is an optional feature — import only if dependencies are installed
|
||||
from gns3server.agent import MCP_AVAILABLE
|
||||
|
||||
if MCP_AVAILABLE:
|
||||
from gns3server.api.routes import mcp
|
||||
_mcp_router = mcp.router
|
||||
else:
|
||||
from fastapi import APIRouter
|
||||
|
||||
_mcp_router = APIRouter(prefix="/mcp", tags=["MCP"])
|
||||
|
||||
@_mcp_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"])
|
||||
async def mcp_not_available(path: str = ""):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="MCP is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -76,11 +94,18 @@ def get_application() -> FastAPI:
|
||||
application.mount("/static", StaticFiles(packages=[('gns3server', 'static')], html=True), name="static")
|
||||
application.mount("/v3/compute", compute_api, name="compute")
|
||||
|
||||
# Register MCP routes (stub returns 501 if MCP dependencies are not installed)
|
||||
application.include_router(_mcp_router, prefix="/v3", tags=["MCP"])
|
||||
|
||||
return application
|
||||
|
||||
|
||||
app = get_application()
|
||||
|
||||
# Register MCP SSE transport routes (Starlette-level, for raw ASGI access)
|
||||
if MCP_AVAILABLE:
|
||||
mcp.register_starlette_routes(app)
|
||||
|
||||
# Monkey Patch uvicorn signal handler to detect the application is shutting down
|
||||
app.state.exiting = False
|
||||
unicorn_exit_handler = UvicornServer.handle_exit
|
||||
@@ -208,15 +233,3 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={"message": str(exc)}
|
||||
)
|
||||
|
||||
# FIXME: do not use this middleware since it creates issue when using StreamingResponse
|
||||
# see https://starlette-context.readthedocs.io/en/latest/middleware.html#why-are-there-two-middlewares-that-do-the-same-thing
|
||||
|
||||
# @app.middleware("http")
|
||||
# async def add_extra_headers(request: Request, call_next):
|
||||
# start_time = time.time()
|
||||
# response = await call_next(request)
|
||||
# process_time = time.time() - start_time
|
||||
# response.headers["X-Process-Time"] = str(process_time)
|
||||
# response.headers["X-GNS3-Server-Version"] = f"{__version__}"
|
||||
# return response
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"appliance_id": "b3b90fde-143a-4129-8031-ccbba73c5e02",
|
||||
"name": "armbian",
|
||||
"category": "guest",
|
||||
"description": "A highly optimized base operating system specialized for single board computers (SBCs) and its extensive build framework.",
|
||||
"vendor_name": "The Armbian team",
|
||||
"vendor_url": "https://armbian.com/",
|
||||
"documentation_url": "https://docs.armbian.com/",
|
||||
"product_name": "Armbian UEFI x86",
|
||||
"product_url": "https://armbian.com/boards/uefi-x86",
|
||||
"registry_version": 4,
|
||||
"status": "stable",
|
||||
"maintainer": "GNS3 Team",
|
||||
"maintainer_email": "developers@gns3.net",
|
||||
"usage": "By first login you create root password and new sudo user.\n\nBoot disk from UEFI shell, type: FS0:EFI\\BOOT\\BOOTX64 and press <Enter>",
|
||||
"port_name_format": "Ethernet{0}",
|
||||
"qemu": {
|
||||
"adapter_type": "virtio-net-pci",
|
||||
"adapters": 2,
|
||||
"ram": 256,
|
||||
"hda_disk_interface": "virtio",
|
||||
"arch": "x86_64",
|
||||
"console_type": "spice+agent",
|
||||
"uefi": false,
|
||||
"boot_priority": "c",
|
||||
"kvm": "require",
|
||||
"options": "-nographic"
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"filename": "OVMF-edk2-stable202305.fd",
|
||||
"version": "stable202305",
|
||||
"md5sum": "6c4cf1519fec4a4b95525d9ae562963a",
|
||||
"filesize": 4194304,
|
||||
"download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/",
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download",
|
||||
"compression": "zip"
|
||||
},
|
||||
{
|
||||
"filename": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2",
|
||||
"version": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"md5sum": "7f4c915668718d6135406de5a6c4fc30",
|
||||
"filesize": 877920512,
|
||||
"download_url": "https://armbian.com/boards/uefi-x86",
|
||||
"direct_download_url": "https://armbian.atomonetworks.com/dl/uefi-x86/archive/Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2.xz",
|
||||
"compression": "xz"
|
||||
}
|
||||
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "Armbian 26.5.1 Minimal (CLI)",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,250 +51,12 @@
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.3-build2487-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.3",
|
||||
"md5sum": "b01d9f86aa27c538407d518df1326863",
|
||||
"filesize": 346107904,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.2-build2397-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.2",
|
||||
"md5sum": "36371fbf06210ded57c00b2ff290f2c5",
|
||||
"filesize": 322514944,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.4.1-build2308-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.4.1",
|
||||
"md5sum": "e542cc8f2d8f46e9c32b783bf31bef39",
|
||||
"filesize": 309387264,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.5-build1574-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.5",
|
||||
"md5sum": "754326845096afd909ec45d98f8d5a83",
|
||||
"filesize": 278401024,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.4-build1460-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.4",
|
||||
"md5sum": "98fa9830d9ecb5911a703d03b80026b6",
|
||||
"filesize": 261992448,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.2-build1334-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.2",
|
||||
"md5sum": "2ff1298257321cd485d2cad91d6ce510",
|
||||
"filesize": 246083584,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.2.1-build1215-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.2.1",
|
||||
"md5sum": "1a3eeff1204fa8f4243773f7521e12b5",
|
||||
"filesize": 242814976,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.12-build0623-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.12",
|
||||
"md5sum": "5b6f6a2b8bc00e56337aa7023a9025cf",
|
||||
"filesize": 249520128,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.11-build0595-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.11",
|
||||
"md5sum": "7b166222136e26190159f37cccbaab6e",
|
||||
"filesize": 249360384,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.9-build0489-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.9",
|
||||
"md5sum": "dbeb6a79b6e421000573dbbbdb50b8b5",
|
||||
"filesize": 247955456,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.6-build0372-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.6",
|
||||
"md5sum": "dfa4df9e976ed87e73cb9601a8a70323",
|
||||
"filesize": 239190016,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v7.0.5-build0365-FORTINET.out.kvm.qcow2",
|
||||
"version": "7.0.5",
|
||||
"md5sum": "e8b9c992784cea766b52a427a5fe0279",
|
||||
"filesize": 237535232,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6.4.14-build2660-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.14",
|
||||
"md5sum": "0fe56e363b166c07b710bde795e36049",
|
||||
"filesize": 219430912,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6.4.12-build2610-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.12",
|
||||
"md5sum": "36c0dc531d921e5f1e1e09b030f7c813",
|
||||
"filesize": 219455488,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build2288-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.5",
|
||||
"md5sum": "bd2791984b03f55a6825297e83c6576a",
|
||||
"filesize": 117014528,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build2253-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.4.4",
|
||||
"md5sum": "3554a47fde2dc91d17eec16fd0dc10a3",
|
||||
"filesize": 116621312,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build1183-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.2.2",
|
||||
"md5sum": "f5051a8fe49d916bb554b9bae32a1eb4",
|
||||
"filesize": 139145216,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build1050-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.2.0",
|
||||
"md5sum": "c19d2527f91ad1bbafbde5bf08487867",
|
||||
"filesize": 126894080,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0349-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.6",
|
||||
"md5sum": "d03f024c948ba6e2bb9e66c11ca8f34c",
|
||||
"filesize": 112553984,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0255-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.3",
|
||||
"md5sum": "5f34d52d9289b0be2a4c04943446ea39",
|
||||
"filesize": 115703808,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0205-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.2",
|
||||
"md5sum": "8f748649c537d9b5466b24c5b4e62017",
|
||||
"filesize": 116981760,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v6-build0092-FORTINET.out.kvm.qcow2",
|
||||
"version": "6.0.0",
|
||||
"md5sum": "73bfe1bc70124521a524d857646b9c2e",
|
||||
"filesize": 119066624,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1631-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.2",
|
||||
"md5sum": "c81cc247e8eb03249b475fe0e847653e",
|
||||
"filesize": 106946560,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1619-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.1",
|
||||
"md5sum": "8cc553842564d232af295d6a0c784c1f",
|
||||
"filesize": 106831872,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1557-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.6.0",
|
||||
"md5sum": "f8bd600796f894f4ca1ea2d6b4066d3d",
|
||||
"filesize": 108363776,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1225-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.4",
|
||||
"md5sum": "53bc6e320fe7bde5d2b636bde95a910c",
|
||||
"filesize": 89911296,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1187-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.3",
|
||||
"md5sum": "53602c776d215d98e32163a10804fc49",
|
||||
"filesize": 87425024,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1151-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.2",
|
||||
"md5sum": "8e131ad40009c740f3efdee6dc3a0ac3",
|
||||
"filesize": 86437888,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1082-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.1",
|
||||
"md5sum": "fc1815410f3f0536e2e3a9c1c5c07f41",
|
||||
"filesize": 83124224,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build1019-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.4.0",
|
||||
"md5sum": "1cfb22671cb372d8bf3e47b9c3c55ded",
|
||||
"filesize": 77541376,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0786-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.10",
|
||||
"md5sum": "377fe38bf07bc2435608e5b65f780f07",
|
||||
"filesize": 64962560,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0780-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.9",
|
||||
"md5sum": "04268e779d3d5e6c928c6fd638423c52",
|
||||
"filesize": 65007616,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0777-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.8",
|
||||
"md5sum": "6dbf148ace9bf309ad383757afd75fad",
|
||||
"filesize": 65011712,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "FMG_VM64_KVM-v5-build0757-FORTINET.out.kvm.qcow2",
|
||||
"version": "5.2.7",
|
||||
"md5sum": "d37dbaa49d7522324681eeba19f7699b",
|
||||
"filesize": 65056768,
|
||||
"download_url": "https://support.fortinet.com/Download/FirmwareImages.aspx"
|
||||
},
|
||||
{
|
||||
"filename": "empty30G.qcow2",
|
||||
"filename": "empty500G.qcow2",
|
||||
"version": "1.0",
|
||||
"md5sum": "3411a599e822f2ac6be560a26405821a",
|
||||
"filesize": 197120,
|
||||
"md5sum": "658c825441b9b3080ba00f9eec002eaa",
|
||||
"filesize": 204608,
|
||||
"download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/",
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/empty30G.qcow2/download"
|
||||
"direct_download_url": "https://sourceforge.net/projects/gns-3/files/Empty%20Qemu%20disk/empty500G.qcow2/download"
|
||||
}
|
||||
],
|
||||
"versions": [
|
||||
@@ -302,259 +64,21 @@
|
||||
"name": "7.4.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.6.M-build2588-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.5.M-build2553-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.4.F-build2550-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.3-build2487-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.2-build2397-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.4.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.4.1-build2308-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.5-build1574-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.4-build1460-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.2-build1334-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.2.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.2.1-build1215-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.12",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.12-build0623-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.11",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.11-build0595-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.9",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.9-build0489-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.6-build0372-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "7.0.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v7.0.5-build0365-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.14",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6.4.14-build2660-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.12",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6.4.12-build2610-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.5",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build2288-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build2253-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.2.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build1183-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.2.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build1050-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.6",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0349-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0255-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0205-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "6.0.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v6-build0092-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1631-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1619-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.6.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1557-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.4",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1225-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.3",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1187-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.2",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1151-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.1",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1082-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.4.0",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build1019-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.10",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0786-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.9",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0780-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.8",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0777-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.2.7",
|
||||
"images": {
|
||||
"hda_disk_image": "FMG_VM64_KVM-v5-build0757-FORTINET.out.kvm.qcow2",
|
||||
"hdb_disk_image": "empty30G.qcow2"
|
||||
"hdb_disk_image": "empty500G.qcow2"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -132,9 +132,37 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"filename": "infix-x86_64-v26.05.0.qcow2",
|
||||
"filesize": 330039296,
|
||||
"md5sum": "60f7c36c389c33ab108acc021f41ccd5",
|
||||
"version": "26.05.0",
|
||||
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.05.0/infix-x86_64-v26.05.0.qcow2"
|
||||
},
|
||||
{
|
||||
"filename": "infix-x86_64-v26.06.0.qcow2",
|
||||
"filesize": 363593728,
|
||||
"md5sum": "79ca8bd8534bbaa1af0ab874a49d6f4c",
|
||||
"version": "26.06.0",
|
||||
"direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.06.0/infix-x86_64-v26.06.0.qcow2"
|
||||
}
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "26.06.0",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "infix-x86_64-v26.06.0.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "26.05.0",
|
||||
"images": {
|
||||
"bios_image": "OVMF-edk2-stable202305.fd",
|
||||
"hda_disk_image": "infix-x86_64-v26.05.0.qcow2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "26.03.0",
|
||||
"images": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"symbol": "linux_guest.svg",
|
||||
"docker": {
|
||||
"adapters": 1,
|
||||
"image": "gns3/ubuntu:noble",
|
||||
"image": "gns3/ubuntu:resolute",
|
||||
"console_type": "telnet"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@
|
||||
"filesize": 17825792,
|
||||
"direct_download_url": "https://dropzone.westermo.com/file.aspx?id=e6a7676d-85a7-4374-8961-c68aacb74921"
|
||||
},
|
||||
{
|
||||
"filename": "WeOS-zero-5.29.0.disk",
|
||||
"version": "5.29.0",
|
||||
"filesize": 81788928,
|
||||
"md5sum": "f464de7b2b424f4a8ad7c650108fee3d",
|
||||
"direct_download_url": "https://dropzone.westermo.com/file.aspx?id=52655074-0fab-4119-ba01-b68200a733ab"
|
||||
},
|
||||
{
|
||||
"filename": "WeOS-zero-5.28.0.disk",
|
||||
"version": "5.28.0",
|
||||
@@ -54,6 +61,13 @@
|
||||
}
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "5.29.0",
|
||||
"images": {
|
||||
"hda_disk_image": "WeOS-zero-5.29.0.disk",
|
||||
"hdb_disk_image": "Config-zero-1.0.0.disk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "5.28.0",
|
||||
"images": {
|
||||
|
||||
@@ -356,6 +356,7 @@ class BaseManager:
|
||||
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_tap":
|
||||
tap_device = nio_settings["tap_device"]
|
||||
|
||||
@@ -100,6 +100,9 @@ class BaseNode:
|
||||
self._internal_aux_port = None
|
||||
self._custom_adapters = []
|
||||
self._ubridge_require_privileged_access = False
|
||||
# marker filter name -> uBridge bridge_name (recorded at apply time so
|
||||
# _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild).
|
||||
self._marker_filter_bridges = {}
|
||||
|
||||
if self._console is not None:
|
||||
# use a previously allocated console port
|
||||
@@ -926,27 +929,71 @@ class BaseNode:
|
||||
raise NodeError("uBridge requires root access or the capability to interact with network adapters")
|
||||
|
||||
server_host = self._manager.config.settings.Server.host
|
||||
transport = self._manager.config.settings.Server.ubridge_control_transport
|
||||
if not self.ubridge:
|
||||
self._ubridge_hypervisor = Hypervisor(self._project, self.ubridge_path, self.working_dir, server_host)
|
||||
log.info(f"Starting new uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}")
|
||||
self._ubridge_hypervisor = Hypervisor(
|
||||
self._project, self.ubridge_path, self.working_dir, transport, server_host, self.id
|
||||
)
|
||||
log.info(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}")
|
||||
await self._ubridge_hypervisor.start()
|
||||
if self._ubridge_hypervisor:
|
||||
log.info(
|
||||
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
|
||||
f"Hypervisor at {self._ubridge_hypervisor.endpoint} has successfully started"
|
||||
)
|
||||
await self._ubridge_hypervisor.connect()
|
||||
# Tell this uBridge where to send MARK signals and which node id to
|
||||
# tag them with. Marker is opt-in and inert until a `mark` filter is
|
||||
# added, so this never disturbs the data plane.
|
||||
await self._ubridge_configure_marker_sink()
|
||||
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
|
||||
self._ubridge_require_privileged_access = require_privileged_access
|
||||
|
||||
async def _ubridge_configure_marker_sink(self):
|
||||
"""
|
||||
Point this node's uBridge at the compute's marker UDP sink and tag its
|
||||
signals with this node's id. Safe to call before any marker filter
|
||||
exists — uBridge stays inert until a ``mark`` filter is configured.
|
||||
|
||||
Old uBridge builds without the marker module are tolerated: the failure
|
||||
is downgraded to a warning so node start is not blocked by an opt-in
|
||||
observability feature.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
if not manager.running or not manager.host or not manager.port:
|
||||
return
|
||||
if self._ubridge_hypervisor is None:
|
||||
return
|
||||
try:
|
||||
# Talk to the hypervisor directly, NOT via _ubridge_send: this runs
|
||||
# inside _start_ubridge, which is reached THROUGH _ubridge_send when
|
||||
# uBridge starts lazily (e.g. linking a stopped node). _ubridge_send's
|
||||
# lock is non-reentrant, so calling it again here would deadlock on
|
||||
# the held ___ubridge_send_lock. uBridge is already running and
|
||||
# connected at this point, so the raw hypervisor send is safe.
|
||||
await self._ubridge_hypervisor.send(f"marker sink {manager.host} {manager.port}")
|
||||
await self._ubridge_hypervisor.send(f"marker node {self._id}")
|
||||
except UbridgeError:
|
||||
log.warning(
|
||||
"uBridge does not support the marker module; traffic insight disabled for node %r",
|
||||
self.name,
|
||||
)
|
||||
|
||||
async def _stop_ubridge(self):
|
||||
"""
|
||||
Stops uBridge.
|
||||
"""
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
log.info(f"Stopping uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}")
|
||||
log.info(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}")
|
||||
await self._ubridge_hypervisor.stop()
|
||||
self._ubridge_hypervisor = None
|
||||
# uBridge is gone, so every marker filter (and its in-bridge state) is
|
||||
# gone too — clear the map so the next apply re-installs them all rather
|
||||
# than skipping them as "already installed".
|
||||
self._marker_filter_bridges.clear()
|
||||
|
||||
async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
|
||||
"""
|
||||
@@ -983,10 +1030,12 @@ class BaseNode:
|
||||
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
|
||||
if destination_nio:
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def ubridge_delete_bridge(self, name):
|
||||
"""
|
||||
@@ -1042,6 +1091,242 @@ class BaseNode:
|
||||
)
|
||||
i += 1
|
||||
|
||||
@staticmethod
|
||||
def _marker_linktype(data_link_type):
|
||||
"""
|
||||
Normalize a GNS3 pcap data-link type (e.g. ``DLT_C_HDLC``) to the bare
|
||||
uBridge ``linktype`` token (``C_HDLC``) by stripping the ``DLT_`` prefix.
|
||||
Returns ``None`` for Ethernet (``DLT_EN10MB`` / unset) so the ``linktype``
|
||||
keyword is omitted and uBridge defaults to EN10MB. Values come straight
|
||||
from ``SerialPort.data_link_types`` (the single source of truth); uBridge
|
||||
resolves them with ``pcap_datalink_name_to_val``, which is case-sensitive
|
||||
and expects the canonical uppercase form.
|
||||
"""
|
||||
if not data_link_type:
|
||||
return None
|
||||
dlt = data_link_type.upper()
|
||||
if dlt.startswith("DLT_"):
|
||||
dlt = dlt[4:]
|
||||
return None if dlt == "EN10MB" else dlt
|
||||
|
||||
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None):
|
||||
"""
|
||||
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
|
||||
|
||||
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
|
||||
and (b) appends the packet to ``pcap_path``. Unlike the impairment
|
||||
filters, this is an observability tap: it never drops or alters traffic,
|
||||
and it is added/removed on its own (not via reset_packet_filters) so the
|
||||
pcap is not closed/reopened on unrelated filter changes.
|
||||
|
||||
:param bridge_name: uBridge bridge carrying the link's traffic
|
||||
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
|
||||
# name travels from the controller REST layer (MarkerCreate schema) but is
|
||||
# validated here too as defense-in-depth against hand-edited topology files.
|
||||
# Note: "global-*" names are legitimate here — they come from project-level
|
||||
# marker definitions (inherit_marker). The prefix is only forbidden at the
|
||||
# user-facing schema layer, not at the uBridge boundary.
|
||||
_MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
||||
# Defense-in-depth vs hand-edited topology: the user-facing name is capped
|
||||
# at 32 by the schema; inherited copies carry a ``global-`` prefix (≤ 39),
|
||||
# so allow up to 48 here.
|
||||
if not _MARKER_NAME_RE.match(name) or len(name) > 48:
|
||||
raise UbridgeError(f"Invalid marker name: {name!r}")
|
||||
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
|
||||
bridge=bridge_name, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# Per-link attribution (contract §3.2): when one ubridge bridge serves
|
||||
# several GNS3 links (e.g. IOU's per-node bridge), bridge+filter collide,
|
||||
# so the link id is the only way to tell signals — and pcap files — apart.
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(data_link_type)
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
# Let BPF compile errors propagate — the marker is the user's intent, so a
|
||||
# bad expression must surface instead of being silently dropped.
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def delete_marker_capture(self, name, link_id, nio=None):
|
||||
"""
|
||||
Remove a marker from uBridge (fine-grained ``delete_packet_filter`` — NOT
|
||||
reset_packet_filters, so sibling markers' pcaps aren't closed/reopened)
|
||||
and delete its capture pcap. Called by the controller when a marker is
|
||||
removed; safe with the node stopped (filter removal is skipped, the file
|
||||
is still unlinked). IOU overrides ``_ubridge_delete_marker_filter`` for
|
||||
its ``iol_bridge`` command shape.
|
||||
|
||||
``nio`` is the port NIO whose cached ``nio.markers`` carries this marker
|
||||
spec; it is dropped here so a later node start / NIO reapply
|
||||
(``_ubridge_apply_markers``) does not reinstall the marker. Without this,
|
||||
deleting a marker while the node is stopped left the spec in
|
||||
``nio.markers``, and starting the node recreated an empty pcap.
|
||||
"""
|
||||
if nio is not None and getattr(nio, "markers", None):
|
||||
nio.markers.pop(name, None)
|
||||
bridge_name = self._marker_filter_bridges.pop((name, link_id), None)
|
||||
if bridge_name is not None:
|
||||
await self._ubridge_delete_marker_filter(bridge_name, name)
|
||||
try:
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")
|
||||
os.remove(pcap_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
"""
|
||||
Remove a single marker filter from uBridge with ``delete_packet_filter``
|
||||
(not a bridge-wide reset) so other markers keep their pcaps open. A no-op
|
||||
when uBridge isn't running — the pcap cleanup in the caller still proceeds.
|
||||
"""
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove marker filter '%s' from %s: %s", name, bridge_name, e)
|
||||
|
||||
async def rebuild_marker_filter(self, name, link_id, bpf, tag=None, direction=None, enabled=True):
|
||||
"""
|
||||
Re-install a single marker filter with new params (delete + add), without
|
||||
a bridge-wide reset — so sibling markers keep their pcaps open. uBridge
|
||||
reopens the marker's own pcap on re-add (a new capture session for the
|
||||
new BPF), which is expected. No-op if the marker isn't installed (node
|
||||
stopped) — the next NIO reapply picks up the updated ``_markers``.
|
||||
|
||||
IOU needs no override: this calls ``_ubridge_delete_marker_filter`` /
|
||||
``_ubridge_add_marker_filter`` / ``_ubridge_set_marker_filter_state``,
|
||||
all of which IOU already overrides for ``iol_bridge``.
|
||||
"""
|
||||
bridge_name = self._marker_filter_bridges.get((name, link_id))
|
||||
if bridge_name is None:
|
||||
return
|
||||
await self._ubridge_delete_marker_filter(bridge_name, name)
|
||||
pcap_path = os.path.join(self.project.markers_working_directory(), f"{self._id}_{link_id}_{name}.pcap")
|
||||
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, direction=direction)
|
||||
if not enabled:
|
||||
await self._ubridge_set_marker_filter_state(name, enabled=False)
|
||||
|
||||
async def _ubridge_apply_markers(self, bridge_name, nio):
|
||||
"""
|
||||
Install the traffic-insight markers carried by *nio* onto bridge
|
||||
*bridge_name* that aren't already there. uBridge's ``reset_packet_filters``
|
||||
preserves mark filters (contract), so on an NIO update we add only the new
|
||||
ones — re-adding an existing marker would either duplicate it or
|
||||
close/reopen its pcap. Called from ``add_ubridge_udp_connection`` (fresh
|
||||
bridge, empty map → installs all) and ``update_ubridge_udp_connection``
|
||||
(incremental).
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
for name, spec in markers.items():
|
||||
link_id = spec.get("link_id", "")
|
||||
# Incremental: skip markers already on this bridge. uBridge keeps mark
|
||||
# filters across reset_packet_filters, so re-adding would duplicate (or
|
||||
# reopen the pcap). A fresh bridge has an empty map → installs all.
|
||||
if (name, link_id) in self._marker_filter_bridges:
|
||||
continue
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
try:
|
||||
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id,
|
||||
direction=spec.get("direction"),
|
||||
data_link_type=spec.get("data_link_type"))
|
||||
except UbridgeError as e:
|
||||
# Swallow BPF compile errors (warn + skip) so a single bad
|
||||
# expression can't break link creation / node restart — mirrors
|
||||
# _ubridge_apply_filters, which does the same for packet filters.
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
# A disabled marker is installed but turned off (a paused tap), not
|
||||
# dropped — so the UI can flip it back on instantly with
|
||||
# enable_packet_filter, no NIO rebuild (ubridge contract §3.2).
|
||||
if not spec.get("enabled", True):
|
||||
try:
|
||||
await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off")
|
||||
except UbridgeError as e:
|
||||
# Old ubridge without enable_packet_filter: leave it installed
|
||||
# (on) rather than fail the whole link/marker apply.
|
||||
log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}")
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
# Remember which bridge hosts this filter so an instant on/off toggle
|
||||
# (no NIO rebuild) can resolve it by name alone.
|
||||
# keyed (name, link_id) so a node that hosts markers for several links
|
||||
# (e.g. IOU with one IOL-BRIDGE and many bays/units) records each
|
||||
# copy independently — toggle below iterates all matching entries.
|
||||
self._marker_filter_bridges[name, link_id] = bridge_name
|
||||
|
||||
async def _ubridge_set_marker_filter_state(self, name, enabled):
|
||||
"""
|
||||
Toggle an installed marker filter on/off with a single uBridge command
|
||||
(``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the
|
||||
pcap identity and emitted counter are preserved (ubridge contract §3.2).
|
||||
The bridge is resolved from the (name, link_id)→bridge map populated at
|
||||
apply time; entries are iterated so a node that hosts the same marker name
|
||||
on several links (e.g. IOU with one IOL-BRIDGE per node) toggles every
|
||||
copy. IOU overrides this for its ``iol_bridge`` command shape.
|
||||
|
||||
:param name: marker filter name
|
||||
:param enabled: True = on (signal+pcap), False = off (paused tap)
|
||||
"""
|
||||
|
||||
state = "on" if enabled else "off"
|
||||
for (n, lid), bridge_name in list(self._marker_filter_bridges.items()):
|
||||
if n == name:
|
||||
await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}")
|
||||
|
||||
async def _ubridge_marker_pause(self):
|
||||
"""
|
||||
Pause all marker signal+pcap emission on this node's uBridge
|
||||
(``marker pause``). Keeps the sink open so ``resume`` is instant. Safe
|
||||
on old ubridge builds (the error is downgraded to a warning). Called by
|
||||
the project-level pause fan-out.
|
||||
"""
|
||||
|
||||
if self._ubridge_hypervisor:
|
||||
try:
|
||||
await self._ubridge_hypervisor.send("marker pause")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not pause markers on node {self._id}: {e}")
|
||||
|
||||
async def _ubridge_marker_resume(self):
|
||||
"""Resume marker signal+pcap emission (``marker resume``)."""
|
||||
|
||||
if self._ubridge_hypervisor:
|
||||
try:
|
||||
await self._ubridge_hypervisor.send("marker resume")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not resume markers on node {self._id}: {e}")
|
||||
|
||||
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
|
||||
"""
|
||||
Creates a connection with an Ethernet interface in uBridge.
|
||||
|
||||
@@ -82,8 +82,20 @@ class Cloud(BaseNode):
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
|
||||
if interface["name"].lower().startswith("gns3"):
|
||||
continue
|
||||
host_interfaces.append(
|
||||
{"name": interface["name"], "type": interface["type"], "special": interface["special"]}
|
||||
{
|
||||
"name": interface["name"],
|
||||
"type": interface["type"],
|
||||
"special": interface["special"],
|
||||
"ip_addresses": interface.get("ip_addresses", []),
|
||||
"status": interface.get("status", "down"),
|
||||
"speed": interface.get("speed", 0),
|
||||
"mtu": interface.get("mtu", 0),
|
||||
"flags": interface.get("flags", []),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -303,6 +315,7 @@ class Cloud(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
if port_info["type"] in ("ethernet", "tap"):
|
||||
|
||||
if not self.manager.has_privileged_access(self.ubridge_path):
|
||||
@@ -443,6 +456,7 @@ class Cloud(BaseNode):
|
||||
bridge_name = f"{self._id}-{port_number}"
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
|
||||
@@ -14,14 +14,45 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
"""
|
||||
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
|
||||
``brctl`` module.
|
||||
|
||||
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
|
||||
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
|
||||
one bridge per switch node, managed over uBridge's hypervisor socket. Each
|
||||
switch port is a persistent TAP that plays two roles at once -- uBridge holds
|
||||
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
|
||||
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
|
||||
pattern the Cloud node already uses for host bridges (see
|
||||
``cloud.py::_add_linux_ethernet``).
|
||||
|
||||
Data path (UDP link mode)::
|
||||
|
||||
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
|
||||
|
||||
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
|
||||
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
|
||||
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
|
||||
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
|
||||
"""
|
||||
|
||||
from ...base_node import BaseNode
|
||||
from ...nios.nio_udp import NIOUDP
|
||||
from ...error import NodeError
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
|
||||
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
|
||||
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
|
||||
# configuring them on a qinq port is rejected.
|
||||
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
|
||||
_QINQ_ETHERTYPE = "0x88a8"
|
||||
|
||||
|
||||
class EthernetSwitch(BaseNode):
|
||||
|
||||
@@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
|
||||
:param node_id: Node identifier
|
||||
:param project: Project instance
|
||||
:param manager: Parent VM Manager
|
||||
:param ports: initial switch ports
|
||||
"""
|
||||
|
||||
def __init__(self, name, node_id, project, manager):
|
||||
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
|
||||
|
||||
super().__init__(name, node_id, project, manager)
|
||||
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
|
||||
# The switch has no console; ``console_type="none"`` makes BaseNode skip
|
||||
# reserving a TCP console port entirely.
|
||||
self._ubridge_require_privileged_access = True
|
||||
|
||||
self._nios = {}
|
||||
self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge
|
||||
self._bridge_name = None # kernel bridge interface name (allocated on start)
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied
|
||||
# Idempotency flag for start(). Decoupled from ``status`` so the node can
|
||||
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
|
||||
# sees status "stopped" and refuses only genuinely running stateful nodes.
|
||||
self._started = False
|
||||
|
||||
if ports is None:
|
||||
# 8 access ports in VLAN 1 by default, matching the historical ESW.
|
||||
self._ports_mapping = []
|
||||
for port_number in range(0, 8):
|
||||
self._ports_mapping.append(
|
||||
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
|
||||
)
|
||||
else:
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ports(ports):
|
||||
"""Assign sequential port numbers/names like the Dynamips ESW did."""
|
||||
port_number = 0
|
||||
normalized = []
|
||||
for port in ports:
|
||||
port = dict(port)
|
||||
port["name"] = f"Ethernet{port_number}"
|
||||
port["port_number"] = port_number
|
||||
normalized.append(port)
|
||||
port_number += 1
|
||||
return normalized
|
||||
|
||||
def _ubridge_bridge_name(self, port_number):
|
||||
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
|
||||
return f"{self._id}-{port_number}"
|
||||
|
||||
def _tap_name(self, port_number):
|
||||
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
|
||||
return f"{self._bridge_name}-{port_number}"
|
||||
|
||||
def _port_settings(self, port_number):
|
||||
for port in self._ports_mapping:
|
||||
if port["port_number"] == port_number:
|
||||
return port
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# properties / serialisation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def nios(self):
|
||||
return self._nios
|
||||
|
||||
@property
|
||||
def ports_mapping(self):
|
||||
return self._ports_mapping
|
||||
|
||||
@ports_mapping.setter
|
||||
def ports_mapping(self, ports):
|
||||
if ports != self._ports_mapping:
|
||||
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
|
||||
raise NodeError("Cannot change the port count of a switch that is already connected.")
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
return self._console
|
||||
|
||||
@console.setter
|
||||
def console(self, console):
|
||||
self._console = console
|
||||
|
||||
@property
|
||||
def console_type(self):
|
||||
return self._console_type
|
||||
|
||||
@console_type.setter
|
||||
def console_type(self, console_type):
|
||||
self._console_type = console_type
|
||||
|
||||
def asdict(self):
|
||||
|
||||
@@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
"node_id": self.id,
|
||||
"project_id": self.project.id
|
||||
"project_id": self.project.id,
|
||||
"ports_mapping": self._ports_mapping,
|
||||
"console": self.console,
|
||||
"console_type": self.console_type,
|
||||
# The switch is always-on once created (a kernel bridge), like the ESW.
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Creates this switch.
|
||||
"""
|
||||
|
||||
super().create()
|
||||
await self.start()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created')
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Starts this switch: bring up uBridge, create the kernel bridge, and
|
||||
re-wire any ports already bound before a restart.
|
||||
"""
|
||||
|
||||
if not self._started:
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._stop_ubridge()
|
||||
await self._start_ubridge(self._ubridge_require_privileged_access)
|
||||
await self._ensure_bridge()
|
||||
for port_number in self._nios:
|
||||
if self._nios[port_number]:
|
||||
try:
|
||||
await self._add_ubridge_connection(self._nios[port_number], port_number)
|
||||
except (UbridgeError, NodeError) as e:
|
||||
self._started = False
|
||||
raise e
|
||||
self._started = True
|
||||
|
||||
async def _ensure_bridge(self):
|
||||
"""
|
||||
Creates the per-node kernel bridge once and enables VLAN filtering.
|
||||
Applies the bridge-level QinQ ethertype if any port needs it.
|
||||
|
||||
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
|
||||
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
|
||||
bridge from a previous crash is deleted first so ``brctl create`` never
|
||||
hits EEXIST.
|
||||
"""
|
||||
|
||||
if self._bridge_created:
|
||||
return
|
||||
# deterministic short name — 10 chars, always fits the 15-char kernel cap
|
||||
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
|
||||
# crash recovery: best-effort delete any leftover bridge
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError:
|
||||
pass # not found = nothing to clean
|
||||
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
|
||||
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
|
||||
await self._ubridge_send(f'link set "{self._bridge_name}" up')
|
||||
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
|
||||
self._bridge_created = True
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
|
||||
async def _apply_bridge_proto_if_needed(self):
|
||||
"""
|
||||
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
|
||||
the whole bridge to that protocol. A Linux bridge has a single VLAN
|
||||
protocol, so mixed QinQ ethertypes within one switch are not supported.
|
||||
"""
|
||||
|
||||
proto = None
|
||||
for port in self._ports_mapping:
|
||||
if port.get("type") == "qinq":
|
||||
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
|
||||
# brctl setvlanproto wants lowercase hex
|
||||
ethertype = port.get("ethertype", "0x8100").lower()
|
||||
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
|
||||
raise NodeError(
|
||||
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
|
||||
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
|
||||
)
|
||||
if ethertype == _QINQ_ETHERTYPE:
|
||||
proto = _QINQ_ETHERTYPE
|
||||
if proto and not self._bridge_proto_set:
|
||||
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
|
||||
self._bridge_proto_set = True
|
||||
|
||||
async def delete(self):
|
||||
"""
|
||||
Deletes this switch.
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return await self.close()
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
|
||||
"""
|
||||
|
||||
if not (await super().close()):
|
||||
return False
|
||||
|
||||
for nio in self._nios.values():
|
||||
if nio and isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
self._nios.clear()
|
||||
self._tap_by_port.clear()
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
|
||||
try:
|
||||
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
|
||||
# them when it stops below.
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False
|
||||
self._bridge_name = None
|
||||
self._started = False
|
||||
|
||||
await self._stop_ubridge()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# per-port wiring
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def add_nio(self, nio, port_number):
|
||||
"""
|
||||
Adds a NIO as new port on this switch.
|
||||
Adds a NIO as a new port on this switch.
|
||||
|
||||
:param nio: NIO instance to add
|
||||
:param port_number: port to allocate for the NIO
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number in self._nios:
|
||||
raise NodeError(f"Port {port_number} isn't free")
|
||||
if not isinstance(nio, NIOUDP):
|
||||
raise NodeError("Ethernet switch ports only support UDP NIOs")
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
try:
|
||||
await self.start()
|
||||
await self._add_ubridge_connection(nio, port_number)
|
||||
self._nios[port_number] = nio
|
||||
except (NodeError, UbridgeError) as e:
|
||||
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
|
||||
await self._stop_ubridge()
|
||||
self.status = "stopped"
|
||||
self._nios[port_number] = nio
|
||||
self.project.emit("log.error", {"message": str(e)})
|
||||
|
||||
async def _add_ubridge_connection(self, nio, port_number):
|
||||
"""
|
||||
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
|
||||
is enslaved to the kernel bridge, with the port's VLAN mode applied.
|
||||
"""
|
||||
|
||||
port_settings = self._port_settings(port_number)
|
||||
if port_settings is None:
|
||||
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
tap = self._tap_name(port_number)
|
||||
|
||||
# per-port uBridge relay -- uBridge holds the TAP fd
|
||||
await self._ubridge_send(f"bridge create {ubridge_bridge}")
|
||||
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
|
||||
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
# VLAN membership for this port's access/trunk/qinq mode
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
# GNS3 link endpoint
|
||||
await self._ubridge_send(
|
||||
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
|
||||
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
if nio.capturing:
|
||||
await self._ubridge_send(
|
||||
'bridge start_capture {name} "{output_file}"'.format(
|
||||
name=ubridge_bridge, output_file=nio.pcap_output_file
|
||||
)
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {ubridge_bridge}")
|
||||
self._tap_by_port[port_number] = tap
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
Tears down one port's wiring: release the TAP from the bridge and delete
|
||||
the per-port uBridge relay.
|
||||
"""
|
||||
|
||||
tap = self._tap_by_port.pop(port_number, None)
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if tap is not None and self._bridge_created:
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
|
||||
try:
|
||||
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
|
||||
|
||||
async def remove_nio(self, port_number):
|
||||
"""
|
||||
Removes the specified NIO as member of this switch.
|
||||
Removes the specified NIO from this switch.
|
||||
|
||||
:param port_number: allocated port number
|
||||
|
||||
:returns: the NIO that was bound to the allocated port
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not allocated")
|
||||
|
||||
await self.stop_capture(port_number)
|
||||
nio = self._nios[port_number]
|
||||
if isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
del self._nios[port_number]
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._delete_ubridge_connection(port_number)
|
||||
return nio
|
||||
|
||||
def get_nio(self, port_number):
|
||||
"""
|
||||
Gets a port NIO binding.
|
||||
|
||||
:param port_number: port number
|
||||
:returns: NIO instance
|
||||
"""
|
||||
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not connected")
|
||||
return self._nios[port_number]
|
||||
|
||||
async def update_nio(self, port_number, nio):
|
||||
"""
|
||||
Re-applies uBridge filters/markers for a port (called when a link is updated).
|
||||
"""
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# VLAN mode translation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _reset_port_vlan(self, tap):
|
||||
"""
|
||||
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
|
||||
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
|
||||
from the previous mode do not leak.
|
||||
"""
|
||||
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
|
||||
async def _apply_port_vlan(self, port_settings, tap):
|
||||
"""
|
||||
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
|
||||
already be enslaved to the bridge and carry the default PVID 1.
|
||||
|
||||
- access VLAN N: drop default 1, add N as PVID + egress untagged.
|
||||
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
|
||||
the native VLAN PVID + untagged. (The ESW model declares only the native
|
||||
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
|
||||
- qinq (outer N): the bridge-level protocol is set separately; the port
|
||||
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
|
||||
"""
|
||||
|
||||
br = self._bridge_name
|
||||
port_type = port_settings["type"]
|
||||
vlan = int(port_settings["vlan"])
|
||||
|
||||
if port_type == "access":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "dot1q":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "qinq":
|
||||
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
else:
|
||||
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
|
||||
|
||||
async def update_port_settings(self):
|
||||
"""
|
||||
Re-applies port settings (called after ``ports_mapping`` is updated). For
|
||||
ports already wired, reset then re-apply so a mode/VLAN change fully
|
||||
replaces the previous VLAN membership.
|
||||
"""
|
||||
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
|
||||
return
|
||||
for port_settings in self._ports_mapping:
|
||||
port_number = port_settings["port_number"]
|
||||
tap = self._tap_by_port.get(port_number)
|
||||
if tap is None:
|
||||
continue
|
||||
await self._reset_port_vlan(tap)
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# capture
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
Starts a packet capture.
|
||||
Starts a packet capture on a port (uBridge captures on the per-port relay).
|
||||
|
||||
:param port_number: allocated port number
|
||||
:param output_file: PCAP destination file for the capture
|
||||
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if nio.capturing:
|
||||
raise NodeError(f"Packet capture is already activated on port {port_number}")
|
||||
nio.start_packet_capture(output_file)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_capture(self, port_number):
|
||||
"""
|
||||
Stops a packet capture.
|
||||
Stops a packet capture on a port.
|
||||
|
||||
:param port_number: allocated port number
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if not nio.capturing:
|
||||
return
|
||||
nio.stop_packet_capture()
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
@@ -87,6 +87,23 @@ class Nat(Cloud):
|
||||
return True
|
||||
|
||||
def asdict(self):
|
||||
|
||||
nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else ""
|
||||
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
if interface["name"] == nat_interface:
|
||||
host_interfaces.append(
|
||||
{
|
||||
"name": interface["name"],
|
||||
"type": interface["type"],
|
||||
"special": interface["special"],
|
||||
"ip_addresses": interface.get("ip_addresses", []),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
@@ -94,4 +111,5 @@ class Nat(Cloud):
|
||||
"project_id": self.project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": self.ports_mapping,
|
||||
"interfaces": host_interfaces,
|
||||
}
|
||||
@@ -260,19 +260,21 @@ class Docker(BaseManager):
|
||||
return connection
|
||||
|
||||
@locking
|
||||
async def pull_image(self, image, progress_callback=None):
|
||||
async def pull_image(self, image, progress_callback=None, force=False):
|
||||
"""
|
||||
Pulls an image from the Docker repository
|
||||
|
||||
:params image: Image name
|
||||
:params progress_callback: A function that receive a log message about image download progress
|
||||
:params force: Pull the image even if it is already available locally
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.query("GET", f"images/{image}/json")
|
||||
return # We already have the image skip the download
|
||||
except DockerHttp404Error:
|
||||
pass
|
||||
if not force:
|
||||
try:
|
||||
await self.query("GET", f"images/{image}/json")
|
||||
return # We already have the image skip the download
|
||||
except DockerHttp404Error:
|
||||
pass
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"Pulling '{image}' from Docker repository")
|
||||
@@ -285,29 +287,45 @@ class Docker(BaseManager):
|
||||
)
|
||||
# The pull api will stream status via an HTTP JSON stream
|
||||
content = ""
|
||||
while True:
|
||||
try:
|
||||
chunk = await response.content.read(CHUNK_SIZE)
|
||||
except aiohttp.ServerDisconnectedError:
|
||||
log.error(f"Disconnected from server while pulling Docker image '{image}' from Docker repository")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Timeout while pulling Docker image '{}' from Docker repository".format(image))
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
content += chunk.decode("utf-8")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await response.content.read(CHUNK_SIZE)
|
||||
except aiohttp.ServerDisconnectedError as e:
|
||||
raise DockerError(
|
||||
f"Disconnected while pulling Docker image '{image}' from Docker repository"
|
||||
) from e
|
||||
except asyncio.TimeoutError as e:
|
||||
raise DockerError(
|
||||
f"Timeout while pulling Docker image '{image}' from Docker repository"
|
||||
) from e
|
||||
if not chunk:
|
||||
break
|
||||
content += chunk.decode("utf-8")
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = content.lstrip(" \r\n\t")
|
||||
answer, index = json.JSONDecoder().raw_decode(content)
|
||||
if not isinstance(answer, dict):
|
||||
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
|
||||
error_detail = answer.get("errorDetail")
|
||||
error = answer.get("error")
|
||||
if not error and isinstance(error_detail, dict):
|
||||
error = error_detail.get("message")
|
||||
if error:
|
||||
raise DockerError(error)
|
||||
if "progress" in answer and progress_callback:
|
||||
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
|
||||
content = content[index:]
|
||||
except ValueError: # Partial JSON
|
||||
pass
|
||||
|
||||
if content.strip():
|
||||
raise DockerError(f"Invalid response while pulling Docker image '{image}'")
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
try:
|
||||
while True:
|
||||
content = content.lstrip(" \r\n\t")
|
||||
answer, index = json.JSONDecoder().raw_decode(content)
|
||||
if "progress" in answer and progress_callback:
|
||||
progress_callback("Pulling image {}:{}: {}".format(image, answer["id"], answer["progress"]))
|
||||
content = content[index:]
|
||||
except ValueError: # Partial JSON
|
||||
pass
|
||||
response.close()
|
||||
if progress_callback:
|
||||
progress_callback(f"Success pulling image {image}")
|
||||
|
||||
|
||||
@@ -669,6 +669,12 @@ class DockerVM(BaseNode):
|
||||
|
||||
await self.manager.query("POST", f"containers/{self._cid}/start")
|
||||
await asyncio.sleep(0.5) # give the Docker container some time to start
|
||||
# Fix host-side directory ownership after Docker (re)creates
|
||||
# volume mount points as root (rootful Docker only).
|
||||
# This allows the GNS3 process to write files into node directories
|
||||
# while the container is running. Permissions are recorded and
|
||||
# restored inside the container by init.sh on next startup.
|
||||
# await self._fix_permissions()
|
||||
self._namespace = await self._get_namespace()
|
||||
|
||||
await self._start_ubridge(require_privileged_access=True)
|
||||
@@ -772,11 +778,19 @@ class DockerVM(BaseNode):
|
||||
' && /gns3/bin/busybox chown {uid}:{gid} -R "{path}"'.format(
|
||||
uid=os.getuid(), gid=os.getgid(), path=volume
|
||||
),
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as e:
|
||||
raise DockerError(f"Could not fix permissions for {volume}: {e}")
|
||||
await process.wait()
|
||||
self._permissions_fixed = True
|
||||
if process.returncode != 0:
|
||||
stderr = (await process.stderr.read()).decode(errors="replace").strip()
|
||||
log.error(
|
||||
"Failed to fix permissions on '%s' for container '%s': %s",
|
||||
volume, self._name, stderr or f"exit code {process.returncode}"
|
||||
)
|
||||
else:
|
||||
self._permissions_fixed = True
|
||||
|
||||
async def _start_vnc_process(self, restart=False):
|
||||
"""
|
||||
@@ -1034,7 +1048,7 @@ class DockerVM(BaseNode):
|
||||
await self._fix_permissions()
|
||||
|
||||
state = await self._get_container_state()
|
||||
if state != "stopped" or state != "exited":
|
||||
if state != "stopped" and state != "exited":
|
||||
# t=5 number of seconds to wait before killing the container
|
||||
try:
|
||||
await self.manager.query("POST", f"containers/{self._cid}/stop", params={"t": 5})
|
||||
@@ -1214,6 +1228,7 @@ class DockerVM(BaseNode):
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def adapter_add_nio_binding(self, adapter_number, nio):
|
||||
"""
|
||||
@@ -1254,7 +1269,7 @@ class DockerVM(BaseNode):
|
||||
bridge_name = f"bridge{adapter_number}"
|
||||
if bridge_name in self._bridges:
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
async def adapter_remove_nio_binding(self, adapter_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
|
||||
@@ -376,6 +376,7 @@ class Dynamips(BaseManager):
|
||||
raise DynamipsError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(node, lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_generic_ethernet":
|
||||
ethernet_device = nio_settings["ethernet_device"]
|
||||
|
||||
@@ -40,6 +40,7 @@ class NIO:
|
||||
self._hypervisor = hypervisor
|
||||
self._name = name
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._suspended = False
|
||||
self._capturing = False
|
||||
self._pcap_output_file = ""
|
||||
@@ -303,6 +304,26 @@ class NIO:
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the list of traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary)
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary)
|
||||
"""
|
||||
|
||||
self._markers = new_markers
|
||||
|
||||
@property
|
||||
def capturing(self):
|
||||
"""
|
||||
|
||||
@@ -82,10 +82,12 @@ class NIOUDP(NIO):
|
||||
self._source_nio = nio_udp.NIOUDP(self._local_tunnel_rport, "127.0.0.1", self._local_tunnel_lport)
|
||||
self._destination_nio = nio_udp.NIOUDP(self._lport, self._rhost, self._rport)
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.add_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def update(self):
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.update_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -54,9 +54,94 @@ import sys
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOUL1KeepaliveProtocol(asyncio.DatagramProtocol):
|
||||
"""Handle IOU/IOL Layer 1 keepalives for connected interfaces."""
|
||||
|
||||
_header = struct.Struct("!HHBBBB")
|
||||
_message_type = 3
|
||||
|
||||
def __init__(self, vm):
|
||||
self._vm = vm
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
@staticmethod
|
||||
def encode_interface(adapter_number, port_number):
|
||||
"""Encode an IOU bay/unit for the L1 keepalive protocol."""
|
||||
|
||||
# IOU stores the zero-based unit in the high nibble and the
|
||||
# zero-based bay in the low nibble.
|
||||
return (port_number << 4) | adapter_number
|
||||
|
||||
@staticmethod
|
||||
def decode_interface(interface):
|
||||
"""Decode an L1 keepalive interface into an IOU bay/unit."""
|
||||
|
||||
return interface & 0x0F, interface >> 4
|
||||
|
||||
def datagram_received(self, data, address):
|
||||
if len(data) != self._header.size:
|
||||
log.debug('IOU "%s": ignored malformed L1 keepalive of %d bytes', self._vm.name, len(data))
|
||||
return
|
||||
|
||||
destination, source, destination_interface, source_interface, message_type, channel = self._header.unpack(data)
|
||||
if (
|
||||
destination != self._vm.l1_bridge_id
|
||||
or source != self._vm.application_id
|
||||
or message_type != self._message_type
|
||||
or not self._vm.has_nio_for_iou_interface(source_interface)
|
||||
):
|
||||
return
|
||||
|
||||
response = self._header.pack(
|
||||
source,
|
||||
destination,
|
||||
source_interface,
|
||||
destination_interface,
|
||||
message_type,
|
||||
channel,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(response, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# IOU creates its endpoint during startup and removes it on stop.
|
||||
# Dropping a keepalive during either transition is harmless.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive response: %s', self._vm.name, e)
|
||||
|
||||
def send_keepalives(self):
|
||||
"""Tell IOU that every interface with an attached NIO has Layer 1 connectivity."""
|
||||
|
||||
for adapter_number, adapter in enumerate(self._vm.adapters):
|
||||
for port_number, nio in adapter.ports.items():
|
||||
if nio is None:
|
||||
continue
|
||||
interface = self.encode_interface(adapter_number, port_number)
|
||||
keepalive = self._header.pack(
|
||||
self._vm.application_id,
|
||||
self._vm.l1_bridge_id,
|
||||
interface,
|
||||
interface,
|
||||
self._message_type,
|
||||
0,
|
||||
)
|
||||
try:
|
||||
self.transport.sendto(keepalive, self._vm.l1_iou_socket_path)
|
||||
except OSError as e:
|
||||
# The IOU endpoint does not exist until the image has started.
|
||||
log.debug('IOU "%s": could not send an L1 keepalive: %s', self._vm.name, e)
|
||||
|
||||
|
||||
class IOUVM(BaseNode):
|
||||
module_name = "iou"
|
||||
|
||||
# Class-level caches shared across all IOU VM instances using the same image.
|
||||
# These avoid redundant subprocess calls during project loading when multiple
|
||||
# IOU nodes use the same image.
|
||||
_loader_cache = {} # image path -> loader command list
|
||||
_default_values_cache = {} # image path -> (ram, nvram)
|
||||
|
||||
"""
|
||||
IOU VM implementation.
|
||||
|
||||
@@ -92,6 +177,8 @@ class IOUVM(BaseNode):
|
||||
self._lib_base = self.manager.get_images_directory()
|
||||
self._loader = None
|
||||
self._license_check = True
|
||||
self._l1_keepalive_transport = None
|
||||
self._l1_keepalive_task = None
|
||||
|
||||
# IOU settings
|
||||
self._ethernet_adapters = []
|
||||
@@ -104,7 +191,7 @@ class IOUVM(BaseNode):
|
||||
self._private_config = ""
|
||||
self._ram = 1024 # Megabytes
|
||||
self._application_id = application_id
|
||||
self._l1_keepalives = False # used to overcome the always-up Ethernet interfaces (not supported by all IOSes).
|
||||
self._l1_keepalives = False
|
||||
|
||||
def _nvram_changed(self, path):
|
||||
"""
|
||||
@@ -180,8 +267,15 @@ class IOUVM(BaseNode):
|
||||
async def update_default_iou_values(self):
|
||||
"""
|
||||
Finds the default RAM and NVRAM values for the IOU image.
|
||||
Results are cached per image path to avoid redundant subprocess calls
|
||||
when multiple IOU nodes use the same image.
|
||||
"""
|
||||
|
||||
# Check class-level cache for default values
|
||||
if self._path in IOUVM._default_values_cache:
|
||||
self._ram, self._nvram = IOUVM._default_values_cache[self._path]
|
||||
return
|
||||
|
||||
await self._check_requirements()
|
||||
try:
|
||||
output = await gns3server.utils.asyncio.subprocess_check_output(
|
||||
@@ -193,6 +287,9 @@ class IOUVM(BaseNode):
|
||||
match = re.search(r"-m <n>\s+Megabytes of router memory \(default ([0-9]+)MB\)", output)
|
||||
if match:
|
||||
self.ram = int(match.group(1))
|
||||
# Only cache on success, so a subsequent call with explicitly set
|
||||
# ram/nvram values won't be overwritten by stale cached defaults
|
||||
IOUVM._default_values_cache[self._path] = (self._ram, self._nvram)
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as e:
|
||||
log.warning(f"could not find default RAM and NVRAM values for {os.path.basename(self._path)}: {e}")
|
||||
|
||||
@@ -207,6 +304,13 @@ class IOUVM(BaseNode):
|
||||
|
||||
if self._loader is not None:
|
||||
return # image already checked
|
||||
|
||||
# Check class-level cache: if another IOU VM already verified this image,
|
||||
# reuse its loader configuration to avoid redundant subprocess calls.
|
||||
if self._path in IOUVM._loader_cache:
|
||||
self._loader = IOUVM._loader_cache[self._path]
|
||||
return
|
||||
|
||||
if not self._path:
|
||||
raise IOUError("IOU image is not configured")
|
||||
if not os.path.isfile(self._path) or not os.path.exists(self._path):
|
||||
@@ -252,6 +356,9 @@ class IOUVM(BaseNode):
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
log.warning(f"Could not use loader {loader}: {e}")
|
||||
|
||||
# Cache the loader result for other IOU VMs using the same image
|
||||
IOUVM._loader_cache[self._path] = self._loader
|
||||
|
||||
def asdict(self):
|
||||
|
||||
iou_vm_info = {
|
||||
@@ -611,6 +718,10 @@ class IOUVM(BaseNode):
|
||||
raise IOUError(f"Could not create symbolic link: {e}")
|
||||
|
||||
command = await self._build_command()
|
||||
# Only start the responder when the capability probe actually
|
||||
# enabled IOU's L1 protocol on the command line.
|
||||
if "-l" in command:
|
||||
await self._start_l1_keepalive_responder()
|
||||
try:
|
||||
if self._loader:
|
||||
log.info(f"Starting IOU: {command} with loader {self._loader}")
|
||||
@@ -631,8 +742,10 @@ class IOUVM(BaseNode):
|
||||
callback = functools.partial(self._termination_callback, "IOU")
|
||||
gns3server.utils.asyncio.monitor_process(self._iou_process, callback)
|
||||
except FileNotFoundError as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU: {e}: 32-bit binary support is probably not installed, it is recommended to use a 64-bit image instead")
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
iou_stdout = self.read_iou_stdout()
|
||||
log.error(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
raise IOUError(f"Could not start IOU {self._path}: {e}\n{iou_stdout}")
|
||||
@@ -720,6 +833,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bay_id, unit_id, nio.filters)
|
||||
await self._ubridge_apply_markers(bay_id, unit_id, nio)
|
||||
unit_id += 1
|
||||
bay_id += 1
|
||||
|
||||
@@ -733,6 +847,7 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
self._terminate_process_iou()
|
||||
self._stop_l1_keepalive_responder()
|
||||
if returncode != 0:
|
||||
if returncode == -11:
|
||||
message = 'IOU VM "{}" process has stopped with return code: {} (segfault). This could be an issue with the IOU image, using a different image may fix this.\n{}'.format(
|
||||
@@ -765,6 +880,7 @@ class IOUVM(BaseNode):
|
||||
Stops the IOU process.
|
||||
"""
|
||||
|
||||
self._stop_l1_keepalive_responder()
|
||||
await self._stop_ubridge()
|
||||
if self._nvram_watcher:
|
||||
self._nvram_watcher.close()
|
||||
@@ -867,6 +983,83 @@ class IOUVM(BaseNode):
|
||||
except OSError as e:
|
||||
raise IOUError(f"Could not create {netmap_path}: {e}")
|
||||
|
||||
@property
|
||||
def l1_bridge_id(self):
|
||||
return self.application_id + 512
|
||||
|
||||
@property
|
||||
def l1_socket_directory(self):
|
||||
# IOU hard-codes this directory independently from TMPDIR.
|
||||
return os.path.join("/tmp", f"netl1{os.geteuid()}")
|
||||
|
||||
@property
|
||||
def l1_bridge_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.l1_bridge_id}")
|
||||
|
||||
@property
|
||||
def l1_iou_socket_path(self):
|
||||
return os.path.join(self.l1_socket_directory, f"L1{self.application_id}")
|
||||
|
||||
def has_nio_for_iou_interface(self, interface):
|
||||
"""Return whether the IOU bay/unit encoded in one byte is connected."""
|
||||
|
||||
adapter_number, port_number = IOUL1KeepaliveProtocol.decode_interface(interface)
|
||||
if adapter_number >= len(self._adapters):
|
||||
return False
|
||||
adapter = self._adapters[adapter_number]
|
||||
return adapter.port_exists(port_number) and adapter.get_nio(port_number) is not None
|
||||
|
||||
async def _start_l1_keepalive_responder(self):
|
||||
"""Create the bridge-side UNIX datagram endpoint used by IOU's ``-l`` option."""
|
||||
|
||||
if self._l1_keepalive_transport is not None:
|
||||
return
|
||||
|
||||
socket_directory = self.l1_socket_directory
|
||||
try:
|
||||
os.makedirs(socket_directory, mode=0o755, exist_ok=True)
|
||||
if os.path.islink(socket_directory) or os.stat(socket_directory).st_uid != os.geteuid():
|
||||
raise IOUError(f"Unsafe IOU L1 keepalive directory '{socket_directory}'")
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: IOUL1KeepaliveProtocol(self),
|
||||
local_addr=self.l1_bridge_socket_path,
|
||||
family=socket.AF_UNIX,
|
||||
)
|
||||
self._l1_keepalive_transport = transport
|
||||
self._l1_keepalive_task = asyncio.create_task(self._send_l1_keepalives(protocol))
|
||||
log.info(
|
||||
'IOU "%s" [%s]: L1 keepalive responder listening on %s',
|
||||
self._name,
|
||||
self._id,
|
||||
self.l1_bridge_socket_path,
|
||||
)
|
||||
except (OSError, RuntimeError) as e:
|
||||
self._stop_l1_keepalive_responder()
|
||||
raise IOUError(f"Could not start IOU L1 keepalive responder: {e}")
|
||||
|
||||
async def _send_l1_keepalives(self, protocol):
|
||||
while self._l1_keepalive_transport is not None:
|
||||
protocol.send_keepalives()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def _stop_l1_keepalive_responder(self):
|
||||
"""Stop the L1 endpoint and remove its bridge-side socket."""
|
||||
|
||||
if self._l1_keepalive_task is not None:
|
||||
self._l1_keepalive_task.cancel()
|
||||
self._l1_keepalive_task = None
|
||||
if self._l1_keepalive_transport is not None:
|
||||
self._l1_keepalive_transport.close()
|
||||
self._l1_keepalive_transport = None
|
||||
try:
|
||||
if os.path.lexists(self.l1_bridge_socket_path):
|
||||
os.unlink(self.l1_bridge_socket_path)
|
||||
except OSError as e:
|
||||
log.warning('Could not remove IOU L1 keepalive socket "%s": %s', self.l1_bridge_socket_path, e)
|
||||
|
||||
async def _build_command(self):
|
||||
"""
|
||||
Command to start the IOU process.
|
||||
@@ -1041,6 +1234,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def adapter_update_nio_binding(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
@@ -1053,6 +1247,7 @@ class IOUVM(BaseNode):
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def _ubridge_apply_filters(self, adapter_number, port_number, filters):
|
||||
"""
|
||||
@@ -1069,6 +1264,105 @@ class IOUVM(BaseNode):
|
||||
cmd = "iol_bridge add_packet_filter {} {}".format(location, filter)
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
(Re-)apply traffic-insight markers to the IOL bridge.
|
||||
|
||||
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
|
||||
command carries extra ``{bay} {unit}`` positional arguments between the
|
||||
bridge name and the filter name — this override mirrors the pattern in
|
||||
``_ubridge_apply_filters`` above.
|
||||
|
||||
:param adapter_number: bay id
|
||||
:param port_number: unit id
|
||||
:param nio: NIO instance carrying ``nio.markers``
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
|
||||
location = "{bridge_name} {bay} {unit}".format(
|
||||
bridge_name=bridge_name, bay=adapter_number, unit=port_number
|
||||
)
|
||||
for name, spec in markers.items():
|
||||
link_id = spec.get("link_id", "")
|
||||
# Incremental: skip markers already installed on this port. A NIO
|
||||
# update carries EVERY marker on the port (e.g. an inherited
|
||||
# global-* copy plus a newly added private one); uBridge's
|
||||
# add_packet_filter rejects a duplicate filter name (packet_filter.c),
|
||||
# so we must not re-add one already here — mirrors the generic
|
||||
# _ubridge_apply_markers guard. A fresh bridge has an empty map
|
||||
# (cleared on _stop_ubridge) so all are installed.
|
||||
if (name, link_id) in self._marker_filter_bridges:
|
||||
continue
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
# Build the iol_bridge marker filter command:
|
||||
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
|
||||
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
|
||||
loc=location, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
|
||||
# are identical across this node's links — `link` is the only way the
|
||||
# controller can tell their signals apart (contract §3.2).
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
direction = spec.get("direction")
|
||||
if direction is not None:
|
||||
cmd += f" dir {direction}"
|
||||
linktype = self._marker_linktype(spec.get("data_link_type"))
|
||||
if linktype is not None:
|
||||
cmd += f" linktype {linktype}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
try:
|
||||
await self._ubridge_send(cmd)
|
||||
except UbridgeError as e:
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
if not spec.get("enabled", True):
|
||||
try:
|
||||
await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} off")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not turn marker '{name}' off on {location}: {e}")
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
# Record name -> location (bridge bay unit) for instant toggle.
|
||||
self._marker_filter_bridges[name, link_id] = location
|
||||
|
||||
async def _ubridge_set_marker_filter_state(self, name, enabled):
|
||||
"""IOU override: toggle every (name, link_id) entry via ``iol_bridge``."""
|
||||
|
||||
state = "on" if enabled else "off"
|
||||
for (n, lid), location in list(self._marker_filter_bridges.items()):
|
||||
if n == name:
|
||||
await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}")
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, location, name):
|
||||
"""IOU override: remove a single marker filter via ``iol_bridge``
|
||||
(location = ``{bridge} {bay} {unit}``), not a bridge-wide reset."""
|
||||
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"iol_bridge delete_packet_filter {location} {name}")
|
||||
except UbridgeError as e:
|
||||
log.warning("Could not remove marker filter '%s' from %s: %s", name, location, e)
|
||||
|
||||
async def adapter_remove_nio_binding(self, adapter_number, port_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
@@ -1181,8 +1475,9 @@ class IOUVM(BaseNode):
|
||||
"""
|
||||
|
||||
env = os.environ.copy()
|
||||
if "IOURC" not in os.environ:
|
||||
env["IOURC"] = self.iourc_path
|
||||
iourc_path = self.iourc_path
|
||||
if "IOURC" not in os.environ and iourc_path:
|
||||
env["IOURC"] = iourc_path
|
||||
try:
|
||||
output = await gns3server.utils.asyncio.subprocess_check_output(
|
||||
*self._loader, self._path, "-h", cwd=self.working_dir, env=env, stderr=True
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
#
|
||||
# Traffic-insight marker subsystem (compute side).
|
||||
#
|
||||
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
|
||||
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
|
||||
# This package owns the compute-side UDP sink: one listener per compute process
|
||||
# serves every ubridge on that host, disambiguated by ``node=<id>``.
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerListener(asyncio.DatagramProtocol):
|
||||
"""
|
||||
Receives ubridge ``MARK`` signal datagrams and turns each into a
|
||||
``marker.match`` notification.
|
||||
|
||||
Signal format (one datagram per match, ASCII)::
|
||||
|
||||
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n> [dir=<tx|rx>]\\n
|
||||
|
||||
The signal carries metadata only (no packet bytes). ``dir`` is optional and
|
||||
additive: uBridge stamps it from the ingress NIO of the matched packet to
|
||||
indicate travel direction relative to the capture node (the ``node=<id>``
|
||||
above) — ``tx`` = the capture node is sending (ingressed on the device-side
|
||||
NIO), ``rx`` = it is receiving (ingressed on the link-side NIO). Older
|
||||
uBridge builds omit it, so the listener leaves ``dir`` unset and consumers
|
||||
fall back to undirected rendering. Unknown keys are always ignored, so the
|
||||
field ships safely with no version coupling.
|
||||
|
||||
The compute-side
|
||||
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
|
||||
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
|
||||
event can be emitted on the right project-scoped notification stream.
|
||||
"""
|
||||
|
||||
def __init__(self, manager):
|
||||
# MarkerManager owns this listener and the registry.
|
||||
self._manager = manager
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
try:
|
||||
self._handle(data)
|
||||
except Exception:
|
||||
# Never let a malformed datagram kill the listener.
|
||||
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
|
||||
|
||||
def _handle(self, data):
|
||||
line = data.decode("utf-8", errors="replace").strip()
|
||||
if not line.startswith("MARK"):
|
||||
return
|
||||
|
||||
parts = line.split()
|
||||
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
try:
|
||||
ts = float(parts[1])
|
||||
except ValueError:
|
||||
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
|
||||
return
|
||||
|
||||
kv = {}
|
||||
for token in parts[2:]:
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
kv[key] = value
|
||||
|
||||
node_id = kv.get("node")
|
||||
filter_name = kv.get("filter")
|
||||
if not node_id or not filter_name:
|
||||
return
|
||||
|
||||
# "-" means the field was unset on the ubridge side (see contract §3.3).
|
||||
link = kv.get("link")
|
||||
tag = kv.get("tag")
|
||||
length = kv.get("len")
|
||||
# Travel direction relative to the capture node (the node=<id> above):
|
||||
# "tx" = capture node is sending (matched packet ingressed on the
|
||||
# device-side NIO), "rx" = it is receiving (link-side NIO). Older
|
||||
# uBridge builds omit dir; None here lets consumers render undirected.
|
||||
direction = kv.get("dir")
|
||||
|
||||
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
|
||||
if project_id is None:
|
||||
log.warning(
|
||||
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
|
||||
)
|
||||
return
|
||||
|
||||
# `link=` is the authoritative per-link id (opaque, set by gns3server at
|
||||
# filter install time). It disambiguates signals that share a node+filter
|
||||
# across several links; fall back to the registry's link only for legacy
|
||||
# signals that carry no `link=`.
|
||||
signal_link = link if link and link != "-" else None
|
||||
|
||||
event = {
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"link_id": signal_link or link_id,
|
||||
"filter": filter_name,
|
||||
# Prefer the value carried in the signal; fall back to the one we registered.
|
||||
"tag": tag if tag and tag != "-" else registered_tag,
|
||||
"ts": ts,
|
||||
"len": int(length) if length and length.isdigit() else 0,
|
||||
# Travel direction relative to the capture node (node_id above);
|
||||
# None when the signal carries none (older uBridge) — undirected.
|
||||
"dir": direction,
|
||||
}
|
||||
self._manager.emit_match(project_id, event)
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.compute.marker.marker_listener import MarkerListener
|
||||
from gns3server.compute.notification_manager import NotificationManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerManager:
|
||||
"""
|
||||
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
|
||||
the registry that maps each ``(node_id, filter_name)`` back to its
|
||||
``(project_id, link_id, tag)``.
|
||||
|
||||
The registry is populated when a marker is created on a link (the compute
|
||||
endpoint has project_id + node_id from its route path and link_id/name/tag
|
||||
from the request body) and cleared when the marker is deleted or the project
|
||||
closed. At signal time it is an O(1) lookup — no node-table scan, and the
|
||||
signal payload is untouched.
|
||||
|
||||
One listener per compute process serves every ubridge on that host; source
|
||||
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._listener = None
|
||||
self._transport = None
|
||||
self._host = None
|
||||
self._port = None
|
||||
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
|
||||
self._entries = {}
|
||||
# Reverse index for O(1) per-project teardown: project_id -> set of keys
|
||||
self._by_project = {}
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""The host the UDP sink is reachable on (for ``marker sink``)."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""The UDP port the sink is bound on (for ``marker sink``)."""
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self._transport is not None
|
||||
|
||||
async def start(self, host="127.0.0.1", port=0):
|
||||
"""
|
||||
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
|
||||
then read back and exposed via :attr:`port` for ``marker sink`` commands.
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._listener = MarkerListener(self)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, port)
|
||||
)
|
||||
except OSError:
|
||||
if port != 0:
|
||||
log.warning(
|
||||
"Marker listener: port %s unavailable, falling back to OS-assigned port", port
|
||||
)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, 0)
|
||||
)
|
||||
except OSError as e:
|
||||
log.error(
|
||||
"Marker listener startup failed: %s. Traffic insight signals are unavailable.", e
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
else:
|
||||
log.error(
|
||||
"Marker listener startup failed on OS-assigned port. Traffic insight signals are unavailable."
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
sock = self._transport.get_extra_info("socket")
|
||||
self._host = host
|
||||
self._port = sock.getsockname()[1] if sock else port
|
||||
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
|
||||
|
||||
async def stop(self):
|
||||
"""Close the UDP sink and drop the whole registry."""
|
||||
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._listener = None
|
||||
self._entries.clear()
|
||||
self._by_project.clear()
|
||||
self._host = None
|
||||
self._port = None
|
||||
|
||||
def register(self, project_id, node_id, filter_name, link_id, tag=None):
|
||||
"""
|
||||
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
|
||||
``link_id``. Called from the compute marker-start endpoint.
|
||||
|
||||
Re-registering the same key updates the stored tag (e.g. on re-add).
|
||||
"""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
|
||||
self._by_project.setdefault(project_id, set()).add(key)
|
||||
|
||||
def unregister(self, node_id, filter_name):
|
||||
"""Forget a single marker. Returns True if something was removed."""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is None:
|
||||
return False
|
||||
project_entries = self._by_project.get(entry["project_id"])
|
||||
if project_entries is not None:
|
||||
project_entries.discard(key)
|
||||
if not project_entries:
|
||||
self._by_project.pop(entry["project_id"], None)
|
||||
return True
|
||||
|
||||
def unregister_project(self, project_id):
|
||||
"""Drop every marker belonging to ``project_id`` (project close)."""
|
||||
|
||||
keys = self._by_project.pop(project_id, None)
|
||||
if not keys:
|
||||
return
|
||||
for key in keys:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def lookup(self, node_id, filter_name):
|
||||
"""
|
||||
O(1) resolution of an incoming signal to its project/link/tag.
|
||||
|
||||
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
|
||||
"""
|
||||
|
||||
entry = self._entries.get((node_id, filter_name))
|
||||
if entry is None:
|
||||
return None, None, None
|
||||
return entry["project_id"], entry["link_id"], entry["tag"]
|
||||
|
||||
def emit_match(self, project_id, event):
|
||||
"""
|
||||
Forward a parsed match as a project-scoped ``marker.match`` notification.
|
||||
Flows compute -> controller dispatch -> project_emit -> web UI WS.
|
||||
"""
|
||||
|
||||
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
|
||||
|
||||
_instance = None
|
||||
|
||||
@staticmethod
|
||||
def instance():
|
||||
if MarkerManager._instance is None:
|
||||
MarkerManager._instance = MarkerManager()
|
||||
return MarkerManager._instance
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
MarkerManager._instance = None
|
||||
@@ -30,6 +30,7 @@ class NIO:
|
||||
self._capturing = False
|
||||
self._suspended = False
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._pcap_output_file = ""
|
||||
self._pcap_data_link_type = ""
|
||||
|
||||
@@ -118,3 +119,24 @@ class NIO:
|
||||
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set the traffic-insight markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
assert isinstance(new_markers, dict)
|
||||
self._markers = new_markers
|
||||
@@ -80,5 +80,6 @@ class NIOUDP(NIO):
|
||||
"rport": self._rport,
|
||||
"rhost": self._rhost,
|
||||
"suspend": self._suspended,
|
||||
"filters": self._filters
|
||||
"filters": self._filters,
|
||||
"markers": self._markers
|
||||
}
|
||||
+141
-41
@@ -16,6 +16,7 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import magic
|
||||
import asyncio
|
||||
import hashlib
|
||||
import datetime
|
||||
@@ -245,6 +246,22 @@ class Project:
|
||||
raise ComputeError(f"Could not create the capture working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def markers_working_directory(self):
|
||||
"""
|
||||
Returns the working directory where uBridge writes per-link marker pcaps
|
||||
(matched packets, kept for later replay).
|
||||
|
||||
:returns: path to the directory
|
||||
"""
|
||||
|
||||
workdir = os.path.join(self._path, "project-files", "markers")
|
||||
if not self._deleted:
|
||||
try:
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ComputeError(f"Could not create the markers working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def add_node(self, node):
|
||||
"""
|
||||
Adds a node to the project.
|
||||
@@ -424,65 +441,148 @@ class Project:
|
||||
|
||||
return files
|
||||
|
||||
async def list_node_files(self, node_path: str):
|
||||
async def list_node_files(self, node_path: str, subpath: str = "", recursive: bool = False):
|
||||
"""
|
||||
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")
|
||||
:param subpath: Optional subdirectory path. Defaults to root of node directory.
|
||||
:param recursive: If True, recursively list all files (use with caution on large directories).
|
||||
:returns: Array of files in the node directory with metadata
|
||||
"""
|
||||
|
||||
node_full_path = os.path.normpath(os.path.join(self.path, node_path))
|
||||
subpath = subpath.lstrip("/")
|
||||
|
||||
# Security check: ensure the path is within the project directory
|
||||
if not os.path.commonpath([node_full_path, self.path]) == self.path:
|
||||
if subpath:
|
||||
target_path = os.path.normpath(os.path.join(node_full_path, subpath))
|
||||
else:
|
||||
target_path = node_full_path
|
||||
|
||||
# Security check: ensure the path is within the node directory
|
||||
if not os.path.commonpath([target_path, node_full_path]) == node_full_path:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Path is outside the project directory")
|
||||
|
||||
if not os.path.exists(node_full_path):
|
||||
detail="Path is outside the node directory")
|
||||
if not os.path.exists(target_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Node directory not found")
|
||||
detail="Path not found")
|
||||
|
||||
if recursive:
|
||||
return await self._list_node_files_recursive(node_full_path, target_path,
|
||||
node_full_path if subpath else None)
|
||||
|
||||
# Non-recursive: list only the current directory level
|
||||
files = []
|
||||
try:
|
||||
filenames = os.listdir(node_full_path)
|
||||
except OSError as e:
|
||||
log.error(f"Error listing node directory: {e}")
|
||||
scandir_iter = os.scandir(target_path)
|
||||
except PermissionError:
|
||||
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
|
||||
|
||||
except OSError as e:
|
||||
log.error(f"Error listing node directory '{target_path}': {e}")
|
||||
return files
|
||||
for entry in scandir_iter:
|
||||
name = entry.name
|
||||
rel_path = name if not subpath else os.path.join(subpath, name)
|
||||
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}")
|
||||
stat_info = await wait_run_in_executor(lambda e=entry: e.stat())
|
||||
is_dir = await wait_run_in_executor(lambda e=entry: e.is_dir())
|
||||
if is_dir:
|
||||
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):
|
||||
created_at = modified_at = ""
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat_info.st_size,
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
"file_type": "directory"
|
||||
})
|
||||
else:
|
||||
if name.endswith(".ghost"):
|
||||
continue
|
||||
try:
|
||||
file_type = await wait_run_in_executor(
|
||||
lambda e=entry: magic.from_file(e.path, mime=False)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error getting file type for '{rel_path}': {e}")
|
||||
file_type = ""
|
||||
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):
|
||||
created_at = modified_at = ""
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat_info.st_size,
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
"file_type": file_type
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
return files
|
||||
|
||||
async def _list_node_files_recursive(self, node_full_path, start_path, base_path=None):
|
||||
"""
|
||||
Recursively list all files and directories under start_path.
|
||||
"""
|
||||
if base_path is None:
|
||||
base_path = node_full_path
|
||||
|
||||
files = []
|
||||
for dirpath, dirnames, filenames in os.walk(start_path, followlinks=False):
|
||||
for dirname in dirnames:
|
||||
dir_full_path = os.path.join(dirpath, dirname)
|
||||
try:
|
||||
stat_info = await wait_run_in_executor(os.stat, dir_full_path)
|
||||
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):
|
||||
created_at = modified_at = ""
|
||||
files.append({
|
||||
"path": os.path.relpath(dir_full_path, base_path),
|
||||
"size": stat_info.st_size,
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
"file_type": "directory"
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for filename in filenames:
|
||||
if filename.endswith(".ghost"):
|
||||
continue
|
||||
file_path = os.path.join(dirpath, filename)
|
||||
rel_path = os.path.relpath(file_path, base_path)
|
||||
try:
|
||||
stat_info = await wait_run_in_executor(os.stat, file_path)
|
||||
try:
|
||||
file_type = await wait_run_in_executor(
|
||||
lambda fp=file_path: magic.from_file(fp, mime=False)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Error getting file type for '{rel_path}': {e}")
|
||||
file_type = ""
|
||||
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 '{rel_path}': {e}")
|
||||
created_at = modified_at = ""
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat_info.st_size,
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
"file_type": file_type
|
||||
})
|
||||
except OSError as e:
|
||||
log.warning(f"Error getting metadata for file '{rel_path}': {e}")
|
||||
continue
|
||||
return files
|
||||
|
||||
def _hash_file(self, path):
|
||||
|
||||
@@ -34,6 +34,7 @@ import json
|
||||
import shlex
|
||||
import psutil
|
||||
|
||||
from pathlib import Path
|
||||
from gns3server.utils import parse_version
|
||||
from gns3server.utils.asyncio import subprocess_check_output, cancellable_wait_run_in_executor
|
||||
from .qemu_error import QemuError
|
||||
@@ -1337,7 +1338,7 @@ class QemuVM(BaseNode):
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
log.debug(
|
||||
f"Connected to QEMU monitor on {self._monitor_host}:{self._monitor} after {time.time() - begin:.4f} seconds"
|
||||
)
|
||||
return reader, writer
|
||||
@@ -1354,7 +1355,7 @@ class QemuVM(BaseNode):
|
||||
|
||||
result = None
|
||||
if self.is_running() and self._monitor:
|
||||
log.info(f"Execute QEMU monitor command: {command}")
|
||||
log.debug(f"Execute QEMU monitor command: {command}")
|
||||
reader, writer = await self._open_qemu_monitor_connection_vm()
|
||||
if reader is None and writer is None:
|
||||
return result
|
||||
@@ -1404,7 +1405,7 @@ class QemuVM(BaseNode):
|
||||
return
|
||||
|
||||
for command in commands:
|
||||
log.info(f"Execute QEMU monitor command: {command}")
|
||||
log.debug(f"Execute QEMU monitor command: {command}")
|
||||
try:
|
||||
cmd_byte = command.encode("ascii")
|
||||
writer.write(cmd_byte + b"\n")
|
||||
@@ -2292,15 +2293,22 @@ class QemuVM(BaseNode):
|
||||
options.extend(["-bios", self._bios_image.replace(",", ",,")])
|
||||
|
||||
elif self._uefi:
|
||||
|
||||
system_ovmf_firmware_dir = Path(self.manager.config.settings.Qemu.ovmf_firmware_dir)
|
||||
log.info("Using OVMF firmware directory: {}".format(system_ovmf_firmware_dir))
|
||||
old_ovmf_vars_path = os.path.join(self.working_dir, "OVMF_VARS.fd")
|
||||
if os.path.exists(old_ovmf_vars_path):
|
||||
# the node has its own UEFI variables store already, we must also use the old UEFI firmware
|
||||
ovmf_firmware_path = self.manager.get_abs_image_path("OVMF_CODE.fd")
|
||||
else:
|
||||
system_ovmf_firmware_path = "/usr/share/OVMF/OVMF_CODE_4M.fd"
|
||||
if os.path.exists(system_ovmf_firmware_path):
|
||||
ovmf_firmware_path = system_ovmf_firmware_path
|
||||
# Use a manual case-insensitive search instead
|
||||
try:
|
||||
system_ovmf_firmware_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd")
|
||||
if f.name.lower() == "ovmf_code_4m.fd"), None)
|
||||
except (FileNotFoundError, StopIteration):
|
||||
system_ovmf_firmware_path = None
|
||||
|
||||
if system_ovmf_firmware_path:
|
||||
ovmf_firmware_path = str(system_ovmf_firmware_path)
|
||||
else:
|
||||
# otherwise, get the UEFI firmware from the images directory
|
||||
ovmf_firmware_path = self.manager.get_abs_image_path("OVMF_CODE_4M.fd")
|
||||
@@ -2309,9 +2317,13 @@ class QemuVM(BaseNode):
|
||||
options.extend(["-drive", "if=pflash,format=raw,readonly,file={}".format(ovmf_firmware_path)])
|
||||
|
||||
# try to use the UEFI variables store from the system first
|
||||
system_ovmf_vars_path = "/usr/share/OVMF/OVMF_VARS_4M.fd"
|
||||
if os.path.exists(system_ovmf_vars_path):
|
||||
ovmf_vars_path = system_ovmf_vars_path
|
||||
try:
|
||||
system_ovmf_vars_path = next((f for f in system_ovmf_firmware_dir.glob("*.fd")
|
||||
if f.name.lower() == "ovmf_vars_4m.fd"), None)
|
||||
except (FileNotFoundError, StopIteration):
|
||||
system_ovmf_vars_path = None
|
||||
if system_ovmf_vars_path:
|
||||
ovmf_vars_path = str(system_ovmf_vars_path)
|
||||
else:
|
||||
# otherwise, get the UEFI variables store from the images directory
|
||||
ovmf_vars_path = self.manager.get_abs_image_path("OVMF_VARS_4M.fd")
|
||||
@@ -2327,6 +2339,10 @@ class QemuVM(BaseNode):
|
||||
except OSError as e:
|
||||
raise QemuError("Cannot copy OVMF_VARS_4M.fd file to the node working directory: {}".format(e))
|
||||
options.extend(["-drive", "if=pflash,format=raw,file={}".format(ovmf_vars_node_path)])
|
||||
|
||||
# edk2 firmware requires a Random Number Generator (RNG) device in order to turn network adapters on
|
||||
options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0"])
|
||||
options.extend(["-device", "virtio-rng-pci,rng=rng0"])
|
||||
return options
|
||||
|
||||
def _linux_boot_options(self):
|
||||
@@ -2649,7 +2665,6 @@ class QemuVM(BaseNode):
|
||||
elif sys.platform.startswith("darwin"):
|
||||
command.extend(["-enable-hax"])
|
||||
command.extend(["-boot", f"order={self._boot_priority}"])
|
||||
command.extend(self._bios_option())
|
||||
command.extend(self._cdrom_option())
|
||||
command.extend(await self._disk_options())
|
||||
command.extend(self._linux_boot_options())
|
||||
@@ -2659,6 +2674,8 @@ class QemuVM(BaseNode):
|
||||
command.extend(self._aux_options())
|
||||
command.extend(self._monitor_options())
|
||||
command.extend(await self._network_options())
|
||||
# bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838
|
||||
command.extend(self._bios_option())
|
||||
if self.on_close != "save_vm_state":
|
||||
await self._clear_save_vm_stated()
|
||||
else:
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
Represents a uBridge hypervisor and starts/stops the associated uBridge process.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import asyncio
|
||||
import socket
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
from gns3server.utils import parse_version
|
||||
@@ -44,17 +44,42 @@ class Hypervisor(UBridgeHypervisor):
|
||||
:param project: Project instance
|
||||
:param path: path to uBridge executable
|
||||
:param working_dir: working directory
|
||||
:param host: host/address for this hypervisor
|
||||
:param port: port for this hypervisor
|
||||
:param transport: control channel transport — "unix" (-U) or "tcp" (-H)
|
||||
:param host: host/address for the TCP transport (unused for "unix")
|
||||
:param node_id: node id used to name the AF_UNIX socket (unix transport)
|
||||
"""
|
||||
|
||||
_instance_count = 1
|
||||
_instance_count = 0
|
||||
|
||||
def __init__(self, project, path, working_dir, host, port=None):
|
||||
def __init__(self, project, path, working_dir, transport, host=None, node_id=None):
|
||||
|
||||
if port is None:
|
||||
self._project = project
|
||||
self._path = path
|
||||
self._working_dir = working_dir
|
||||
|
||||
if transport == "unix":
|
||||
# AF_UNIX control socket (-U). Name it after the node so the socket
|
||||
# is self-describing (one ubridge per node => node_id is unique).
|
||||
# sun_path is capped at 107 bytes; a single UUID fits comfortably
|
||||
# (~69 bytes with this prefix), so no project_id is needed.
|
||||
if node_id:
|
||||
socket_name = f"ubridge-{node_id}.sock"
|
||||
else:
|
||||
Hypervisor._instance_count += 1
|
||||
socket_name = f"ubridge-{Hypervisor._instance_count}.sock"
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
|
||||
socket_dir = os.path.join(runtime_dir, "gns3")
|
||||
try:
|
||||
os.makedirs(socket_dir, mode=0o700, exist_ok=True)
|
||||
os.chmod(socket_dir, 0o700)
|
||||
except OSError as e:
|
||||
raise UbridgeError(f"Could not create uBridge socket directory {socket_dir}: {e}")
|
||||
socket_path = os.path.join(socket_dir, socket_name)
|
||||
super().__init__(socket_path=socket_path)
|
||||
else:
|
||||
# TCP control channel (-H): let the OS find an unused local port.
|
||||
port = None
|
||||
try:
|
||||
port = None
|
||||
info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
|
||||
if not info:
|
||||
raise UbridgeError(f"getaddrinfo returns an empty list on {host}")
|
||||
@@ -68,11 +93,8 @@ class Hypervisor(UBridgeHypervisor):
|
||||
break
|
||||
except OSError as e:
|
||||
raise UbridgeError(f"Could not find free port for the uBridge hypervisor: {e}")
|
||||
super().__init__(host=host, port=port)
|
||||
|
||||
super().__init__(host, port)
|
||||
self._project = project
|
||||
self._path = path
|
||||
self._working_dir = working_dir
|
||||
self._command = []
|
||||
self._process = None
|
||||
self._stdout_file = ""
|
||||
@@ -131,19 +153,17 @@ class Hypervisor(UBridgeHypervisor):
|
||||
|
||||
async def _check_ubridge_version(self, env=None):
|
||||
"""
|
||||
Checks if the ubridge executable version
|
||||
Checks if the ubridge executable version meets the minimum required.
|
||||
"""
|
||||
try:
|
||||
output = await subprocess_check_output(self._path, "-v", cwd=self._working_dir, env=env)
|
||||
match = re.search(r"ubridge version ([0-9a-z\.]+)", output)
|
||||
if match:
|
||||
self._version = match.group(1)
|
||||
if sys.platform.startswith("darwin"):
|
||||
minimum_required_version = "0.9.12"
|
||||
else:
|
||||
# uBridge version 0.9.14 is required for packet filters
|
||||
# to work for IOU nodes.
|
||||
minimum_required_version = "0.9.14"
|
||||
# uBridge >= 1.2.0 is required for features this server now
|
||||
# relies on: the AF_UNIX control channel (-U), the marker
|
||||
# (mark) filter, and the brctl-backed builtin Ethernet Switch.
|
||||
minimum_required_version = "1.2.0"
|
||||
if parse_version(self._version) < parse_version(minimum_required_version):
|
||||
raise UbridgeError(f"uBridge executable version must be >= {minimum_required_version}")
|
||||
else:
|
||||
@@ -169,6 +189,17 @@ class Hypervisor(UBridgeHypervisor):
|
||||
)
|
||||
|
||||
log.info(f"ubridge started PID={self._process.pid}")
|
||||
# An unsupported flag (e.g. -U on an old ubridge build) makes ubridge exit
|
||||
# immediately with a non-zero code. Detect that here and surface the real
|
||||
# reason from ubridge.log instead of waiting for connect() to time out with
|
||||
# a confusing "couldn't connect" error.
|
||||
await asyncio.sleep(0.3)
|
||||
if self._process.returncode is not None:
|
||||
raise UbridgeError(
|
||||
f"uBridge exited immediately (code {self._process.returncode}); if "
|
||||
f"ubridge_control_transport is 'unix', the installed ubridge may not "
|
||||
f"support -U.\n{self.read_stdout()}"
|
||||
)
|
||||
# recv: Bad address is received by uBridge when a docker image stops by itself
|
||||
# see https://github.com/GNS3/gns3-gui/issues/2957
|
||||
# monitor_process(self._process, self._termination_callback)
|
||||
@@ -214,6 +245,16 @@ class Hypervisor(UBridgeHypervisor):
|
||||
os.remove(self._stdout_file)
|
||||
except OSError as e:
|
||||
log.warning(f"could not delete temporary uBridge log file: {e}")
|
||||
|
||||
# ubridge unlinks its AF_UNIX control socket on a clean exit; for the
|
||||
# unix transport remove it here too so a killed process leaves no stale
|
||||
# socket behind. The TCP transport has no socket_path.
|
||||
if self._socket_path:
|
||||
try:
|
||||
os.unlink(self._socket_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self._process = None
|
||||
self._started = False
|
||||
|
||||
@@ -250,7 +291,10 @@ class Hypervisor(UBridgeHypervisor):
|
||||
"""
|
||||
|
||||
command = [self._path]
|
||||
command.extend(["-H", f"{self._host}:{self._port}"])
|
||||
if self._socket_path:
|
||||
command.extend(["-U", self._socket_path])
|
||||
else:
|
||||
command.extend(["-H", f"{self._host}:{self._port}"])
|
||||
if log.getEffectiveLevel() == logging.DEBUG:
|
||||
command.extend(["-d", "1"])
|
||||
return command
|
||||
@@ -28,20 +28,29 @@ log = logging.getLogger(__name__)
|
||||
class UBridgeHypervisor:
|
||||
|
||||
"""
|
||||
Creates a new connection to uBridge hypervisor.
|
||||
Creates a new connection to a uBridge hypervisor control channel.
|
||||
|
||||
:param host: the hostname or ip address string of the uBridge hypervisor
|
||||
:param port: the tcp port integer
|
||||
Two transports, selected by which argument is set:
|
||||
* ``socket_path`` -> AF_UNIX (``-U``), authenticated in-kernel via
|
||||
SO_PEERCRED (ubridge accepts only its own UID; the compute process that
|
||||
spawned it shares that UID). Recommended on Linux.
|
||||
* ``host``/``port`` -> TCP (``-H``), retained for backward compatibility.
|
||||
|
||||
:param socket_path: path to the uBridge AF_UNIX control socket (None for TCP)
|
||||
:param host: TCP hostname/IP (None for AF_UNIX)
|
||||
:param port: TCP port
|
||||
:param timeout: timeout integer for how long to wait for a response to commands sent to the
|
||||
hypervisor (defaults to 30 seconds)
|
||||
hypervisor (defaults to 30 seconds)
|
||||
"""
|
||||
|
||||
# Used to parse Ubridge response codes
|
||||
error_re = re.compile(r"""^2[0-9]{2}-""")
|
||||
success_re = re.compile(r"""^1[0-9]{2}\s{1}""")
|
||||
|
||||
def __init__(self, host, port, timeout=30.0):
|
||||
def __init__(self, socket_path=None, host=None, port=None, timeout=30.0):
|
||||
|
||||
# Exactly one transport is active: socket_path (AF_UNIX) or host/port (TCP).
|
||||
self._socket_path = socket_path
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._version = "N/A"
|
||||
@@ -54,22 +63,23 @@ class UBridgeHypervisor:
|
||||
Connects to the hypervisor.
|
||||
"""
|
||||
|
||||
# connect to a local address by default
|
||||
# if listening to all addresses (IPv4 or IPv6)
|
||||
if self._host == "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
elif self._host == "::":
|
||||
host = "::1"
|
||||
else:
|
||||
host = self._host
|
||||
|
||||
begin = time.time()
|
||||
connection_success = False
|
||||
last_exception = None
|
||||
while time.time() - begin < timeout:
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.open_connection(host, self._port)
|
||||
if self._socket_path:
|
||||
self._reader, self._writer = await asyncio.open_unix_connection(self._socket_path)
|
||||
else:
|
||||
# connect to a local address by default if listening on all addresses
|
||||
if self._host == "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
elif self._host == "::":
|
||||
host = "::1"
|
||||
else:
|
||||
host = self._host
|
||||
self._reader, self._writer = await asyncio.open_connection(host, self._port)
|
||||
except OSError as e:
|
||||
last_exception = e
|
||||
continue
|
||||
@@ -77,9 +87,9 @@ class UBridgeHypervisor:
|
||||
break
|
||||
|
||||
if not connection_success:
|
||||
raise UbridgeError(f"Couldn't connect to hypervisor on {host}:{self._port} :{last_exception}")
|
||||
raise UbridgeError(f"Couldn't connect to hypervisor on {self.endpoint} :{last_exception}")
|
||||
else:
|
||||
log.info(f"Connected to uBridge hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds")
|
||||
log.info(f"Connected to uBridge hypervisor on {self.endpoint} after {time.time() - begin:.4f} seconds")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -122,7 +132,7 @@ class UBridgeHypervisor:
|
||||
await self._writer.drain()
|
||||
self._writer.close()
|
||||
except OSError as e:
|
||||
log.debug(f"Stopping hypervisor {self._host}:{self._port} {e}")
|
||||
log.debug(f"Stopping hypervisor {self.endpoint} {e}")
|
||||
self._reader = self._writer = None
|
||||
|
||||
async def reset(self):
|
||||
@@ -133,44 +143,17 @@ class UBridgeHypervisor:
|
||||
await self.send("hypervisor reset")
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
def endpoint(self):
|
||||
"""
|
||||
Returns the port used to start the hypervisor.
|
||||
Returns a human-readable control endpoint: the AF_UNIX socket path when
|
||||
using -U, or host:port when using -H. Used for logging and errors.
|
||||
|
||||
:returns: port number (integer)
|
||||
:returns: endpoint (string)
|
||||
"""
|
||||
|
||||
return self._port
|
||||
|
||||
@port.setter
|
||||
def port(self, port):
|
||||
"""
|
||||
Sets the port used to start the hypervisor.
|
||||
|
||||
:param port: port number (integer)
|
||||
"""
|
||||
|
||||
self._port = port
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""
|
||||
Returns the host (binding) used to start the hypervisor.
|
||||
|
||||
:returns: host/address (string)
|
||||
"""
|
||||
|
||||
return self._host
|
||||
|
||||
@host.setter
|
||||
def host(self, host):
|
||||
"""
|
||||
Sets the host (binding) used to start the hypervisor.
|
||||
|
||||
:param host: host/address (string)
|
||||
"""
|
||||
|
||||
self._host = host
|
||||
if self._socket_path:
|
||||
return self._socket_path
|
||||
return f"{self._host}:{self._port}"
|
||||
|
||||
@locking
|
||||
async def send(self, command):
|
||||
@@ -205,8 +188,8 @@ class UBridgeHypervisor:
|
||||
await self._writer.drain()
|
||||
except OSError as e:
|
||||
raise UbridgeError(
|
||||
"Lost communication with {host}:{port} when sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, error=e, run=self.is_running()
|
||||
"Lost communication with {endpoint} when sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, error=e, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -232,8 +215,8 @@ class UBridgeHypervisor:
|
||||
if not chunk:
|
||||
if retries > max_retries:
|
||||
raise UbridgeError(
|
||||
"No data returned from {host}:{port} after sending command '{command}', uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, run=self.is_running()
|
||||
"No data returned from {endpoint} after sending command '{command}', uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, run=self.is_running()
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -244,8 +227,8 @@ class UBridgeHypervisor:
|
||||
buf += chunk.decode("utf-8")
|
||||
except OSError as e:
|
||||
raise UbridgeError(
|
||||
"Lost communication with {host}:{port} after sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, error=e, run=self.is_running()
|
||||
"Lost communication with {endpoint} after sending command '{command}': {error}, uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, error=e, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -255,8 +238,8 @@ class UBridgeHypervisor:
|
||||
continue
|
||||
except IndexError:
|
||||
raise UbridgeError(
|
||||
"Could not communicate with {host}:{port} after sending command '{command}', uBridge process running: {run}".format(
|
||||
host=self._host, port=self._port, command=command, run=self.is_running()
|
||||
"Could not communicate with {endpoint} after sending command '{command}', uBridge process running: {run}".format(
|
||||
endpoint=self.endpoint, command=command, run=self.is_running()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
jwt_secret_key = efd08eccec3bd0a1be2e086670e5efa90969c68d07e072d7354a76cea5e33d4e
|
||||
jwt_algorithm = HS256
|
||||
jwt_access_token_expire_minutes = 1440
|
||||
jwt_refresh_token_expire_minutes = 43200
|
||||
|
||||
; Initial default super admin username
|
||||
; It cannot be changed once the controller has started once
|
||||
@@ -91,6 +92,19 @@ udp_end_port_range = 30000
|
||||
; uBridge executable location, default: search in PATH
|
||||
;ubridge_path = ubridge
|
||||
|
||||
; uBridge control channel transport: "unix" (-U socket_path; AF_UNIX +
|
||||
; SO_PEERCRED, default — recommended on Linux for kernel-level peer
|
||||
; authentication) or "tcp" (-H host:port; retained for backward compatibility,
|
||||
; binds loopback).
|
||||
;ubridge_control_transport = unix
|
||||
|
||||
; Marker (traffic-insight) UDP sink: one listener per compute process that
|
||||
; receives uBridge MARK signals from every uBridge on this host.
|
||||
; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally.
|
||||
; marker_listen_port defaults to 3070 (set to 0 for OS-chosen).
|
||||
;marker_listen_host = 127.0.0.1
|
||||
;marker_listen_port = 3070
|
||||
|
||||
; Option to enable or disable compute HTTP authentication
|
||||
enable_http_auth = True
|
||||
|
||||
@@ -169,6 +183,8 @@ enable_hardware_acceleration = True
|
||||
require_hardware_acceleration = False
|
||||
; Allow unsafe additional command line options
|
||||
allow_unsafe_options = False
|
||||
; Path to the OVMF firmware directory
|
||||
ovmf_firmware_dir = "/usr/share/OVMF"
|
||||
|
||||
[WebWireshark]
|
||||
; Enable Web Wireshark feature (container-based Wireshark in browser)
|
||||
@@ -183,4 +199,12 @@ memory = 2g
|
||||
; CPU cores per container (e.g., 1.0, 2.0)
|
||||
cpus = 1.0
|
||||
; Process limit per container
|
||||
pids_limit = 1000
|
||||
pids_limit = 1000
|
||||
; MCP (Model Context Protocol) transport security settings
|
||||
; Disabled by default — allows connections from any host (matches GNS3
|
||||
; server's 0.0.0.0 binding). Enable and configure allowed hosts below
|
||||
; for enhanced security against DNS rebinding attacks.
|
||||
; Note: Only "host:*" port wildcards are supported (e.g., "127.0.0.1:*").
|
||||
;mcp_enable_dns_rebinding_protection = true
|
||||
;mcp_allowed_hosts = 127.0.0.1:*,localhost:*
|
||||
;mcp_allowed_origins = http://127.0.0.1:*,http://localhost:*
|
||||
@@ -29,7 +29,7 @@ try:
|
||||
except ImportError:
|
||||
from importlib import resources as importlib_resources
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.events import FileSystemEventHandler, DirDeletedEvent, FileDeletedEvent
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from ..config import Config
|
||||
@@ -73,6 +73,9 @@ class _ProjectsDirectoryEventHandler(FileSystemEventHandler):
|
||||
def on_moved(self, event):
|
||||
self._handle_event(event)
|
||||
|
||||
def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
|
||||
self._handle_event(event)
|
||||
|
||||
def _handle_event(self, event):
|
||||
if event.is_directory:
|
||||
# Only react to direct child directories of the projects path
|
||||
@@ -446,6 +449,12 @@ class Controller:
|
||||
return # Monitor was stopped, skip the scan
|
||||
try:
|
||||
await self.load_projects()
|
||||
# Remove stale projects that no longer exist on disk
|
||||
for project_id in list(self._projects):
|
||||
project = self._projects[project_id]
|
||||
if not os.path.exists(project.path):
|
||||
log.info(f"Removing stale project '{project.name}' ('{project.path}' no longer exists)")
|
||||
del self._projects[project.id]
|
||||
except Exception as e:
|
||||
log.warning(f"Projects directory rescan failed: {e}")
|
||||
|
||||
@@ -741,6 +750,9 @@ class Controller:
|
||||
topo_data.pop("version")
|
||||
topo_data.pop("revision")
|
||||
topo_data.pop("type")
|
||||
# marker_definitions is restored by Project.open() from the topology
|
||||
# file; it must not be passed to Project.__init__.
|
||||
topo_data.pop("marker_definitions", None)
|
||||
|
||||
if topo_data["project_id"] in self._projects:
|
||||
project = self._projects[topo_data["project_id"]]
|
||||
|
||||
@@ -32,7 +32,7 @@ else:
|
||||
from async_timeout import timeout as asynctimeout
|
||||
|
||||
from ..utils import parse_version
|
||||
from ..utils.asyncio import locking
|
||||
from ..utils.asyncio import locking, async_iterable_to_stream
|
||||
from ..controller.controller_error import (
|
||||
ControllerError,
|
||||
ControllerBadRequestError,
|
||||
@@ -341,9 +341,11 @@ class Compute:
|
||||
raise ControllerNotFoundError(f"{image} not found on compute")
|
||||
return response
|
||||
|
||||
async def http_query(self, method, path, data=None, dont_connect=False, **kwargs):
|
||||
async def http_query(self, method, path, data=None, dont_connect=False, stream=False, params=None, **kwargs):
|
||||
"""
|
||||
:param dont_connect: If true do not reconnect if not connected
|
||||
:param stream: If True, return raw aiohttp response for streaming
|
||||
:param params: Optional dict of query parameters to append to the URL
|
||||
"""
|
||||
|
||||
if not self._connected and not dont_connect:
|
||||
@@ -352,7 +354,7 @@ class Compute:
|
||||
await self.connect()
|
||||
if not self._connected and not dont_connect:
|
||||
raise ComputeError(f"Cannot connect to compute '{self._name}' with request {method} {path}")
|
||||
response = await self._run_http_query(method, path, data=data, **kwargs)
|
||||
response = await self._run_http_query(method, path, data=data, stream=stream, params=params, **kwargs)
|
||||
return response
|
||||
|
||||
async def _try_reconnect(self):
|
||||
@@ -515,7 +517,7 @@ class Compute:
|
||||
""" Returns URL for specific path at Compute"""
|
||||
return self._getUrl(path)
|
||||
|
||||
async def _run_http_query(self, method, path, data=None, timeout=120, raw=False):
|
||||
async def _run_http_query(self, method, path, data=None, timeout=120, raw=False, stream=False, params=None):
|
||||
async with asynctimeout(delay=timeout):
|
||||
url = self._getUrl(path)
|
||||
headers = {"content-type": "application/json"}
|
||||
@@ -531,6 +533,11 @@ class Compute:
|
||||
elif isinstance(data, aiohttp.streams.StreamReader) or isinstance(data, bytes):
|
||||
chunked = True
|
||||
headers["content-type"] = "application/octet-stream"
|
||||
# Stream from an async iterable (e.g. Starlette request.stream())
|
||||
elif hasattr(data, "__aiter__"):
|
||||
chunked = True
|
||||
headers["content-type"] = "application/octet-stream"
|
||||
data = await async_iterable_to_stream(data)
|
||||
# If the data is an open file we will iterate on it
|
||||
elif isinstance(data, io.BufferedIOBase):
|
||||
chunked = True
|
||||
@@ -540,7 +547,7 @@ class Compute:
|
||||
try:
|
||||
log.debug(f"Attempting request to compute: {method} {url} {headers}")
|
||||
response = await self._session().request(
|
||||
method, url, headers=headers, data=data, auth=self._auth, chunked=chunked, timeout=timeout
|
||||
method, url, headers=headers, data=data, auth=self._auth, params=params, chunked=chunked, timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ComputeError(f"Timeout error for {method} call to {url} after {timeout}s")
|
||||
@@ -554,6 +561,18 @@ class Compute:
|
||||
) as e:
|
||||
# aiohttp 2.3.1 raises socket.gaierror when cannot find host
|
||||
raise ComputeError(str(e))
|
||||
|
||||
if stream:
|
||||
if response.status >= 300:
|
||||
body = await response.read()
|
||||
msg = body.decode() if body else ""
|
||||
if response.status == 404:
|
||||
raise ControllerNotFoundError(f"{method} {path} not found")
|
||||
elif response.status == 403:
|
||||
raise ControllerForbiddenError(msg)
|
||||
raise ControllerError(f"HTTP {response.status}: {msg}")
|
||||
return response
|
||||
|
||||
body = await response.read()
|
||||
if body and not raw:
|
||||
body = body.decode()
|
||||
|
||||
@@ -182,24 +182,46 @@ async def import_project(
|
||||
project = await controller.load_project(dot_gns3_path, load=False)
|
||||
return project
|
||||
|
||||
|
||||
def _create_symbolic_links(zip_file, path):
|
||||
"""
|
||||
Manually create symbolic links (if any) because ZipFile does not support it.
|
||||
Refuse any target that escapes `path`.
|
||||
|
||||
:param zip_file: ZipFile instance
|
||||
:param path: project location
|
||||
"""
|
||||
|
||||
path_root = os.path.realpath(path) + os.sep
|
||||
for zip_info in zip_file.infolist():
|
||||
if stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
try:
|
||||
# remove the regular file and replace it by a symbolic link
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Cannot create symbolic link: {e}")
|
||||
if not stat.S_ISLNK(zip_info.external_attr >> 16):
|
||||
continue
|
||||
symlink_target = zip_file.read(zip_info.filename).decode()
|
||||
symlink_path = os.path.join(path, zip_info.filename)
|
||||
|
||||
# 1. Reject absolute targets outright.
|
||||
if os.path.isabs(symlink_target):
|
||||
raise ControllerError(f"Symlink {zip_info.filename!r} has absolute target {symlink_target!r}, refusing")
|
||||
|
||||
# 2. Reject paths where the entry name itself escapes (defence in depth;
|
||||
# extractall normally would already have caught this).
|
||||
member_abs = os.path.realpath(symlink_path)
|
||||
if not (member_abs + os.sep).startswith(path_root) and member_abs + os.sep != path_root:
|
||||
raise ControllerError(f"Symlink entry {zip_info.filename!r} escapes project dir, refusing")
|
||||
|
||||
# 3. Resolve the symlink target relative to the entry's own parent
|
||||
# directory and verify the resolved real path stays inside `path`.
|
||||
link_dir = os.path.realpath(os.path.dirname(symlink_path))
|
||||
resolved_target = os.path.realpath(os.path.join(link_dir, symlink_target))
|
||||
if not (resolved_target + os.sep).startswith(path_root) and resolved_target + os.sep != path_root:
|
||||
raise ControllerError("Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing")
|
||||
|
||||
try:
|
||||
os.remove(symlink_path)
|
||||
os.symlink(symlink_target, symlink_path)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Cannot create symbolic link: {e}")
|
||||
|
||||
|
||||
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
|
||||
"""
|
||||
@@ -295,14 +317,19 @@ async def _import_images(controller, images_path):
|
||||
for (dirpath, dirnames, filenames) in os.walk(root, followlinks=False):
|
||||
for filename in filenames:
|
||||
path = os.path.join(dirpath, filename)
|
||||
if os.path.islink(path):
|
||||
continue
|
||||
dst = os.path.join(image_dir, os.path.relpath(path, root))
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if not os.path.exists(dst):
|
||||
await wait_run_in_executor(shutil.move, path, dst)
|
||||
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
|
||||
try:
|
||||
with open(dst, "rb") as f:
|
||||
# read the first 7 bytes of the file.
|
||||
elf_header_start = f.read(7)
|
||||
# IOU images must start with the ELF magic number, be 32-bit or 64-bit, little endian and have an ELF version of 1
|
||||
if elf_header_start == b'\x7fELF\x01\x01\x01' or elf_header_start == b'\x7fELF\x02\x01\x01':
|
||||
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
||||
except OSError as e:
|
||||
continue
|
||||
|
||||
async def update_snapshots(snapshots_dir, project_path, project_name, project_id, reset_mac_addresses=True):
|
||||
"""
|
||||
|
||||
@@ -30,6 +30,13 @@ import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Sentinel for "argument not passed". Distinct from None so marker/definition
|
||||
# updaters can tell "caller omitted direction" (keep current value) from
|
||||
# "caller passed direction=None" (clear it back to both directions). See
|
||||
# UDPLink.update_marker and Project.update_marker_definition.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
FILTERS = [
|
||||
{
|
||||
"type": "frequency_drop",
|
||||
@@ -88,6 +95,7 @@ class Link:
|
||||
self._link_type = "ethernet"
|
||||
self._suspended = False
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._link_style = {}
|
||||
self._wireshark = False
|
||||
self._show_filters_icon = True
|
||||
@@ -99,6 +107,57 @@ class Link:
|
||||
"""
|
||||
return self._filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Get the traffic insight markers dict: name → {bpf, tag, enabled}
|
||||
"""
|
||||
return self._markers
|
||||
|
||||
async def inherit_marker(self, def_name, marker_def, dump=True):
|
||||
"""
|
||||
Apply a project-level marker definition to this link.
|
||||
|
||||
The marker is stored under ``global-{def_name}`` so it cannot collide
|
||||
with a per-link private marker of the same name. It carries an
|
||||
``inherited_from`` back-reference that (a) guards against per-link
|
||||
edits and (b) lets the project sync changes to every copy at once.
|
||||
|
||||
The pcap link-layer follows the link type: Ethernet is always EN10MB.
|
||||
A serial link needs the definition's WAN encapsulation (HDLC / PPP /
|
||||
Frame Relay); if none was chosen the serial link is skipped — an EN10MB
|
||||
pcap on a serial link is undecodable.
|
||||
"""
|
||||
|
||||
def_data_link_type = marker_def.get("data_link_type", "DLT_EN10MB")
|
||||
if self._link_type == "serial":
|
||||
if def_data_link_type.upper() == "DLT_EN10MB":
|
||||
return # definition is Ethernet-only; skip this serial link
|
||||
data_link_type = def_data_link_type
|
||||
else:
|
||||
data_link_type = "DLT_EN10MB"
|
||||
|
||||
await self.start_marker(
|
||||
name=f"global-{def_name}",
|
||||
bpf=marker_def["bpf"],
|
||||
tag=marker_def.get("tag"),
|
||||
direction=marker_def.get("direction"),
|
||||
data_link_type=data_link_type,
|
||||
color=marker_def.get("color"),
|
||||
highlight_duration=marker_def.get("highlight_duration"),
|
||||
enabled=not marker_def.get("paused", False),
|
||||
inherited_from=def_name,
|
||||
dump=dump,
|
||||
)
|
||||
|
||||
def _persist_markers(self):
|
||||
"""
|
||||
Return only the per-link (non-inherited) markers suitable for
|
||||
persistence in a topology dump. Inherited markers are re-created from
|
||||
``project._marker_definitions`` on load so they do not need to be saved.
|
||||
"""
|
||||
return {k: v for k, v in self._markers.items() if not v.get("inherited_from")}
|
||||
|
||||
@property
|
||||
def show_filters_icon(self):
|
||||
"""
|
||||
@@ -298,6 +357,27 @@ class Link:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
async def start_marker(self, name, bpf, tag=None, direction=None, capture_node_id=None, enabled=True):
|
||||
"""
|
||||
Attach a traffic-insight marker to this link (base — UDPLink overrides).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def stop_marker(self, name):
|
||||
"""
|
||||
Remove a traffic-insight marker from this link (base — UDPLink overrides).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET):
|
||||
"""
|
||||
Update an existing marker's BPF, tag, or enabled flag.
|
||||
|
||||
A BPF change is a delete+re-add on the ubridge side so the pcap is
|
||||
flushed and the new filter takes effect.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
|
||||
"""
|
||||
Start capture on the link
|
||||
@@ -571,6 +651,7 @@ class Link:
|
||||
"nodes": res,
|
||||
"link_id": self._id,
|
||||
"filters": self._filters,
|
||||
"markers": self._persist_markers(),
|
||||
"link_style": self._link_style,
|
||||
"suspend": self._suspended,
|
||||
"show_filters_icon": getattr(self, '_show_filters_icon', True),
|
||||
@@ -585,6 +666,7 @@ class Link:
|
||||
"capture_compute_id": self.capture_compute_id,
|
||||
"link_type": self._link_type,
|
||||
"filters": self._filters,
|
||||
"markers": self._markers,
|
||||
"suspend": self._suspended,
|
||||
"link_style": self._link_style,
|
||||
"wireshark": self._wireshark,
|
||||
|
||||
@@ -639,6 +639,14 @@ class Node:
|
||||
except asyncio.TimeoutError:
|
||||
raise ControllerTimeoutError(f"Timeout when reset console {self._name}")
|
||||
|
||||
async def get(self, path="", **kwargs):
|
||||
"""
|
||||
HTTP get on the node
|
||||
"""
|
||||
return await self._compute.get(
|
||||
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
|
||||
)
|
||||
|
||||
async def post(self, path, data=None, **kwargs):
|
||||
"""
|
||||
HTTP post on the node
|
||||
|
||||
@@ -37,10 +37,12 @@ from .snapshot import Snapshot
|
||||
from .drawing import Drawing
|
||||
from .topology import project_to_topology, load_topology
|
||||
from .udp_link import UDPLink
|
||||
from .link import _UNSET
|
||||
from ..config import Config
|
||||
from ..utils.path import check_path_allowed, get_default_project_directory
|
||||
from ..utils.application_id import get_next_application_id
|
||||
from ..utils.asyncio.pool import Pool
|
||||
from ..utils.packet_filter_validation import validate_bpf_syntax
|
||||
from ..utils.asyncio import locking
|
||||
from ..utils.asyncio import aiozipstream
|
||||
from ..utils.asyncio import wait_run_in_executor
|
||||
@@ -158,6 +160,7 @@ class Project:
|
||||
self.dump()
|
||||
|
||||
self._iou_id_lock = asyncio.Lock()
|
||||
self._preallocated_udp_ports = {} # compute_id -> list of pre-allocated UDP ports
|
||||
log.debug(f'Project "{self.name}" [{self._id}] loaded')
|
||||
self.emit_controller_notification("project.created", self.asdict())
|
||||
|
||||
@@ -197,9 +200,11 @@ class Project:
|
||||
self.emit_controller_notification("project.updated", self.asdict())
|
||||
self.dump()
|
||||
|
||||
# update on computes
|
||||
for compute in list(self._project_created_on_compute):
|
||||
await compute.put(f"/projects/{self._id}", {"variables": self.variables})
|
||||
# Only notify computes if variables actually changed and have content
|
||||
# None and empty list are semantically equivalent (no variables) and don't affect running nodes
|
||||
if "variables" in kwargs and kwargs["variables"]:
|
||||
for compute in list(self._project_created_on_compute):
|
||||
await compute.put(f"/projects/{self._id}", {"variables": self.variables})
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
@@ -208,12 +213,14 @@ class Project:
|
||||
self._allocated_node_names = set()
|
||||
self._nodes = {}
|
||||
self._links = {}
|
||||
self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration}
|
||||
self._drawings = {}
|
||||
self._snapshots = {}
|
||||
self._computes = []
|
||||
self._load_snapshot_config()
|
||||
# Create the project on demand on the compute node
|
||||
self._project_created_on_compute = set()
|
||||
self._preallocated_udp_ports = {}
|
||||
|
||||
@property
|
||||
def scene_height(self):
|
||||
@@ -428,7 +435,21 @@ class Project:
|
||||
|
||||
@name.setter
|
||||
def name(self, val):
|
||||
old_filename = self._filename
|
||||
self._name = val
|
||||
self._filename = val + ".gns3"
|
||||
|
||||
# Rename the .gns3 file on disk when the project name changes
|
||||
if old_filename != self._filename:
|
||||
old_path = os.path.join(self._path, old_filename)
|
||||
new_path = os.path.join(self._path, self._filename)
|
||||
if os.path.exists(old_path):
|
||||
try:
|
||||
shutil.move(old_path, new_path)
|
||||
log.info(f"Project file renamed from '{old_filename}' to '{self._filename}'")
|
||||
except OSError as e:
|
||||
log.warning(f"Could not rename project file from '{old_filename}' to '{self._filename}': {e}")
|
||||
self._filename = old_filename
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
@@ -545,13 +566,11 @@ class Project:
|
||||
"""
|
||||
Create a node from a template.
|
||||
"""
|
||||
|
||||
template["x"] = x
|
||||
template["y"] = y
|
||||
node_type = template.pop("template_type")
|
||||
|
||||
if compute_id:
|
||||
# use a custom compute_id
|
||||
compute = self.controller.get_compute(compute_id)
|
||||
else:
|
||||
compute = self.controller.get_compute(template.pop("compute_id"))
|
||||
@@ -568,15 +587,12 @@ class Project:
|
||||
|
||||
node = Node(self, compute, name, node_id=node_id, node_type=node_type, **kwargs)
|
||||
if compute not in self._project_created_on_compute:
|
||||
# For a local server we send the project path
|
||||
if compute.id == "local":
|
||||
data = {"name": self._name, "project_id": self._id, "path": self._path}
|
||||
else:
|
||||
data = {"name": self._name, "project_id": self._id}
|
||||
|
||||
if self._variables:
|
||||
data["variables"] = self._variables
|
||||
|
||||
await compute.post("/projects", data=data)
|
||||
self._project_created_on_compute.add(compute)
|
||||
|
||||
@@ -602,16 +618,15 @@ class Project:
|
||||
|
||||
if node_type == "iou":
|
||||
async with self._iou_id_lock:
|
||||
# wait for an IOU node to be completely created before adding a new one
|
||||
# this is important otherwise we allocate the same application ID (used
|
||||
# to generate MAC addresses) when creating multiple IOU node at the same time
|
||||
# IOU application IDs must be allocated serially to avoid duplicates.
|
||||
# The lock must also cover _create_node() because get_next_application_id()
|
||||
# checks in-memory nodes (self._nodes), which are only registered
|
||||
# after _create_node() completes.
|
||||
if "properties" in kwargs.keys():
|
||||
# allocate a new application id for nodes loaded from the project
|
||||
kwargs.get("properties")["application_id"] = get_next_application_id(
|
||||
self._controller.projects, self._computes
|
||||
)
|
||||
elif "application_id" not in kwargs.keys() and not kwargs.get("properties"):
|
||||
# allocate a new application id for nodes added to the project
|
||||
kwargs["application_id"] = get_next_application_id(self._controller.projects, self._computes)
|
||||
node = await self._create_node(compute, name, node_id, node_type, **kwargs)
|
||||
else:
|
||||
@@ -735,6 +750,83 @@ class Project:
|
||||
self.dump()
|
||||
self.emit_notification("drawing.deleted", drawing.asdict())
|
||||
|
||||
async def _create_link_from_topology_data(self, link_data):
|
||||
"""
|
||||
Create a link from topology data (used during project loading).
|
||||
|
||||
Extracted into a separate method so links can be created in parallel
|
||||
via Pool() during project.open().
|
||||
|
||||
:param link_data: Link data from the topology file
|
||||
"""
|
||||
link = await self.add_link(link_id=link_data["link_id"])
|
||||
if "filters" in link_data:
|
||||
try:
|
||||
await link.update_filters(link_data["filters"])
|
||||
except ControllerError as e:
|
||||
log.warning(
|
||||
"Dropping invalid filters on link %s: %s",
|
||||
link_data.get("link_id"), e
|
||||
)
|
||||
# Restore traffic-insight markers directly into link state (mirrors how
|
||||
# filters are restored via update_filters). The capture_node_id persisted
|
||||
# last time is reused for NIO routing; no side resolution is possible here
|
||||
# because the link's nodes are added later. The marker is applied to
|
||||
# uBridge by _ubridge_apply_markers when create() runs. Invalid BPF is
|
||||
# dropped (like invalid filters).
|
||||
for name, marker in (link_data.get("markers") or {}).items():
|
||||
bpf = marker.get("bpf")
|
||||
if not bpf:
|
||||
log.warning("Dropping marker %s on link %s: missing bpf", name, link_data.get("link_id"))
|
||||
continue
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
log.warning(
|
||||
"Dropping marker %s on link %s: invalid BPF (%s)",
|
||||
name, link_data.get("link_id"), result.get("error")
|
||||
)
|
||||
continue
|
||||
link._markers[name] = {
|
||||
"bpf": bpf,
|
||||
"tag": marker.get("tag"),
|
||||
"enabled": marker.get("enabled", True),
|
||||
"color": marker.get("color"),
|
||||
"highlight_duration": marker.get("highlight_duration"),
|
||||
"capture_node_id": marker.get("capture_node_id"),
|
||||
"direction": marker.get("direction"),
|
||||
}
|
||||
if "link_style" in link_data:
|
||||
await link.update_link_style(link_data["link_style"])
|
||||
if "show_filters_icon" in link_data:
|
||||
await link.update_show_filters_icon(link_data["show_filters_icon"])
|
||||
for node_link in link_data.get("nodes", []):
|
||||
node = self.get_node(node_link["node_id"])
|
||||
port = node.get_port(node_link["adapter_number"], node_link["port_number"])
|
||||
if port is None:
|
||||
log.warning(
|
||||
"Port {}/{} for {} not found".format(
|
||||
node_link["adapter_number"], node_link["port_number"], node.name
|
||||
)
|
||||
)
|
||||
continue
|
||||
if port.link is not None:
|
||||
log.warning(
|
||||
"Port {}/{} is already connected to link ID {}".format(
|
||||
node_link["adapter_number"], node_link["port_number"], port.link.id
|
||||
)
|
||||
)
|
||||
continue
|
||||
await link.add_node(
|
||||
node,
|
||||
node_link["adapter_number"],
|
||||
node_link["port_number"],
|
||||
label=node_link.get("label"),
|
||||
dump=False,
|
||||
)
|
||||
if len(link.nodes) != 2:
|
||||
# a link should have 2 attached nodes, this can happen with corrupted projects
|
||||
await self.delete_link(link.id, force_delete=True)
|
||||
|
||||
@open_required
|
||||
async def add_link(self, link_id=None, dump=True):
|
||||
"""
|
||||
@@ -750,6 +842,35 @@ class Project:
|
||||
self.dump()
|
||||
return link
|
||||
|
||||
async def preallocate_udp_ports_for_compute(self, compute, count):
|
||||
"""
|
||||
Pre-allocate UDP ports from a compute in a single batch call.
|
||||
|
||||
Used during project loading to reduce HTTP round-trips when
|
||||
creating many links.
|
||||
|
||||
:param compute: Compute instance
|
||||
:param count: Number of UDP ports to pre-allocate
|
||||
"""
|
||||
if count <= 0:
|
||||
return
|
||||
response = await compute.post(f"/projects/{self._id}/ports/udp/batch", data={"count": count})
|
||||
ports = response.json["udp_ports"]
|
||||
self._preallocated_udp_ports.setdefault(compute.id, [])
|
||||
self._preallocated_udp_ports[compute.id].extend(ports)
|
||||
|
||||
def pop_preallocated_udp_port(self, compute_id):
|
||||
"""
|
||||
Pop a pre-allocated UDP port for a compute.
|
||||
|
||||
:param compute_id: Compute ID
|
||||
:returns: UDP port number or None if no pre-allocated port is available
|
||||
"""
|
||||
ports = self._preallocated_udp_ports.get(compute_id, [])
|
||||
if ports:
|
||||
return ports.pop()
|
||||
return None
|
||||
|
||||
@open_required
|
||||
async def delete_link(self, link_id, force_delete=False):
|
||||
link = self.get_link(link_id)
|
||||
@@ -781,6 +902,285 @@ class Project:
|
||||
return self._get_closed_data("links", "link_id")
|
||||
return self._links
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Project-level read-only aggregation of all markers across every link.
|
||||
|
||||
Each entry is keyed ``"{link_id}/{marker_name}"`` so the flat dict is
|
||||
globally unique within the project. The value is a clone of the link's
|
||||
per-marker dict plus ``link_id`` and ``node_id`` (the capture-side node)
|
||||
for convenience — the frontend can filter/group by link or node without
|
||||
extra round-trips.
|
||||
|
||||
:returns: dict[str, dict] — keyed by "{link_id}/{marker_name}"
|
||||
"""
|
||||
result = {}
|
||||
for link_id, link in self._links.items():
|
||||
for name, info in link.markers.items():
|
||||
key = f"{link_id}/{name}"
|
||||
result[key] = {
|
||||
**info,
|
||||
"link_id": link_id,
|
||||
"node_id": info.get("capture_node_id"),
|
||||
}
|
||||
return result
|
||||
|
||||
async def pause_marker_definition(self, name):
|
||||
"""
|
||||
Pause every inherited copy of a definition (``global-{name}``) on every
|
||||
link: toggle each filter off in place via ``update_marker(enabled=False)``
|
||||
— uBridge ``enable_packet_filter off``, no NIO rebuild, pcap/emitted
|
||||
preserved. The definition's ``paused`` flag is persisted, so links
|
||||
created later inherit the marker already paused.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerError(f"Marker definition '{name}' not found")
|
||||
self._marker_definitions[name]["paused"] = True
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(marker_name, enabled=False, inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to pause marker {marker_name} on link {link.id}: {e}",
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def resume_marker_definition(self, name):
|
||||
"""Resume every inherited copy of a definition (toggle on)."""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerError(f"Marker definition '{name}' not found")
|
||||
self._marker_definitions[name]["paused"] = False
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(marker_name, enabled=True, inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to resume marker {marker_name} on link {link.id}: {e}",
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
@property
|
||||
def marker_definitions(self):
|
||||
"""
|
||||
:returns: dict of project-level marker definitions (name → {bpf, tag, color, highlight_duration})
|
||||
"""
|
||||
return self._marker_definitions
|
||||
|
||||
def _validate_marker_definition_bpf(self, name, bpf):
|
||||
"""
|
||||
Validate a marker definition's BPF once, here, so the fan-out to every
|
||||
link (``_apply_def_to_all_links`` → ``inherit_marker`` → ``start_marker``)
|
||||
and the per-link sync (``update_marker_definition`` → ``update_marker``)
|
||||
can skip re-validation for the inherited copies — otherwise one
|
||||
``tcpdump -d`` subprocess runs per link for the same expression. A
|
||||
private per-link marker still validates in ``start_marker``/``update_marker``.
|
||||
"""
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}"
|
||||
)
|
||||
|
||||
def _validate_marker_definition_direction(self, name, direction):
|
||||
"""
|
||||
Reject tx/rx on a marker definition: a definition fans out to every link
|
||||
and auto-selects its capture node on each (``_choose_marker_side``),
|
||||
while tx/rx is relative to that node, so a fixed direction has no
|
||||
consistent meaning across links. Only 'both' (the default, = ``None``)
|
||||
is allowed — encode the direction in the BPF instead (e.g.
|
||||
``icmp[icmptype]==8`` for echo requests), or use a per-link marker whose
|
||||
capture node is pinned.
|
||||
"""
|
||||
if direction in ("tx", "rx"):
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}': direction '{direction}' is not allowed. "
|
||||
"A definition fans out to every link and auto-selects its capture node on each, "
|
||||
"but tx/rx is relative to that node, so a fixed direction has no consistent "
|
||||
"meaning across links. Keep 'both' (the default) and encode the direction in "
|
||||
"the BPF instead, e.g. 'icmp and icmp[icmptype]==8' for echo requests only. "
|
||||
"For a capture-node-relative direction on a single link, use a per-link marker."
|
||||
)
|
||||
|
||||
async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
Create a project-level marker definition and fan out to every existing
|
||||
link that has a capable node. Links without a capable node are silently
|
||||
skipped.
|
||||
"""
|
||||
|
||||
if name in self._marker_definitions:
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}' already exists in this project"
|
||||
)
|
||||
|
||||
self._validate_marker_definition_bpf(name, bpf)
|
||||
self._validate_marker_definition_direction(name, direction)
|
||||
self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False}
|
||||
await self._apply_def_to_all_links(name)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET):
|
||||
"""
|
||||
Update a marker definition and sync every inherited copy on every link.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerNotFoundError(
|
||||
f"Marker definition '{name}' not found in this project"
|
||||
)
|
||||
|
||||
d = self._marker_definitions[name]
|
||||
if bpf is not None:
|
||||
self._validate_marker_definition_bpf(name, bpf)
|
||||
d["bpf"] = bpf
|
||||
if tag is not None:
|
||||
d["tag"] = tag
|
||||
if color is not None:
|
||||
d["color"] = color
|
||||
if highlight_duration is not None:
|
||||
d["highlight_duration"] = highlight_duration
|
||||
if direction is not _UNSET:
|
||||
self._validate_marker_definition_direction(name, direction)
|
||||
d["direction"] = direction # None = clear back to both directions
|
||||
if data_link_type is not _UNSET:
|
||||
d["data_link_type"] = data_link_type
|
||||
|
||||
# Links that currently carry an inherited copy of this definition.
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
|
||||
if data_link_type is not _UNSET:
|
||||
# data_link_type decides which links host an inherited copy (serial
|
||||
# links are skipped unless a WAN encapsulation is chosen), so a change
|
||||
# needs a full re-fan-out: drop every copy, then re-apply.
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.stop_marker(f"global-{name}", inherited=True, dump=False),
|
||||
lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}",
|
||||
)
|
||||
await self._apply_def_to_all_links(name)
|
||||
else:
|
||||
# Sync: update every inherited copy across all links.
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.update_marker(
|
||||
f"global-{name}", bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"),
|
||||
color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True,
|
||||
dump=False
|
||||
),
|
||||
lambda link, e: f"Failed to sync marker global-{name} on link {link.id}: {e}",
|
||||
)
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def delete_marker_definition(self, name):
|
||||
"""
|
||||
Delete a marker definition and remove every inherited copy from every link.
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerNotFoundError(
|
||||
f"Marker definition '{name}' not found in this project"
|
||||
)
|
||||
|
||||
del self._marker_definitions[name]
|
||||
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
if f"global-{name}" in link.markers
|
||||
and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
await self._marker_apply_concurrently(
|
||||
affected,
|
||||
lambda link: link.stop_marker(f"global-{name}", inherited=True),
|
||||
# A missing compute or broken link shouldn't block the delete.
|
||||
lambda link, e: f"Failed to remove inherited marker global-{name} from link {link.id}: {e}",
|
||||
)
|
||||
|
||||
self.dump()
|
||||
self.emit_notification("project.updated", self.asdict())
|
||||
|
||||
async def _apply_def_to_all_links(self, def_name):
|
||||
"""
|
||||
Fan out a single marker definition to every existing link in the project.
|
||||
Links that have no capable node (``_MARKER_CAPABLE_TYPES``) are silently
|
||||
skipped — the marker can only live on a uBridge bridge.
|
||||
"""
|
||||
|
||||
d = self._marker_definitions[def_name]
|
||||
# dump=False: per-link topology writes are the dominant cost on large
|
||||
# projects — the callers (create/update_marker_definition) dump once
|
||||
# after the fan-out.
|
||||
await self._marker_apply_concurrently(
|
||||
list(self._links.values()),
|
||||
lambda link: link.inherit_marker(def_name, d, dump=False),
|
||||
lambda link, e: f"Marker definition '{def_name}' could not be applied to link {link.id}: {e}",
|
||||
)
|
||||
|
||||
async def apply_defs_to_new_link(self, link):
|
||||
"""
|
||||
Apply every active marker definition to a newly created link so it
|
||||
inherits project-level rules automatically.
|
||||
|
||||
Deliberately serial: all definitions share the same link, and each
|
||||
``inherit_marker`` pushes the link's full marker set — concurrent
|
||||
pushes would race (a later push overwriting an earlier one's spec and
|
||||
losing markers).
|
||||
"""
|
||||
|
||||
for def_name, d in self._marker_definitions.items():
|
||||
try:
|
||||
# dump=False: the caller (link create / project open) dumps once
|
||||
# after; per-def dumps here would be N full topology writes.
|
||||
await link.inherit_marker(def_name, d, dump=False)
|
||||
except ControllerError as e:
|
||||
log.warning(
|
||||
"Marker definition '%s' could not be applied to new link %s: %s",
|
||||
def_name, link.id, e
|
||||
)
|
||||
|
||||
async def _marker_apply_concurrently(self, links, operation, fail_msg):
|
||||
"""
|
||||
Run an async per-link marker operation across *links* with bounded
|
||||
concurrency. A serial loop takes N sequential compute round-trips — a
|
||||
definition over 1000 links would take minutes on remote computes — so
|
||||
fan out in parallel batches. Links are independent (own ``_markers`` /
|
||||
``_link_data``), so this is race-free; per-link ``ControllerError`` is
|
||||
logged and skipped, preserving the serial loop's isolation semantics.
|
||||
``Project.dump`` is synchronous and writes atomically (tmp + rename),
|
||||
so concurrent dumps from the fan-out cannot corrupt the topology file.
|
||||
|
||||
:param links: iterable of links to operate on
|
||||
:param operation: async callable ``(link) -> coroutine``
|
||||
:param fail_msg: callable ``(link, error) -> log message``
|
||||
"""
|
||||
|
||||
sem = asyncio.Semaphore(32)
|
||||
|
||||
async def guarded(link):
|
||||
async with sem:
|
||||
try:
|
||||
await operation(link)
|
||||
except ControllerError as e:
|
||||
log.warning(fail_msg(link, e))
|
||||
|
||||
await asyncio.gather(*(guarded(link) for link in links))
|
||||
|
||||
@property
|
||||
def snapshots(self):
|
||||
"""
|
||||
@@ -1034,7 +1434,7 @@ class Project:
|
||||
|
||||
if self._status != "opened":
|
||||
try:
|
||||
await self.open()
|
||||
await self.open(auto_start=False)
|
||||
except ControllerError as e:
|
||||
# ignore missing images or other conflicts when deleting a project
|
||||
log.warning(f"Conflict while deleting project: {e}")
|
||||
@@ -1124,9 +1524,12 @@ class Project:
|
||||
return os.path.join(self.path, self._filename)
|
||||
|
||||
@locking
|
||||
async def open(self):
|
||||
async def open(self, auto_start=True):
|
||||
"""
|
||||
Load topology elements
|
||||
|
||||
:param auto_start: whether the nodes may be started when the project
|
||||
has auto start enabled
|
||||
"""
|
||||
|
||||
if self._closing is True:
|
||||
@@ -1171,6 +1574,29 @@ class Project:
|
||||
if val is not None:
|
||||
setattr(self, key, val)
|
||||
|
||||
# marker_definitions is loaded separately (it is not a __init__ kwarg
|
||||
# nor a simple attribute — it backs a read-only property). Each BPF
|
||||
# is validated once here so the inherited fan-out (start_marker) can
|
||||
# skip re-validation; an invalid definition is dropped with a warning
|
||||
# rather than failing the open — it could not fan out anyway.
|
||||
defs = project_data.get("marker_definitions")
|
||||
if isinstance(defs, dict):
|
||||
clean_defs = {}
|
||||
for def_name, d in defs.items():
|
||||
bpf = d.get("bpf")
|
||||
if not bpf:
|
||||
log.warning("Dropping marker definition '%s' on load: missing bpf", def_name)
|
||||
continue
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
log.warning(
|
||||
"Dropping marker definition '%s' on load: invalid BPF (%s)",
|
||||
def_name, result.get("error")
|
||||
)
|
||||
continue
|
||||
clean_defs[def_name] = d
|
||||
self._marker_definitions = clean_defs
|
||||
|
||||
topology = project_data["topology"]
|
||||
for compute in topology.get("computes", []):
|
||||
compute_id = compute.get("compute_id")
|
||||
@@ -1205,57 +1631,42 @@ class Project:
|
||||
|
||||
# Create nodes in parallel with limited concurrency
|
||||
# to avoid overwhelming the system with too many simultaneous operations
|
||||
pool = Pool(concurrency=5)
|
||||
pool = Pool(concurrency=100)
|
||||
for compute, name, node_id, node_data in nodes_to_create:
|
||||
pool.append(self.add_node, compute, name, node_id, dump=False, **node_data)
|
||||
await pool.join()
|
||||
# Pre-allocate UDP ports for all links in batch to reduce HTTP round-trips
|
||||
ports_per_compute = {}
|
||||
for link_data in topology.get("links", []):
|
||||
if "link_id" not in link_data.keys():
|
||||
# skip the link
|
||||
continue
|
||||
link = await self.add_link(link_id=link_data["link_id"])
|
||||
if "filters" in link_data:
|
||||
try:
|
||||
await link.update_filters(link_data["filters"])
|
||||
except ControllerError as e:
|
||||
log.warning(
|
||||
"Dropping invalid filters on link %s: %s",
|
||||
link_data.get("link_id"), e
|
||||
)
|
||||
if "link_style" in link_data:
|
||||
await link.update_link_style(link_data["link_style"])
|
||||
if "show_filters_icon" in link_data:
|
||||
await link.update_show_filters_icon(link_data["show_filters_icon"])
|
||||
for node_link in link_data.get("nodes", []):
|
||||
node = self.get_node(node_link["node_id"])
|
||||
port = node.get_port(node_link["adapter_number"], node_link["port_number"])
|
||||
if port is None:
|
||||
log.warning(
|
||||
"Port {}/{} for {} not found".format(
|
||||
node_link["adapter_number"], node_link["port_number"], node.name
|
||||
)
|
||||
)
|
||||
continue
|
||||
if port.link is not None:
|
||||
log.warning(
|
||||
"Port {}/{} is already connected to link ID {}".format(
|
||||
node_link["adapter_number"], node_link["port_number"], port.link.id
|
||||
)
|
||||
)
|
||||
continue
|
||||
await link.add_node(
|
||||
node,
|
||||
node_link["adapter_number"],
|
||||
node_link["port_number"],
|
||||
label=node_link.get("label"),
|
||||
dump=False,
|
||||
)
|
||||
if len(link.nodes) != 2:
|
||||
# a link should have 2 attached nodes, this can happen with corrupted projects
|
||||
await self.delete_link(link.id, force_delete=True)
|
||||
node = self._nodes.get(node_link["node_id"])
|
||||
if node:
|
||||
ports_per_compute[node.compute.id] = ports_per_compute.get(node.compute.id, 0) + 1
|
||||
for compute in self.computes:
|
||||
count = ports_per_compute.get(compute.id, 0)
|
||||
if count > 0:
|
||||
await self.preallocate_udp_ports_for_compute(compute, count)
|
||||
# Create links in parallel for improved performance
|
||||
pool = Pool(concurrency=100)
|
||||
for link_data in topology.get("links", []):
|
||||
if "link_id" not in link_data.keys():
|
||||
continue
|
||||
pool.append(self._create_link_from_topology_data, link_data)
|
||||
await pool.join()
|
||||
# Release any pre-allocated UDP ports that were not consumed by links
|
||||
for compute_id, ports in self._preallocated_udp_ports.items():
|
||||
if ports:
|
||||
log.warning(f"Releasing {len(ports)} unconsumed pre-allocated UDP ports on compute {compute_id}")
|
||||
self._preallocated_udp_ports.clear()
|
||||
for drawing_data in topology.get("drawings", []):
|
||||
await self.add_drawing(dump=False, **drawing_data)
|
||||
|
||||
# Note: project-level marker definitions are applied to each link
|
||||
# inside UDPLink.create() (the inheritance hook), so they are
|
||||
# already present once links are loaded — no separate fan-out here.
|
||||
|
||||
self.dump()
|
||||
# We catch all error to be able to roll back the .gns3 to the previous state
|
||||
except Exception as e:
|
||||
@@ -1284,7 +1695,7 @@ class Project:
|
||||
self._loading = False
|
||||
self.emit_controller_notification("project.opened", self.asdict())
|
||||
# Should we start the nodes when project is open
|
||||
if self._auto_start:
|
||||
if self._auto_start and auto_start:
|
||||
# Start all in the background without waiting for completion
|
||||
# we ignore errors because we want to let the user open
|
||||
# their project and fix it
|
||||
@@ -1388,6 +1799,10 @@ class Project:
|
||||
:param reset_mac_addresses: Reset MAC addresses for the duplicated project
|
||||
"""
|
||||
|
||||
# We don't duplicate a running project
|
||||
if self.is_running():
|
||||
raise ControllerError("Project must be stopped in order to duplicate it")
|
||||
|
||||
# remote replication is not supported with remote computes
|
||||
for compute in self.computes:
|
||||
if compute.id != "local":
|
||||
@@ -1404,7 +1819,11 @@ class Project:
|
||||
# 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())
|
||||
|
||||
# Read the topology file using the actual filename (self._filename), not self.name
|
||||
# This handles the case where a project has been renamed but we need to read the actual file
|
||||
old_gns3_file = new_project_path.joinpath(self._filename)
|
||||
topology = json.loads(old_gns3_file.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)
|
||||
@@ -1428,7 +1847,8 @@ class Project:
|
||||
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)))
|
||||
# Remove the old .gns3 file (which has the original project name)
|
||||
os.remove(old_gns3_file)
|
||||
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
|
||||
@@ -1507,21 +1927,23 @@ class Project:
|
||||
@open_required
|
||||
async def start_all(self):
|
||||
"""
|
||||
Start all nodes
|
||||
Start all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.)
|
||||
"""
|
||||
pool = Pool(concurrency=3)
|
||||
for node in self.nodes.values():
|
||||
pool.append(node.start)
|
||||
if not node.is_always_running():
|
||||
pool.append(node.start)
|
||||
await pool.join()
|
||||
|
||||
@open_required
|
||||
async def stop_all(self):
|
||||
"""
|
||||
Stop all nodes
|
||||
Stop all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.)
|
||||
"""
|
||||
pool = Pool(concurrency=3)
|
||||
for node in self.nodes.values():
|
||||
pool.append(node.stop)
|
||||
if not node.is_always_running():
|
||||
pool.append(node.stop)
|
||||
await pool.join()
|
||||
|
||||
@open_required
|
||||
@@ -1601,6 +2023,7 @@ class Project:
|
||||
"links": len(self._links),
|
||||
"drawings": len(self._drawings),
|
||||
"snapshots": len(self._snapshots),
|
||||
"markers": sum(len(link.markers) for link in self._links.values()),
|
||||
}
|
||||
|
||||
def asdict(self):
|
||||
@@ -1625,6 +2048,7 @@ class Project:
|
||||
"supplier": self._supplier,
|
||||
"variables": self._variables,
|
||||
"created_by": self._created_by,
|
||||
"marker_definitions": self._marker_definitions,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -88,6 +88,7 @@ def project_to_topology(project):
|
||||
"variables": project.variables,
|
||||
"supplier": project.supplier,
|
||||
"created_by": project.created_by,
|
||||
"marker_definitions": project.marker_definitions,
|
||||
"topology": {"nodes": [], "links": [], "computes": [], "drawings": []},
|
||||
"type": "topology",
|
||||
"revision": GNS3_FILE_FORMAT_REVISION,
|
||||
|
||||
@@ -17,8 +17,17 @@
|
||||
|
||||
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import Link
|
||||
from .link import Link, _UNSET
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
|
||||
|
||||
# Node types without a uBridge bridge — a marker filter has nothing to attach to.
|
||||
# Node types that can host a marker (have a uBridge bridge to attach the
|
||||
# `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat"
|
||||
# (which has no uBridge).
|
||||
_MARKER_CAPABLE_TYPES = frozenset({
|
||||
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud",
|
||||
})
|
||||
|
||||
|
||||
class UDPLink(Link):
|
||||
@@ -37,7 +46,7 @@ class UDPLink(Link):
|
||||
def _get_node_filters(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
|
||||
|
||||
:returns: Tuple of (node1_filters, node2_filters)
|
||||
"""
|
||||
filter_node = self._get_filter_node()
|
||||
@@ -46,6 +55,32 @@ class UDPLink(Link):
|
||||
self.get_active_filters() if filter_node == node2 else {},
|
||||
)
|
||||
|
||||
def _markers_for_node(self, node):
|
||||
"""
|
||||
Marker specs (name -> {bpf, tag, link_id, direction, data_link_type,
|
||||
enabled}) for the markers whose capture side is ``node``. Routed by
|
||||
capture_node_id so a marker only rides the NIO of the node whose uBridge
|
||||
will host it. A disabled marker is included (installed then turned
|
||||
``off`` at uBridge, not dropped) so the UI can toggle it instantly
|
||||
without an NIO rebuild.
|
||||
"""
|
||||
return {
|
||||
name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id,
|
||||
"direction": m.get("direction"),
|
||||
"data_link_type": m.get("data_link_type", "DLT_EN10MB"),
|
||||
"enabled": m.get("enabled", True)}
|
||||
for name, m in self._markers.items()
|
||||
if m.get("capture_node_id") == node.id
|
||||
}
|
||||
|
||||
def _get_node_markers(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets which markers applied.
|
||||
|
||||
:returns: Tuple of (node1_markers, node2_markers)
|
||||
"""
|
||||
return self._markers_for_node(node1), self._markers_for_node(node2)
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Create the link on the nodes
|
||||
@@ -65,12 +100,22 @@ class UDPLink(Link):
|
||||
raise ControllerError(f"Cannot get an IP address on same subnet: {e}")
|
||||
|
||||
# Reserve a UDP port on both side
|
||||
response = await node1.compute.post(f"/projects/{self._project.id}/ports/udp")
|
||||
self._node1_port = response.json["udp_port"]
|
||||
response = await node2.compute.post(f"/projects/{self._project.id}/ports/udp")
|
||||
self._node2_port = response.json["udp_port"]
|
||||
# Try pre-allocated ports first (used during batch project loading)
|
||||
port = self._project.pop_preallocated_udp_port(node1.compute.id)
|
||||
if port is not None:
|
||||
self._node1_port = port
|
||||
else:
|
||||
response = await node1.compute.post(f"/projects/{self._project.id}/ports/udp")
|
||||
self._node1_port = response.json["udp_port"]
|
||||
port = self._project.pop_preallocated_udp_port(node2.compute.id)
|
||||
if port is not None:
|
||||
self._node2_port = port
|
||||
else:
|
||||
response = await node2.compute.post(f"/projects/{self._project.id}/ports/udp")
|
||||
self._node2_port = response.json["udp_port"]
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
# Create the tunnel on both side
|
||||
self._link_data.append(
|
||||
@@ -80,6 +125,7 @@ class UDPLink(Link):
|
||||
"rport": self._node2_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@@ -92,6 +138,7 @@ class UDPLink(Link):
|
||||
"rport": self._node1_port,
|
||||
"type": "nio_udp",
|
||||
"filters": node2_filters,
|
||||
"markers": node2_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
)
|
||||
@@ -104,6 +151,9 @@ class UDPLink(Link):
|
||||
await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
|
||||
raise e
|
||||
self._created = True
|
||||
# New links automatically inherit every active project-level marker
|
||||
# definition so the user doesn't have to reconfigure.
|
||||
await self._project.apply_defs_to_new_link(self)
|
||||
|
||||
async def update(self):
|
||||
"""
|
||||
@@ -116,10 +166,12 @@ class UDPLink(Link):
|
||||
node2 = self._nodes[1]["node"]
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
adapter_number1 = self._nodes[0]["adapter_number"]
|
||||
port_number1 = self._nodes[0]["port_number"]
|
||||
self._link_data[0]["filters"] = node1_filters
|
||||
self._link_data[0]["markers"] = node1_markers
|
||||
self._link_data[0]["suspend"] = self._suspended
|
||||
if node1.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
await node1.put(
|
||||
@@ -129,6 +181,7 @@ class UDPLink(Link):
|
||||
adapter_number2 = self._nodes[1]["adapter_number"]
|
||||
port_number2 = self._nodes[1]["port_number"]
|
||||
self._link_data[1]["filters"] = node2_filters
|
||||
self._link_data[1]["markers"] = node2_markers
|
||||
self._link_data[1]["suspend"] = self._suspended
|
||||
if node2.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
await node2.put(
|
||||
@@ -236,9 +289,256 @@ class UDPLink(Link):
|
||||
|
||||
raise ControllerError("Cannot capture because there is no running device on this link")
|
||||
|
||||
def _choose_marker_side(self):
|
||||
"""
|
||||
Pick the node that will host the marker, mirroring ``_get_filter_node``
|
||||
in link.py. Only types with a uBridge bridge (``_MARKER_CAPABLE_TYPES``)
|
||||
are eligible. A running node is preferred, but a stopped one is
|
||||
accepted — like packet filters, the marker is stored on the NIO and
|
||||
applied when the node starts.
|
||||
"""
|
||||
|
||||
# Prefer started.
|
||||
for node in self._nodes:
|
||||
if (
|
||||
node["node"].node_type in _MARKER_CAPABLE_TYPES
|
||||
and node["node"].status == "started"
|
||||
):
|
||||
return node
|
||||
|
||||
# Accept stopped but capable (marker rides NIO, applied at start).
|
||||
for node in self._nodes:
|
||||
if node["node"].node_type in _MARKER_CAPABLE_TYPES:
|
||||
return node
|
||||
|
||||
raise ControllerError(
|
||||
"Cannot add marker because no device on this link supports "
|
||||
"traffic insight"
|
||||
)
|
||||
|
||||
def _node_by_id(self, node_id):
|
||||
"""
|
||||
Resolve a caller-chosen capture node by id, validating it is an
|
||||
endpoint of this link and marker-capable. Used when the caller
|
||||
(REST/MCP) explicitly pins the observer side instead of letting
|
||||
``_choose_marker_side`` auto-pick.
|
||||
|
||||
:param node_id: node id (UUID or str) the caller requested
|
||||
:returns: a ``self._nodes`` entry (node/adapter_number/port_number)
|
||||
"""
|
||||
|
||||
target = str(node_id)
|
||||
for node in self._nodes:
|
||||
if str(node["node"].id) != target:
|
||||
continue
|
||||
if node["node"].node_type not in _MARKER_CAPABLE_TYPES:
|
||||
raise ControllerError(
|
||||
f"Node {node_id} ({node['node'].node_type}) cannot host a "
|
||||
f"marker — no uBridge bridge to attach the filter to"
|
||||
)
|
||||
return node
|
||||
raise ControllerNotFoundError(
|
||||
f"Node {node_id} is not an endpoint of link {self._id}"
|
||||
)
|
||||
|
||||
async def node_updated(self, node):
|
||||
"""
|
||||
Called when a node member of the link is updated
|
||||
"""
|
||||
if self._capture_node and node == self._capture_node["node"] and node.status != "started":
|
||||
await self.stop_capture()
|
||||
# Marker clean-up is *not* done on node stop — markers are a persistent
|
||||
# link-scoped feature that recovers via NIO on restart (see
|
||||
# _ubridge_apply_markers in add_ubridge_udp_connection). The user
|
||||
# explicitly deletes a marker via the REST API, and a marker is torn
|
||||
# down automatically only when its link is deleted.
|
||||
|
||||
async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None, dump=True):
|
||||
"""
|
||||
Attach a traffic-insight marker to this link.
|
||||
|
||||
State-only model (mirrors ``update_filters``): record the marker in
|
||||
``_markers`` (with its capture-side node id for NIO routing), then push
|
||||
via ``self.update()`` so it rides the NIO and is applied by
|
||||
``_ubridge_apply_markers``. No dedicated uBridge round-trip — exactly
|
||||
how packet filters are applied.
|
||||
|
||||
:param name: stable filter name — echoed in MARK signals + pcap identity
|
||||
:param bpf: libpcap BPF expression
|
||||
:param tag: optional correlation id
|
||||
:param capture_node_id: optional explicit observer node. When set the
|
||||
marker is pinned to that endpoint's uBridge (and ``direction`` is
|
||||
interpreted from its perspective); validated by ``_node_by_id``.
|
||||
Omitted = auto-pick via ``_choose_marker_side``. Ignored for
|
||||
inherited markers (project defs are link-agnostic → always auto).
|
||||
:param color: optional hex color for the Web UI (e.g. '#ff5722'); stored
|
||||
with the link and persisted in the topology, never sent to uBridge
|
||||
:param highlight_duration: optional UI-only hint (milliseconds) for how
|
||||
long a match keeps the marker highlighted; stored, never sent to uBridge
|
||||
:param inherited_from: def name when this marker is a project-level
|
||||
inheritance copy; set automatically, never exposed to REST callers
|
||||
"""
|
||||
|
||||
if name in self._markers:
|
||||
raise ControllerError(f"Marker '{name}' already exists on link {self._id}")
|
||||
|
||||
# Validate the BPF only for private per-link markers. An inherited copy
|
||||
# (``inherited_from`` set) fans out from a definition whose BPF was
|
||||
# already validated once at create/update (and on project load), so
|
||||
# re-validating per link would spawn one ``tcpdump -d`` per link for the
|
||||
# same expression.
|
||||
if not inherited_from:
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
|
||||
|
||||
if capture_node_id and not inherited_from:
|
||||
marker_side = self._node_by_id(capture_node_id)
|
||||
else:
|
||||
marker_side = self._choose_marker_side()
|
||||
marker_entry = {
|
||||
"bpf": bpf,
|
||||
"tag": tag,
|
||||
"enabled": enabled,
|
||||
"color": color,
|
||||
"highlight_duration": highlight_duration,
|
||||
"capture_node_id": marker_side["node"].id,
|
||||
"direction": direction,
|
||||
"data_link_type": data_link_type,
|
||||
}
|
||||
if inherited_from:
|
||||
marker_entry["inherited_from"] = inherited_from
|
||||
self._markers[name] = marker_entry
|
||||
if self._created:
|
||||
await self.update()
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
# Bulk fan-out passes dump=False: N per-link topology writes on a
|
||||
# 500-link project are the dominant cost — the caller dumps once after.
|
||||
if dump:
|
||||
self._project.dump()
|
||||
|
||||
async def stop_marker(self, name, inherited=False, dump=True):
|
||||
"""
|
||||
Remove a traffic-insight marker from this link.
|
||||
|
||||
Drop it from ``_markers`` and push via ``self.update()``: the NIO
|
||||
reset+reapply in ``_ubridge_apply_filters``/``_ubridge_apply_markers``
|
||||
drops it from uBridge. Mirrors how deleting a packet filter works.
|
||||
|
||||
:param name: filter name to remove
|
||||
:param inherited: set by project-level def-delete to bypass the
|
||||
inheritance guard (the project layer is the legitimate remover)
|
||||
"""
|
||||
|
||||
if name not in self._markers:
|
||||
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
|
||||
|
||||
if self._markers[name].get("inherited_from") and not inherited:
|
||||
raise ControllerError(
|
||||
f"Marker '{name}' is inherited from the project-level "
|
||||
f"definition '{self._markers[name]['inherited_from']}'. "
|
||||
"Delete or update it via the marker-definitions API instead."
|
||||
)
|
||||
|
||||
capture_node_id = self._markers[name].get("capture_node_id")
|
||||
del self._markers[name]
|
||||
# Remove the marker filter + its pcap on the capture node directly — NOT a
|
||||
# full NIO reapply (which would reset_packet_filters and close/reopen every
|
||||
# sibling marker's pcap). delete_packet_filter removes just this filter;
|
||||
# the marker is already gone from _markers, so any later reapply (filter
|
||||
# change, node restart) won't re-add it either.
|
||||
if capture_node_id is not None:
|
||||
side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None)
|
||||
if side is not None:
|
||||
try:
|
||||
await side["node"].delete(
|
||||
f"/adapters/{side['adapter_number']}/ports/{side['port_number']}/markers/{name}",
|
||||
params={"link_id": self._id},
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort: old compute without the route leaves the file
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
if dump:
|
||||
self._project.dump()
|
||||
|
||||
async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=_UNSET, color=None, highlight_duration=None, inherited=False, dump=True):
|
||||
"""
|
||||
Update an existing marker's fields and push to uBridge fine-grained — no
|
||||
full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction
|
||||
rebuild just this filter (delete + add); enabled is an instant toggle;
|
||||
color/highlight_duration are UI-only (stored, never pushed).
|
||||
|
||||
:param name: filter name to update
|
||||
:param bpf: new BPF expression (None = keep existing)
|
||||
:param tag: new tag id (None = keep existing)
|
||||
:param enabled: toggle (None = keep existing)
|
||||
:param color: new hex color (None = keep existing)
|
||||
:param highlight_duration: new UI highlight duration in ms (None = keep existing)
|
||||
:param inherited: set by project-level sync to bypass the inheritance
|
||||
guard (the project layer is the legitimate editor)
|
||||
"""
|
||||
|
||||
marker_info = self._markers.get(name)
|
||||
if not marker_info:
|
||||
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
|
||||
|
||||
if marker_info.get("inherited_from") and not inherited:
|
||||
raise ControllerError(
|
||||
f"Marker '{name}' is inherited from the project-level "
|
||||
f"definition '{marker_info['inherited_from']}'. "
|
||||
"Update it via the marker-definitions API instead."
|
||||
)
|
||||
|
||||
# Merge every changed field into the marker state first.
|
||||
if bpf is not None and bpf != marker_info["bpf"]:
|
||||
# An inherited marker is synced from a definition whose BPF was
|
||||
# already validated at create/update (or load); re-validating per
|
||||
# link is redundant. Private markers validate here as before.
|
||||
if not inherited:
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
|
||||
marker_info["bpf"] = bpf
|
||||
if tag is not None:
|
||||
marker_info["tag"] = tag
|
||||
if enabled is not None:
|
||||
marker_info["enabled"] = enabled
|
||||
if color is not None:
|
||||
marker_info["color"] = color
|
||||
if highlight_duration is not None:
|
||||
marker_info["highlight_duration"] = highlight_duration
|
||||
if direction is not _UNSET:
|
||||
marker_info["direction"] = direction # None = clear back to both directions
|
||||
|
||||
# Push to uBridge fine-grained — NO full NIO reapply (which would
|
||||
# reset_packet_filters and close/reopen every sibling marker's pcap):
|
||||
# * bpf/tag/direction changed → rebuild just this filter (delete + add),
|
||||
# reopening only this marker's pcap (expected, new BPF)
|
||||
# * only enabled changed → instant toggle (enable_packet_filter)
|
||||
# * only UI fields changed → nothing to push to uBridge
|
||||
if self._created:
|
||||
ubridge_rebuild = (bpf is not None) or (tag is not None) or (direction is not _UNSET)
|
||||
capture_node_id = marker_info.get("capture_node_id")
|
||||
side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None)
|
||||
if side is not None:
|
||||
try:
|
||||
if ubridge_rebuild:
|
||||
await side["node"].put(
|
||||
f"/markers/{name}/rebuild",
|
||||
data={
|
||||
"bpf": marker_info["bpf"],
|
||||
"tag": marker_info.get("tag"),
|
||||
"direction": marker_info.get("direction"),
|
||||
"enabled": marker_info.get("enabled", True),
|
||||
"link_id": self._id,
|
||||
},
|
||||
)
|
||||
elif enabled is not None:
|
||||
await side["node"].put(f"/markers/{name}", data={"enabled": enabled})
|
||||
except Exception:
|
||||
# Old compute without the route / node down: state is already
|
||||
# correct in _markers; the next NIO reapply converges uBridge.
|
||||
pass
|
||||
self._project.emit_notification("link.updated", self.asdict())
|
||||
if dump:
|
||||
self._project.dump()
|
||||
@@ -24,6 +24,7 @@ from gns3server.controller import Controller
|
||||
from gns3server.config import Config
|
||||
from gns3server.compute import MODULES
|
||||
from gns3server.compute.port_manager import PortManager
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
from gns3server.db.tasks import connect_to_db, get_computes, disconnect_from_db, discover_images_on_filesystem
|
||||
|
||||
@@ -84,6 +85,22 @@ async def startup(app: FastAPI) -> None:
|
||||
m = module.instance()
|
||||
m.port_manager = PortManager.instance()
|
||||
|
||||
# Start the marker (traffic-insight) UDP sink. One listener per compute
|
||||
# process receives ubridge MARK signals; ubridges are told its host/port at
|
||||
# startup (see BaseNode._start_ubridge).
|
||||
server_settings = Config.instance().settings.Server
|
||||
await MarkerManager.instance().start(
|
||||
host=server_settings.marker_listen_host, port=server_settings.marker_listen_port
|
||||
)
|
||||
|
||||
# Mark MCP server as ready to accept connections (if MCP is available)
|
||||
from gns3server.agent import MCP_AVAILABLE
|
||||
|
||||
if MCP_AVAILABLE:
|
||||
from gns3server.api.routes.mcp import set_mcp_server_ready
|
||||
set_mcp_server_ready(True)
|
||||
log.info("GNS3 server startup completed")
|
||||
|
||||
|
||||
async def shutdown(app: FastAPI) -> None:
|
||||
"""
|
||||
@@ -93,6 +110,7 @@ async def shutdown(app: FastAPI) -> None:
|
||||
if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled():
|
||||
auto_discover_images_task_handle.cancel()
|
||||
await HTTPClient.close_session()
|
||||
await MarkerManager.instance().stop()
|
||||
await Controller.instance().stop()
|
||||
|
||||
for module in MODULES:
|
||||
|
||||
@@ -58,7 +58,7 @@ class CrashReport:
|
||||
Report crash to a third party service
|
||||
"""
|
||||
|
||||
DSN = "https://2fe45e33c13e7f8cc1f5483e323765f8@o19455.ingest.us.sentry.io/38482"
|
||||
DSN = "https://7b5d8f1189b61e674dc6ee41b0ec9da8@o19455.ingest.us.sentry.io/38482"
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -24,6 +24,7 @@ from .computes import Compute
|
||||
from .images import Image
|
||||
from .pools import Resource, ResourcePool
|
||||
from .llm_model_configs import LLMModelConfig
|
||||
from .api_keys import ApiKey
|
||||
from .templates import (
|
||||
Template,
|
||||
CloudTemplate,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func
|
||||
|
||||
from .base import BaseTable, GUID
|
||||
|
||||
|
||||
class ApiKey(BaseTable):
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
api_key_id = Column(GUID, primary_key=True)
|
||||
user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String(128), nullable=False)
|
||||
key_hash = Column(String(128), nullable=False)
|
||||
key_prefix = Column(String(8), nullable=False)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False)
|
||||
revoked = Column(Boolean, default=False, nullable=False)
|
||||
@@ -0,0 +1,104 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from uuid import UUID
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select, update, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from .base import BaseRepository
|
||||
import gns3server.db.models as models
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiKeysRepository(BaseRepository):
|
||||
|
||||
def __init__(self, db_session: AsyncSession) -> None:
|
||||
super().__init__(db_session)
|
||||
|
||||
async def create_api_key(
|
||||
self, api_key_id: UUID, user_id: UUID, name: str, key_hash: str, key_prefix: str
|
||||
) -> models.ApiKey:
|
||||
db_api_key = models.ApiKey(
|
||||
api_key_id=api_key_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
self._db_session.add(db_api_key)
|
||||
await self._db_session.commit()
|
||||
await self._db_session.refresh(db_api_key)
|
||||
return db_api_key
|
||||
|
||||
async def get_api_key(self, api_key_id: UUID) -> Optional[models.ApiKey]:
|
||||
query = select(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]:
|
||||
query = (
|
||||
select(models.ApiKey)
|
||||
.where(models.ApiKey.user_id == user_id)
|
||||
.order_by(models.ApiKey.created_at.desc())
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_api_key_by_hash(self, key_hash: str) -> Optional[models.ApiKey]:
|
||||
query = select(models.ApiKey).where(models.ApiKey.key_hash == key_hash)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def revoke_api_key(self, api_key_id: UUID) -> bool:
|
||||
query = (
|
||||
update(models.ApiKey)
|
||||
.where(models.ApiKey.api_key_id == api_key_id)
|
||||
.values(revoked=True)
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
await self._db_session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def restore_api_key(self, api_key_id: UUID) -> bool:
|
||||
query = (
|
||||
update(models.ApiKey)
|
||||
.where(models.ApiKey.api_key_id == api_key_id)
|
||||
.values(revoked=False)
|
||||
)
|
||||
result = await self._db_session.execute(query)
|
||||
await self._db_session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def update_last_used(self, api_key_id: UUID) -> None:
|
||||
query = (
|
||||
update(models.ApiKey)
|
||||
.where(models.ApiKey.api_key_id == api_key_id)
|
||||
.values(last_used_at=func.now())
|
||||
)
|
||||
await self._db_session.execute(query)
|
||||
await self._db_session.commit()
|
||||
|
||||
async def delete_api_key(self, api_key_id: UUID) -> bool:
|
||||
query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id)
|
||||
result = await self._db_session.execute(query)
|
||||
await self._db_session.commit()
|
||||
return result.rowcount > 0
|
||||
@@ -16,6 +16,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from uuid import UUID
|
||||
from typing import List, Union, Optional
|
||||
@@ -29,6 +30,8 @@ from .base import BaseRepository
|
||||
import gns3server.db.models as models
|
||||
from gns3server import schemas
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_TYPE_TO_MODEL = {
|
||||
"cloud": models.CloudTemplate,
|
||||
"docker": models.DockerTemplate,
|
||||
@@ -126,8 +129,16 @@ class TemplatesRepository(BaseRepository):
|
||||
where(models.Image.filename == image_name, models.Image.path.endswith(image_path))
|
||||
else:
|
||||
query = select(models.Image).where(models.Image.filename == image_name)
|
||||
query = query.order_by(models.Image.image_id)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().one_or_none()
|
||||
images = result.scalars().all()
|
||||
if len(images) > 1:
|
||||
log.warning(
|
||||
f"Multiple DB entries found for image '{image_path}' "
|
||||
f"({len(images)} rows). This indicates a data integrity issue. "
|
||||
f"Using the entry with the lowest image_id ({images[0].image_id})."
|
||||
)
|
||||
return images[0] if images else None
|
||||
|
||||
async def add_image_to_template(
|
||||
self,
|
||||
|
||||
+21
-10
@@ -80,6 +80,27 @@ async def connect_to_db(app: FastAPI) -> None:
|
||||
db_path = os.path.join(Config.instance().config_dir, "gns3_controller.db")
|
||||
db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+aiosqlite:///{db_path}")
|
||||
engine = create_async_engine(db_url, connect_args={"check_same_thread": False, "timeout": 20}, future=True, pool_size=512, max_overflow=1024)
|
||||
|
||||
# Register PRAGMA on the sync engine to ensure it fires for async connections
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
# Verify WAL mode is active
|
||||
async with engine.connect() as _verify_conn:
|
||||
def _check_wal(conn):
|
||||
cursor = conn.connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode")
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
return row[0] if row else "unknown"
|
||||
wal_mode = await _verify_conn.run_sync(_check_wal)
|
||||
log.info(f"SQLite journal mode: {wal_mode}")
|
||||
if wal_mode and wal_mode.upper() != "WAL":
|
||||
log.warning("WAL mode not active - concurrent writes may cause 'database is locked' errors")
|
||||
alembic_cfg = config.Config()
|
||||
alembic_cfg.set_main_option("script_location", "gns3server:db_migrations")
|
||||
#alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
||||
@@ -146,16 +167,6 @@ async def disconnect_from_db(app: FastAPI) -> None:
|
||||
log.info(f"Disconnected from database")
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
|
||||
# Enable SQL foreign key support for SQLite
|
||||
# https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
async def get_computes(app: FastAPI) -> List[dict]:
|
||||
|
||||
computes = []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add api_keys table
|
||||
|
||||
Revision ID: f0b0de2a9
|
||||
Revises: a8829e6c069b
|
||||
Create Date: 2026-06-11 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import gns3server.db.models.base as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f0b0de2a9'
|
||||
down_revision = 'a8829e6c069b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('api_key_id', models.GUID(), nullable=False),
|
||||
sa.Column('user_id', models.GUID(), nullable=False),
|
||||
sa.Column('name', sa.String(128), nullable=False),
|
||||
sa.Column('key_hash', sa.String(128), nullable=False),
|
||||
sa.Column('key_prefix', sa.String(8), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), default=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('api_key_id'),
|
||||
)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
@@ -31,6 +31,8 @@ import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
import resource
|
||||
|
||||
|
||||
def daemonize():
|
||||
@@ -97,6 +99,34 @@ def parse_arguments(argv):
|
||||
return parser, args
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _raise_open_files_limit(target=65535):
|
||||
"""
|
||||
Raise RLIMIT_NOFILE at startup so large topologies don't hit EMFILE.
|
||||
Every started node holds ~3 file descriptors in the server's table
|
||||
(pidfd + stdout/stderr pipes per child process), so a few hundred nodes
|
||||
exhaust the default 1024 limit. Best-effort: the hard limit caps what we
|
||||
can request; failures are logged but never fatal. Runs before daemonize()
|
||||
so the daemon inherits the raised limit.
|
||||
"""
|
||||
try:
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
if soft >= target:
|
||||
return
|
||||
new_soft = min(target, hard)
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
|
||||
if new_soft < target:
|
||||
log.warning(
|
||||
f"Open-files limit raised to {new_soft} (hard limit), below the requested {target}"
|
||||
)
|
||||
else:
|
||||
log.info(f"Open-files limit raised from {soft} to {new_soft}")
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning(f"Could not raise the open-files limit: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Entry point for GNS3 server
|
||||
@@ -104,6 +134,7 @@ def main():
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
raise SystemExit("Windows is not a supported platform to run the GNS3 server")
|
||||
_raise_open_files_limit()
|
||||
if "--daemon" in sys.argv:
|
||||
daemonize()
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from .common import ErrorMessage
|
||||
from .version import Version
|
||||
|
||||
# Controller schemas
|
||||
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture
|
||||
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerUpdate, MarkerDefinitionCreate
|
||||
from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute
|
||||
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
|
||||
from .controller.images import Image, ImageType
|
||||
@@ -57,7 +57,7 @@ except ImportError:
|
||||
|
||||
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
|
||||
from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
|
||||
from .controller.snapshots import SnapshotCreate, Snapshot
|
||||
from .controller.iou_license import IOULicense
|
||||
from .controller.capabilities import Capabilities
|
||||
@@ -91,7 +91,7 @@ from .controller.templates.dynamips_templates import (
|
||||
)
|
||||
|
||||
# Compute schemas
|
||||
from .compute.nios import UDPNIO, TAPNIO, EthernetNIO
|
||||
from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle, MarkerRebuild
|
||||
from .compute.atm_switch_nodes import ATMSwitchCreate, ATMSwitchUpdate, ATMSwitch
|
||||
from .compute.cloud_nodes import CloudCreate, CloudUpdate, Cloud
|
||||
from .compute.docker_nodes import DockerCreate, DockerUpdate, Docker
|
||||
|
||||
@@ -28,6 +28,28 @@ class HostInterfaceType(str, Enum):
|
||||
tap = "tap"
|
||||
|
||||
|
||||
class IPAddressFamily(str, Enum):
|
||||
|
||||
ipv4 = "ipv4"
|
||||
ipv6 = "ipv6"
|
||||
|
||||
|
||||
class InterfaceStatus(str, Enum):
|
||||
|
||||
up = "up"
|
||||
down = "down"
|
||||
|
||||
|
||||
class HostInterfaceIPAddress(BaseModel):
|
||||
"""
|
||||
An IP address (with optional netmask) bound to a host interface.
|
||||
"""
|
||||
|
||||
family: IPAddressFamily = Field(..., description="Address family (ipv4 or ipv6)")
|
||||
address: str = Field(..., description="IP address")
|
||||
netmask: Optional[str] = Field(None, description="Network mask, if available")
|
||||
|
||||
|
||||
class HostInterface(BaseModel):
|
||||
"""
|
||||
Interface on this host.
|
||||
@@ -36,6 +58,13 @@ class HostInterface(BaseModel):
|
||||
name: str = Field(..., description="Interface name")
|
||||
type: HostInterfaceType = Field(..., description="Interface type")
|
||||
special: bool = Field(..., description="Whether the interface is non standard")
|
||||
ip_addresses: List[HostInterfaceIPAddress] = Field(
|
||||
default_factory=list, description="All IPv4 and IPv6 addresses on this interface"
|
||||
)
|
||||
status: InterfaceStatus = Field(InterfaceStatus.down, description="Interface status (up or down)")
|
||||
speed: int = Field(0, description="Interface speed in Mbit/s (0 if unknown)")
|
||||
mtu: int = Field(0, description="Interface MTU")
|
||||
flags: List[str] = Field(default_factory=list, description="Interface flags")
|
||||
|
||||
|
||||
class EthernetType(str, Enum):
|
||||
|
||||
@@ -38,7 +38,10 @@ class IOUBase(BaseModel):
|
||||
ethernet_adapters: Optional[int] = Field(None, description="How many Ethernet adapters are connected to IOU")
|
||||
ram: Optional[int] = Field(None, gt=0, description="Amount of RAM in MB")
|
||||
nvram: Optional[int] = Field(None, gt=0, description="Amount of NVRAM in KB")
|
||||
l1_keepalives: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
None,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
use_default_iou_values: Optional[bool] = Field(None, description="Use default IOU values")
|
||||
startup_config_content: Optional[str] = Field(None, description="Content of IOU startup configuration file")
|
||||
private_config_content: Optional[str] = Field(None, description="Content of IOU private configuration file")
|
||||
|
||||
@@ -36,6 +36,7 @@ class UDPNIO(BaseModel):
|
||||
rport: int = Field(..., gt=0, le=65535, description="Remote port")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters")
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
|
||||
|
||||
|
||||
class EthernetNIOType(str, Enum):
|
||||
@@ -64,3 +65,29 @@ class TAPNIO(BaseModel):
|
||||
|
||||
type: TAPNIOType
|
||||
tap_device: str = Field(..., description="TAP device name e.g. tap0")
|
||||
|
||||
|
||||
class MarkerToggle(BaseModel):
|
||||
"""
|
||||
Body for the per-marker enable/disable toggle endpoint: flips a running
|
||||
uBridge marker filter with ``enable_packet_filter on|off`` (no NIO rebuild,
|
||||
so the pcap identity and emitted counter are preserved).
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MarkerRebuild(BaseModel):
|
||||
"""
|
||||
Body for the per-marker rebuild endpoint: re-install a single uBridge marker
|
||||
filter with new BPF/tag/direction via ``delete_packet_filter`` + add (NOT a
|
||||
bridge-wide reset), so sibling markers keep their pcaps open. The marker's
|
||||
own pcap is reopened by uBridge on re-add (new capture session for the new
|
||||
BPF), which is expected.
|
||||
"""
|
||||
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
direction: Optional[str] = None
|
||||
enabled: bool = True
|
||||
link_id: str = ""
|
||||
@@ -35,6 +35,7 @@ class ControllerSettings(BaseModel):
|
||||
jwt_secret_key: str = None
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = 1440 # 24 hours
|
||||
jwt_refresh_token_expire_minutes: int = 43200 # 30 days
|
||||
default_admin_username: str = "admin"
|
||||
default_admin_password: SecretStr = SecretStr("admin")
|
||||
model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
|
||||
@@ -70,6 +71,7 @@ class QemuSettings(BaseModel):
|
||||
enable_hardware_acceleration: bool = True
|
||||
require_hardware_acceleration: bool = False
|
||||
allow_unsafe_options: bool = False
|
||||
ovmf_firmware_dir: str = "/usr/share/OVMF"
|
||||
model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
@@ -111,6 +113,16 @@ class ServerProtocol(str, Enum):
|
||||
https = "https"
|
||||
|
||||
|
||||
class UbridgeControlTransport(str, Enum):
|
||||
|
||||
# TCP control channel: -H host:port. ubridge now binds loopback by default,
|
||||
# so this is reachable only locally. Retained for backward compatibility.
|
||||
tcp = "tcp"
|
||||
# AF_UNIX control channel: -U socket_path, authenticated in-kernel via
|
||||
# SO_PEERCRED (ubridge accepts only its own UID). Recommended on Linux.
|
||||
unix = "unix"
|
||||
|
||||
|
||||
class BuiltinSymbolTheme(str, Enum):
|
||||
|
||||
classic = "Classic"
|
||||
@@ -152,6 +164,17 @@ class ServerSettings(BaseModel):
|
||||
udp_start_port_range: int = Field(10000, gt=0, le=65535)
|
||||
udp_end_port_range: int = Field(30000, gt=0, le=65535)
|
||||
ubridge_path: str = "ubridge"
|
||||
# Transport for the uBridge hypervisor control channel. "unix" (-U,
|
||||
# AF_UNIX + SO_PEERCRED) is the default — recommended on Linux for
|
||||
# kernel-level peer authentication. "tcp" (-H) is retained for backward
|
||||
# compatibility.
|
||||
ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.unix
|
||||
# Marker (traffic-insight) UDP sink: one listener per compute process that
|
||||
# receives ubridge MARK signals from every ubridge on this host. The host
|
||||
# defaults to loopback because ubridge runs on the same host as the compute.
|
||||
# port=0 lets the OS choose a free port (read back and handed to ubridge).
|
||||
marker_listen_host: str = "127.0.0.1"
|
||||
marker_listen_port: int = Field(3070, ge=0, le=65535)
|
||||
compute_username: str = "gns3"
|
||||
compute_password: SecretStr = SecretStr("")
|
||||
allowed_interfaces: List[str] = Field(default_factory=list)
|
||||
@@ -162,8 +185,32 @@ class ServerSettings(BaseModel):
|
||||
skills_repo_url: str = "https://github.com/gns3/gns3-skills.git"
|
||||
skills_repo_branch: str = "main"
|
||||
skills_auto_update: bool = True
|
||||
|
||||
# MCP (Model Context Protocol) transport security settings
|
||||
# DNS rebinding protection is disabled by default to allow connections
|
||||
# from any host (aligns with GNS3 server's 0.0.0.0 binding).
|
||||
# Users with security requirements can enable protection and specify
|
||||
# allowed hosts using "host:*" port wildcard patterns.
|
||||
mcp_enable_dns_rebinding_protection: bool = False
|
||||
mcp_allowed_hosts: list[str] = Field(default_factory=list)
|
||||
mcp_allowed_origins: list[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
|
||||
|
||||
@field_validator("mcp_allowed_hosts", mode="before")
|
||||
@classmethod
|
||||
def split_mcp_allowed_hosts(cls, v):
|
||||
if v and isinstance(v, str):
|
||||
return v.split(",")
|
||||
return list()
|
||||
|
||||
@field_validator("mcp_allowed_origins", mode="before")
|
||||
@classmethod
|
||||
def split_mcp_allowed_origins(cls, v):
|
||||
if v and isinstance(v, str):
|
||||
return v.split(",")
|
||||
return list()
|
||||
|
||||
@field_validator("additional_images_paths", mode="before")
|
||||
@classmethod
|
||||
def split_additional_images_paths(cls, v):
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
@@ -62,6 +62,10 @@ class LinkBase(BaseModel):
|
||||
suspend: Optional[bool] = None
|
||||
link_style: Optional[LinkStyle] = None
|
||||
filters: Optional[dict] = None
|
||||
markers: Optional[dict] = Field(
|
||||
None,
|
||||
description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
|
||||
)
|
||||
show_filters_icon: Optional[bool] = Field(
|
||||
True,
|
||||
description="Show filters icon in Web UI"
|
||||
@@ -135,3 +139,154 @@ class LinkCapture(BaseModel):
|
||||
data_link_type: str = "DLT_EN10MB"
|
||||
capture_file_name: Optional[str] = None
|
||||
wireshark: bool = False
|
||||
|
||||
|
||||
class MarkerCreate(BaseModel):
|
||||
"""
|
||||
Body for attaching a traffic-insight marker to a link.
|
||||
|
||||
``name`` is optional at the controller REST layer (auto-generated when
|
||||
absent) but always set when the controller forwards to the compute.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
|
||||
max_length=32,
|
||||
description='Unique marker name on the link. Auto-generated when absent.',
|
||||
)
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
link_id: Optional[str] = None
|
||||
color: Optional[str] = Field(
|
||||
None,
|
||||
description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'",
|
||||
)
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description=(
|
||||
"How long (milliseconds) the Web UI keeps this marker highlighted "
|
||||
"after a match. Omitted = use the UI default. Pure render hint — "
|
||||
"stored on the link, never sent to uBridge."
|
||||
),
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="Whether the marker is active. Defaults to true on creation.",
|
||||
)
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
|
||||
)
|
||||
capture_node_id: Optional[UUID] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Which endpoint's uBridge hosts this marker (the 'observer'). "
|
||||
"tx/rx in `direction` are interpreted from this node's perspective. "
|
||||
"Must be one of the link's two endpoints and a marker-capable type. "
|
||||
"Omitted = server auto-picks (first started marker-capable endpoint)."
|
||||
),
|
||||
)
|
||||
data_link_type: str = Field(
|
||||
"DLT_EN10MB",
|
||||
description=(
|
||||
"pcap link-layer type the marker's BPF compiles against and its "
|
||||
"capture file is written with (a uBridge `linktype` token). Defaults "
|
||||
"to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. "
|
||||
"Only meaningful for serial links: set it to the matching serial DLT "
|
||||
"from the port's data_link_types — DLT_C_HDLC / DLT_PPP_SERIAL / "
|
||||
"DLT_FRELAY / DLT_ATM_RFC1483 — so the BPF offsets and pcap decode "
|
||||
"match the encapsulation configured in IOS. Create-only (changing it "
|
||||
"would invalidate the pcap)."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
class MarkerUpdate(BaseModel):
|
||||
"""
|
||||
Body for updating a marker — partial update, every field optional.
|
||||
|
||||
``bpf`` is optional here (it is required on create). ``capture_node_id`` and
|
||||
``name`` are create-only / path-driven and intentionally absent; an explicit
|
||||
``direction: null`` clears the direction back to both (omitting keeps it).
|
||||
"""
|
||||
|
||||
bpf: Optional[str] = None
|
||||
tag: Optional[int] = None
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter; 'both' or an explicit null clears it to both. Omit to keep.",
|
||||
)
|
||||
color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'")
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None, ge=1, description="UI highlight duration in ms; null = UI default"
|
||||
)
|
||||
enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).")
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
class MarkerDefinitionCreate(BaseModel):
|
||||
"""
|
||||
Body for creating / updating a project-level marker definition.
|
||||
|
||||
The definition is a template — when applied to a link the marker name is
|
||||
prefixed with ``global-`` (e.g. ``arp`` → ``global-arp``) so it can never
|
||||
collide with a per-link private marker.
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
|
||||
max_length=32,
|
||||
description="Unique definition name. Auto-generated when absent.",
|
||||
)
|
||||
bpf: str
|
||||
tag: Optional[int] = None
|
||||
color: Optional[str] = Field(
|
||||
None,
|
||||
description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'",
|
||||
)
|
||||
highlight_duration: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description=(
|
||||
"How long (milliseconds) the Web UI keeps this marker highlighted "
|
||||
"after a match. Omitted = use the UI default. Pure render hint — "
|
||||
"stored with the definition, never sent to uBridge."
|
||||
),
|
||||
)
|
||||
direction: Optional[str] = Field(
|
||||
None,
|
||||
pattern=r"^(tx|rx|both)$",
|
||||
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
|
||||
)
|
||||
data_link_type: str = Field(
|
||||
"DLT_EN10MB",
|
||||
description=(
|
||||
"pcap link-layer type for inherited markers on serial links (uBridge "
|
||||
"`linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then "
|
||||
"applies only to Ethernet links and serial links are skipped. Set a "
|
||||
"serial DLT — DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / "
|
||||
"DLT_ATM_RFC1483 — to also cover serial links with that encapsulation; "
|
||||
"Ethernet links stay EN10MB regardless. Changing it re-fans-out."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("direction", mode="before")
|
||||
@classmethod
|
||||
def _both_to_none(cls, v):
|
||||
return None if v == "both" else v
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class NodeFile(BaseModel):
|
||||
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")
|
||||
file_type: str = Field(..., description="File type determined by the file command")
|
||||
|
||||
|
||||
class ProjectCompression(str, Enum):
|
||||
|
||||
@@ -35,7 +35,10 @@ class IOUTemplate(TemplateBase):
|
||||
use_default_iou_values: Optional[bool] = Field(False, description="Use default IOU values")
|
||||
startup_config: Optional[str] = Field("iou_l3_base_startup-config.txt", description="Startup-config of IOU")
|
||||
private_config: Optional[str] = Field("", description="Private-config of IOU")
|
||||
l1_keepalives: Optional[bool] = Field(False, description="Always keep up Ethernet interface (does not always work)")
|
||||
l1_keepalives: Optional[bool] = Field(
|
||||
False,
|
||||
description="Enable Layer 1 keepalives so IOU interfaces report accurate link state",
|
||||
)
|
||||
console_type: Optional[ConsoleType] = Field(ConsoleType.telnet, description="Console type")
|
||||
console_auto_start: Optional[bool] = Field(
|
||||
False, description="Automatically start the console when the node has started"
|
||||
|
||||
@@ -22,9 +22,23 @@ class Token(BaseModel):
|
||||
|
||||
access_token: str
|
||||
token_type: str
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
|
||||
username: Optional[str] = None
|
||||
token_version: int = 0
|
||||
token_use: str = "access"
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for requesting a token refresh."""
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
"""Schema for creating a new API key."""
|
||||
|
||||
name: str
|
||||
@@ -17,6 +17,7 @@
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import OctKey
|
||||
from joserfc.errors import JoseError
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import bcrypt
|
||||
|
||||
@@ -45,12 +46,11 @@ class AuthService:
|
||||
|
||||
return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8'))
|
||||
|
||||
def create_access_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str:
|
||||
def _create_token(self, username, token_version, token_type, expires_in, secret_key=None) -> str:
|
||||
"""Shared helper to create any kind of signed JWT token."""
|
||||
|
||||
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, "ver": token_version}
|
||||
to_encode = {"sub": username, "exp": expire, "ver": token_version, "type": token_type}
|
||||
if secret_key is None:
|
||||
secret_key = Config.instance().settings.Controller.jwt_secret_key
|
||||
if secret_key is None:
|
||||
@@ -61,6 +61,18 @@ class AuthService:
|
||||
encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key)
|
||||
return encoded_jwt
|
||||
|
||||
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
|
||||
return self._create_token(username, token_version, "access", expires_in, secret_key)
|
||||
|
||||
def create_refresh_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_refresh_token_expire_minutes
|
||||
return self._create_token(username, token_version, "refresh", expires_in, secret_key)
|
||||
|
||||
def get_token_data(self, token: str, secret_key: str = None) -> TokenData:
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
@@ -80,8 +92,13 @@ class AuthService:
|
||||
username: str = payload.claims.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
# Validate the exp claim — joserfc does not validate time-based claims by default
|
||||
token_exp: int = payload.claims.get("exp", 0)
|
||||
if token_exp and time.time() > token_exp:
|
||||
raise credentials_exception
|
||||
token_version: int = payload.claims.get("ver", 0)
|
||||
token_data = TokenData(username=username, token_version=token_version)
|
||||
token_use: str = payload.claims.get("type", "access")
|
||||
token_data = TokenData(username=username, token_version=token_version, token_use=token_use)
|
||||
except (JoseError, ValidationError, ValueError):
|
||||
raise credentials_exception
|
||||
return token_data
|
||||
|
||||
@@ -33,6 +33,7 @@ from gns3server.controller.controller_error import (
|
||||
ControllerForbiddenError,
|
||||
)
|
||||
|
||||
|
||||
TEMPLATE_TYPE_TO_SCHEMA = {
|
||||
"cloud": schemas.CloudTemplate,
|
||||
"ethernet_hub": schemas.EthernetHubTemplate,
|
||||
@@ -262,6 +263,7 @@ class TemplatesService:
|
||||
async def get_template(self, template_id: UUID) -> dict:
|
||||
|
||||
db_template = await self._templates_repo.get_template(template_id)
|
||||
|
||||
if db_template:
|
||||
template = db_template.asjson()
|
||||
else:
|
||||
@@ -270,9 +272,13 @@ class TemplatesService:
|
||||
raise ControllerNotFoundError(f"Template '{template_id}' not found")
|
||||
return template
|
||||
|
||||
async def _remove_image(self, template_id: UUID, image_path:str) -> None:
|
||||
async def _remove_image(self, template_id: UUID, image_path: str) -> None:
|
||||
|
||||
if not image_path:
|
||||
return
|
||||
image = await self._templates_repo.get_image(image_path)
|
||||
if image is None:
|
||||
return
|
||||
await self._templates_repo.remove_image_from_template(template_id, image)
|
||||
|
||||
async def update_template(self, template_id: UUID, template_update: schemas.TemplateUpdate) -> dict:
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user