YueGuobin 117025a8a2
fix(web-wireshark): improve Docker API version compatibility with fallback mechanism
- Change initial API version from 1.40 to 1.44 to support Docker 29.3+ (API 1.54)
- Add automatic fallback to API 1.40 for older Docker versions (20.10, API 1.41)
- Fixes "client version 1.40 is too old. Minimum supported API version is 1.44" error
- Ensures compatibility across Docker versions 20.10 (API 1.41) to 29.3+ (API 1.54+)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:12:56 +08:00

292 lines
11 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (C) 2026 YueGuobin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Web Wireshark - Docker HTTP API client
This module handles Docker API operations via aiohttp Unix socket.
"""
import asyncio
import logging
import aiohttp
from gns3server.utils import parse_version
logger = logging.getLogger(__name__)
# Docker API configuration
DOCKER_SOCKET = "/var/run/docker.sock"
DOCKER_MINIMUM_API_VERSION = "1.40"
DOCKER_PREFERRED_API_VERSION = "1.44"
class DockerHTTPClient:
"""Docker HTTP API client using aiohttp with Unix socket connection."""
# API request timeout (seconds)
REQUEST_TIMEOUT = 10
def __init__(self):
self._connector = None
self._session = None
self._connected = False
# Start with preferred API version (1.44) to support newer Docker
# Will fallback to minimum version (1.40) if server doesn't support it
self._api_version = DOCKER_PREFERRED_API_VERSION
async def _get_connector(self):
"""Get or create Unix socket connector."""
if self._connector is None or self._connector.closed:
try:
self._connector = aiohttp.connector.UnixConnector(DOCKER_SOCKET, limit=None)
except (aiohttp.ClientError, FileNotFoundError):
raise RuntimeError(f"Can't connect to Docker daemon at {DOCKER_SOCKET}")
return self._connector
async def _get_session(self):
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
connector = await self._get_connector()
self._session = aiohttp.ClientSession(connector=connector)
return self._session
async def close(self):
"""Close connections."""
if self._session and not self._session.closed:
await self._session.close()
if self._connector and not self._connector.closed:
await self._connector.close()
self._connected = False
async def _check_connection(self):
"""Check Docker connection and detect API version."""
if not self._connected:
try:
# Get Docker version info
docker_info = await self._request("GET", "version", check_connection=False)
self._connected = True
# Parse API version
api_version = parse_version(docker_info['ApiVersion'])
docker_version = docker_info["Version"]
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
# Check minimum version requirement
if api_version < parse_version(DOCKER_MINIMUM_API_VERSION):
raise RuntimeError(
f"Docker version is {docker_version}. "
f"GNS3 requires a minimum API version of {DOCKER_MINIMUM_API_VERSION}"
)
# Use preferred API version if supported
preferred_api_version = parse_version(DOCKER_PREFERRED_API_VERSION)
if api_version >= preferred_api_version:
self._api_version = DOCKER_PREFERRED_API_VERSION
logger.info(f"Using Docker API version {self._api_version}")
else:
# Use Docker daemon's actual API version
self._api_version = docker_info['ApiVersion']
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
except (aiohttp.ClientError, FileNotFoundError) as e:
self._connected = False
raise RuntimeError(f"Can't connect to Docker daemon: {e}") from e
except KeyError as e:
raise RuntimeError(f"Unexpected Docker API response: missing {e}") from e
async def _request(self, method: str, endpoint: str, **kwargs):
"""
Send request to Docker API.
Args:
method: HTTP method
endpoint: API endpoint (without version prefix)
**kwargs: Other parameters for aiohttp.request
Returns:
Response JSON data
"""
# Check connection and version on first request
check_connection = kwargs.pop('check_connection', True)
if check_connection and not self._connected:
await self._check_connection()
session = await self._get_session()
url = f"http://docker/v{self._api_version}/{endpoint}"
try:
async with asyncio.timeout(self.REQUEST_TIMEOUT):
async with session.request(method, url, **kwargs) as response:
if response.status >= 300:
error_text = await response.text()
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
if response.status == 204: # No Content
return None
return await response.json()
except asyncio.TimeoutError:
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for {endpoint}")
except aiohttp.ClientError as e:
raise RuntimeError(f"Docker connection error: {e}") from e
except RuntimeError as e:
# Retry with lower API version if Docker daemon doesn't support current version
error_msg = str(e)
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION:
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}")
self._api_version = DOCKER_MINIMUM_API_VERSION
return await self._request(method, endpoint, check_connection=False, **kwargs)
raise
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
"""Create Docker network."""
data = {
"Name": name,
"Driver": driver
}
if subnet:
data["IPAM"] = {
"Config": [{"Subnet": subnet}]
}
await self._request("POST", "networks/create", json=data)
async def get_network(self, name: str):
"""Get network info."""
try:
return await self._request("GET", f"networks/{name}")
except RuntimeError as e:
if "404" in str(e):
return None
raise
async def create_container(self, name: str, image: str, **kwargs):
"""Create container."""
data = {
"Image": image,
"name": name,
"HostConfig": {},
"NetworkingConfig": {}
}
# Handle network config
if "network" in kwargs:
network_name = kwargs.pop("network")
data["NetworkingConfig"]["EndpointsConfig"] = {
network_name: {}
}
# Handle environment variables
if "environment" in kwargs:
data["Env"] = [f"{k}={v}" for k, v in kwargs.pop("environment").items()]
# Handle resource limits
if "host_config" in kwargs:
data["HostConfig"].update(kwargs.pop("host_config"))
else:
# Legacy parameter format
host_config = {}
if "mem_limit" in kwargs:
host_config["Memory"] = kwargs.pop("mem_limit")
if "cpu_quota" in kwargs:
host_config["NanoCpus"] = kwargs.pop("cpu_quota")
if "pids_limit" in kwargs:
host_config["PidsLimit"] = kwargs.pop("pids_limit")
if "restart_policy" in kwargs:
host_config["RestartPolicy"] = kwargs.pop("restart_policy")
data["HostConfig"].update(host_config)
# Handle health check
if "health_config" in kwargs:
data["HealthCheck"] = kwargs.pop("health_config")
# Create container
result = await self._request("POST", "containers/create", params={"name": name}, json=data)
return result["Id"]
async def start_container(self, container_id: str):
"""Start container."""
await self._request("POST", f"containers/{container_id}/start")
async def get_container(self, name: str):
"""Get container info."""
try:
return await self._request("GET", f"containers/{name}/json")
except RuntimeError as e:
if "404" in str(e):
return None
raise
async def stop_container(self, container_id: str, timeout: int = 0):
"""Stop container immediately (force kill, no graceful shutdown)."""
await self._request("POST", f"containers/{container_id}/stop", params={"t": timeout})
async def remove_container(self, container_id: str, force: bool = False):
"""Remove container."""
params = {"force": "true"} if force else {}
await self._request("DELETE", f"containers/{container_id}", params=params)
async def list_processes(self, container_name: str) -> list:
"""Get process list from container.
Args:
container_name: Container name (e.g., "gns3-wireshark-xxx")
Returns:
List of process dicts with keys: PID, USER, COMMAND, etc.
"""
if not self._connected:
await self._check_connection()
session = await self._get_session()
url = f"http://docker/v{self._api_version}/containers/{container_name}/top?ps_args=aux"
try:
async with asyncio.timeout(self.REQUEST_TIMEOUT):
async with session.get(url) as response:
if response.status >= 300:
error_text = await response.text()
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
result = await response.json()
except asyncio.TimeoutError:
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for containers/{container_name}/top")
except aiohttp.ClientError as e:
raise RuntimeError(f"Docker connection error: {e}") from e
# Docker API returns JSON format:
# {"Processes": [["yueguob+", "259847", ...], [...]], "Titles": ["USER", "PID", "%CPU", ...]}
# Headers are in "Titles" key, data rows are in "Processes"
headers = result.get("Titles", [])
processes_data = result.get("Processes", [])
if not headers or not processes_data:
return []
processes = []
for values in processes_data:
if not values or len(values) < len(headers):
continue
proc = {}
for i, header in enumerate(headers):
if i < len(values):
proc[header] = values[i]
processes.append(proc)
return processes