YueGuobin 629bb9194f
refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers
- Slim custom_gns3fy.py to connector-only Gns3Connector and rename to
  connector.py; delete the unused Node/Link/Project dataclasses and
  endpoint wrapper methods (~3000 lines)
- Move the MCP node/link handler implementations into
  gns3_client/api_handlers.py as the shared REST client layer consumed
  by both the MCP service and copilot tools; add available_filters
  handler (exposed as link_available_filters MCP tool) and
  build_gns3_ctx() for copilot callers
- Rewrite the tools_v2 node/link tools on top of the handlers: batch
  lifecycle actions now run in parallel, node creation is a single
  POST, project-wide status reads replace per-node GETs
- Port Project.nodes_inventory/links_summary aggregation into
  project_inventory.py (output shape preserved) and rewrite the
  topology reader / project info tools on it, dropping the unused
  stats/snapshots/drawings calls
- Delete the dead mcp/nodes.py and mcp/links.py (NODE_TOOLS/LINK_TOOLS
  had no consumers; __init__ imports handlers from api_handlers)
- Retarget mcp handler tests to patch api_handlers._get_connector and
  replace test_custom_gns3fy.py with inventory contract tests
2026-08-25 09:15:32 +08:00

310 lines
10 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
GNS3 REST API connector.
A minimal authenticated HTTP session for the GNS3 controller API: URL/base
URL handling, v2 basic / v3 JWT authentication and token refresh, plus GNS3
error extraction. Callers make requests through ``http_call`` — the
endpoint-specific logic lives in ``api_handlers``.
The class is adapted from the upstream gns3fy project
(https://github.com/davidban77/gns3fy) Gns3Connector.
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol)
service. Modifications must be tested with BOTH gns3-copilot AND MCP.
"""
import time
from typing import Any
import jwt
import requests
import urllib3
from requests import HTTPError
class Gns3Connector:
"""
Connector to be used for interaction against the GNS3 server controller API.
**Attributes:**
- `url` (str): URL of the GNS3 server (**required**)
- `user` (str): User used for authentication
- `cred` (str): Password used for authentication
- `jwt_token` (str): JWT token for direct authentication (API v3)
- `verify` (bool): Whether or not to verify SSL
- `api_version` (int): GNS3 server REST API version
- `api_calls`: Counter of amount of `http_calls` has been performed
- `base_url`: url passed + api_version
- `session`: Requests Session object
**Returns:**
`Gns3Connector` instance
**Example:**
```python
>>> # API v2 with basic auth
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=2
... )
>>> # API v3 with username/password (auto-fetches JWT token)
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=3
... )
>>> # API v3 with direct JWT token
>>> server = Gns3Connector(
... url="http://<address>:3080",
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
... api_version=3
... )
>>> print(server.http_call("get", f"{server.base_url}/version").json())
{'local': False, 'version': '2.2.0b4'}
```
"""
access_token: str | None
token_expiry: float | None
def __init__(
self,
url: str | None = None,
user: str | None = None,
cred: str | None = None,
jwt_token: str | None = None,
verify: bool = False,
api_version: int = 2,
) -> None:
# Disable SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if url is None:
raise ValueError("URL is required for Gns3Connector")
self.url = url.strip("/") # Store original URL for reference
self.base_url = f"{self.url}/v{api_version}"
self.user = user
self.cred = cred
self.headers = {"Content-Type": "application/json"}
self.verify = verify
self.api_calls = 0
# v3 authentication attributes
# If jwt_token is provided directly, use it; otherwise will be
# fetched via username/password
self.access_token = jwt_token
self.token_expiry = None
self.auth_type = "basic" if api_version == 2 else "jwt"
self.api_version = api_version
# Create session object
self._create_session()
def _create_session(self) -> None:
"""
Creates the requests.Session object and applies the necessary parameters
"""
self.session = requests.Session() # pragma: no cover
# Increase connection pool size to support concurrent MCP batch operations
adapter = requests.adapters.HTTPAdapter(pool_connections=500, pool_maxsize=1000)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version
if (
self.auth_type == "basic"
and self.user is not None
and self.cred is not None
):
self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
def _authenticate_v3(self) -> None:
"""
Performs v3 API authentication using username and password to get JWT token.
Skips authentication if a JWT token is already provided.
"""
# If token is already provided, skip authentication
if self.access_token:
return
if not self.user or not self.cred:
raise ValueError(
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
# Construct authentication URL (v3 API uses different base URL)
auth_url = (
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication
temp_session = requests.Session()
temp_session.headers["Content-Type"] = "application/json"
try:
response = temp_session.post(
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
if response.status_code == 200:
auth_result = response.json()
self.access_token = auth_result["access_token"]
# Update session with new token
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
else:
raise HTTPError(
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
def _is_token_expired(self) -> bool:
"""
Check if the JWT token is expired (basic implementation)
"""
token = self.access_token
if not token:
return True
try:
# Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode(
token, options={"verify_signature": False}
)
exp = decoded.get("exp")
if exp is not None:
return time.time() > float(exp)
return False
except (jwt.PyJWTError, ValueError, TypeError):
return True
def _refresh_token(self) -> None:
"""
Refresh the JWT token (for now, just re-authenticate)
"""
print("Refreshing v3 API token...")
self._authenticate_v3()
def http_call(
self,
method: str,
url: str,
data: Any | None = None,
json_data: dict[str, Any] | list[Any] | None = None,
headers: dict[str, str] | None = None,
verify: bool = False,
params: dict[str, Any] | None = None,
) -> requests.Response:
"""
Executes HTTP operations and handles GNS3-specific error logic.
"""
# Handle JWT authentication
if (
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
self._authenticate_v3()
# Get request function (e.g., session.get, session.post)
caller = getattr(self.session, method.lower())
# Prepare request parameters, avoiding multiple repeated calls to caller
kwargs: dict[str, Any] = {
"headers": headers,
"params": params,
"verify": verify,
"timeout": 30.0, # Main request timeout (auth call uses 10s)
}
if data is not None:
kwargs["data"] = data
elif json_data is not None:
kwargs["json"] = json_data
# Execute request
_response: requests.Response = caller(url, **kwargs)
self.api_calls += 1
try:
_response.raise_for_status()
except HTTPError as e:
# Throw enhanced error
raise self._extract_gns3_error(e) from e
return _response
def _extract_gns3_error(self, e: HTTPError) -> HTTPError:
"""
Extract GNS3-specific JSON error information from HTTPError.
If parsing fails, return the original error.
"""
# e.response might be None, need explicit check
response = e.response
if response is None:
return e
try:
# Only attempt parsing when Content-Type is JSON
if (
"application/json"
in response.headers.get("Content-Type", "").lower()
):
error_json = response.json()
status = error_json.get("status", "Unknown Status")
message = error_json.get(
"message", "No message provided in JSON."
)
# Construct a more descriptive new error
new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)",
response=response,
)
return new_err
except Exception:
# If JSON parsing fails, return error with original text
return HTTPError(
f"Original Error: {str(e)}. GNS3 response text: {response.text}",
response=response,
)
return e