mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge branch '3.0' into fix/telnet-server-connection-race-condition-3.0
This commit is contained in:
commit
08b579ff83
@ -154,6 +154,17 @@ async def upload_image(
|
||||
if os.path.commonprefix([base_images_directory, full_path]) != base_images_directory:
|
||||
raise ControllerForbiddenError(f"Cannot write image, '{image_path}' is forbidden")
|
||||
|
||||
# If the client sends X-MD5-Checksum, check for a duplicate before consuming the upload stream
|
||||
checksum_header = request.headers.get("X-MD5-Checksum")
|
||||
if checksum_header:
|
||||
check_dir = os.path.dirname(full_path) if image_dir else None
|
||||
duplicate = await images_repo.get_image_by_checksum(checksum_header, check_dir)
|
||||
if duplicate:
|
||||
location = f" in '{check_dir}'" if check_dir else ""
|
||||
raise ControllerError(
|
||||
f"Image '{duplicate.filename}' with the same checksum already exists{location}"
|
||||
)
|
||||
|
||||
try:
|
||||
allow_raw_image = Config.instance().settings.Server.allow_raw_images
|
||||
image = await write_image(image_path, full_path, request.stream(), images_repo, allow_raw_image=allow_raw_image)
|
||||
|
||||
@ -105,6 +105,16 @@ async def dep_node(node_id: UUID, project: Project = Depends(dep_project)) -> No
|
||||
return node
|
||||
|
||||
|
||||
def _check_node_type(node: Node, *required_types: str) -> None:
|
||||
"""
|
||||
Raise ControllerBadRequestError if node is not one of the required types.
|
||||
"""
|
||||
|
||||
if node.node_type not in required_types:
|
||||
type_str = "/".join(required_types)
|
||||
raise ControllerBadRequestError(f"This endpoint is only supported on a {type_str} node")
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
@ -407,8 +417,7 @@ async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
|
||||
Required privilege: Node.Audit
|
||||
"""
|
||||
|
||||
if node.node_type != "dynamips":
|
||||
raise ControllerBadRequestError("Auto Idle-PC is only supported on a Dynamips node")
|
||||
_check_node_type(node, "dynamips")
|
||||
return await node.dynamips_auto_idlepc()
|
||||
|
||||
|
||||
@ -420,8 +429,7 @@ async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]:
|
||||
Required privilege: Node.Audit
|
||||
"""
|
||||
|
||||
if node.node_type != "dynamips":
|
||||
raise ControllerBadRequestError("Idle-PC proposals is only supported on a Dynamips node")
|
||||
_check_node_type(node, "dynamips")
|
||||
return await node.dynamips_idlepc_proposals()
|
||||
|
||||
|
||||
@ -441,8 +449,7 @@ async def create_disk_image(
|
||||
Required privilege: Node.Allocate
|
||||
"""
|
||||
|
||||
if node.node_type != "qemu":
|
||||
raise ControllerBadRequestError("Creating a disk image is only supported on a Qemu node")
|
||||
_check_node_type(node, "qemu")
|
||||
await node.post(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@ -462,8 +469,7 @@ async def update_disk_image(
|
||||
Required privilege: Node.Allocate
|
||||
"""
|
||||
|
||||
if node.node_type != "qemu":
|
||||
raise ControllerBadRequestError("Updating a disk image is only supported on a Qemu node")
|
||||
_check_node_type(node, "qemu")
|
||||
await node.put(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@ -482,8 +488,7 @@ async def delete_disk_image(
|
||||
Required privilege: Node.Allocate
|
||||
"""
|
||||
|
||||
if node.node_type != "qemu":
|
||||
raise ControllerBadRequestError("Deleting a disk image is only supported on a Qemu node")
|
||||
_check_node_type(node, "qemu")
|
||||
await node.delete(f"/disk_image/{disk_name}")
|
||||
|
||||
|
||||
|
||||
@ -207,7 +207,7 @@ async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
log.error(f"Request validation error in {request.url.path} ({request.method}): {exc}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={"message": str(exc)}
|
||||
)
|
||||
|
||||
|
||||
@ -289,7 +289,7 @@ def _export_local_image(image, zstream):
|
||||
# Some modules don't have images
|
||||
continue
|
||||
|
||||
directory = os.path.split(images_directory)[-1:][0]
|
||||
directory = os.path.basename(images_directory)
|
||||
if os.path.exists(image):
|
||||
path = image
|
||||
else:
|
||||
@ -309,7 +309,7 @@ async def _export_remote_images(project, compute_id, image_type, image, project_
|
||||
|
||||
log.debug(f"Downloading image '{image}' from compute '{compute_id}'")
|
||||
try:
|
||||
compute = [compute for compute in project.computes if compute.id == compute_id][0]
|
||||
compute = next(c for c in project.computes if c.id == compute_id)
|
||||
except IndexError:
|
||||
raise ControllerNotFoundError(f"Cannot export image from '{compute_id}' compute. Compute doesn't exist.")
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ from .controller_error import (
|
||||
ComputeError,
|
||||
ComputeConflictError
|
||||
)
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory
|
||||
from ..utils.images import images_directories
|
||||
from ..utils import macaddress_to_int, int_to_macaddress
|
||||
@ -452,8 +453,7 @@ class Node:
|
||||
if (
|
||||
prop == "name"
|
||||
and self.status == "started"
|
||||
and self._node_type
|
||||
not in ("cloud", "nat", "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch")
|
||||
and self._node_type not in BUILTIN_NODE_TYPES
|
||||
):
|
||||
raise ControllerError("Sorry, it is not possible to rename a node that is already powered on")
|
||||
setattr(self, prop, kwargs[prop])
|
||||
@ -541,35 +541,22 @@ class Node:
|
||||
if self._console:
|
||||
# console is optional for builtin nodes
|
||||
data["console"] = self._console
|
||||
if self._console_type and self._node_type not in (
|
||||
"cloud",
|
||||
"nat",
|
||||
"ethernet_hub",
|
||||
"frame_relay_switch",
|
||||
"atm_switch",
|
||||
):
|
||||
if self._console_type and self._node_type not in (BUILTIN_NODE_TYPES - {"ethernet_switch"}):
|
||||
# console_type is not supported by all builtin nodes excepting Ethernet switch
|
||||
data["console_type"] = self._console_type
|
||||
if self._aux:
|
||||
# aux is optional for builtin nodes
|
||||
data["aux"] = self._aux
|
||||
if self._aux_type and self._node_type not in (
|
||||
"cloud",
|
||||
"nat",
|
||||
"ethernet_switch",
|
||||
"ethernet_hub",
|
||||
"frame_relay_switch",
|
||||
"atm_switch",
|
||||
):
|
||||
if self._aux_type and self._node_type not in BUILTIN_NODE_TYPES:
|
||||
# aux_type is not supported by all builtin nodes
|
||||
data["aux_type"] = self._aux_type
|
||||
if self.custom_adapters:
|
||||
data["custom_adapters"] = self.custom_adapters
|
||||
|
||||
# None properties are not be sent because it can mean the emulator doesn't support it
|
||||
for key in list(data.keys()):
|
||||
if data[key] is None or data[key] == {} or key in self.CONTROLLER_ONLY_PROPERTIES:
|
||||
del data[key]
|
||||
for key, value in list(data.items()):
|
||||
if value is None or value == {} or key in self.CONTROLLER_ONLY_PROPERTIES:
|
||||
del value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
31
gns3server/controller/node_types.py
Normal file
31
gns3server/controller/node_types.py
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
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",
|
||||
})
|
||||
@ -175,7 +175,8 @@ def load_topology(path):
|
||||
# Version GNS3 2.2 dev (for project created with 2.2dev).
|
||||
# Appliance ID has been replaced by Template ID
|
||||
if topo["revision"] == 9:
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
if "appliance_id" in node:
|
||||
node["template_id"] = node["appliance_id"]
|
||||
del node["appliance_id"]
|
||||
@ -218,7 +219,8 @@ def _convert_2_2_0(topo, topo_path):
|
||||
|
||||
topo["revision"] = 10
|
||||
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
if "properties" in node:
|
||||
if node["node_type"] in ("qemu", "docker") and not is_rfc1123_hostname_valid(node["name"]):
|
||||
new_name = to_rfc1123_hostname(node["name"])
|
||||
@ -246,7 +248,8 @@ def _convert_2_1_0(topo, topo_path):
|
||||
# to avoid overlapping grids
|
||||
topo["drawing_grid_size"] = topo["grid_size"]
|
||||
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
# make sure console_type is not None but "none" string
|
||||
if "console_type" in node and node["console_type"] is None:
|
||||
node["console_type"] = "none"
|
||||
@ -272,7 +275,8 @@ def _convert_2_0_0(topo, topo_path):
|
||||
"""
|
||||
topo["revision"] = 8
|
||||
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
if "properties" in node:
|
||||
if node["node_type"] == "vpcs":
|
||||
if "startup_script_path" in node["properties"]:
|
||||
@ -301,7 +305,8 @@ def _convert_2_0_0_beta_2(topo, topo_path):
|
||||
topo_dir = os.path.dirname(topo_path)
|
||||
topo["revision"] = 7
|
||||
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
if node["node_type"] == "dynamips":
|
||||
node_id = node["node_id"]
|
||||
dynamips_id = node["properties"]["dynamips_id"]
|
||||
@ -328,7 +333,8 @@ def _convert_2_0_0_alpha(topo, topo_path):
|
||||
* No more option for VMware / VirtualBox remote console (always use telnet)
|
||||
"""
|
||||
topo["revision"] = 6
|
||||
for node in topo.get("topology", {}).get("nodes", []):
|
||||
nodes = topo.get("topology", {}).get("nodes", [])
|
||||
for node in nodes:
|
||||
if node.get("console_type") == "serial":
|
||||
node["console_type"] = "telnet"
|
||||
if node["node_type"] in ("vmware", "virtualbox"):
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import Link
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
|
||||
|
||||
class UDPLink(Link):
|
||||
@ -32,6 +33,18 @@ class UDPLink(Link):
|
||||
Use for the debug exports
|
||||
"""
|
||||
return self._link_data
|
||||
|
||||
def _get_node_filters(self, node1, node2):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
|
||||
:returns: Tuple of (node1_filters, node2_filters)
|
||||
"""
|
||||
filter_node = self._get_filter_node()
|
||||
return (
|
||||
self.get_active_filters() if filter_node == node1 else {},
|
||||
self.get_active_filters() if filter_node == node2 else {},
|
||||
)
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
@ -57,13 +70,7 @@ class UDPLink(Link):
|
||||
response = await node2.compute.post(f"/projects/{self._project.id}/ports/udp")
|
||||
self._node2_port = response.json["udp_port"]
|
||||
|
||||
node1_filters = {}
|
||||
node2_filters = {}
|
||||
filter_node = self._get_filter_node()
|
||||
if filter_node == node1:
|
||||
node1_filters = self.get_active_filters()
|
||||
elif filter_node == node2:
|
||||
node2_filters = self.get_active_filters()
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
|
||||
# Create the tunnel on both side
|
||||
self._link_data.append(
|
||||
@ -108,13 +115,7 @@ class UDPLink(Link):
|
||||
node1 = self._nodes[0]["node"]
|
||||
node2 = self._nodes[1]["node"]
|
||||
|
||||
node1_filters = {}
|
||||
node2_filters = {}
|
||||
filter_node = self._get_filter_node()
|
||||
if filter_node == node1:
|
||||
node1_filters = self.get_active_filters()
|
||||
elif filter_node == node2:
|
||||
node2_filters = self.get_active_filters()
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
|
||||
adapter_number1 = self._nodes[0]["adapter_number"]
|
||||
port_number1 = self._nodes[0]["port_number"]
|
||||
@ -213,18 +214,16 @@ class UDPLink(Link):
|
||||
:returns: Node where the capture should run
|
||||
"""
|
||||
|
||||
ALWAYS_RUNNING_NODES_TYPE = ("cloud", "nat", "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch")
|
||||
|
||||
for node in self._nodes:
|
||||
if (
|
||||
node["node"].compute.id == "local"
|
||||
and node["node"].node_type in ALWAYS_RUNNING_NODES_TYPE
|
||||
and node["node"].node_type in BUILTIN_NODE_TYPES
|
||||
and node["node"].status == "started"
|
||||
):
|
||||
return node
|
||||
|
||||
for node in self._nodes:
|
||||
if node["node"].node_type in ALWAYS_RUNNING_NODES_TYPE and node["node"].status == "started":
|
||||
if node["node"].node_type in BUILTIN_NODE_TYPES and node["node"].status == "started":
|
||||
return node
|
||||
|
||||
for node in self._nodes:
|
||||
|
||||
@ -297,7 +297,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
node_id=ethernet_switch["node_id"]),
|
||||
json=port_params
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
async def test_ethernet_switch_delete(
|
||||
|
||||
@ -133,7 +133,7 @@ class TestTemplateRoutes:
|
||||
"template_type": "invalid_template_type"}
|
||||
|
||||
response = await client.post(app.url_path_for("create_template"), json=params)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
async def test_template_update(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
|
||||
@ -75,10 +75,10 @@ class TestUserRoutes:
|
||||
(
|
||||
("email", "user2@email.com", status.HTTP_400_BAD_REQUEST),
|
||||
("username", "user2", status.HTTP_400_BAD_REQUEST),
|
||||
("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("password", "short", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("username", "ab", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
)
|
||||
)
|
||||
async def test_user_registration_fails_when_credentials_are_taken(
|
||||
@ -101,10 +101,10 @@ class TestUserRoutes:
|
||||
("email", "user@email.com", status.HTTP_200_OK),
|
||||
("email", "user@email.com", status.HTTP_400_BAD_REQUEST),
|
||||
("username", "user2", status.HTTP_400_BAD_REQUEST),
|
||||
("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("password", "short", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("username", "ab", status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("email", "invalid_email@one@two.io", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("password", "short", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("username", "user2@#$%^<>", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("username", "ab", status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
("full_name", "John Doe", status.HTTP_200_OK),
|
||||
("password", "password123", status.HTTP_200_OK),
|
||||
("is_active", True, status.HTTP_200_OK),
|
||||
@ -259,7 +259,7 @@ class TestUserLogin:
|
||||
(
|
||||
("wrong_username", "user1_password", status.HTTP_401_UNAUTHORIZED),
|
||||
("user1", "wrong_password", status.HTTP_401_UNAUTHORIZED),
|
||||
("user1", None, status.HTTP_422_UNPROCESSABLE_ENTITY),
|
||||
("user1", None, status.HTTP_422_UNPROCESSABLE_CONTENT),
|
||||
),
|
||||
)
|
||||
async def test_user_with_wrong_creds_doesnt_receive_token(
|
||||
|
||||
@ -62,4 +62,4 @@ class TestVersionRoutes:
|
||||
|
||||
params = "BOUM"
|
||||
response = await client.post(app.url_path_for("check_version"), json=params)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user