mirror of
https://github.com/GNS3/gns3-server.git
synced 2024-12-27 00:11:07 +00:00
390 lines
15 KiB
Python
390 lines
15 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
#
|
||
|
# 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 endpoints for projects.
|
||
|
"""
|
||
|
|
||
|
from fastapi import APIRouter, Request, Body, HTTPException, status, WebSocket, WebSocketDisconnect
|
||
|
from fastapi.encoders import jsonable_encoder
|
||
|
from fastapi.responses import StreamingResponse, FileResponse
|
||
|
from websockets.exceptions import WebSocketException
|
||
|
from typing import List
|
||
|
from pathlib import Path
|
||
|
from uuid import UUID
|
||
|
|
||
|
from gns3server.endpoints.schemas.common import ErrorMessage
|
||
|
from gns3server.endpoints import schemas
|
||
|
from gns3server.controller import Controller
|
||
|
from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError
|
||
|
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.config import Config
|
||
|
|
||
|
router = APIRouter()
|
||
|
|
||
|
|
||
|
import os
|
||
|
import aiohttp
|
||
|
import asyncio
|
||
|
import tempfile
|
||
|
import zipfile
|
||
|
import aiofiles
|
||
|
import time
|
||
|
|
||
|
import logging
|
||
|
log = logging.getLogger()
|
||
|
|
||
|
|
||
|
CHUNK_SIZE = 1024 * 8 # 8KB
|
||
|
|
||
|
|
||
|
@router.post("/",
|
||
|
summary="Create project",
|
||
|
status_code=status.HTTP_201_CREATED,
|
||
|
response_model=schemas.Project,
|
||
|
response_model_exclude_unset=True,
|
||
|
responses={409: {"model": ErrorMessage, "description": "Could not create project"}})
|
||
|
async def create_project(project_data: schemas.ProjectCreate):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True))
|
||
|
print(project.__json__()["variables"])
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
@router.get("/",
|
||
|
summary="List of all projects",
|
||
|
response_model=List[schemas.Project],
|
||
|
response_description="List of projects",
|
||
|
response_model_exclude_unset=True)
|
||
|
def list_projects():
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
return [p.__json__() for p in controller.projects.values()]
|
||
|
|
||
|
|
||
|
@router.get("/{project_id}",
|
||
|
summary="Get a project",
|
||
|
response_model=schemas.Project,
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
def get_project(project_id: UUID):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
@router.put("/{project_id}",
|
||
|
summary="Update a project",
|
||
|
response_model=schemas.Project,
|
||
|
response_model_exclude_unset=True,
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
async def update_project(project_id: UUID, project_data: schemas.ProjectUpdate):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
await project.update(**jsonable_encoder(project_data, exclude_unset=True))
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
@router.delete("/{project_id}",
|
||
|
summary="Delete a project",
|
||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
async def delete_project(project_id: UUID):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
await project.delete()
|
||
|
controller.remove_project(project)
|
||
|
|
||
|
|
||
|
@router.get("/{project_id}/stats",
|
||
|
summary="Get a project statistics",
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
def get_project_stats(project_id: UUID):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
return project.stats()
|
||
|
|
||
|
|
||
|
@router.post("/{project_id}/close",
|
||
|
summary="Close a project",
|
||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||
|
responses={409: {"model": ErrorMessage, "description": "Could not create project"}})
|
||
|
async def close_project(project_id: UUID):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
await project.close()
|
||
|
|
||
|
|
||
|
@router.post("/{project_id}/open",
|
||
|
summary="Open a project",
|
||
|
status_code=status.HTTP_201_CREATED,
|
||
|
response_model=schemas.Project,
|
||
|
responses={409: {"model": ErrorMessage, "description": "Could not create project"}})
|
||
|
async def open_project(project_id: UUID):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
await project.open()
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
@router.post("/load",
|
||
|
summary="Open a project (local server only)",
|
||
|
status_code=status.HTTP_201_CREATED,
|
||
|
response_model=schemas.Project,
|
||
|
responses={409: {"model": ErrorMessage, "description": "Could not load project"}})
|
||
|
async def load_project(path: str = Body(..., embed=True)):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
config = Config.instance()
|
||
|
dot_gns3_file = path
|
||
|
if config.get_section_config("Server").getboolean("local", False) is False:
|
||
|
log.error("Cannot load '{}' because the server has not been started with the '--local' parameter".format(dot_gns3_file))
|
||
|
raise ControllerForbiddenError("Cannot load project when server is not local")
|
||
|
project = await controller.load_project(dot_gns3_file,)
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
# @router.get("/projects/{project_id}/notifications",
|
||
|
# summary="Receive notifications about projects",
|
||
|
# responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
# async def notification(project_id: UUID):
|
||
|
#
|
||
|
# controller = Controller.instance()
|
||
|
# project = controller.get_project(str(project_id))
|
||
|
# #response.content_type = "application/json"
|
||
|
# #response.set_status(200)
|
||
|
# #response.enable_chunked_encoding()
|
||
|
# #await response.prepare(request)
|
||
|
# log.info("New client has connected to the notification stream for project ID '{}' (HTTP long-polling method)".format(project.id))
|
||
|
#
|
||
|
# try:
|
||
|
# with controller.notification.project_queue(project.id) as queue:
|
||
|
# while True:
|
||
|
# msg = await queue.get_json(5)
|
||
|
# await response.write(("{}\n".format(msg)).encode("utf-8"))
|
||
|
# finally:
|
||
|
# log.info("Client has disconnected from notification for project ID '{}' (HTTP long-polling method)".format(project.id))
|
||
|
# 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):
|
||
|
# log.info("Project '{}' is automatically closing due to no client listening".format(project.id))
|
||
|
# await project.close()
|
||
|
|
||
|
|
||
|
@router.websocket("/{project_id}/notifications/ws")
|
||
|
async def notification_ws(project_id: UUID, websocket: WebSocket):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
await websocket.accept()
|
||
|
|
||
|
#request.app['websockets'].add(ws)
|
||
|
#asyncio.ensure_future(process_websocket(ws))
|
||
|
log.info("New client has connected to the notification stream for project ID '{}' (WebSocket method)".format(project.id))
|
||
|
try:
|
||
|
with controller.notification.project_queue(project.id) as queue:
|
||
|
while True:
|
||
|
notification = await queue.get_json(5)
|
||
|
await websocket.send_text(notification)
|
||
|
except (WebSocketException, WebSocketDisconnect):
|
||
|
log.info("Client has disconnected from notification stream for project ID '{}' (WebSocket method)".format(project.id))
|
||
|
finally:
|
||
|
await websocket.close()
|
||
|
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):
|
||
|
log.info("Project '{}' is automatically closing due to no client listening".format(project.id))
|
||
|
await project.close()
|
||
|
|
||
|
|
||
|
@router.get("/{project_id}/export",
|
||
|
summary="Export project",
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
async def export_project(project_id: UUID,
|
||
|
include_snapshots: bool = False,
|
||
|
include_images: bool = False,
|
||
|
reset_mac_addresses: bool = False,
|
||
|
compression: str = "zip"):
|
||
|
"""
|
||
|
Export a project as a portable archive.
|
||
|
"""
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = controller.get_project(str(project_id))
|
||
|
|
||
|
compression_query = compression.lower()
|
||
|
if compression_query == "zip":
|
||
|
compression = zipfile.ZIP_DEFLATED
|
||
|
elif compression_query == "none":
|
||
|
compression = zipfile.ZIP_STORED
|
||
|
elif compression_query == "bzip2":
|
||
|
compression = zipfile.ZIP_BZIP2
|
||
|
elif compression_query == "lzma":
|
||
|
compression = zipfile.ZIP_LZMA
|
||
|
|
||
|
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():
|
||
|
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
|
||
|
with aiozipstream.ZipFile(compression=compression) as zstream:
|
||
|
await export_controller_project(zstream,
|
||
|
project,
|
||
|
tmpdir,
|
||
|
include_snapshots=include_snapshots,
|
||
|
include_images=include_images,
|
||
|
reset_mac_addresses=reset_mac_addresses)
|
||
|
async for chunk in zstream:
|
||
|
yield chunk
|
||
|
|
||
|
log.info("Project '{}' exported in {:.4f} seconds".format(project.name, time.time() - begin))
|
||
|
|
||
|
# 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:
|
||
|
raise ConnectionError("Cannot export project: {}".format(e))
|
||
|
|
||
|
headers = {"CONTENT-DISPOSITION": 'attachment; filename="{}.gns3project"'.format(project.name)}
|
||
|
return StreamingResponse(streamer(), media_type="application/gns3project", headers=headers)
|
||
|
|
||
|
|
||
|
@router.post("/{project_id}/import",
|
||
|
summary="Import a project",
|
||
|
status_code=status.HTTP_201_CREATED,
|
||
|
response_model=schemas.Project,
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"}})
|
||
|
async def import_project(project_id: UUID, request: Request):
|
||
|
"""
|
||
|
Import a project from a portable archive.
|
||
|
"""
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
config = Config.instance()
|
||
|
if config.get_section_config("Server").getboolean("local", False) is False:
|
||
|
raise ControllerForbiddenError("The server is not local")
|
||
|
|
||
|
#FIXME: broken
|
||
|
#path = None
|
||
|
#name = "test"
|
||
|
|
||
|
# We write the content to a temporary location and after we extract it all.
|
||
|
# It could be more optimal to stream this but it is not implemented in Python.
|
||
|
try:
|
||
|
begin = time.time()
|
||
|
# use the parent directory or projects dir as a temporary working dir
|
||
|
if path:
|
||
|
working_dir = os.path.abspath(os.path.join(path, os.pardir))
|
||
|
else:
|
||
|
working_dir = controller.projects_directory()
|
||
|
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
|
||
|
temp_project_path = os.path.join(tmpdir, "project.zip")
|
||
|
async with aiofiles.open(temp_project_path, 'wb') as f:
|
||
|
async for chunk in request.stream():
|
||
|
await f.write(chunk)
|
||
|
with open(temp_project_path, "rb") as f:
|
||
|
project = await import_controller_project(controller, str(project_id), f, location=path, name=name)
|
||
|
|
||
|
log.info("Project '{}' imported in {:.4f} seconds".format(project.name, time.time() - begin))
|
||
|
except OSError as e:
|
||
|
raise ControllerError("Could not import the project: {}".format(e))
|
||
|
return project.__json__()
|
||
|
|
||
|
|
||
|
@router.post("/{project_id}/duplicate",
|
||
|
summary="Duplicate a project",
|
||
|
status_code=status.HTTP_201_CREATED,
|
||
|
response_model=schemas.Project,
|
||
|
responses={404: {"model": ErrorMessage, "description": "Could not find project"},
|
||
|
409: {"model": ErrorMessage, "description": "Could not duplicate project"}})
|
||
|
async def duplicate(project_id: UUID, project_data: schemas.ProjectDuplicate):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = await controller.get_loaded_project(str(project_id))
|
||
|
|
||
|
if project_data.path:
|
||
|
config = Config.instance()
|
||
|
if config.get_section_config("Server").getboolean("local", False) is False:
|
||
|
raise ControllerForbiddenError("The server is not a local server")
|
||
|
location = project_data.path
|
||
|
else:
|
||
|
location = None
|
||
|
|
||
|
reset_mac_addresses = project_data.reset_mac_addresses
|
||
|
new_project = await project.duplicate(name=project_data.name, location=location, reset_mac_addresses=reset_mac_addresses)
|
||
|
return new_project.__json__()
|
||
|
|
||
|
|
||
|
@router.get("/{project_id}/files/{file_path:path}")
|
||
|
async def get_file(project_id: UUID, file_path: str):
|
||
|
"""
|
||
|
Get a file from a project.
|
||
|
"""
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = await controller.get_loaded_project(str(project_id))
|
||
|
path = os.path.normpath(file_path).strip('/')
|
||
|
|
||
|
# Raise error if user try to escape
|
||
|
if path[0] == ".":
|
||
|
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")
|
||
|
|
||
|
|
||
|
@router.post("/{project_id}/files/{file_path:path}",
|
||
|
status_code=status.HTTP_204_NO_CONTENT)
|
||
|
async def write_file(project_id: UUID, file_path: str, request: Request):
|
||
|
|
||
|
controller = Controller.instance()
|
||
|
project = await controller.get_loaded_project(str(project_id))
|
||
|
path = os.path.normpath(file_path).strip("/")
|
||
|
|
||
|
# Raise error if user try to escape
|
||
|
if path[0] == ".":
|
||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||
|
|
||
|
path = os.path.join(project.path, path)
|
||
|
|
||
|
try:
|
||
|
async with aiofiles.open(path, 'wb+') as f:
|
||
|
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))
|