mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 14:01:15 +02:00
refactor: ruff autofixes
This commit is contained in:
455 files changed
+12322
-14762
No files matched your search
@@ -202,12 +202,12 @@ class Controller:
|
||||
if computes:
|
||||
for c in computes:
|
||||
try:
|
||||
#FIXME: Task exception was never retrieved
|
||||
# FIXME: Task exception was never retrieved
|
||||
await self.add_compute(
|
||||
compute_id=str(c.compute_id),
|
||||
connect=False,
|
||||
**c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}),
|
||||
ssl_context=self._ssl_context
|
||||
ssl_context=self._ssl_context,
|
||||
)
|
||||
except (ControllerError, KeyError):
|
||||
pass # Skip not available servers at loading
|
||||
@@ -222,11 +222,9 @@ class Controller:
|
||||
|
||||
# start to auto open projects (if configured) 5 seconds after the controller has started
|
||||
self._project_auto_open_task_handle = asyncio.get_event_loop().call_later(
|
||||
5,
|
||||
lambda: asyncio.create_task(self._project_auto_open())
|
||||
5, lambda: asyncio.create_task(self._project_auto_open())
|
||||
)
|
||||
|
||||
|
||||
def _create_ssl_context(self, server_config):
|
||||
|
||||
import ssl
|
||||
@@ -303,11 +301,10 @@ class Controller:
|
||||
controller_vars = {
|
||||
"appliances_etag": self._appliance_manager.appliances_etag,
|
||||
"iou_license_check": self._iou_license_settings["license_check"],
|
||||
"version": __version__
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
if self._iou_license_settings["iourc_content"]:
|
||||
|
||||
server_config = Config.instance().settings.Server
|
||||
os.makedirs(server_config.secrets_dir, exist_ok=True)
|
||||
iourc_path = os.path.join(server_config.secrets_dir, "iou_license")
|
||||
@@ -321,7 +318,7 @@ class Controller:
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self._vars_file), exist_ok=True)
|
||||
with open(self._vars_file, 'w+') as f:
|
||||
with open(self._vars_file, "w+") as f:
|
||||
json.dump(controller_vars, f, indent=4, sort_keys=True)
|
||||
except OSError as e:
|
||||
log.error(f"Cannot write controller vars file '{self._vars_file}': {e}")
|
||||
@@ -357,15 +354,20 @@ class Controller:
|
||||
|
||||
# IOU license check is disabled by default
|
||||
self._iou_license_settings["license_check"] = controller_vars.get("iou_license_check", False)
|
||||
log.info("IOU license check is {} on the controller".format("enabled" if self._iou_license_settings["license_check"] else "disabled"))
|
||||
log.info(
|
||||
"IOU license check is {} on the controller".format(
|
||||
"enabled" if self._iou_license_settings["license_check"] else "disabled"
|
||||
)
|
||||
)
|
||||
|
||||
# install the built-in appliances if needed
|
||||
if Config.instance().settings.Server.install_builtin_appliances:
|
||||
previous_version = controller_vars.get("version")
|
||||
log.info("Comparing controller version {} with config version {}".format(__version__, previous_version))
|
||||
builtin_appliances_path = self._appliance_manager.builtin_appliances_path()
|
||||
if not previous_version or \
|
||||
parse_version(__version__.split("+")[0]) > parse_version(previous_version.split("+")[0]):
|
||||
if not previous_version or parse_version(__version__.split("+")[0]) > parse_version(
|
||||
previous_version.split("+")[0]
|
||||
):
|
||||
await self._appliance_manager.install_builtin_appliances()
|
||||
elif not os.listdir(builtin_appliances_path):
|
||||
await self._appliance_manager.install_builtin_appliances()
|
||||
@@ -467,7 +469,9 @@ class Controller:
|
||||
self._projects_scan_handle.cancel()
|
||||
|
||||
loop = self._projects_monitor_loop or asyncio.get_running_loop()
|
||||
self._projects_scan_handle = loop.call_later(delay, lambda: asyncio.create_task(self._scan_projects_directory()))
|
||||
self._projects_scan_handle = loop.call_later(
|
||||
delay, lambda: asyncio.create_task(self._scan_projects_directory())
|
||||
)
|
||||
|
||||
async def _scan_projects_directory(self):
|
||||
"""
|
||||
@@ -488,7 +492,6 @@ class Controller:
|
||||
except Exception as e:
|
||||
log.warning(f"Projects directory rescan failed: {e}")
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def install_resource_files(dst_path, resource_name, upgrade_resources=True):
|
||||
"""
|
||||
@@ -496,6 +499,7 @@ class Controller:
|
||||
"""
|
||||
|
||||
installed_resources = []
|
||||
|
||||
async def should_copy(src, dst, upgrade_resources):
|
||||
if not os.path.exists(dst):
|
||||
return True
|
||||
@@ -512,7 +516,7 @@ class Controller:
|
||||
if not os.path.exists(os.path.join(dst_path, filename)):
|
||||
shutil.copy(os.path.join(resource_path, filename), os.path.join(dst_path, filename))
|
||||
else:
|
||||
for entry in importlib_resources.files('gns3server').joinpath(resource_name).iterdir():
|
||||
for entry in importlib_resources.files("gns3server").joinpath(resource_name).iterdir():
|
||||
full_path = os.path.join(dst_path, entry.name)
|
||||
if entry.is_file() and await should_copy(str(entry), full_path, upgrade_resources):
|
||||
log.debug(f'Installing {resource_name} resource file "{entry.name}" to "{full_path}"')
|
||||
@@ -604,7 +608,6 @@ class Controller:
|
||||
"""
|
||||
|
||||
if compute_id not in self._computes:
|
||||
|
||||
# We disallow to create from the outside the local and VM server
|
||||
if (compute_id == "local" or compute_id == "vm") and not force:
|
||||
return None
|
||||
@@ -799,10 +802,7 @@ class Controller:
|
||||
project = self._projects[topo_data["project_id"]]
|
||||
else:
|
||||
project = await self.add_project(
|
||||
path=os.path.dirname(path),
|
||||
status="closed",
|
||||
filename=os.path.basename(path),
|
||||
**topo_data
|
||||
path=os.path.dirname(path), status="closed", filename=os.path.basename(path), **topo_data
|
||||
)
|
||||
if load or project.auto_open:
|
||||
await project.open()
|
||||
|
||||
@@ -22,7 +22,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Appliance:
|
||||
|
||||
def __init__(self, path, data, builtin=True):
|
||||
|
||||
self._data = data.copy()
|
||||
|
||||
@@ -116,6 +116,7 @@ class ApplianceManager:
|
||||
dst_path = self.builtin_appliances_path()
|
||||
log.info(f"Installing built-in appliances in '{dst_path}'")
|
||||
from . import Controller
|
||||
|
||||
try:
|
||||
await Controller.instance().install_resource_files(dst_path, "appliances")
|
||||
except OSError as e:
|
||||
@@ -135,12 +136,7 @@ class ApplianceManager:
|
||||
return appliances
|
||||
|
||||
async def _download_image(
|
||||
self,
|
||||
image_dir: str,
|
||||
image_name: str,
|
||||
image_type: str,
|
||||
image_url: str,
|
||||
images_repo: ImagesRepository
|
||||
self, image_dir: str, image_name: str, image_type: str, image_url: str, images_repo: ImagesRepository
|
||||
) -> None:
|
||||
"""
|
||||
Download an image.
|
||||
@@ -152,7 +148,9 @@ class ApplianceManager:
|
||||
async with HTTPClient.get(image_url) as response:
|
||||
if response.status != 200:
|
||||
raise ControllerError(f"Could not download '{image_name}' due to HTTP error code {response.status}")
|
||||
await write_image(image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True)
|
||||
await write_image(
|
||||
image_name, image_path, response.content.iter_any(), images_repo, allow_raw_image=True
|
||||
)
|
||||
except (OSError, InvalidImageError) as e:
|
||||
raise ControllerError(f"Could not save {image_type} image '{image_path}': {e}")
|
||||
except ClientError as e:
|
||||
@@ -161,11 +159,7 @@ class ApplianceManager:
|
||||
raise ControllerError(f"Timeout while downloading '{image_name}' from '{image_url}'")
|
||||
|
||||
async def _find_appliance_version_images(
|
||||
self,
|
||||
appliance: Appliance,
|
||||
version: dict,
|
||||
images_repo: ImagesRepository,
|
||||
image_dir: str
|
||||
self, appliance: Appliance, version: dict, images_repo: ImagesRepository, image_dir: str
|
||||
) -> None:
|
||||
"""
|
||||
Find all the images belonging to a specific appliance version.
|
||||
@@ -196,11 +190,8 @@ class ApplianceManager:
|
||||
direct_download_url = image.get("direct_download_url")
|
||||
if direct_download_url:
|
||||
await self._download_image(
|
||||
image_dir,
|
||||
appliance_file,
|
||||
appliance.type,
|
||||
direct_download_url,
|
||||
images_repo)
|
||||
image_dir, appliance_file, appliance.type, direct_download_url, images_repo
|
||||
)
|
||||
else:
|
||||
raise ControllerError(f"Could not find '{appliance_file}'")
|
||||
|
||||
@@ -214,8 +205,8 @@ class ApplianceManager:
|
||||
except ValidationError as e:
|
||||
raise ControllerError(message=f"Could not validate template data: {e}")
|
||||
template = await TemplatesService(templates_repo).create_template(template_create)
|
||||
#template_id = template.get("template_id")
|
||||
#await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
|
||||
# template_id = template.get("template_id")
|
||||
# await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
|
||||
log.info(f"Template '{template.get('name')}' has been created")
|
||||
return template
|
||||
|
||||
@@ -237,14 +228,14 @@ class ApplianceManager:
|
||||
return template_data
|
||||
|
||||
async def install_appliances_from_image(
|
||||
self,
|
||||
image_path: str,
|
||||
image_checksum: str,
|
||||
images_repo: ImagesRepository,
|
||||
templates_repo: TemplatesRepository,
|
||||
rbac_repo: RbacRepository,
|
||||
current_user: schemas.User,
|
||||
image_dir: str
|
||||
self,
|
||||
image_path: str,
|
||||
image_checksum: str,
|
||||
images_repo: ImagesRepository,
|
||||
templates_repo: TemplatesRepository,
|
||||
rbac_repo: RbacRepository,
|
||||
current_user: schemas.User,
|
||||
image_dir: str,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Install appliances using an image checksum.
|
||||
@@ -262,11 +253,13 @@ class ApplianceManager:
|
||||
ApplianceModel.model_validate(appliance.asdict())
|
||||
except ValidationError as e:
|
||||
log.warning(f"Could not validate appliance '{appliance.id}': {e}")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": f"could not validate appliance '{appliance.id}': {e}",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": f"could not validate appliance '{appliance.id}': {e}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if appliance.versions:
|
||||
for version in appliance.versions:
|
||||
@@ -282,37 +275,45 @@ class ApplianceManager:
|
||||
# duplicates when the appliance version differs, but two
|
||||
# templates sharing a name is never what the user asked for here
|
||||
log.warning(f"Template '{name}' already exists, skipping automatic template creation")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": name,
|
||||
"reason": f"a template named '{name}' already exists",
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"status": "skipped",
|
||||
"name": name,
|
||||
"reason": f"a template named '{name}' already exists",
|
||||
}
|
||||
)
|
||||
continue
|
||||
template = await self._create_template(template_data, templates_repo, rbac_repo, current_user)
|
||||
results.append({
|
||||
"status": "created",
|
||||
"template_id": str(template.get("template_id")),
|
||||
"name": template.get("name"),
|
||||
"version": template.get("version"),
|
||||
"template_type": template.get("template_type"),
|
||||
})
|
||||
template = await self._create_template(
|
||||
template_data, templates_repo, rbac_repo, current_user
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"status": "created",
|
||||
"template_id": str(template.get("template_id")),
|
||||
"name": template.get("name"),
|
||||
"version": template.get("version"),
|
||||
"template_type": template.get("template_type"),
|
||||
}
|
||||
)
|
||||
except (ControllerError, InvalidImageError) as e:
|
||||
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": str(e),
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": str(e),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
async def install_appliance(
|
||||
self,
|
||||
appliance_id: UUID,
|
||||
version: str,
|
||||
images_repo: ImagesRepository,
|
||||
templates_repo: TemplatesRepository,
|
||||
rbac_repo: RbacRepository,
|
||||
current_user: schemas.User
|
||||
self,
|
||||
appliance_id: UUID,
|
||||
version: str,
|
||||
images_repo: ImagesRepository,
|
||||
templates_repo: TemplatesRepository,
|
||||
rbac_repo: RbacRepository,
|
||||
current_user: schemas.User,
|
||||
) -> None:
|
||||
"""
|
||||
Install a new appliance
|
||||
@@ -335,11 +336,15 @@ class ApplianceManager:
|
||||
for appliance_version_info in appliance.versions:
|
||||
if appliance_version_info.get("name") == version:
|
||||
try:
|
||||
template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info)
|
||||
template_type = ApplianceToTemplate().get_template_type(
|
||||
appliance.asdict(), appliance_version_info
|
||||
)
|
||||
if template_type != "docker":
|
||||
# docker appliances have no image files to find or download
|
||||
image_dir = default_images_directory(template_type)
|
||||
await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
|
||||
await self._find_appliance_version_images(
|
||||
appliance, appliance_version_info, images_repo, image_dir
|
||||
)
|
||||
except InvalidImageError as e:
|
||||
raise ControllerError(message=f"Image error: {e}")
|
||||
template_data = await self._appliance_to_template(appliance, appliance_version_info)
|
||||
@@ -350,8 +355,9 @@ class ApplianceManager:
|
||||
else:
|
||||
if appliance.versions:
|
||||
# TODO: install appliance versions based on available images
|
||||
raise ControllerBadRequestError(message=f"Selecting a version is required to install "
|
||||
f"appliance '{appliance_id}'")
|
||||
raise ControllerBadRequestError(
|
||||
message=f"Selecting a version is required to install appliance '{appliance_id}'"
|
||||
)
|
||||
|
||||
template_data = await self._appliance_to_template(appliance)
|
||||
return await self._create_template(template_data, templates_repo, rbac_repo, current_user)
|
||||
@@ -383,7 +389,9 @@ class ApplianceManager:
|
||||
json_data = appliance.asdict() # Check if loaded without error
|
||||
if appliance.status != "broken":
|
||||
# Validate using discriminated union - automatically routes to correct version
|
||||
log.debug(f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}")
|
||||
log.debug(
|
||||
f"Validating appliance '{appliance.id}' with registry version {appliance.registry_version}"
|
||||
)
|
||||
ApplianceModel.model_validate(json_data)
|
||||
self._appliances[appliance.id] = appliance
|
||||
if not appliance.symbol or appliance.symbol.startswith(":/symbols/"):
|
||||
|
||||
@@ -315,8 +315,7 @@ class ApplianceToTemplate:
|
||||
return settings_list[0]
|
||||
|
||||
raise ControllerError(
|
||||
f"Appliance '{appliance_config['name']}' has multiple settings "
|
||||
f"but none is marked as default"
|
||||
f"Appliance '{appliance_config['name']}' has multiple settings but none is marked as default"
|
||||
)
|
||||
|
||||
def _merge_v8_properties(self, settings, appliance_config):
|
||||
|
||||
@@ -40,7 +40,7 @@ from ..controller.controller_error import (
|
||||
ControllerTimeoutError,
|
||||
ControllerUnauthorizedError,
|
||||
ComputeError,
|
||||
ComputeConflictError
|
||||
ComputeConflictError,
|
||||
)
|
||||
from ..version import __version__, __version_info__
|
||||
|
||||
@@ -483,10 +483,7 @@ class Compute:
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
else:
|
||||
await self._controller.notification.dispatch(
|
||||
action,
|
||||
event,
|
||||
project_id=project_id,
|
||||
compute_id=self.id
|
||||
action, event, project_id=project_id, compute_id=self.id
|
||||
)
|
||||
else:
|
||||
if response.type == aiohttp.WSMsgType.CLOSE:
|
||||
@@ -514,6 +511,7 @@ class Compute:
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb)
|
||||
from gns3server.api.server import app
|
||||
|
||||
if not app.state.exiting and not hasattr(sys, "_called_from_test"):
|
||||
log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'")
|
||||
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
|
||||
@@ -534,7 +532,7 @@ class Compute:
|
||||
return f"{self._protocol}://{host}:{self._port}/v3/compute{path}"
|
||||
|
||||
def get_url(self, path):
|
||||
""" Returns URL for specific path at 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, stream=False, params=None):
|
||||
@@ -567,7 +565,14 @@ 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, params=params, 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")
|
||||
@@ -627,7 +632,7 @@ class Compute:
|
||||
raise HTTPException(
|
||||
status_code=response.status,
|
||||
detail=f"HTTP error {response.status} received from compute "
|
||||
f"'{self.name}' for request {method} {path}: {msg}"
|
||||
f"'{self.name}' for request {method} {path}: {msg}",
|
||||
)
|
||||
|
||||
if body and len(body):
|
||||
|
||||
@@ -81,7 +81,7 @@ async def export_project(
|
||||
keep_compute_ids,
|
||||
allow_all_nodes,
|
||||
temporary_dir,
|
||||
reset_mac_addresses
|
||||
reset_mac_addresses,
|
||||
)
|
||||
|
||||
# Export the local files
|
||||
@@ -198,14 +198,7 @@ def _is_exportable(path, include_snapshots=False):
|
||||
|
||||
|
||||
async def _patch_project_file(
|
||||
project,
|
||||
path,
|
||||
zstream,
|
||||
include_images,
|
||||
keep_compute_ids,
|
||||
allow_all_nodes,
|
||||
temporary_dir,
|
||||
reset_mac_addresses
|
||||
project, path, zstream, include_images, keep_compute_ids, allow_all_nodes, temporary_dir, reset_mac_addresses
|
||||
):
|
||||
"""
|
||||
Patch a project file (.gns3) to export a project.
|
||||
@@ -242,7 +235,6 @@ async def _patch_project_file(
|
||||
|
||||
if "properties" in node:
|
||||
for prop, value in node["properties"].items():
|
||||
|
||||
# reset the MAC address
|
||||
if reset_mac_addresses and prop in ("mac_addr", "mac_address"):
|
||||
node["properties"][prop] = None
|
||||
|
||||
@@ -28,7 +28,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HyperVGNS3VM(BaseGNS3VM):
|
||||
|
||||
_HYPERV_VM_STATE_ENABLED = 2
|
||||
_HYPERV_VM_STATE_DISABLED = 3
|
||||
_HYPERV_VM_STATE_SHUTDOWN = 4
|
||||
|
||||
@@ -135,8 +135,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
|
||||
result = await self._execute("showvminfo", [self._vmname, "--machinereadable"])
|
||||
for info in result.splitlines():
|
||||
if '=' in info:
|
||||
name, value = info.split('=', 1)
|
||||
if "=" in info:
|
||||
name, value = info.split("=", 1)
|
||||
if name == "{}{}".format(backend_type, interface_number):
|
||||
return value.strip('"')
|
||||
return None
|
||||
@@ -188,7 +188,9 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
lower_ip = str(interface.ip + 2)
|
||||
upper_ip = str(subnet.network_address + subnet.num_addresses - 2)
|
||||
except ValueError:
|
||||
raise GNS3VMError("Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask))
|
||||
raise GNS3VMError(
|
||||
"Invalid IP address and netmask for vboxnet {}: {}/{}".format(vboxnet, ip_address, netmask)
|
||||
)
|
||||
|
||||
dhcp_server_args = [
|
||||
"add",
|
||||
@@ -197,7 +199,7 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
"--netmask={}".format(netmask),
|
||||
"--lower-ip={}".format(lower_ip),
|
||||
"--upper-ip={}".format(upper_ip),
|
||||
"--enable"
|
||||
"--enable",
|
||||
]
|
||||
await self._execute("dhcpserver", dhcp_server_args)
|
||||
|
||||
@@ -249,7 +251,6 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def list(self):
|
||||
"""
|
||||
List all VirtualBox VMs
|
||||
@@ -271,16 +272,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
# get a NAT interface number
|
||||
nat_interface_number = await self._look_for_interface("nat")
|
||||
if nat_interface_number < 0 and await self._look_for_interface("natnetwork") < 0:
|
||||
raise GNS3VMError(f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start')
|
||||
raise GNS3VMError(
|
||||
f'VM "{self.vmname}" must have a NAT interface or NAT Network configured in order to start'
|
||||
)
|
||||
|
||||
if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version("7_0"):
|
||||
if sys.platform.startswith("darwin") and parse_version(self._system_properties["API version"]) >= parse_version(
|
||||
"7_0"
|
||||
):
|
||||
# VirtualBox 7.0+ on macOS requires a host-only network interface
|
||||
backend_type = "hostonly-network"
|
||||
backend_description = "host-only network"
|
||||
vboxnet_type = "hostonlynets"
|
||||
interface_number = await self._look_for_interface("hostonlynetwork")
|
||||
if interface_number < 0:
|
||||
raise GNS3VMError('VM "{}" must have a network adapter attached to a host-only network in order to start'.format(self.vmname))
|
||||
raise GNS3VMError(
|
||||
'VM "{}" must have a network adapter attached to a host-only network in order to start'.format(
|
||||
self.vmname
|
||||
)
|
||||
)
|
||||
else:
|
||||
backend_type = "hostonlyadapter"
|
||||
backend_description = "host-only adapter"
|
||||
@@ -288,11 +297,19 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
interface_number = await self._look_for_interface("hostonly")
|
||||
|
||||
if interface_number < 0:
|
||||
raise GNS3VMError('VM "{}" must have a network adapter attached to a {} in order to start'.format(self.vmname, backend_description))
|
||||
raise GNS3VMError(
|
||||
'VM "{}" must have a network adapter attached to a {} in order to start'.format(
|
||||
self.vmname, backend_description
|
||||
)
|
||||
)
|
||||
|
||||
vboxnet = await self._look_for_vboxnet(backend_type, interface_number)
|
||||
if vboxnet is None:
|
||||
raise GNS3VMError('A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format(interface_number, self._vmname))
|
||||
raise GNS3VMError(
|
||||
'A VirtualBox host-only network could not be found on network adapter {} for "{}"'.format(
|
||||
interface_number, self._vmname
|
||||
)
|
||||
)
|
||||
|
||||
if not (await self._check_vboxnet_exists(vboxnet, vboxnet_type)):
|
||||
if sys.platform.startswith("win") and vboxnet == "vboxnet0":
|
||||
@@ -300,24 +317,30 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
# on Windows. Try to patch this with the first available vboxnet we find.
|
||||
first_available_vboxnet = await self._find_first_available_vboxnet()
|
||||
if first_available_vboxnet is None:
|
||||
raise GNS3VMError('Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format(interface_number, self._vmname))
|
||||
raise GNS3VMError(
|
||||
'Please add a VirtualBox host-only network with DHCP enabled and attached it to network adapter {} for "{}"'.format(
|
||||
interface_number, self._vmname
|
||||
)
|
||||
)
|
||||
await self.set_hostonly_network(interface_number, first_available_vboxnet)
|
||||
vboxnet = first_available_vboxnet
|
||||
else:
|
||||
try:
|
||||
await self._execute("hostonlyif", ["create"])
|
||||
except GNS3VMError:
|
||||
raise GNS3VMError('VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format(
|
||||
vboxnet,
|
||||
interface_number,
|
||||
self._vmname
|
||||
))
|
||||
raise GNS3VMError(
|
||||
'VirtualBox host-only network "{}" does not exist and could not be automatically created, please make the sure the network adapter {} configuration is valid for "{}"'.format(
|
||||
vboxnet, interface_number, self._vmname
|
||||
)
|
||||
)
|
||||
|
||||
if backend_type == "hostonlyadapter" and not (await self._check_dhcp_server(vboxnet)):
|
||||
try:
|
||||
await self._add_dhcp_server(vboxnet)
|
||||
except GNS3VMError as e:
|
||||
raise GNS3VMError("Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e))
|
||||
raise GNS3VMError(
|
||||
"Could not add DHCP server for vboxnet {}: {}, please configure manually".format(vboxnet, e)
|
||||
)
|
||||
|
||||
vm_state = await self._get_state()
|
||||
log.info(f'"{self._vmname}" state is {vm_state}')
|
||||
@@ -363,12 +386,24 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
if await self._check_vbox_port_forwarding():
|
||||
# delete the GNS3VM NAT port forwarding rule if it exists
|
||||
log.info("Removing GNS3VM NAT port forwarding rule from interface {}".format(nat_interface_number))
|
||||
await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"])
|
||||
await self._execute(
|
||||
"controlvm", [self._vmname, "natpf{}".format(nat_interface_number), "delete", "GNS3VM"]
|
||||
)
|
||||
|
||||
# add a GNS3VM NAT port forwarding rule to redirect 127.0.0.1 with random port to the port in the VM
|
||||
log.info("Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format(api_port, nat_interface_number))
|
||||
await self._execute("controlvm", [self._vmname, "natpf{}".format(nat_interface_number),
|
||||
"GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port)])
|
||||
log.info(
|
||||
"Adding GNS3VM NAT port forwarding rule with port {} to interface {}".format(
|
||||
api_port, nat_interface_number
|
||||
)
|
||||
)
|
||||
await self._execute(
|
||||
"controlvm",
|
||||
[
|
||||
self._vmname,
|
||||
"natpf{}".format(nat_interface_number),
|
||||
"GNS3VM,tcp,{},{},,{}".format(ip_address, api_port, self.port),
|
||||
],
|
||||
)
|
||||
|
||||
self.ip_address = await self._get_ip_from_server(interface_number, api_port)
|
||||
|
||||
@@ -384,8 +419,8 @@ class VirtualBoxGNS3VM(BaseGNS3VM):
|
||||
while remaining_try > 0:
|
||||
result = await self._execute("guestproperty", ["get", self._vmname, "/VirtualBox/GuestInfo/Net/0/V4/IP"])
|
||||
for info in result.splitlines():
|
||||
if ':' in info:
|
||||
name, value = info.split(':', 1)
|
||||
if ":" in info:
|
||||
name, value = info.split(":", 1)
|
||||
if name == "Value":
|
||||
return value.strip()
|
||||
remaining_try -= 1
|
||||
|
||||
@@ -41,16 +41,16 @@ Handle the import of project from a .gns3project
|
||||
|
||||
|
||||
async def import_project(
|
||||
controller,
|
||||
project_id,
|
||||
stream,
|
||||
location=None,
|
||||
name=None,
|
||||
reset_mac_addresses=False,
|
||||
keep_compute_ids=False,
|
||||
auto_start=False,
|
||||
auto_open=False,
|
||||
auto_close=True,
|
||||
controller,
|
||||
project_id,
|
||||
stream,
|
||||
location=None,
|
||||
name=None,
|
||||
reset_mac_addresses=False,
|
||||
keep_compute_ids=False,
|
||||
auto_start=False,
|
||||
auto_open=False,
|
||||
auto_close=True,
|
||||
):
|
||||
"""
|
||||
Import a project contain in a zip file
|
||||
@@ -135,7 +135,9 @@ async def import_project(
|
||||
else:
|
||||
# Round-robin through available compute resources.
|
||||
# Only use computes that are connected to avoid import failures
|
||||
available_computes = {compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected}
|
||||
available_computes = {
|
||||
compute_id: compute for compute_id, compute in controller.computes.items() if compute.connected
|
||||
}
|
||||
if available_computes:
|
||||
compute_nodes = itertools.cycle(available_computes)
|
||||
for node in topology["topology"]["nodes"]:
|
||||
@@ -258,6 +260,7 @@ def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=Fals
|
||||
for drawing in topology["topology"]["drawings"]:
|
||||
drawing["drawing_id"] = str(uuid.uuid4())
|
||||
|
||||
|
||||
def _move_node_file(path, old_id, new_id):
|
||||
"""
|
||||
Move a file from a node when changing its id
|
||||
@@ -284,7 +287,7 @@ async def _move_files_to_compute(compute, project_id, directory, files_path):
|
||||
|
||||
location = os.path.join(directory, files_path)
|
||||
if os.path.exists(location):
|
||||
for (dirpath, dirnames, filenames) in os.walk(location, followlinks=False):
|
||||
for dirpath, dirnames, filenames in os.walk(location, followlinks=False):
|
||||
for filename in filenames:
|
||||
path = os.path.join(dirpath, filename)
|
||||
if os.path.islink(path):
|
||||
@@ -314,7 +317,7 @@ async def _import_images(controller, images_path):
|
||||
|
||||
image_dir = controller.images_path()
|
||||
root = images_path
|
||||
for (dirpath, dirnames, filenames) in os.walk(root, followlinks=False):
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
for filename in filenames:
|
||||
path = os.path.join(dirpath, filename)
|
||||
dst = os.path.join(image_dir, os.path.relpath(path, root))
|
||||
@@ -326,11 +329,12 @@ async def _import_images(controller, images_path):
|
||||
# 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':
|
||||
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):
|
||||
"""
|
||||
Load the snapshots and update their project name and project ID to be the same as the main project.
|
||||
@@ -342,7 +346,6 @@ async def update_snapshots(snapshots_dir, project_path, project_name, project_id
|
||||
continue
|
||||
snapshot_path = os.path.join(snapshots_dir, snapshot)
|
||||
with tempfile.TemporaryDirectory(dir=snapshots_dir) as tmpdir:
|
||||
|
||||
# extract everything to a temporary directory
|
||||
try:
|
||||
with open(snapshot_path, "rb") as f:
|
||||
|
||||
@@ -23,7 +23,11 @@ import html
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.packet_filter_validation import validate_all_filters, filter_inactive_filters, FilterValidationError
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
validate_all_filters,
|
||||
filter_inactive_filters,
|
||||
FilterValidationError,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
@@ -164,7 +168,7 @@ class Link:
|
||||
"""
|
||||
Get whether to show filters icon in Web UI
|
||||
"""
|
||||
return getattr(self, '_show_filters_icon', True)
|
||||
return getattr(self, "_show_filters_icon", True)
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
@@ -438,14 +442,13 @@ class Link:
|
||||
jwt_token=jwt_token,
|
||||
memory=webwireshark_config.memory,
|
||||
cpus=webwireshark_config.cpus,
|
||||
pids_limit=webwireshark_config.pids_limit
|
||||
pids_limit=webwireshark_config.pids_limit,
|
||||
)
|
||||
|
||||
# Send notification
|
||||
self._project.emit_notification("link.web_wireshark_started", {
|
||||
"link_id": self.id,
|
||||
"ws_url": result.get("ws_url", result.get("url"))
|
||||
})
|
||||
self._project.emit_notification(
|
||||
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
|
||||
)
|
||||
|
||||
log.info(f"Web Wireshark started for link {self.id}: {result.get('ws_url')}")
|
||||
self._wireshark = True
|
||||
@@ -466,10 +469,7 @@ class Link:
|
||||
try:
|
||||
log.info(f"Stopping Web Wireshark for link {self.id}")
|
||||
|
||||
await manager.stop_wireshark_session(
|
||||
project_id=self._project.id,
|
||||
link_id=self.id
|
||||
)
|
||||
await manager.stop_wireshark_session(project_id=self._project.id, link_id=self.id)
|
||||
|
||||
log.info(f"Web Wireshark stopped for link {self.id}")
|
||||
self._wireshark = False
|
||||
@@ -494,15 +494,12 @@ class Link:
|
||||
log.info(f"Restarting Web Wireshark for link {self.id}")
|
||||
|
||||
result = await manager.restart_wireshark_session(
|
||||
project_id=self._project.id,
|
||||
link_id=self.id,
|
||||
jwt_token=jwt_token
|
||||
project_id=self._project.id, link_id=self.id, jwt_token=jwt_token
|
||||
)
|
||||
|
||||
self._project.emit_notification("link.web_wireshark_started", {
|
||||
"link_id": self.id,
|
||||
"ws_url": result.get("ws_url", result.get("url"))
|
||||
})
|
||||
self._project.emit_notification(
|
||||
"link.web_wireshark_started", {"link_id": self.id, "ws_url": result.get("ws_url", result.get("url"))}
|
||||
)
|
||||
|
||||
log.info(f"Web Wireshark restarted for link {self.id}: {result.get('ws_url')}")
|
||||
|
||||
@@ -660,7 +657,7 @@ class Link:
|
||||
"markers": self._persist_markers(),
|
||||
"link_style": self._link_style,
|
||||
"suspend": self._suspended,
|
||||
"show_filters_icon": getattr(self, '_show_filters_icon', True),
|
||||
"show_filters_icon": getattr(self, "_show_filters_icon", True),
|
||||
}
|
||||
result = {
|
||||
"nodes": res,
|
||||
@@ -676,6 +673,6 @@ class Link:
|
||||
"suspend": self._suspended,
|
||||
"link_style": self._link_style,
|
||||
"wireshark": self._wireshark,
|
||||
"show_filters_icon": getattr(self, '_show_filters_icon', True),
|
||||
"show_filters_icon": getattr(self, "_show_filters_icon", True),
|
||||
}
|
||||
return result
|
||||
@@ -124,10 +124,10 @@ class _SharkdRpcError(Exception):
|
||||
# magic → (byte order, timestamp unit). Both pcap families uBridge can write
|
||||
# (libpcap default µs; ns variant accepted defensively) and both endiannesses.
|
||||
_PCAP_MAGICS = {
|
||||
0xA1B2C3D4: ("<", 1), # little-endian, microseconds
|
||||
0xD4C3B2A1: (">", 1), # big-endian, microseconds
|
||||
0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds
|
||||
0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds
|
||||
0xA1B2C3D4: ("<", 1), # little-endian, microseconds
|
||||
0xD4C3B2A1: (">", 1), # big-endian, microseconds
|
||||
0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds
|
||||
0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds
|
||||
}
|
||||
|
||||
|
||||
@@ -174,9 +174,7 @@ def scan_pcap_frames(path):
|
||||
|
||||
pos = 24
|
||||
while pos + 16 <= len(data):
|
||||
ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(
|
||||
byteorder + "IIII", data[pos:pos + 16]
|
||||
)
|
||||
ts_sec, ts_frac, incl_len, _orig_len = struct.unpack(byteorder + "IIII", data[pos : pos + 16])
|
||||
if incl_len > 0xFFFF or pos + 16 + incl_len > len(data):
|
||||
break # truncated tail (snapshot mid-write / torn final record)
|
||||
# Normalize ns pcaps to µs by truncation — uBridge writes µs anyway.
|
||||
@@ -197,7 +195,7 @@ def read_frame_bytes(path, frame_number):
|
||||
return None
|
||||
# Offset arithmetic mirrors the header scan: global header + every full
|
||||
# record before the target + the target's own record header.
|
||||
offset = 24 + sum(16 + incl for _s, _u, incl in frames[:frame_number - 1]) + 16
|
||||
offset = 24 + sum(16 + incl for _s, _u, incl in frames[: frame_number - 1]) + 16
|
||||
incl_len = frames[frame_number - 1][2]
|
||||
with open(path, "rb") as f:
|
||||
f.seek(offset)
|
||||
@@ -213,10 +211,7 @@ def read_frame_bytes(path, frame_number):
|
||||
# (/etc/wireshark &c.), so the column indexes in _columns_for are a contract
|
||||
# we own rather than an environment default. Exactly the four columns the
|
||||
# frame entries consume — nothing else rides along in every page.
|
||||
_PINNED_COLUMNS = (
|
||||
'gui.column.format: "Source", "%s", "Destination", "%d", '
|
||||
'"Protocol", "%p", "Info", "%i"\n'
|
||||
)
|
||||
_PINNED_COLUMNS = 'gui.column.format: "Source", "%s", "Destination", "%d", "Protocol", "%p", "Info", "%i"\n'
|
||||
|
||||
|
||||
async def _prepare_scratch(pcap):
|
||||
@@ -262,13 +257,13 @@ def _engine_env(scratch_dir):
|
||||
# semantics live in VALUES (field names, labels, filter expressions), which
|
||||
# are never touched.
|
||||
_KEY_RENAME = {
|
||||
"t": "element", # node type ("proto", …)
|
||||
"l": "label", # display text
|
||||
"fn": "name", # field name (e.g. "ip.ttl")
|
||||
"f": "filter_expr", # ready-made display filter with the value baked in
|
||||
"s": "expert", # expert severity name ("Chat", "Warn", …)
|
||||
"g": "generated", # generated-by-wireshark flag
|
||||
"n": "children", # nested fields
|
||||
"t": "element", # node type ("proto", …)
|
||||
"l": "label", # display text
|
||||
"fn": "name", # field name (e.g. "ip.ttl")
|
||||
"f": "filter_expr", # ready-made display filter with the value baked in
|
||||
"s": "expert", # expert severity name ("Chat", "Warn", …)
|
||||
"g": "generated", # generated-by-wireshark flag
|
||||
"n": "children", # nested fields
|
||||
}
|
||||
# "h" → pos + size (byte range for hex highlighting) — handled specially.
|
||||
# "e" is sharkd's internal header-field registry id — unstable across
|
||||
@@ -308,6 +303,7 @@ def _count_tree_nodes(value):
|
||||
# sharkd sessions (one resident process per source pcap)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SharkdSession:
|
||||
"""A resident `sharkd -` process with one pcap loaded, addressed through
|
||||
line-oriented JSON-RPC. Serialized by an asyncio lock (sharkd serves one
|
||||
@@ -425,8 +421,7 @@ class _SharkdManager:
|
||||
async def _acquire(self, pcap):
|
||||
if shutil.which("sharkd") is None:
|
||||
raise SharkdMissingError(
|
||||
"sharkd is not available on this server — marker replay requires sharkd "
|
||||
"(part of the Wireshark package)"
|
||||
"sharkd is not available on this server — marker replay requires sharkd (part of the Wireshark package)"
|
||||
)
|
||||
try:
|
||||
stat = os.stat(pcap)
|
||||
@@ -492,9 +487,12 @@ class _SharkdManager:
|
||||
scratch_dir, scratch = await _prepare_scratch(pcap)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sharkd", "-",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL, env=_engine_env(scratch_dir),
|
||||
"sharkd",
|
||||
"-",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_engine_env(scratch_dir),
|
||||
limit=_STREAM_LIMIT_BYTES,
|
||||
)
|
||||
except OSError as e:
|
||||
@@ -541,6 +539,7 @@ async def close_sessions():
|
||||
# Columns + display filter (sharkd `frames` RPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _columns_for(pcap, filter_expr):
|
||||
"""
|
||||
One resident `frames` pass over the source pcap. Returns
|
||||
@@ -596,6 +595,7 @@ async def _columns_for(pcap, filter_expr):
|
||||
# Tag gate + timeline assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tag_markers(project, tag):
|
||||
"""
|
||||
Every marker entry in the project carrying ``tag`` (flat
|
||||
@@ -606,13 +606,15 @@ def _tag_markers(project, tag):
|
||||
for key, info in project.markers.items():
|
||||
if info.get("tag") == tag:
|
||||
link_id, _, name = key.partition("/")
|
||||
entries.append({
|
||||
"node_id": info["node_id"],
|
||||
"link_id": link_id,
|
||||
"marker": name,
|
||||
"enabled": info.get("enabled", True),
|
||||
"data_link_type": info.get("data_link_type", "DLT_EN10MB"),
|
||||
})
|
||||
entries.append(
|
||||
{
|
||||
"node_id": info["node_id"],
|
||||
"link_id": link_id,
|
||||
"marker": name,
|
||||
"enabled": info.get("enabled", True),
|
||||
"data_link_type": info.get("data_link_type", "DLT_EN10MB"),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
@@ -659,13 +661,12 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
|
||||
merged = []
|
||||
sources = []
|
||||
for entry in entries:
|
||||
pcap = os.path.join(
|
||||
markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap"
|
||||
)
|
||||
pcap = os.path.join(markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap")
|
||||
frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else []
|
||||
# Inventory first: every source, engine-free totals.
|
||||
sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
|
||||
"count": len(frames)})
|
||||
sources.append(
|
||||
{**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, "count": len(frames)}
|
||||
)
|
||||
if link_id and entry["link_id"] != link_id:
|
||||
continue # link narrows the stream before any engine work
|
||||
if frames:
|
||||
@@ -677,22 +678,24 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
|
||||
if filter_expr is not None and frame_number not in columns:
|
||||
continue
|
||||
cols = columns.get(frame_number, {})
|
||||
merged.append({
|
||||
"ts": _format_ts(sec, usec),
|
||||
"ts_us": sec * 1_000_000 + usec,
|
||||
"_source": source_key,
|
||||
"len": incl_len,
|
||||
"node_id": entry["node_id"],
|
||||
"link_id": entry["link_id"],
|
||||
"marker": entry["marker"],
|
||||
"frame_number": frame_number,
|
||||
"src": cols.get("src"),
|
||||
"dst": cols.get("dst"),
|
||||
"proto": cols.get("proto"),
|
||||
"info": cols.get("info"),
|
||||
"bg": cols.get("bg"),
|
||||
"fg": cols.get("fg"),
|
||||
})
|
||||
merged.append(
|
||||
{
|
||||
"ts": _format_ts(sec, usec),
|
||||
"ts_us": sec * 1_000_000 + usec,
|
||||
"_source": source_key,
|
||||
"len": incl_len,
|
||||
"node_id": entry["node_id"],
|
||||
"link_id": entry["link_id"],
|
||||
"marker": entry["marker"],
|
||||
"frame_number": frame_number,
|
||||
"src": cols.get("src"),
|
||||
"dst": cols.get("dst"),
|
||||
"proto": cols.get("proto"),
|
||||
"info": cols.get("info"),
|
||||
"bg": cols.get("bg"),
|
||||
"fg": cols.get("fg"),
|
||||
}
|
||||
)
|
||||
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
|
||||
for frame in merged:
|
||||
del frame["ts_us"]
|
||||
@@ -702,9 +705,7 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
|
||||
|
||||
def _validate_filter(filter_expr):
|
||||
if filter_expr is not None and len(filter_expr) > FILTER_MAX_LENGTH:
|
||||
raise ControllerBadRequestError(
|
||||
f"Display filter too long (max {FILTER_MAX_LENGTH} characters)"
|
||||
)
|
||||
raise ControllerBadRequestError(f"Display filter too long (max {FILTER_MAX_LENGTH} characters)")
|
||||
|
||||
|
||||
async def build_timeline(project, tag, filter_expr=None, link_id=None):
|
||||
@@ -746,13 +747,14 @@ async def query_frames(project, tag, ts, window_ms=100, limit=1000, filter_expr=
|
||||
start_us = _parse_ts(ts)
|
||||
end_us = start_us + max(window_ms, 0) * 1000
|
||||
hits = [f for f in frames if start_us <= _parse_ts(f["ts"]) <= end_us]
|
||||
return {"frames": hits[:max(limit, 0)]}
|
||||
return {"frames": hits[: max(limit, 0)]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame detail (lazy — one frame per call, via the resident session)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=None):
|
||||
"""
|
||||
Decode exactly one frame: locate its pcap by source identity, verify the
|
||||
@@ -770,29 +772,23 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
|
||||
|
||||
entries = gate_tag(project, tag)
|
||||
entry = next(
|
||||
(e for e in entries
|
||||
if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker),
|
||||
(e for e in entries if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
raise ControllerNotFoundError(
|
||||
f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}"
|
||||
)
|
||||
raise ControllerNotFoundError(f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}")
|
||||
|
||||
pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap")
|
||||
if not os.path.exists(pcap):
|
||||
raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)")
|
||||
|
||||
frames = scan_pcap_frames(pcap)
|
||||
rebuilt_message = (
|
||||
f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
|
||||
)
|
||||
rebuilt_message = f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
|
||||
if frame_number is None:
|
||||
# The ts must be the exact string the timeline returned; find the frame
|
||||
# it identifies rather than trusting any position hint from the client.
|
||||
frame_number = next(
|
||||
(i for i, (sec, usec, _len) in enumerate(frames, start=1)
|
||||
if _format_ts(sec, usec) == ts),
|
||||
(i for i, (sec, usec, _len) in enumerate(frames, start=1) if _format_ts(sec, usec) == ts),
|
||||
None,
|
||||
)
|
||||
if frame_number is None:
|
||||
@@ -820,8 +816,7 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
|
||||
tree = _rename_value(result.get("tree", []))
|
||||
return {
|
||||
"ts": ts,
|
||||
"source": {"node_id": node_id, "link_id": link_id, "marker": marker,
|
||||
"frame_number": frame_number},
|
||||
"source": {"node_id": node_id, "link_id": link_id, "marker": marker, "frame_number": frame_number},
|
||||
"field_count": _count_tree_nodes(tree),
|
||||
"hex": raw_hex,
|
||||
"tree": tree,
|
||||
|
||||
@@ -21,12 +21,7 @@ import copy
|
||||
import uuid
|
||||
import os
|
||||
|
||||
from .controller_error import (
|
||||
ControllerError,
|
||||
ControllerTimeoutError,
|
||||
ComputeError,
|
||||
ComputeConflictError
|
||||
)
|
||||
from .controller_error import ControllerError, ControllerTimeoutError, ComputeError, ComputeConflictError
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory
|
||||
from ..utils.images import images_directories
|
||||
@@ -540,7 +535,10 @@ class Node:
|
||||
raise
|
||||
log.warning(
|
||||
"Could not provide missing image '%s' for node '%s' [%s]: %s",
|
||||
last_missing_image, self._name, self._id, upload_error
|
||||
last_missing_image,
|
||||
self._name,
|
||||
self._id,
|
||||
upload_error,
|
||||
)
|
||||
if not res:
|
||||
if allow_missing_image:
|
||||
@@ -554,7 +552,9 @@ class Node:
|
||||
self._missing_images = missing_images
|
||||
log.warning(
|
||||
"Node '%s' [%s] is kept in degraded state, missing image(s): %s",
|
||||
self._name, self._id, ", ".join(m["image"] for m in self._missing_images)
|
||||
self._name,
|
||||
self._id,
|
||||
", ".join(m["image"] for m in self._missing_images),
|
||||
)
|
||||
return False
|
||||
raise e
|
||||
@@ -571,7 +571,9 @@ class Node:
|
||||
self._missing_images = self._compute_missing_images(last_missing_image)
|
||||
log.warning(
|
||||
"Node '%s' [%s] could not be created, missing image(s): %s",
|
||||
self._name, self._id, ", ".join(m["image"] for m in self._missing_images)
|
||||
self._name,
|
||||
self._id,
|
||||
", ".join(m["image"] for m in self._missing_images),
|
||||
)
|
||||
elif last_missing_error is not None:
|
||||
# Uploading appeared to succeed, but the compute rejected every
|
||||
@@ -655,18 +657,13 @@ class Node:
|
||||
if prop is None:
|
||||
fallback_basename = os.path.basename(fallback_image)
|
||||
prop = next(
|
||||
(
|
||||
p
|
||||
for p in mapping
|
||||
if properties.get(p) and os.path.basename(properties[p]) == fallback_basename
|
||||
),
|
||||
(p for p in mapping if properties.get(p) and os.path.basename(properties[p]) == fallback_basename),
|
||||
None,
|
||||
)
|
||||
if prop is None and mapping:
|
||||
prop = next(iter(mapping))
|
||||
already_reported = any(
|
||||
m["image"] == fallback_image or (prop is not None and m["property"] == prop)
|
||||
for m in missing
|
||||
m["image"] == fallback_image or (prop is not None and m["property"] == prop) for m in missing
|
||||
)
|
||||
if not already_reported:
|
||||
image_type = mapping.get(prop, self._node_type)
|
||||
@@ -722,11 +719,7 @@ class Node:
|
||||
if prop == "properties":
|
||||
compute_properties = copy.deepcopy(kwargs[prop])
|
||||
else:
|
||||
if (
|
||||
prop == "name"
|
||||
and self.status == "started"
|
||||
and self._node_type not in BUILTIN_NODE_TYPES
|
||||
):
|
||||
if prop == "name" and self.status == "started" and self._node_type not in BUILTIN_NODE_TYPES:
|
||||
raise ControllerError("Sorry, it is not possible to rename a node that is already powered on")
|
||||
setattr(self, prop, kwargs[prop])
|
||||
|
||||
@@ -808,7 +801,7 @@ class Node:
|
||||
"startup_config_content",
|
||||
"private_config_content",
|
||||
"startup_script",
|
||||
"custom_adapters"
|
||||
"custom_adapters",
|
||||
]:
|
||||
if key in self._properties:
|
||||
del self._properties[key]
|
||||
@@ -866,10 +859,17 @@ class Node:
|
||||
# Image path fields for various node types
|
||||
image_path_fields = {
|
||||
# Common fields (IOU, Docker, etc.)
|
||||
"path", "image",
|
||||
"path",
|
||||
"image",
|
||||
# QEMU-specific fields
|
||||
"hda_disk_image", "hdb_disk_image", "hdc_disk_image", "hdd_disk_image",
|
||||
"cdrom_image", "bios_image", "initrd", "kernel_image",
|
||||
"hda_disk_image",
|
||||
"hdb_disk_image",
|
||||
"hdc_disk_image",
|
||||
"hdd_disk_image",
|
||||
"cdrom_image",
|
||||
"bios_image",
|
||||
"initrd",
|
||||
"kernel_image",
|
||||
# VMware-specific fields
|
||||
"vmx_path",
|
||||
}
|
||||
@@ -931,7 +931,9 @@ class Node:
|
||||
if self.node_type == "iou":
|
||||
license_check = self._project.controller.iou_license.get("license_check", True)
|
||||
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
|
||||
await self.post("/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content})
|
||||
await self.post(
|
||||
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
|
||||
)
|
||||
else:
|
||||
await self.post("/start", data=data, timeout=240)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -1046,9 +1048,7 @@ class Node:
|
||||
self.project.emit_notification("log.info", {"message": f"Uploading missing image {img}"})
|
||||
try:
|
||||
with open(image, "rb") as f:
|
||||
await self._compute.post(
|
||||
f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None
|
||||
)
|
||||
await self._compute.post(f"/{self._node_type}/images/{os.path.basename(img)}", data=f, timeout=None)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Can't upload {image}: {str(e)}")
|
||||
self.project.emit_notification("log.info", {"message": f"Upload finished for {img}"})
|
||||
@@ -1094,23 +1094,23 @@ class Node:
|
||||
# compute to pull it from the Docker repository as a fallback
|
||||
self.project.emit_notification(
|
||||
"log.info",
|
||||
{"message": f"Docker image '{image}' is not on the controller host, "
|
||||
f"asking compute '{self._compute.name}' to pull it"}
|
||||
{
|
||||
"message": f"Docker image '{image}' is not on the controller host, "
|
||||
f"asking compute '{self._compute.name}' to pull it"
|
||||
},
|
||||
)
|
||||
await self._compute.post("/docker/images/pull", data={"image": image}, timeout=None)
|
||||
return True
|
||||
|
||||
self.project.emit_notification(
|
||||
"log.info",
|
||||
{"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
|
||||
"log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
|
||||
)
|
||||
try:
|
||||
await self._compute.post("/docker/images/load", data=response.content, timeout=None)
|
||||
finally:
|
||||
response.close()
|
||||
self.project.emit_notification(
|
||||
"log.info",
|
||||
{"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"}
|
||||
"log.info", {"message": f"Docker image '{image}' has been synced to compute '{self._compute.name}'"}
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -1201,7 +1201,9 @@ class Node:
|
||||
port_name = custom_adapter_settings.get("port_name", port_name)
|
||||
mac_address = custom_adapter_settings.get("mac_address")
|
||||
if not mac_address and "mac_address" in self._properties:
|
||||
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
|
||||
mac_address = int_to_macaddress(
|
||||
macaddress_to_int(self._properties["mac_address"]) + adapter_number
|
||||
)
|
||||
|
||||
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
|
||||
port.mac_address = mac_address
|
||||
@@ -1245,33 +1247,33 @@ class Node:
|
||||
"""
|
||||
|
||||
topology = {
|
||||
"compute_id": str(self._compute.id),
|
||||
"node_id": self._id,
|
||||
"node_type": self._node_type,
|
||||
"template_id": self._template_id,
|
||||
"name": self._name,
|
||||
"console": self._console,
|
||||
"console_type": self._console_type,
|
||||
"console_auto_start": self._console_auto_start,
|
||||
"netmiko_device_type": self._netmiko_device_type,
|
||||
"default_username": self._default_username,
|
||||
"default_password": self._default_password,
|
||||
"aux": self._aux,
|
||||
"aux_type": self._aux_type,
|
||||
"properties": self._properties,
|
||||
"label": self._label,
|
||||
"x": self._x,
|
||||
"y": self._y,
|
||||
"z": self._z,
|
||||
"locked": self._locked,
|
||||
"width": self._width,
|
||||
"height": self._height,
|
||||
"symbol": self._symbol,
|
||||
"port_name_format": self._port_name_format,
|
||||
"port_segment_size": self._port_segment_size,
|
||||
"first_port_name": self._first_port_name,
|
||||
"custom_adapters": self._custom_adapters,
|
||||
"tags": self._tags,
|
||||
"compute_id": str(self._compute.id),
|
||||
"node_id": self._id,
|
||||
"node_type": self._node_type,
|
||||
"template_id": self._template_id,
|
||||
"name": self._name,
|
||||
"console": self._console,
|
||||
"console_type": self._console_type,
|
||||
"console_auto_start": self._console_auto_start,
|
||||
"netmiko_device_type": self._netmiko_device_type,
|
||||
"default_username": self._default_username,
|
||||
"default_password": self._default_password,
|
||||
"aux": self._aux,
|
||||
"aux_type": self._aux_type,
|
||||
"properties": self._properties,
|
||||
"label": self._label,
|
||||
"x": self._x,
|
||||
"y": self._y,
|
||||
"z": self._z,
|
||||
"locked": self._locked,
|
||||
"width": self._width,
|
||||
"height": self._height,
|
||||
"symbol": self._symbol,
|
||||
"port_name_format": self._port_name_format,
|
||||
"port_segment_size": self._port_segment_size,
|
||||
"first_port_name": self._first_port_name,
|
||||
"custom_adapters": self._custom_adapters,
|
||||
"tags": self._tags,
|
||||
}
|
||||
|
||||
if topology_dump:
|
||||
|
||||
@@ -21,11 +21,13 @@ Shared node type constants used across the controller.
|
||||
|
||||
# Node types that are always running (builtin/virtual switch nodes).
|
||||
# These nodes do not require explicit start/stop and have limited feature support.
|
||||
BUILTIN_NODE_TYPES = frozenset({
|
||||
"cloud",
|
||||
"nat",
|
||||
"ethernet_switch",
|
||||
"ethernet_hub",
|
||||
"frame_relay_switch",
|
||||
"atm_switch",
|
||||
})
|
||||
BUILTIN_NODE_TYPES = frozenset(
|
||||
{
|
||||
"cloud",
|
||||
"nat",
|
||||
"ethernet_switch",
|
||||
"ethernet_hub",
|
||||
"frame_relay_switch",
|
||||
"atm_switch",
|
||||
}
|
||||
)
|
||||
@@ -18,12 +18,10 @@
|
||||
Ethernet port for Ethernet link end points.
|
||||
"""
|
||||
|
||||
|
||||
from .port import Port
|
||||
|
||||
|
||||
class EthernetPort(Port):
|
||||
|
||||
"""
|
||||
Ethernet port.
|
||||
"""
|
||||
|
||||
@@ -83,7 +83,7 @@ class Port:
|
||||
# If port name format has changed we use the port name as the short name (1.X behavior)
|
||||
if self._short_name:
|
||||
return self._short_name
|
||||
elif self._name and '/' in self._name:
|
||||
elif self._name and "/" in self._name:
|
||||
return self._name.replace(self.long_name_type(), self.short_name_type())
|
||||
elif self._name and self._name.startswith(f"{self.long_name_type()}{self._interface_number}"):
|
||||
return self.short_name_type() + f"{self._interface_number}"
|
||||
|
||||
@@ -66,7 +66,6 @@ class StandardPortFactory:
|
||||
ethernet_adapters = properties.get("adapters", 1)
|
||||
|
||||
for adapter_number in range(adapter_number, ethernet_adapters + adapter_number):
|
||||
|
||||
custom_adapter_settings = {}
|
||||
if custom_adapters:
|
||||
for custom_adapter in custom_adapters:
|
||||
|
||||
@@ -657,9 +657,7 @@ class Project:
|
||||
return node
|
||||
|
||||
@open_required
|
||||
async def add_node(
|
||||
self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs
|
||||
):
|
||||
async def add_node(self, compute, name, node_id, dump=True, node_type=None, allow_missing_image=False, **kwargs):
|
||||
"""
|
||||
Create a node or return an existing node
|
||||
|
||||
@@ -846,10 +844,7 @@ class Project:
|
||||
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
|
||||
)
|
||||
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
|
||||
@@ -865,7 +860,9 @@ class Project:
|
||||
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")
|
||||
name,
|
||||
link_data.get("link_id"),
|
||||
result.get("error"),
|
||||
)
|
||||
continue
|
||||
link._markers[name] = {
|
||||
@@ -935,7 +932,9 @@ class Project:
|
||||
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")
|
||||
name,
|
||||
link_data.get("link_id"),
|
||||
result.get("error"),
|
||||
)
|
||||
continue
|
||||
link._markers[name] = {
|
||||
@@ -996,7 +995,9 @@ class Project:
|
||||
link._deferred = True
|
||||
log.info(
|
||||
"Project '%s' [%s]: deferring link %s until missing image(s) are resolved",
|
||||
self._name, self._id, link.id,
|
||||
self._name,
|
||||
self._id,
|
||||
link.id,
|
||||
)
|
||||
return None
|
||||
# Apply project-level marker definitions onto the link's memory
|
||||
@@ -1151,7 +1152,8 @@ class Project:
|
||||
self._marker_definitions[name]["paused"] = True
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
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(
|
||||
@@ -1170,7 +1172,8 @@ class Project:
|
||||
self._marker_definitions[name]["paused"] = False
|
||||
marker_name = f"global-{name}"
|
||||
affected = [
|
||||
link for link in self._links.values()
|
||||
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(
|
||||
@@ -1199,9 +1202,7 @@ class Project:
|
||||
"""
|
||||
result = validate_bpf_syntax(bpf)
|
||||
if not result.get("valid"):
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}"
|
||||
)
|
||||
raise ControllerError(f"Marker definition '{name}': invalid BPF — {result.get('error', 'unknown error')}")
|
||||
|
||||
def _validate_marker_definition_direction(self, name, direction):
|
||||
"""
|
||||
@@ -1223,7 +1224,9 @@ class Project:
|
||||
"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"):
|
||||
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
|
||||
@@ -1231,26 +1234,32 @@ class Project:
|
||||
"""
|
||||
|
||||
if name in self._marker_definitions:
|
||||
raise ControllerError(
|
||||
f"Marker definition '{name}' already exists in this project"
|
||||
)
|
||||
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}
|
||||
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):
|
||||
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"
|
||||
)
|
||||
raise ControllerNotFoundError(f"Marker definition '{name}' not found in this project")
|
||||
|
||||
d = self._marker_definitions[name]
|
||||
if bpf is not None:
|
||||
@@ -1270,9 +1279,9 @@ class Project:
|
||||
|
||||
# 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
|
||||
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:
|
||||
@@ -1291,9 +1300,15 @@ class Project:
|
||||
for link in affected:
|
||||
try:
|
||||
await 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, memory_only=True
|
||||
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,
|
||||
memory_only=True,
|
||||
)
|
||||
except ControllerError as e:
|
||||
log.warning("Failed to sync marker global-%s on link %s: %s", name, link.id, e)
|
||||
@@ -1307,16 +1322,14 @@ class Project:
|
||||
"""
|
||||
|
||||
if name not in self._marker_definitions:
|
||||
raise ControllerNotFoundError(
|
||||
f"Marker definition '{name}' not found in this project"
|
||||
)
|
||||
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
|
||||
link
|
||||
for link in self._links.values()
|
||||
if f"global-{name}" in link.markers and link.markers[f"global-{name}"].get("inherited_from") == name
|
||||
]
|
||||
for link in affected:
|
||||
try:
|
||||
@@ -1382,9 +1395,7 @@ class Project:
|
||||
)
|
||||
|
||||
if per_compute:
|
||||
await asyncio.gather(
|
||||
*[_dispatch(c, n) for c, n in per_compute.items()]
|
||||
)
|
||||
await asyncio.gather(*[_dispatch(c, n) for c, n in per_compute.items()])
|
||||
|
||||
async def apply_defs_to_new_link(self, link):
|
||||
"""
|
||||
@@ -1403,10 +1414,7 @@ class Project:
|
||||
# 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
|
||||
)
|
||||
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):
|
||||
"""
|
||||
@@ -1428,10 +1436,7 @@ class Project:
|
||||
if not links:
|
||||
return
|
||||
_t0 = time.time()
|
||||
log.info(
|
||||
"Project '%s' [%s]: fanning out marker operation to %d links...",
|
||||
self._name, self._id, len(links)
|
||||
)
|
||||
log.info("Project '%s' [%s]: fanning out marker operation to %d links...", self._name, self._id, len(links))
|
||||
sem = asyncio.Semaphore(32)
|
||||
|
||||
async def guarded(link):
|
||||
@@ -1442,10 +1447,7 @@ class Project:
|
||||
log.warning(fail_msg(link, e))
|
||||
|
||||
await asyncio.gather(*(guarded(link) for link in links))
|
||||
log.info(
|
||||
"Project '%s' [%s]: marker fan-out done in %.2fs",
|
||||
self._name, self._id, time.time() - _t0
|
||||
)
|
||||
log.info("Project '%s' [%s]: marker fan-out done in %.2fs", self._name, self._id, time.time() - _t0)
|
||||
|
||||
@property
|
||||
def snapshots(self):
|
||||
@@ -1512,7 +1514,7 @@ class Project:
|
||||
for snapshot in self._snapshots.values():
|
||||
self._snapshot_conf.append(snapshot.asdict())
|
||||
try:
|
||||
with open(self._snapshot_conf_path, 'w+') as f:
|
||||
with open(self._snapshot_conf_path, "w+") as f:
|
||||
json.dump(self._snapshot_conf, f, indent=4)
|
||||
except OSError as e:
|
||||
log.error("Cannot write snapshot config '{}': {}".format(self._snapshot_conf_path, e))
|
||||
@@ -1874,8 +1876,7 @@ class Project:
|
||||
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")
|
||||
"Dropping marker definition '%s' on load: invalid BPF (%s)", def_name, result.get("error")
|
||||
)
|
||||
continue
|
||||
clean_defs[def_name] = d
|
||||
@@ -1986,9 +1987,7 @@ class Project:
|
||||
)
|
||||
|
||||
if per_compute:
|
||||
await asyncio.gather(
|
||||
*[_dispatch_batch(c, n) for c, n in per_compute.items()]
|
||||
)
|
||||
await asyncio.gather(*[_dispatch_batch(c, n) for c, n in per_compute.items()])
|
||||
|
||||
# Finalise every link: wire node/port back-references, mark created,
|
||||
# notify clients, and apply project-level marker definitions.
|
||||
@@ -2096,12 +2095,7 @@ class Project:
|
||||
# Do not compress the exported project when duplicating
|
||||
with aiozipstream.ZipFile(compression=zipfile.ZIP_STORED) as zstream:
|
||||
await export_project(
|
||||
zstream,
|
||||
self,
|
||||
tmpdir,
|
||||
keep_compute_ids=True,
|
||||
include_snapshots=True,
|
||||
allow_all_nodes=True
|
||||
zstream, self, tmpdir, keep_compute_ids=True, include_snapshots=True, allow_all_nodes=True
|
||||
)
|
||||
|
||||
# export the project to a temporary location
|
||||
@@ -2120,7 +2114,7 @@ class Project:
|
||||
f,
|
||||
name=name,
|
||||
reset_mac_addresses=reset_mac_addresses,
|
||||
keep_compute_ids=True
|
||||
keep_compute_ids=True,
|
||||
)
|
||||
|
||||
log.info(f"Project '{project.name}' duplicated in {time.time() - begin:.4f} seconds")
|
||||
@@ -2161,8 +2155,12 @@ class Project:
|
||||
else:
|
||||
new_project_path = p_work.joinpath(new_project_id)
|
||||
# copy dir
|
||||
await wait_run_in_executor(shutil.copytree, self.path, new_project_path.as_posix(), symlinks=True, ignore_dangling_symlinks=True)
|
||||
log.info("Project content copied from '{}' to '{}' in {}s".format(self.path, new_project_path, time.time() - t0))
|
||||
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)
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -2181,7 +2179,7 @@ class Project:
|
||||
regenerate_topology_ids(topology, new_project_path, reset_mac_addresses)
|
||||
|
||||
# dump the updated .gns3 project file
|
||||
dot_gns3_path = new_project_path.joinpath('{}.gns3'.format(project_name))
|
||||
dot_gns3_path = new_project_path.joinpath("{}.gns3".format(project_name))
|
||||
topology["project_id"] = new_project_id
|
||||
with open(dot_gns3_path, "w+") as f:
|
||||
json.dump(topology, f, indent=4, sort_keys=True)
|
||||
@@ -2359,13 +2357,7 @@ class Project:
|
||||
data["z"] = z
|
||||
data["locked"] = False # duplicated node must not be locked
|
||||
new_node_uuid = str(uuid.uuid4())
|
||||
new_node = await self.add_node(
|
||||
node.compute,
|
||||
node.name,
|
||||
new_node_uuid,
|
||||
node_type=node_type,
|
||||
**data
|
||||
)
|
||||
new_node = await self.add_node(node.compute, node.name, new_node_uuid, node_type=node_type, **data)
|
||||
try:
|
||||
await node.post("/duplicate", timeout=None, data={"destination_node_id": new_node_uuid})
|
||||
except ControllerNotFoundError:
|
||||
|
||||
@@ -42,6 +42,7 @@ FILENAME_DATETIME_FORMAT = "%d%m%y_%H%M%S"
|
||||
# Used to create a description of the snapshot with a human-readable date and time
|
||||
DESCRIPTION_DATETIME_FORMAT = "%Y-%m-%d at %H:%M:%S"
|
||||
|
||||
|
||||
class Snapshot:
|
||||
"""
|
||||
A snapshot object
|
||||
@@ -69,10 +70,16 @@ class Snapshot:
|
||||
else:
|
||||
self._name = filename.rsplit("_", 2)[0]
|
||||
datestring = filename.replace(self._name + "_", "").split(".")[0]
|
||||
self._created_at = int(datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp())
|
||||
self._created_at = int(
|
||||
datetime.strptime(datestring, FILENAME_DATETIME_FORMAT).replace(tzinfo=timezone.utc).timestamp()
|
||||
)
|
||||
|
||||
if not description:
|
||||
date = datetime.fromtimestamp(self._created_at, tz=timezone.utc).replace(tzinfo=None).strftime(DESCRIPTION_DATETIME_FORMAT)
|
||||
date = (
|
||||
datetime.fromtimestamp(self._created_at, tz=timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
.strftime(DESCRIPTION_DATETIME_FORMAT)
|
||||
)
|
||||
description = "Snapshot '{}' taken on {}".format(self._name, date)
|
||||
|
||||
self._description = description
|
||||
@@ -149,7 +156,7 @@ class Snapshot:
|
||||
location=self._project.path,
|
||||
auto_start=self._project.auto_start,
|
||||
auto_open=self._project.auto_open,
|
||||
auto_close=self._project.auto_close
|
||||
auto_close=self._project.auto_close,
|
||||
)
|
||||
log.info("Snapshot '{}' restored in {:.4f} seconds".format(self.name, time.time() - begin))
|
||||
except (OSError, PermissionError) as e:
|
||||
@@ -165,5 +172,5 @@ class Snapshot:
|
||||
"created_at": self._created_at,
|
||||
"description": self._description,
|
||||
"filename": self._filename,
|
||||
"project_id": self._project.id
|
||||
"project_id": self._project.id,
|
||||
}
|
||||
@@ -35,7 +35,12 @@ from .drawing import Drawing
|
||||
from .node import Node
|
||||
from .link import Link
|
||||
|
||||
from gns3server.utils.hostname import is_ios_hostname_valid, is_rfc1123_hostname_valid, to_rfc1123_hostname, to_ios_hostname
|
||||
from gns3server.utils.hostname import (
|
||||
is_ios_hostname_valid,
|
||||
is_rfc1123_hostname_valid,
|
||||
to_rfc1123_hostname,
|
||||
to_ios_hostname,
|
||||
)
|
||||
from gns3server.schemas.controller.topology import Topology
|
||||
from gns3server.schemas.compute.dynamips_nodes import DynamipsCreate
|
||||
|
||||
@@ -227,7 +232,7 @@ def _convert_2_2_0(topo, topo_path):
|
||||
new_name = to_rfc1123_hostname(node["name"])
|
||||
log.info(f"Convert node name {node['name']} to {new_name} (RFC1123)")
|
||||
node["name"] = new_name
|
||||
if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"] ):
|
||||
if node["node_type"] in ("dynamips", "iou") and not is_ios_hostname_valid(node["name"]):
|
||||
new_name = to_ios_hostname(node["name"])
|
||||
log.info(f"Convert node name {node['name']} to {new_name} (IOS)")
|
||||
node["name"] = new_name
|
||||
|
||||
@@ -29,9 +29,17 @@ from gns3server.utils.packet_filter_validation import validate_bpf_syntax, Filte
|
||||
# (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no
|
||||
# uBridge of its own). "ethernet_switch" hosts markers on the per-port
|
||||
# uBridge relays of its brctl kernel-bridge backend.
|
||||
_MARKER_CAPABLE_TYPES = frozenset({
|
||||
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch",
|
||||
})
|
||||
_MARKER_CAPABLE_TYPES = frozenset(
|
||||
{
|
||||
"vpcs",
|
||||
"qemu",
|
||||
"docker",
|
||||
"iou",
|
||||
"dynamips",
|
||||
"cloud",
|
||||
"ethernet_switch",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -58,7 +66,7 @@ class UDPLink(Link):
|
||||
def deferred(self):
|
||||
"""Whether NIO creation is waiting for missing node images."""
|
||||
return self._deferred
|
||||
|
||||
|
||||
def _get_node_filters(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
@@ -81,10 +89,14 @@ class UDPLink(Link):
|
||||
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)}
|
||||
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
|
||||
}
|
||||
@@ -183,33 +195,24 @@ class UDPLink(Link):
|
||||
tuples returned by :meth:`_prepare`.
|
||||
"""
|
||||
|
||||
(node1, adapter_number1, port_number1, nio_data1), \
|
||||
(node2, adapter_number2, port_number2, nio_data2) = entries
|
||||
(node1, adapter_number1, port_number1, nio_data1), (node2, adapter_number2, port_number2, nio_data2) = entries
|
||||
|
||||
# The two ends are independent once the ports and peer addresses are
|
||||
# known — each node talks to its own compute/uBridge with no shared
|
||||
# lock between them — so the two POSTs overlap. If either fails, roll
|
||||
# back whichever side succeeded before re-raising the first error.
|
||||
results = await asyncio.gather(
|
||||
node1.post(
|
||||
f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120
|
||||
),
|
||||
node2.post(
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120
|
||||
),
|
||||
node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120),
|
||||
node2.post(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120),
|
||||
return_exceptions=True,
|
||||
)
|
||||
errors = [result for result in results if isinstance(result, Exception)]
|
||||
if errors:
|
||||
cleanup = []
|
||||
if not isinstance(results[0], Exception):
|
||||
cleanup.append(
|
||||
node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
|
||||
)
|
||||
cleanup.append(node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120))
|
||||
if not isinstance(results[1], Exception):
|
||||
cleanup.append(
|
||||
node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120)
|
||||
)
|
||||
cleanup.append(node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120))
|
||||
if cleanup:
|
||||
await asyncio.gather(*cleanup, return_exceptions=True)
|
||||
raise errors[0]
|
||||
@@ -320,7 +323,9 @@ class UDPLink(Link):
|
||||
),
|
||||
data=data,
|
||||
)
|
||||
await super().start_capture(data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token)
|
||||
await super().start_capture(
|
||||
data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token
|
||||
)
|
||||
|
||||
async def stop_capture(self):
|
||||
"""
|
||||
@@ -378,10 +383,7 @@ class UDPLink(Link):
|
||||
|
||||
# Prefer started.
|
||||
for node in self._nodes:
|
||||
if (
|
||||
node["node"].node_type in _MARKER_CAPABLE_TYPES
|
||||
and node["node"].status == "started"
|
||||
):
|
||||
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).
|
||||
@@ -389,10 +391,7 @@ class UDPLink(Link):
|
||||
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"
|
||||
)
|
||||
raise ControllerError("Cannot add marker because no device on this link supports traffic insight")
|
||||
|
||||
def _node_by_id(self, node_id):
|
||||
"""
|
||||
@@ -415,9 +414,7 @@ class UDPLink(Link):
|
||||
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}"
|
||||
)
|
||||
raise ControllerNotFoundError(f"Node {node_id} is not an endpoint of link {self._id}")
|
||||
|
||||
async def node_updated(self, node):
|
||||
"""
|
||||
@@ -431,7 +428,21 @@ class UDPLink(Link):
|
||||
# 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, memory_only=False):
|
||||
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,
|
||||
memory_only=False,
|
||||
):
|
||||
"""
|
||||
Attach a traffic-insight marker to this link.
|
||||
|
||||
@@ -571,7 +582,19 @@ class UDPLink(Link):
|
||||
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, memory_only=False):
|
||||
async def update_marker(
|
||||
self,
|
||||
name,
|
||||
bpf=None,
|
||||
tag=None,
|
||||
enabled=None,
|
||||
direction=_UNSET,
|
||||
color=None,
|
||||
highlight_duration=None,
|
||||
inherited=False,
|
||||
dump=True,
|
||||
memory_only=False,
|
||||
):
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user