gns3-server/gns3server/api/routes/controller/projects.py

486 lines
18 KiB
Python
Raw Normal View History

2020-10-02 06:37:50 +00:00
#
# Copyright (C) 2020 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
API routes for projects.
2020-10-02 06:37:50 +00:00
"""
import os
import asyncio
import tempfile
import aiofiles
import time
import urllib.parse
import gns3server.utils.zipfile_zstd as zipfile
import logging
2021-04-13 09:16:50 +00:00
log = logging.getLogger()
from fastapi import APIRouter, Depends, Request, Response, Body, HTTPException, status, WebSocket, WebSocketDisconnect
2020-10-02 06:37:50 +00:00
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse, FileResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
from typing import List, Optional
2020-10-02 06:37:50 +00:00
from uuid import UUID
2020-10-31 05:32:21 +00:00
from gns3server import schemas
2020-10-02 06:37:50 +00:00
from gns3server.controller import Controller
from gns3server.controller.project import Project
2022-06-01 13:26:59 +00:00
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError
2020-10-02 06:37:50 +00:00
from gns3server.controller.import_project import import_project as import_controller_project
from gns3server.controller.export_project import export_project as export_controller_project
from gns3server.utils.asyncio import aiozipstream
from gns3server.utils.path import is_safe_path
2021-05-27 07:58:44 +00:00
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.templates import TemplatesService
2021-05-27 07:58:44 +00:00
2021-11-01 06:15:14 +00:00
from .dependencies.authentication import get_current_active_user, get_current_active_user_from_websocket
2021-05-27 07:58:44 +00:00
from .dependencies.database import get_repository
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
2020-10-02 06:37:50 +00:00
2021-04-13 06:49:56 +00:00
router = APIRouter(responses=responses)
2020-10-02 06:37:50 +00:00
def dep_project(project_id: UUID) -> Project:
"""
Dependency to retrieve a project.
"""
project = Controller.instance().get_project(str(project_id))
return project
2020-10-02 06:37:50 +00:00
CHUNK_SIZE = 1024 * 8 # 8KB
2021-04-13 09:16:50 +00:00
@router.get("", response_model=List[schemas.Project], response_model_exclude_unset=True)
2021-06-03 06:10:12 +00:00
async def get_projects(
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> List[schemas.Project]:
"""
Return all projects.
"""
controller = Controller.instance()
2021-06-03 06:10:12 +00:00
if current_user.is_superadmin:
return [p.asdict() for p in controller.projects.values()]
else:
user_projects = []
for project in controller.projects.values():
authorized = await rbac_repo.check_user_is_authorized(
current_user.user_id, "GET", f"/projects/{project.id}")
if authorized:
user_projects.append(project.asdict())
return user_projects
2021-04-13 09:16:50 +00:00
@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
response_model_exclude_unset=True,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}},
)
2021-05-27 07:58:44 +00:00
async def create_project(
project_data: schemas.ProjectCreate,
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Project:
"""
Create a new project.
"""
2020-10-02 06:37:50 +00:00
controller = Controller.instance()
project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True))
await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/projects/{project.id}/*")
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
@router.get("/{project_id}", response_model=schemas.Project)
def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
"""
Return a project.
"""
2020-10-02 06:37:50 +00:00
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
@router.put("/{project_id}", response_model=schemas.Project, response_model_exclude_unset=True)
async def update_project(
project_data: schemas.ProjectUpdate,
project: Project = Depends(dep_project)
) -> schemas.Project:
"""
Update a project.
"""
2020-10-02 06:37:50 +00:00
await project.update(**jsonable_encoder(project_data, exclude_unset=True))
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
2021-05-27 07:58:44 +00:00
async def delete_project(
project: Project = Depends(dep_project),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> None:
"""
Delete a project.
"""
2020-10-02 06:37:50 +00:00
controller = Controller.instance()
await project.delete()
controller.remove_project(project)
2021-06-03 06:10:12 +00:00
await rbac_repo.delete_all_permissions_with_path(f"/projects/{project.id}")
2020-10-02 06:37:50 +00:00
2021-04-13 06:49:56 +00:00
@router.get("/{project_id}/stats")
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"""
Return a project statistics.
"""
2020-10-02 06:37:50 +00:00
return project.stats()
2021-04-13 09:16:50 +00:00
@router.post(
"/{project_id}/close",
status_code=status.HTTP_204_NO_CONTENT,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}},
)
async def close_project(project: Project = Depends(dep_project)) -> None:
"""
Close a project.
"""
2020-10-02 06:37:50 +00:00
await project.close()
2021-04-13 09:16:50 +00:00
@router.post(
"/{project_id}/open",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}},
)
async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
"""
Open a project.
"""
2020-10-02 06:37:50 +00:00
await project.open()
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
@router.post(
"/load",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load project"}},
)
async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
"""
Load a project (local server only).
"""
2020-10-02 06:37:50 +00:00
controller = Controller.instance()
dot_gns3_file = path
project = await controller.load_project(dot_gns3_file)
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
@router.get("/{project_id}/notifications")
2021-11-01 06:15:14 +00:00
async def project_http_notifications(project_id: UUID) -> StreamingResponse:
"""
Receive project notifications about the controller from HTTP stream.
"""
controller = Controller.instance()
project = controller.get_project(str(project_id))
2021-04-13 09:07:58 +00:00
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (HTTP steam method)")
async def event_stream():
try:
with controller.notification.project_queue(project.id) as queue:
while True:
msg = await queue.get_json(5)
2021-04-13 09:07:58 +00:00
yield (f"{msg}\n").encode("utf-8")
finally:
2021-04-13 09:07:58 +00:00
log.info(f"Client has disconnected from notification for project ID '{project.id}' (HTTP stream method)")
if project.auto_close:
# To avoid trouble with client connecting disconnecting we sleep few seconds before checking
# if someone else is not connected
await asyncio.sleep(5)
if not controller.notification.project_has_listeners(project.id):
2021-04-13 09:07:58 +00:00
log.info(f"Project '{project.id}' is automatically closing due to no client listening")
await project.close()
return StreamingResponse(event_stream(), media_type="application/json")
2020-10-02 06:37:50 +00:00
@router.websocket("/{project_id}/notifications/ws")
2021-11-01 06:15:14 +00:00
async def project_ws_notifications(
project_id: UUID,
websocket: WebSocket,
current_user: schemas.User = Depends(get_current_active_user_from_websocket)
) -> None:
"""
Receive project notifications about the controller from WebSocket.
"""
2020-10-02 06:37:50 +00:00
2021-11-01 06:15:14 +00:00
if current_user is None:
return
2020-10-02 06:37:50 +00:00
controller = Controller.instance()
project = controller.get_project(str(project_id))
2021-04-13 09:07:58 +00:00
log.info(f"New client has connected to the notification stream for project ID '{project.id}' (WebSocket method)")
2020-10-02 06:37:50 +00:00
try:
with controller.notification.project_queue(project.id) as queue:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
2021-04-13 09:07:58 +00:00
log.info(f"Client has disconnected from notification stream for project ID '{project.id}' (WebSocket method)")
except WebSocketException as e:
2021-04-13 09:07:58 +00:00
log.warning(f"Error while sending to project event to WebSocket client: {e}")
2020-10-02 06:37:50 +00:00
finally:
2021-12-07 13:30:54 +00:00
try:
await websocket.close()
except OSError:
pass # ignore OSError: [Errno 107] Transport endpoint is not connected
2020-10-02 06:37:50 +00:00
if project.auto_close:
# To avoid trouble with client connecting disconnecting we sleep few seconds before checking
# if someone else is not connected
await asyncio.sleep(5)
if not controller.notification.project_has_listeners(project.id):
2021-04-13 09:07:58 +00:00
log.info(f"Project '{project.id}' is automatically closing due to no client listening")
2020-10-02 06:37:50 +00:00
await project.close()
2021-04-13 06:49:56 +00:00
@router.get("/{project_id}/export")
2021-04-13 09:16:50 +00:00
async def export_project(
project: Project = Depends(dep_project),
include_snapshots: bool = False,
include_images: bool = False,
reset_mac_addresses: bool = False,
compression: schemas.ProjectCompression = "zstd",
2022-06-01 13:26:59 +00:00
compression_level: int = None,
) -> StreamingResponse:
2020-10-02 06:37:50 +00:00
"""
Export a project as a portable archive.
"""
compression_query = compression.lower()
if compression_query == "zip":
compression = zipfile.ZIP_DEFLATED
2022-06-01 13:26:59 +00:00
if compression_level is not None and (compression_level < 0 or compression_level > 9):
raise ControllerBadRequestError("Compression level must be between 0 and 9 for ZIP compression")
2020-10-02 06:37:50 +00:00
elif compression_query == "none":
compression = zipfile.ZIP_STORED
elif compression_query == "bzip2":
compression = zipfile.ZIP_BZIP2
2022-06-01 13:26:59 +00:00
if compression_level is not None and (compression_level < 1 or compression_level > 9):
raise ControllerBadRequestError("Compression level must be between 1 and 9 for BZIP2 compression")
2020-10-02 06:37:50 +00:00
elif compression_query == "lzma":
compression = zipfile.ZIP_LZMA
elif compression_query == "zstd":
compression = zipfile.ZIP_ZSTANDARD
2022-06-01 13:26:59 +00:00
if compression_level is not None and (compression_level < 1 or compression_level > 22):
raise ControllerBadRequestError("Compression level must be between 1 and 22 for Zstandard compression")
if compression_level is not None and compression_query in ("none", "lzma"):
raise ControllerBadRequestError(f"Compression level is not supported for '{compression_query}' compression method")
2020-10-02 06:37:50 +00:00
try:
begin = time.time()
# use the parent directory as a temporary working dir
working_dir = os.path.abspath(os.path.join(project.path, os.pardir))
async def streamer():
2022-06-01 13:26:59 +00:00
log.info(f"Exporting project '{project.name}' with '{compression_query}' compression "
f"(level {compression_level})")
2020-10-02 06:37:50 +00:00
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
2022-06-01 13:26:59 +00:00
with aiozipstream.ZipFile(compression=compression, compresslevel=compression_level) as zstream:
2021-04-13 09:16:50 +00:00
await export_controller_project(
zstream,
project,
tmpdir,
include_snapshots=include_snapshots,
include_images=include_images,
reset_mac_addresses=reset_mac_addresses,
)
2020-10-02 06:37:50 +00:00
async for chunk in zstream:
yield chunk
2021-04-13 09:07:58 +00:00
log.info(f"Project '{project.name}' exported in {time.time() - begin:.4f} seconds")
2020-10-02 06:37:50 +00:00
# Will be raise if you have no space left or permission issue on your temporary directory
# RuntimeError: something was wrong during the zip process
except (ValueError, OSError, RuntimeError) as e:
2021-04-13 09:07:58 +00:00
raise ConnectionError(f"Cannot export project: {e}")
2020-10-02 06:37:50 +00:00
2021-04-13 09:07:58 +00:00
headers = {"CONTENT-DISPOSITION": f'attachment; filename="{project.name}.gns3project"'}
2020-10-02 06:37:50 +00:00
return StreamingResponse(streamer(), media_type="application/gns3project", headers=headers)
2021-04-13 09:16:50 +00:00
@router.post("/{project_id}/import", status_code=status.HTTP_201_CREATED, response_model=schemas.Project)
async def import_project(
project_id: UUID,
request: Request,
name: Optional[str] = None
) -> schemas.Project:
2020-10-02 06:37:50 +00:00
"""
Import a project from a portable archive.
"""
controller = Controller.instance()
2022-07-05 21:01:44 +00:00
# We write the content to a temporary location and then we extract it all.
2020-10-02 06:37:50 +00:00
# It could be more optimal to stream this but it is not implemented in Python.
try:
begin = time.time()
2022-07-05 21:01:44 +00:00
working_dir = controller.projects_directory()
2020-10-02 06:37:50 +00:00
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
temp_project_path = os.path.join(tmpdir, "project.zip")
2021-04-13 09:16:50 +00:00
async with aiofiles.open(temp_project_path, "wb") as f:
2020-10-02 06:37:50 +00:00
async for chunk in request.stream():
await f.write(chunk)
with open(temp_project_path, "rb") as f:
2022-07-05 21:01:44 +00:00
project = await import_controller_project(controller, str(project_id), f, name=name)
2020-10-02 06:37:50 +00:00
2021-04-13 09:07:58 +00:00
log.info(f"Project '{project.name}' imported in {time.time() - begin:.4f} seconds")
2020-10-02 06:37:50 +00:00
except OSError as e:
2021-04-13 09:07:58 +00:00
raise ControllerError(f"Could not import the project: {e}")
2021-04-17 14:04:28 +00:00
return project.asdict()
2020-10-02 06:37:50 +00:00
2021-04-13 09:16:50 +00:00
@router.post(
"/{project_id}/duplicate",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate project"}},
)
async def duplicate_project(
project_data: schemas.ProjectDuplicate,
2021-06-03 06:10:12 +00:00
project: Project = Depends(dep_project),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Project:
"""
Duplicate a project.
"""
2020-10-02 06:37:50 +00:00
reset_mac_addresses = project_data.reset_mac_addresses
2021-04-13 09:16:50 +00:00
new_project = await project.duplicate(
name=project_data.name, reset_mac_addresses=reset_mac_addresses
2021-04-13 09:16:50 +00:00
)
await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/projects/{new_project.id}/*")
2021-04-17 14:04:28 +00:00
return new_project.asdict()
2020-10-02 06:37:50 +00:00
2022-08-30 20:49:47 +00:00
@router.post("/{project_id}/lock", status_code=status.HTTP_204_NO_CONTENT)
async def lock_project(project: Project = Depends(dep_project)) -> None:
"""
Lock all drawings and nodes in a given project.
"""
project.lock()
@router.post("/{project_id}/unlock", status_code=status.HTTP_204_NO_CONTENT)
async def unlock_project(project: Project = Depends(dep_project)) -> None:
"""
Unlock all drawings and nodes in a given project.
"""
project.unlock()
2020-10-02 06:37:50 +00:00
@router.get("/{project_id}/files/{file_path:path}")
async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
2020-10-02 06:37:50 +00:00
"""
Return a file from a project.
2020-10-02 06:37:50 +00:00
"""
file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)
2020-10-02 06:37:50 +00:00
# Raise error if user try to escape
if not is_safe_path(path, project.path):
2020-10-02 06:37:50 +00:00
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
if not os.path.exists(path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return FileResponse(path, media_type="application/octet-stream")
2021-04-13 09:16:50 +00:00
@router.post("/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
"""
Write a file to a project.
"""
2020-10-02 06:37:50 +00:00
file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)
2020-10-02 06:37:50 +00:00
# Raise error if user try to escape
if not is_safe_path(path, project.path):
2020-10-02 06:37:50 +00:00
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
try:
2021-04-13 09:16:50 +00:00
async with aiofiles.open(path, "wb+") as f:
2020-10-02 06:37:50 +00:00
async for chunk in request.stream():
await f.write(chunk)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except PermissionError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
except OSError as e:
raise ControllerError(str(e))
@router.post(
"/{project_id}/templates/{template_id}",
response_model=schemas.Node,
status_code=status.HTTP_201_CREATED,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}},
)
async def create_node_from_template(
project_id: UUID,
template_id: UUID,
template_usage: schemas.TemplateUsage,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> schemas.Node:
"""
Create a new node from a template.
"""
template = await TemplatesService(templates_repo).get_template(template_id)
controller = Controller.instance()
project = controller.get_project(str(project_id))
node = await project.add_node_from_template(
template, x=template_usage.x, y=template_usage.y, compute_id=template_usage.compute_id
)
return node.asdict()