diff --git a/CHANGELOG b/CHANGELOG
index bad6f531..ed317762 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,10 @@
# Change Log
+## 2.2.21 10/05/2021
+
+* Release Web-Ui v2.2.21
+* Improvements for get symbol dimensions endpoint. Ref #1885
+
## 2.2.20 09/04/2021
* Release Web UI version 2.2.20
diff --git a/README.rst b/README.rst
index bb4c9138..3c70ede9 100644
--- a/README.rst
+++ b/README.rst
@@ -76,14 +76,16 @@ Finally these commands will install the server as well as the rest of the depend
.. code:: bash
cd gns3-server-master
+ python3 -m venv venv-gns3server
+ source venv-gns3server/bin/activate
sudo python3 setup.py install
- gns3server
+ python3 -m gns3server --local
To run tests use:
.. code:: bash
- py.test -v
+ python3 -m pytest tests
Docker container
diff --git a/conf/gns3_server.conf b/conf/gns3_server.conf
index 85c795be..8560d59d 100644
--- a/conf/gns3_server.conf
+++ b/conf/gns3_server.conf
@@ -64,6 +64,14 @@ user = gns3
; Password for HTTP authentication.
password = gns3
+; Initial default super admin username
+; It cannot be changed once the server has started once
+default_admin_username = "admin"
+
+; Initial default super admin username
+; It cannot be changed once the server has started once
+default_admin_password = "admin"
+
; Only allow these interfaces to be used by GNS3, for the Cloud node for example (Linux/OSX only)
; Do not forget to allow virbr0 in order for the NAT node to work
allowed_interfaces = eth0,eth1,virbr0
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 3f17e10b..d6cfcd64 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,8 +1,8 @@
-r requirements.txt
-pytest==6.2.3
-flake8==3.9.0
+pytest==6.2.4
+flake8==3.9.1
pytest-timeout==1.4.2
-pytest-asyncio==0.14.0
+pytest-asyncio==0.15.1
requests==2.25.1
-httpx==0.17.1
+httpx==0.18.1
diff --git a/gns3server/api/routes/compute/images.py b/gns3server/api/routes/compute/images.py
index b5103aaf..e556c1d3 100644
--- a/gns3server/api/routes/compute/images.py
+++ b/gns3server/api/routes/compute/images.py
@@ -69,13 +69,15 @@ async def download_dynamips_image(filename: str) -> FileResponse:
Download a Dynamips IOS image.
"""
- dynamips_manager = Dynamips.instance()
filename = urllib.parse.unquote(filename)
- image_path = dynamips_manager.get_abs_image_path(filename)
- if filename[0] == ".":
+ # Raise error if user try to escape
+ if filename[0] == "." or os.path.sep in filename:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+ dynamips_manager = Dynamips.instance()
+ image_path = dynamips_manager.get_abs_image_path(filename)
+
if not os.path.exists(image_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -108,13 +110,14 @@ async def download_iou_image(filename: str) -> FileResponse:
Download an IOU image.
"""
- iou_manager = IOU.instance()
filename = urllib.parse.unquote(filename)
- image_path = iou_manager.get_abs_image_path(filename)
- if filename[0] == ".":
+ # Raise error if user try to escape
+ if filename[0] == "." or os.path.sep in filename:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+ iou_manager = IOU.instance()
+ image_path = iou_manager.get_abs_image_path(filename)
if not os.path.exists(image_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -138,13 +141,13 @@ async def upload_qemu_image(filename: str, request: Request) -> None:
@router.get("/qemu/images/{filename:path}")
async def download_qemu_image(filename: str) -> FileResponse:
- qemu_manager = Qemu.instance()
filename = urllib.parse.unquote(filename)
# Raise error if user try to escape
- if filename[0] == ".":
+ if filename[0] == "." or os.path.sep in filename:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+ qemu_manager = Qemu.instance()
image_path = qemu_manager.get_abs_image_path(filename)
if not os.path.exists(image_path):
diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py
index 8d9fe3b8..a1ddbfc4 100644
--- a/gns3server/api/routes/compute/projects.py
+++ b/gns3server/api/routes/compute/projects.py
@@ -19,6 +19,7 @@ API routes for projects.
"""
import os
+import urllib.parse
import logging
@@ -32,6 +33,7 @@ from uuid import UUID
from gns3server.compute.project_manager import ProjectManager
from gns3server.compute.project import Project
+from gns3server.utils.path import is_safe_path
from gns3server import schemas
@@ -197,10 +199,11 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de
Get a file from a project.
"""
+ file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)
# Raise error if user try to escape
- if path[0] == ".":
+ if not is_safe_path(path, project.path):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
@@ -213,10 +216,11 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de
@router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
+ file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)
# Raise error if user try to escape
- if path[0] == ".":
+ if not is_safe_path(path, project.path):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py
index cb1e6ddc..9a2dc526 100644
--- a/gns3server/api/routes/controller/__init__.py
+++ b/gns3server/api/routes/controller/__init__.py
@@ -29,6 +29,7 @@ from . import snapshots
from . import symbols
from . import templates
from . import users
+from . import groups
from .dependencies.authentication import get_current_active_user
@@ -37,6 +38,13 @@ router = APIRouter()
router.include_router(controller.router, tags=["Controller"])
router.include_router(users.router, prefix="/users", tags=["Users"])
+router.include_router(
+ groups.router,
+ dependencies=[Depends(get_current_active_user)],
+ prefix="/groups",
+ tags=["Users groups"]
+)
+
router.include_router(
appliances.router,
dependencies=[Depends(get_current_active_user)],
@@ -59,6 +67,7 @@ router.include_router(
router.include_router(
gns3vm.router,
+ deprecated=True,
dependencies=[Depends(get_current_active_user)],
prefix="/gns3vm",
tags=["GNS3 VM"]
diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 0a2dfc7e..89dbe281 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -25,7 +25,7 @@ router = APIRouter()
@router.get("")
-async def get_appliances(update: Optional[bool] = None, symbol_theme: Optional[str] = "Classic") -> List[dict]:
+async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[str] = "Classic") -> List[dict]:
"""
Return all appliances known by the controller.
"""
diff --git a/gns3server/api/routes/controller/groups.py b/gns3server/api/routes/controller/groups.py
new file mode 100644
index 00000000..b3a96f66
--- /dev/null
+++ b/gns3server/api/routes/controller/groups.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2021 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 .
+
+"""
+API routes for user groups.
+"""
+
+from fastapi import APIRouter, Depends, status
+from uuid import UUID
+from typing import List
+
+from gns3server import schemas
+from gns3server.controller.controller_error import (
+ ControllerBadRequestError,
+ ControllerNotFoundError,
+ ControllerForbiddenError,
+)
+
+from gns3server.db.repositories.users import UsersRepository
+from .dependencies.database import get_repository
+
+import logging
+
+log = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[schemas.UserGroup])
+async def get_user_groups(
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> List[schemas.UserGroup]:
+ """
+ Get all user groups.
+ """
+
+ return await users_repo.get_user_groups()
+
+
+@router.post(
+ "",
+ response_model=schemas.UserGroup,
+ status_code=status.HTTP_201_CREATED
+)
+async def create_user_group(
+ user_group_create: schemas.UserGroupCreate,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> schemas.UserGroup:
+ """
+ Create a new user group.
+ """
+
+ if await users_repo.get_user_group_by_name(user_group_create.name):
+ raise ControllerBadRequestError(f"User group '{user_group_create.name}' already exists")
+
+ return await users_repo.create_user_group(user_group_create)
+
+
+@router.get("/{user_group_id}", response_model=schemas.UserGroup)
+async def get_user_group(
+ user_group_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+) -> schemas.UserGroup:
+ """
+ Get an user group.
+ """
+
+ user_group = await users_repo.get_user_group(user_group_id)
+ if not user_group:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
+ return user_group
+
+
+@router.put("/{user_group_id}", response_model=schemas.UserGroup)
+async def update_user_group(
+ user_group_id: UUID,
+ user_group_update: schemas.UserGroupUpdate,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> schemas.UserGroup:
+ """
+ Update an user group.
+ """
+ user_group = await users_repo.get_user_group(user_group_id)
+ if not user_group:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
+
+ if not user_group.is_updatable:
+ raise ControllerForbiddenError(f"User group '{user_group_id}' cannot be updated")
+
+ return await users_repo.update_user_group(user_group_id, user_group_update)
+
+
+@router.delete(
+ "/{user_group_id}",
+ status_code=status.HTTP_204_NO_CONTENT
+)
+async def delete_user_group(
+ user_group_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+) -> None:
+ """
+ Delete an user group
+ """
+
+ user_group = await users_repo.get_user_group(user_group_id)
+ if not user_group:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
+
+ if not user_group.is_updatable:
+ raise ControllerForbiddenError(f"User group '{user_group_id}' cannot be deleted")
+
+ success = await users_repo.delete_user_group(user_group_id)
+ if not success:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' could not be deleted")
+
+
+@router.get("/{user_group_id}/members", response_model=List[schemas.User])
+async def get_user_group_members(
+ user_group_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> List[schemas.User]:
+ """
+ Get all user group members.
+ """
+
+ return await users_repo.get_user_group_members(user_group_id)
+
+
+@router.put(
+ "/{user_group_id}/members/{user_id}",
+ status_code=status.HTTP_204_NO_CONTENT
+)
+async def add_member_to_group(
+ user_group_id: UUID,
+ user_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> None:
+ """
+ Add member to an user group.
+ """
+
+ user = await users_repo.get_user(user_id)
+ if not user:
+ raise ControllerNotFoundError(f"User '{user_id}' not found")
+
+ user_group = await users_repo.add_member_to_user_group(user_group_id, user)
+ if not user_group:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
+
+
+@router.delete(
+ "/{user_group_id}/members/{user_id}",
+ status_code=status.HTTP_204_NO_CONTENT
+)
+async def remove_member_from_group(
+ user_group_id: UUID,
+ user_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+) -> None:
+ """
+ Remove member from an user group.
+ """
+
+ user = await users_repo.get_user(user_id)
+ if not user:
+ raise ControllerNotFoundError(f"User '{user_id}' not found")
+
+ user_group = await users_repo.remove_member_from_user_group(user_group_id, user)
+ if not user_group:
+ raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index 899e9a31..3016d84f 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -305,11 +305,11 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
path = f"/project-files/{node_type}/{node.id}/{path}"
res = await node.compute.http_query("GET", f"/projects/{node.project.id}/files{path}", timeout=None, raw=True)
- return Response(res.body, media_type="application/octet-stream")
+ return Response(res.body, media_type="application/octet-stream", status_code=res.status)
@router.post("/{node_id}/files/{file_path:path}", status_code=status.HTTP_201_CREATED)
-async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)) -> dict:
+async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)):
"""
Write a file in the node directory.
"""
@@ -324,8 +324,8 @@ async def post_file(file_path: str, request: Request, node: Node = Depends(dep_n
path = f"/project-files/{node_type}/{node.id}/{path}"
data = await request.body() # FIXME: are we handling timeout or large files correctly?
-
await node.compute.http_query("POST", f"/projects/{node.project.id}/files{path}", data=data, timeout=None, raw=True)
+ # FIXME: response with correct status code (from compute)
@router.websocket("/{node_id}/console/ws")
diff --git a/gns3server/api/routes/controller/notifications.py b/gns3server/api/routes/controller/notifications.py
index 3634a7d4..79e99328 100644
--- a/gns3server/api/routes/controller/notifications.py
+++ b/gns3server/api/routes/controller/notifications.py
@@ -18,12 +18,15 @@
API routes for controller notifications.
"""
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import StreamingResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
+from gns3server.services import auth_service
from gns3server.controller import Controller
+from .dependencies.authentication import get_current_active_user
+
import logging
log = logging.getLogger(__name__)
@@ -31,7 +34,7 @@ log = logging.getLogger(__name__)
router = APIRouter()
-@router.get("")
+@router.get("", dependencies=[Depends(get_current_active_user)])
async def http_notification() -> StreamingResponse:
"""
Receive controller notifications about the controller from HTTP stream.
@@ -41,18 +44,26 @@ async def http_notification() -> StreamingResponse:
with Controller.instance().notification.controller_queue() as queue:
while True:
msg = await queue.get_json(5)
- yield (f"{msg}\n").encode("utf-8")
+ yield f"{msg}\n".encode("utf-8")
return StreamingResponse(event_stream(), media_type="application/json")
@router.websocket("/ws")
-async def notification_ws(websocket: WebSocket) -> None:
+async def notification_ws(websocket: WebSocket, token: str = Query(None)) -> None:
"""
Receive project notifications about the controller from WebSocket.
"""
-
await websocket.accept()
+
+ if token:
+ try:
+ username = auth_service.get_username_from_token(token)
+ except HTTPException:
+ log.error("Invalid token received")
+ await websocket.close(code=1008)
+ return
+
log.info(f"New client {websocket.client.host}:{websocket.client.port} has connected to controller WebSocket")
try:
with Controller.instance().notification.controller_queue() as queue:
diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py
index c8936f5f..5e8022c8 100644
--- a/gns3server/api/routes/controller/projects.py
+++ b/gns3server/api/routes/controller/projects.py
@@ -24,6 +24,7 @@ import tempfile
import zipfile
import aiofiles
import time
+import urllib.parse
import logging
@@ -44,6 +45,7 @@ from gns3server.controller.controller_error import ControllerError, ControllerFo
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
from gns3server.config import Config
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
@@ -368,10 +370,11 @@ async def get_file(file_path: str, project: Project = Depends(dep_project)) -> F
Return a file from a project.
"""
- path = os.path.normpath(file_path).strip("/")
+ file_path = urllib.parse.unquote(file_path)
+ path = os.path.normpath(file_path)
# Raise error if user try to escape
- if path[0] == ".":
+ if not is_safe_path(path, project.path):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
@@ -387,10 +390,11 @@ async def write_file(file_path: str, request: Request, project: Project = Depend
Write a file from a project.
"""
- path = os.path.normpath(file_path).strip("/")
+ file_path = urllib.parse.unquote(file_path)
+ path = os.path.normpath(file_path)
# Raise error if user try to escape
- if path[0] == ".":
+ if not is_safe_path(path, project.path):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
path = os.path.join(project.path, path)
diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py
index bb2c5ae9..ee54fdc7 100644
--- a/gns3server/api/routes/controller/templates.py
+++ b/gns3server/api/routes/controller/templates.py
@@ -139,7 +139,7 @@ async def create_node_from_template(
Create a new node from a template.
"""
- template = TemplatesService(templates_repo).get_template(template_id)
+ 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(
diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py
index c830c8b5..e3fc578e 100644
--- a/gns3server/api/routes/controller/users.py
+++ b/gns3server/api/routes/controller/users.py
@@ -153,22 +153,29 @@ async def update_user(
return user
-@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+ "/{user_id}",
+ dependencies=[Depends(get_current_active_user)],
+ status_code=status.HTTP_204_NO_CONTENT
+)
async def delete_user(
user_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
- current_user: schemas.User = Depends(get_current_active_user),
) -> None:
"""
Delete an user.
"""
- if current_user.is_superadmin:
+ user = await users_repo.get_user(user_id)
+ if not user:
+ raise ControllerNotFoundError(f"User '{user_id}' not found")
+
+ if user.is_superadmin:
raise ControllerForbiddenError("The super admin cannot be deleted")
success = await users_repo.delete_user(user_id)
if not success:
- raise ControllerNotFoundError(f"User '{user_id}' not found")
+ raise ControllerNotFoundError(f"User '{user_id}' could not be deleted")
@router.get("/me/", response_model=schemas.User)
@@ -178,3 +185,19 @@ async def get_current_active_user(current_user: schemas.User = Depends(get_curre
"""
return current_user
+
+
+@router.get(
+ "/{user_id}/groups",
+ dependencies=[Depends(get_current_active_user)],
+ response_model=List[schemas.UserGroup]
+)
+async def get_user_memberships(
+ user_id: UUID,
+ users_repo: UsersRepository = Depends(get_repository(UsersRepository))
+) -> List[schemas.UserGroup]:
+ """
+ Get user memberships.
+ """
+
+ return await users_repo.get_user_memberships(user_id)
diff --git a/gns3server/api/server.py b/gns3server/api/server.py
index da948299..1529c065 100644
--- a/gns3server/api/server.py
+++ b/gns3server/api/server.py
@@ -25,6 +25,7 @@ from fastapi import FastAPI, Request
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
+from sqlalchemy.exc import SQLAlchemyError
from uvicorn.main import Server as UvicornServer
from gns3server.controller.controller_error import (
@@ -55,6 +56,8 @@ def get_application() -> FastAPI:
origins = [
"http://127.0.0.1",
"http://localhost",
+ "http://localhost:4200",
+ "http://127.0.0.1:4200"
"http://127.0.0.1:8080",
"http://localhost:8080",
"http://127.0.0.1:3080",
@@ -158,6 +161,15 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
)
+@app.exception_handler(SQLAlchemyError)
+async def sqlalchemry_error_handler(request: Request, exc: SQLAlchemyError):
+ log.error(f"Controller database error: {exc}")
+ return JSONResponse(
+ status_code=500,
+ content={"message": "Database error detected, please check logs to find details"},
+ )
+
+
@app.middleware("http")
async def add_extra_headers(request: Request, call_next):
start_time = time.time()
diff --git a/gns3server/appliances/arista-veos.gns3a b/gns3server/appliances/arista-veos.gns3a
index ea6a5aeb..7d49517e 100644
--- a/gns3server/appliances/arista-veos.gns3a
+++ b/gns3server/appliances/arista-veos.gns3a
@@ -27,10 +27,10 @@
},
"images": [
{
- "filename": "vEOS-lab-4.25.0F.vmdk",
- "version": "4.25.0F",
- "md5sum": "d420763fdf3bc50e7e5b88418bd9d1fd",
- "filesize": 468779008,
+ "filename": "vEOS-lab-4.25.3M.vmdk",
+ "version": "4.25.3M",
+ "md5sum": "2f196969036b4d283e86f15118d59c26",
+ "filesize": 451543040,
"download_url": "https://www.arista.com/en/support/software-download"
},
{
@@ -211,10 +211,10 @@
],
"versions": [
{
- "name": "4.25.0F",
+ "name": "4.25.3M",
"images": {
"hda_disk_image": "Aboot-veos-serial-8.0.0.iso",
- "hdb_disk_image": "vEOS-lab-4.25.0F.vmdk"
+ "hdb_disk_image": "vEOS-lab-4.25.3M.vmdk"
}
},
{
diff --git a/gns3server/appliances/cisco-c8000v.gns3a b/gns3server/appliances/cisco-c8000v.gns3a
new file mode 100644
index 00000000..d10c4a58
--- /dev/null
+++ b/gns3server/appliances/cisco-c8000v.gns3a
@@ -0,0 +1,55 @@
+{
+ "name": "Cisco Catalyst 8000V",
+ "category": "router",
+ "description": "The Cisco Catalyst 8000V Edge Software is a virtual, form-factor router deployed on a virtual machine (VM) running on an x86 server hardware.",
+ "vendor_name": "Cisco",
+ "vendor_url": "http://www.cisco.com/",
+ "documentation_url": "https://www.cisco.com/c/en/us/td/docs/routers/C8000V/Configuration/c8000v-installation-configuration-guide.html",
+ "product_name": "c8000v",
+ "product_url": "https://www.cisco.com/c/en/us/support/routers/catalyst-8000v-edge-software/series.html",
+ "registry_version": 3,
+ "status": "stable",
+ "maintainer": "GNS3 Team",
+ "maintainer_email": "developers@gns3.net",
+ "usage": "There is no default password and enable password. A default configuration is present.",
+ "port_name_format": "Gi{port1}",
+ "qemu": {
+ "adapter_type": "vmxnet3",
+ "adapters": 4,
+ "ram": 4096,
+ "hda_disk_interface": "ide",
+ "arch": "x86_64",
+ "console_type": "telnet",
+ "kvm": "require"
+ },
+ "images": [
+ {
+ "filename": "c8000v-universalk9_8G_serial.17.04.01a.qcow2",
+ "version": "17.04.01a 8G",
+ "md5sum": "5c1dd1d3757ea43b5b02e0af7a010525",
+ "filesize": 1623130112,
+ "download_url": "https://software.cisco.com/download/home/286327102/type/282046477/release/Bengaluru-17.4.1a"
+ },
+ {
+ "filename": "c8000v-universalk9_8G_serial.17.04.01b.qcow2",
+ "version": "17.04.01b 8G",
+ "md5sum": "84aebb7f5f38bdd4df8e7607643027be",
+ "filesize": 1623130112,
+ "download_url": "https://software.cisco.com/download/home/286327102/type/282046477/release/Bengaluru-17.4.1b"
+ }
+ ],
+ "versions": [
+ {
+ "name": "17.04.01a 8G",
+ "images": {
+ "hda_disk_image": "c8000v-universalk9_8G_serial.17.04.01a.qcow2"
+ }
+ },
+ {
+ "name": "17.04.01b 8G",
+ "images": {
+ "hda_disk_image": "c8000v-universalk9_8G_serial.17.04.01b.qcow2"
+ }
+ }
+ ]
+}
diff --git a/gns3server/appliances/cisco-nxosv9k.gns3a b/gns3server/appliances/cisco-nxosv9k.gns3a
index 5fde4058..ae3c6e84 100644
--- a/gns3server/appliances/cisco-nxosv9k.gns3a
+++ b/gns3server/appliances/cisco-nxosv9k.gns3a
@@ -25,6 +25,20 @@
"kvm": "require"
},
"images": [
+ {
+ "filename": "nexus9500v64.10.1.1.qcow2",
+ "version": "9500v 10.1.1",
+ "md5sum": "35672370b0f43e725d5b2d92488524f0",
+ "filesize": 1592000512,
+ "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/10.1(1)"
+ },
+ {
+ "filename": "nexus9500v.9.3.7.qcow2",
+ "version": "9500v 9.3.7",
+ "md5sum": "65f669e0dd379a05a8cdbb9d7592a064",
+ "filesize": 1986068480,
+ "download_url": "https://software.cisco.com/download/home/286312239/type/282088129/release/9.3(7)"
+ },
{
"filename": "nexus9500v.9.3.3.qcow2",
"version": "9500v 9.3.3",
@@ -148,6 +162,20 @@
}
],
"versions": [
+ {
+ "name": "9500v 10.1.1",
+ "images": {
+ "bios_image": "OVMF-20160813.fd",
+ "hda_disk_image": "nexus9500v64.10.1.1.qcow2"
+ }
+ },
+ {
+ "name": "9500v 9.3.7",
+ "images": {
+ "bios_image": "OVMF-20160813.fd",
+ "hda_disk_image": "nexus9500v.9.3.7.qcow2"
+ }
+ },
{
"name": "9500v 9.3.3",
"images": {
diff --git a/gns3server/appliances/danos.gns3a b/gns3server/appliances/danos.gns3a
index 8739db48..bb9ed3d3 100644
--- a/gns3server/appliances/danos.gns3a
+++ b/gns3server/appliances/danos.gns3a
@@ -11,29 +11,29 @@
"status": "stable",
"maintainer": "GNS3 Team",
"maintainer_email": "developers@gns3.net",
- "usage": "Default username/password is vyatta/vyatta. DANOS will live boot and drop into a shell. DANOS can then be installed inside the VM by typing install image. Defaults to using a telnet console, but the vnc console can provide additional help if it's not booting.",
+ "usage": "Default username / password is tmpuser / tmppwd. DANOS will live boot and drop into a shell. DANOS can then be installed inside the VM by typing install image. Defaults to using a telnet console, but the vnc console can provide additional help if it's not booting.",
"symbol": ":/symbols/affinity/circle/gray/router_cloud.svg",
- "port_name_format": "dp0p{1}s{0}",
+ "port_name_format": "dp0s{3}",
"qemu": {
"adapter_type": "virtio-net-pci",
- "adapters": 3,
+ "adapters": 8,
"ram": 4096,
- "cpus": 2,
+ "cpus": 4,
"hda_disk_interface": "ide",
"arch": "x86_64",
"console_type": "telnet",
- "boot_priority": "dc",
- "kvm": "allow",
+ "boot_priority": "cd",
+ "kvm": "require",
"options": "-cpu host"
},
"images": [
{
- "filename": "danos-1908-amd64-vrouter.iso",
- "version": "1908",
- "md5sum": "e850b6aa2859de1075c11b9149fa50f4",
- "filesize": 409993216,
- "download_url": "https://danosproject.atlassian.net/wiki/spaces/DAN/pages/753667/DANOS+1908",
- "direct_download_url": "http://repos.danosproject.org.s3-website-us-west-1.amazonaws.com/images/danos-1908-amd64-vrouter.iso"
+ "filename": "danos-2012-base-amd64.iso",
+ "version": "2012",
+ "md5sum": "fb7a60dc9afecdb274464832b3ab1ccb",
+ "filesize": 441450496,
+ "download_url": "https://danosproject.atlassian.net/wiki/spaces/DAN/pages/892141595/DANOS+2012",
+ "direct_download_url": "https://s3-us-west-1.amazonaws.com/2012.repos.danosproject.org/2012/iso/danos-2012-base-amd64.iso"
},
{
"filename": "empty8G.qcow2",
@@ -46,10 +46,10 @@
],
"versions": [
{
- "name": "1908",
+ "name": "2012",
"images": {
"hda_disk_image": "empty8G.qcow2",
- "cdrom_image": "danos-1908-amd64-vrouter.iso"
+ "cdrom_image": "danos-2012-base-amd64.iso"
}
}
]
diff --git a/gns3server/appliances/kali-linux.gns3a b/gns3server/appliances/kali-linux.gns3a
index 2661c8a5..06288bd7 100644
--- a/gns3server/appliances/kali-linux.gns3a
+++ b/gns3server/appliances/kali-linux.gns3a
@@ -28,96 +28,96 @@
"version": "2019.3",
"md5sum": "9c6fb00558f78ed06992d89f745ef975",
"filesize": 3037736960,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2019.3/kali-linux-2019.3-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2019.3",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2019.3/kali-linux-2019.3-amd64.iso"
},
{
"filename": "kali-linux-2019.2-amd64.iso",
"version": "2019.2",
"md5sum": "0f89b6225d7ea9c18682f7cc541c1179",
"filesize": 3353227264,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2019.2/kali-linux-2019.2-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2019.2",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2019.2/kali-linux-2019.2-amd64.iso"
},
{
"filename": "kali-linux-mate-2019.2-amd64.iso",
"version": "2019.2 (MATE)",
"md5sum": "fec8dd7009f932c51a74323df965a709",
"filesize": 3313217536,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2019.2/kali-linux-mate-2019.2-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2019.2",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2019.2/kali-linux-mate-2019.2-amd64.iso"
},
{
"filename": "kali-linux-2019.1a-amd64.iso",
"version": "2019.1a",
"md5sum": "58c6111ed0be1919ea87267e7e65ab0f",
"filesize": 3483873280,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2019.1a/kali-linux-2019.1a-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2019.1a",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2019.1a/kali-linux-2019.1a-amd64.iso"
},
{
"filename": "kali-linux-2018.4-amd64.iso",
"version": "2018.4",
"md5sum": "1b2d598bb8d2003e6207c119c0ba42fe",
"filesize": 3139436544,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2018.4/kali-linux-2018.4-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2018.4",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2018.4/kali-linux-2018.4-amd64.iso"
},
{
"filename": "kali-linux-2018.3a-amd64.iso",
"version": "2018.3a",
"md5sum": "2da675d016bd690c05e180e33aa98b94",
"filesize": 3192651776,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2018.3a/kali-linux-2018.3a-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2018.3a",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2018.3a/kali-linux-2018.3a-amd64.iso"
},
{
"filename": "kali-linux-2018.1-amd64.iso",
"version": "2018.1",
"md5sum": "a3feb90df5b71b3c7f4a02bdddf221d7",
"filesize": 3028500480,
- "download_url": "https://www.kali.org/downloads/",
- "direct_download_url": "http://cdimage.kali.org/kali-2018.1/kali-linux-2018.1-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2018.1",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2018.1/kali-linux-2018.1-amd64.iso"
},
{
"filename": "kali-linux-2017.3-amd64.iso",
"version": "2017.3",
"md5sum": "b465580c897e94675ac1daf031fa66b9",
"filesize": 2886402048,
- "download_url": "http://cdimage.kali.org/kali-2017.3/",
- "direct_download_url": "http://cdimage.kali.org/kali-2017.3/kali-linux-2017.3-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2017.3/",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2017.3/kali-linux-2017.3-amd64.iso"
},
{
"filename": "kali-linux-2017.2-amd64.iso",
"version": "2017.2",
"md5sum": "541654f8f818450dc0db866a0a0f6eec",
"filesize": 3020619776,
- "download_url": "http://cdimage.kali.org/kali-2017.2/",
- "direct_download_url": "http://cdimage.kali.org/kali-2017.2/kali-linux-2017.2-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2017.2/",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2017.2/kali-linux-2017.2-amd64.iso"
},
{
"filename": "kali-linux-2017.1-amd64.iso",
"version": "2017.1",
"md5sum": "c8e742283929d7a12dbe7c58e398ff08",
"filesize": 2794307584,
- "download_url": "http://cdimage.kali.org/kali-2017.1/",
- "direct_download_url": "http://cdimage.kali.org/kali-2017.1/kali-linux-2017.1-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2017.1/",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2017.1/kali-linux-2017.1-amd64.iso"
},
{
"filename": "kali-linux-2016.2-amd64.iso",
"version": "2016.2",
"md5sum": "3d163746bc5148e61ad689d94bc263f9",
"filesize": 3076767744,
- "download_url": "http://cdimage.kali.org/kali-2016.2/",
- "direct_download_url": "http://cdimage.kali.org/kali-2016.2/kali-linux-2016.2-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2016.2/",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2016.2/kali-linux-2016.2-amd64.iso"
},
{
"filename": "kali-linux-2016.1-amd64.iso",
"version": "2016.1",
"md5sum": "2e1230dc14036935b3279dfe3e49ad39",
"filesize": 2945482752,
- "download_url": "http://cdimage.kali.org/kali-2016.1/",
- "direct_download_url": "http://cdimage.kali.org/kali-2016.1/kali-linux-2016.1-amd64.iso"
+ "download_url": "http://old.kali.org/kali-images/kali-2016.1/",
+ "direct_download_url": "http://old.kali.org/kali-images/kali-2016.1/kali-linux-2016.1-amd64.iso"
},
{
"filename": "kali-linux-2.0-amd64.iso",
diff --git a/gns3server/appliances/mcjoin.gns3a b/gns3server/appliances/mcjoin.gns3a
new file mode 100644
index 00000000..74515a34
--- /dev/null
+++ b/gns3server/appliances/mcjoin.gns3a
@@ -0,0 +1,18 @@
+{
+ "name": "mcjoin",
+ "category": "guest",
+ "description": "mcjoin is a very simple and easy-to-use tool to test IPv4 and IPv6 multicast.",
+ "vendor_name": "Joachim Nilsson",
+ "vendor_url": "https://github.com/troglobit",
+ "product_name": "mcjoin",
+ "registry_version": 3,
+ "status": "stable",
+ "maintainer": "GNS3 Team",
+ "maintainer_email": "developers@gns3.net",
+ "symbol": "linux_guest.svg",
+ "docker": {
+ "adapters": 1,
+ "image": "troglobit/mcjoin:latest",
+ "console_type": "telnet"
+ }
+}
diff --git a/gns3server/appliances/openbsd.gns3a b/gns3server/appliances/openbsd.gns3a
index 71c7a3ce..87bd0738 100644
--- a/gns3server/appliances/openbsd.gns3a
+++ b/gns3server/appliances/openbsd.gns3a
@@ -11,7 +11,7 @@
"maintainer": "GNS3 Team",
"maintainer_email": "developers@gns3.net",
"usage": "User root, password gns3",
- "first_port_name": "fxp0",
+ "first_port_name": "em0",
"port_name_format": "em{0}",
"qemu": {
"adapter_type": "e1000",
diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py
index 57c7e252..82f206d9 100644
--- a/gns3server/compute/base_manager.py
+++ b/gns3server/compute/base_manager.py
@@ -417,7 +417,7 @@ class BaseManager:
if not path or path == ".":
return ""
- orig_path = path
+ orig_path = os.path.normpath(path)
img_directory = self.get_images_directory()
valid_directory_prefices = images_directories(self._NODE_TYPE)
@@ -431,7 +431,8 @@ class BaseManager:
f"'{path}' is not allowed on this remote server. Please only use a file from '{img_directory}'"
)
- if not os.path.isabs(path):
+ if not os.path.isabs(orig_path):
+
for directory in valid_directory_prefices:
log.debug(f"Searching for image '{orig_path}' in '{directory}'")
path = self._recursive_search_file_in_directory(directory, orig_path)
@@ -475,7 +476,7 @@ class BaseManager:
for root, dirs, files in os.walk(directory):
for file in files:
# If filename is the same
- if s[1] == file and (s[0] == "" or s[0] == os.path.basename(root)):
+ if s[1] == file and (s[0] == '' or os.path.basename(s[0]) == os.path.basename(root)):
path = os.path.normpath(os.path.join(root, s[1]))
if os.path.exists(path):
return path
diff --git a/gns3server/config.py b/gns3server/config.py
index 022a3ce1..3388ed97 100644
--- a/gns3server/config.py
+++ b/gns3server/config.py
@@ -153,8 +153,6 @@ class Config:
Return the settings.
"""
- if self._settings is None:
- return ServerConfig()
return self._settings
def listen_for_config_changes(self, callback):
@@ -273,6 +271,7 @@ class Config:
return
if not parsed_files:
log.warning("No configuration file could be found or read")
+ self._settings = ServerConfig()
return
for file in parsed_files:
diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py
index 2774dd0f..22be7f8d 100644
--- a/gns3server/controller/__init__.py
+++ b/gns3server/controller/__init__.py
@@ -17,7 +17,6 @@
import os
import sys
-import json
import uuid
import socket
import shutil
@@ -30,7 +29,6 @@ from .appliance_manager import ApplianceManager
from .compute import Compute, ComputeError
from .notification import Notification
from .symbols import Symbols
-from ..version import __version__
from .topology import load_topology
from .gns3vm import GNS3VM
from ..utils.get_resource import get_resource
@@ -205,7 +203,7 @@ class Controller:
if iou_config.iourc_path:
iourc_path = iou_config.iourc_path
else:
- os.makedirs(os.path.dirname(server_config.secrets_dir), exist_ok=True)
+ os.makedirs(server_config.secrets_dir, exist_ok=True)
iourc_path = os.path.join(server_config.secrets_dir, "gns3_iourc_license")
try:
@@ -215,9 +213,17 @@ class Controller:
except OSError as e:
log.error(f"Cannot write IOU license file '{iourc_path}': {e}")
- # if self._appliance_manager.appliances_etag:
- # config._config.set("Controller", "appliances_etag", self._appliance_manager.appliances_etag)
- # config.write_config()
+ if self._appliance_manager.appliances_etag:
+ etag_directory = os.path.dirname(Config.instance().server_config)
+ os.makedirs(etag_directory, exist_ok=True)
+ etag_appliances_path = os.path.join(etag_directory, "gns3_appliances_etag")
+
+ try:
+ with open(etag_appliances_path, "w+") as f:
+ f.write(self._appliance_manager.appliances_etag)
+ log.info(f"etag appliances file '{etag_appliances_path}' saved")
+ except OSError as e:
+ log.error(f"Cannot write Etag appliance file '{etag_appliances_path}': {e}")
def _load_controller_settings(self):
"""
@@ -263,8 +269,19 @@ class Controller:
log.error(f"Cannot read IOU license file '{iourc_path}': {e}")
self._iou_license_settings["license_check"] = iou_config.license_check
- # self._appliance_manager.appliances_etag = controller_config.get("appliances_etag", None)
- # self._appliance_manager.load_appliances()
+
+ etag_directory = os.path.dirname(Config.instance().server_config)
+ etag_appliances_path = os.path.join(etag_directory, "gns3_appliances_etag")
+ self._appliance_manager.appliances_etag = None
+ if os.path.exists(etag_appliances_path):
+ try:
+ with open(etag_appliances_path) as f:
+ self._appliance_manager.appliances_etag = f.read()
+ log.info(f"etag appliances file '{etag_appliances_path}' loaded")
+ except OSError as e:
+ log.error(f"Cannot read Etag appliance file '{etag_appliances_path}': {e}")
+
+ self._appliance_manager.load_appliances()
self._config_loaded = True
async def load_projects(self):
diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py
index ebe969b7..e3c1cca5 100644
--- a/gns3server/controller/compute.py
+++ b/gns3server/controller/compute.py
@@ -487,7 +487,7 @@ class Compute:
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a ressources usage bomb)
from gns3server.api.server import app
- if not app.state.exiting and not hasattr(sys, "_called_from_test") or not sys._called_from_test:
+ if not app.state.exiting and not hasattr(sys, "_called_from_test"):
log.info(f"Reconnecting to to compute '{self._id}' WebSocket '{ws_url}'")
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py
index 80f63129..3b1f4473 100644
--- a/gns3server/controller/topology.py
+++ b/gns3server/controller/topology.py
@@ -50,7 +50,7 @@ class DynamipsNodeValidation(DynamipsCreate):
name: Optional[str] = None
-def _check_topology_schema(topo):
+def _check_topology_schema(topo, path):
try:
Topology.parse_obj(topo)
@@ -60,7 +60,7 @@ def _check_topology_schema(topo):
DynamipsNodeValidation.parse_obj(node.get("properties", {}))
except pydantic.ValidationError as e:
- error = f"Invalid data in topology file: {e}"
+ error = f"Invalid data in topology file {path}: {e}"
log.critical(error)
raise ControllerError(error)
@@ -117,7 +117,7 @@ def project_to_topology(project):
data["topology"]["computes"].append(compute)
elif isinstance(compute, dict):
data["topology"]["computes"].append(compute)
- _check_topology_schema(data)
+ _check_topology_schema(data, project.path)
return data
@@ -187,7 +187,7 @@ def load_topology(path):
topo["variables"] = [var for var in variables if var.get("name")]
try:
- _check_topology_schema(topo)
+ _check_topology_schema(topo, path)
except ControllerError as e:
log.error("Can't load the topology %s", path)
raise e
diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py
index 012806e6..8c48494f 100644
--- a/gns3server/crash_report.py
+++ b/gns3server/crash_report.py
@@ -59,7 +59,7 @@ class CrashReport:
Report crash to a third party service
"""
- DSN = "https://d8001b95f4f244fe82ea720c9d4f0c09:690d4fe9fe3b4aa9aab004bc9e76cb8a@o19455.ingest.sentry.io/38482"
+ DSN = "https://ccd9829f1391432c900aa835e7eb1050:83d10f4d74654e2b8428129a62cf31cf@o19455.ingest.sentry.io/38482"
_instance = None
def __init__(self):
diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py
index 71ead9d8..1c644f72 100644
--- a/gns3server/db/models/__init__.py
+++ b/gns3server/db/models/__init__.py
@@ -16,7 +16,7 @@
# along with this program. If not, see .
from .base import Base
-from .users import User
+from .users import User, UserGroup
from .computes import Compute
from .templates import (
Template,
diff --git a/gns3server/db/models/base.py b/gns3server/db/models/base.py
index 17c68f76..1dbae1af 100644
--- a/gns3server/db/models/base.py
+++ b/gns3server/db/models/base.py
@@ -43,6 +43,7 @@ class GUID(TypeDecorator):
"""
impl = CHAR
+ cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
@@ -75,8 +76,8 @@ class BaseTable(Base):
__abstract__ = True
- created_at = Column(DateTime, default=func.current_timestamp())
- updated_at = Column(DateTime, default=func.current_timestamp(), onupdate=func.current_timestamp())
+ created_at = Column(DateTime, server_default=func.current_timestamp())
+ updated_at = Column(DateTime, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
def generate_uuid():
diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py
index e39e0695..470b88cb 100644
--- a/gns3server/db/models/templates.py
+++ b/gns3server/db/models/templates.py
@@ -45,7 +45,7 @@ class CloudTemplate(Template):
__tablename__ = "cloud_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
ports_mapping = Column(PickleType)
remote_console_host = Column(String)
remote_console_port = Column(Integer)
@@ -59,7 +59,7 @@ class DockerTemplate(Template):
__tablename__ = "docker_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
image = Column(String)
adapters = Column(Integer)
start_command = Column(String)
@@ -83,7 +83,7 @@ class DynamipsTemplate(Template):
__tablename__ = "dynamips_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
platform = Column(String)
chassis = Column(String)
image = Column(String)
@@ -126,7 +126,7 @@ class EthernetHubTemplate(Template):
__tablename__ = "ethernet_hub_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
ports_mapping = Column(PickleType)
__mapper_args__ = {"polymorphic_identity": "ethernet_hub", "polymorphic_load": "selectin"}
@@ -136,7 +136,7 @@ class EthernetSwitchTemplate(Template):
__tablename__ = "ethernet_switch_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
ports_mapping = Column(PickleType)
console_type = Column(String)
@@ -147,7 +147,7 @@ class IOUTemplate(Template):
__tablename__ = "iou_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
path = Column(String)
ethernet_adapters = Column(Integer)
serial_adapters = Column(Integer)
@@ -167,7 +167,7 @@ class QemuTemplate(Template):
__tablename__ = "qemu_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
qemu_path = Column(String)
platform = Column(String)
linked_clone = Column(Boolean)
@@ -213,7 +213,7 @@ class VirtualBoxTemplate(Template):
__tablename__ = "virtualbox_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
vmname = Column(String)
ram = Column(Integer)
linked_clone = Column(Boolean)
@@ -236,7 +236,7 @@ class VMwareTemplate(Template):
__tablename__ = "vmware_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
vmx_path = Column(String)
linked_clone = Column(Boolean)
first_port_name = Column(String)
@@ -258,7 +258,7 @@ class VPCSTemplate(Template):
__tablename__ = "vpcs_templates"
- template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
+ template_id = Column(GUID, ForeignKey("templates.template_id", ondelete="CASCADE"), primary_key=True)
base_script_file = Column(String)
console_type = Column(String)
console_auto_start = Column(Boolean, default=False)
diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py
index 6f9400ba..046ec5c4 100644
--- a/gns3server/db/models/users.py
+++ b/gns3server/db/models/users.py
@@ -15,15 +15,24 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-from sqlalchemy import Boolean, Column, String, event
+from sqlalchemy import Table, Boolean, Column, String, ForeignKey, event
+from sqlalchemy.orm import relationship
-from .base import BaseTable, generate_uuid, GUID
+from .base import Base, BaseTable, generate_uuid, GUID
+from gns3server.config import Config
from gns3server.services import auth_service
import logging
log = logging.getLogger(__name__)
+users_group_members = Table(
+ "users_group_members",
+ Base.metadata,
+ Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")),
+ Column("user_group_id", GUID, ForeignKey("users_group.user_group_id", ondelete="CASCADE"))
+)
+
class User(BaseTable):
@@ -36,14 +45,18 @@ class User(BaseTable):
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False)
+ groups = relationship("UserGroup", secondary=users_group_members, back_populates="users")
@event.listens_for(User.__table__, 'after_create')
def create_default_super_admin(target, connection, **kw):
- hashed_password = auth_service.hash_password("admin")
+ config = Config.instance().settings
+ default_admin_username = config.Server.default_admin_username
+ default_admin_password = config.Server.default_admin_password.get_secret_value()
+ hashed_password = auth_service.hash_password(default_admin_password)
stmt = target.insert().values(
- username="admin",
+ username=default_admin_username,
full_name="Super Administrator",
hashed_password=hashed_password,
is_superadmin=True
@@ -51,3 +64,46 @@ def create_default_super_admin(target, connection, **kw):
connection.execute(stmt)
connection.commit()
log.info("The default super admin account has been created in the database")
+
+
+class UserGroup(BaseTable):
+
+ __tablename__ = "users_group"
+
+ user_group_id = Column(GUID, primary_key=True, default=generate_uuid)
+ name = Column(String, unique=True, index=True)
+ is_updatable = Column(Boolean, default=True)
+ users = relationship("User", secondary=users_group_members, back_populates="groups")
+
+
+@event.listens_for(UserGroup.__table__, 'after_create')
+def create_default_user_groups(target, connection, **kw):
+
+ default_groups = [
+ {"name": "Administrators", "is_updatable": False},
+ {"name": "Editors", "is_updatable": False},
+ {"name": "Users", "is_updatable": False}
+ ]
+
+ stmt = target.insert().values(default_groups)
+ connection.execute(stmt)
+ connection.commit()
+ log.info("The default user groups have been created in the database")
+
+
+@event.listens_for(users_group_members, 'after_create')
+def add_admin_to_group(target, connection, **kw):
+
+ users_group_table = UserGroup.__table__
+ stmt = users_group_table.select().where(users_group_table.c.name == "Administrators")
+ result = connection.execute(stmt)
+ user_group_id = result.first().user_group_id
+
+ users_table = User.__table__
+ stmt = users_table.select().where(users_table.c.is_superadmin.is_(True))
+ result = connection.execute(stmt)
+ user_id = result.first().user_id
+
+ stmt = target.insert().values(user_id=user_id, user_group_id=user_group_id)
+ connection.execute(stmt)
+ connection.commit()
diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py
index 96576cfc..c93c741e 100644
--- a/gns3server/db/repositories/users.py
+++ b/gns3server/db/repositories/users.py
@@ -16,9 +16,10 @@
# along with this program. If not, see .
from uuid import UUID
-from typing import Optional, List
+from typing import Optional, List, Union
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
from .base import BaseRepository
@@ -107,3 +108,105 @@ class UsersRepository(BaseRepository):
if not self._auth_service.verify_password(password, user.hashed_password):
return None
return user
+
+ async def get_user_memberships(self, user_id: UUID) -> List[models.UserGroup]:
+
+ query = select(models.UserGroup).\
+ join(models.UserGroup.users).\
+ filter(models.User.user_id == user_id)
+
+ result = await self._db_session.execute(query)
+ return result.scalars().all()
+
+ async def get_user_group(self, user_group_id: UUID) -> Optional[models.UserGroup]:
+
+ query = select(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id)
+ result = await self._db_session.execute(query)
+ return result.scalars().first()
+
+ async def get_user_group_by_name(self, name: str) -> Optional[models.UserGroup]:
+
+ query = select(models.UserGroup).where(models.UserGroup.name == name)
+ result = await self._db_session.execute(query)
+ return result.scalars().first()
+
+ async def get_user_groups(self) -> List[models.UserGroup]:
+
+ query = select(models.UserGroup)
+ result = await self._db_session.execute(query)
+ return result.scalars().all()
+
+ async def create_user_group(self, user_group: schemas.UserGroupCreate) -> models.UserGroup:
+
+ db_user_group = models.UserGroup(name=user_group.name)
+ self._db_session.add(db_user_group)
+ await self._db_session.commit()
+ await self._db_session.refresh(db_user_group)
+ return db_user_group
+
+ async def update_user_group(
+ self,
+ user_group_id: UUID,
+ user_group_update: schemas.UserGroupUpdate
+ ) -> Optional[models.UserGroup]:
+
+ update_values = user_group_update.dict(exclude_unset=True)
+ query = update(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id).values(update_values)
+
+ await self._db_session.execute(query)
+ await self._db_session.commit()
+ return await self.get_user_group(user_group_id)
+
+ async def delete_user_group(self, user_group_id: UUID) -> bool:
+
+ query = delete(models.UserGroup).where(models.UserGroup.user_group_id == user_group_id)
+ result = await self._db_session.execute(query)
+ await self._db_session.commit()
+ return result.rowcount > 0
+
+ async def add_member_to_user_group(
+ self,
+ user_group_id: UUID,
+ user: models.User
+ ) -> Union[None, models.UserGroup]:
+
+ query = select(models.UserGroup).\
+ options(selectinload(models.UserGroup.users)).\
+ where(models.UserGroup.user_group_id == user_group_id)
+ result = await self._db_session.execute(query)
+ user_group_db = result.scalars().first()
+ if not user_group_db:
+ return None
+
+ user_group_db.users.append(user)
+ await self._db_session.commit()
+ await self._db_session.refresh(user_group_db)
+ return user_group_db
+
+ async def remove_member_from_user_group(
+ self,
+ user_group_id: UUID,
+ user: models.User
+ ) -> Union[None, models.UserGroup]:
+
+ query = select(models.UserGroup).\
+ options(selectinload(models.UserGroup.users)).\
+ where(models.UserGroup.user_group_id == user_group_id)
+ result = await self._db_session.execute(query)
+ user_group_db = result.scalars().first()
+ if not user_group_db:
+ return None
+
+ user_group_db.users.remove(user)
+ await self._db_session.commit()
+ await self._db_session.refresh(user_group_db)
+ return user_group_db
+
+ async def get_user_group_members(self, user_group_id: UUID) -> List[models.User]:
+
+ query = select(models.User).\
+ join(models.User.groups).\
+ filter(models.UserGroup.user_group_id == user_group_id)
+
+ result = await self._db_session.execute(query)
+ return result.scalars().all()
diff --git a/gns3server/db/tasks.py b/gns3server/db/tasks.py
index fb53ed4f..c3c5ec0c 100644
--- a/gns3server/db/tasks.py
+++ b/gns3server/db/tasks.py
@@ -22,6 +22,8 @@ from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError
from typing import List
+from sqlalchemy import event
+from sqlalchemy.engine import Engine
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from gns3server.db.repositories.computes import ComputesRepository
@@ -41,12 +43,22 @@ async def connect_to_db(app: FastAPI) -> None:
db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+aiosqlite:///{db_path}")
engine = create_async_engine(db_url, connect_args={"check_same_thread": False}, future=True)
try:
- async with engine.begin() as conn:
+ async with engine.connect() as conn:
await conn.run_sync(Base.metadata.create_all)
log.info(f"Successfully connected to database '{db_url}'")
app.state._db_engine = engine
except SQLAlchemyError as e:
- log.error(f"Error while connecting to database '{db_url}: {e}")
+ log.fatal(f"Error while connecting to database '{db_url}: {e}")
+
+
+@event.listens_for(Engine, "connect")
+def set_sqlite_pragma(dbapi_connection, connection_record):
+
+ # Enable SQL foreign key support for SQLite
+ # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.close()
async def get_computes(app: FastAPI) -> List[dict]:
diff --git a/gns3server/logger.py b/gns3server/logger.py
index 592d9ada..74cd2093 100644
--- a/gns3server/logger.py
+++ b/gns3server/logger.py
@@ -111,7 +111,7 @@ class LogFilter:
"""
def filter(record):
- if "/settings" in record.msg and "200" in record.msg:
+ if isinstance(record.msg, str) and "/settings" in record.msg and "200" in record.msg:
return 0
return 1
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index 838141f8..f32985a5 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -27,7 +27,7 @@ from .controller.drawings import Drawing
from .controller.gns3vm import GNS3VM
from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile
-from .controller.users import UserCreate, UserUpdate, User, Credentials
+from .controller.users import UserCreate, UserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup
from .controller.tokens import Token
from .controller.snapshots import SnapshotCreate, Snapshot
from .controller.iou_license import IOULicense
diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py
index 80f52add..ec671823 100644
--- a/gns3server/schemas/config.py
+++ b/gns3server/schemas/config.py
@@ -134,6 +134,8 @@ class ServerSettings(BaseModel):
user: str = None
password: SecretStr = None
enable_http_auth: bool = False
+ default_admin_username: str = "admin"
+ default_admin_password: SecretStr = SecretStr("admin")
allowed_interfaces: List[str] = Field(default_factory=list)
default_nat_interface: str = None
allow_remote_console: bool = False
diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py
index f84bb527..00343e64 100644
--- a/gns3server/schemas/controller/computes.py
+++ b/gns3server/schemas/controller/computes.py
@@ -14,9 +14,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import uuid
+
from pydantic import BaseModel, Field, SecretStr, validator
from typing import List, Optional, Union
-from uuid import UUID, uuid4
from enum import Enum
from .nodes import NodeType
@@ -49,7 +50,7 @@ class ComputeCreate(ComputeBase):
Data to create a compute.
"""
- compute_id: Union[str, UUID] = Field(default_factory=uuid4)
+ compute_id: Union[str, uuid.UUID] = None
password: Optional[SecretStr] = None
class Config:
@@ -63,7 +64,18 @@ class ComputeCreate(ComputeBase):
}
}
- @validator("name", always=True)
+ @validator("compute_id", pre=True, always=True)
+ def default_compute_id(cls, v, values):
+
+ if v is not None:
+ return v
+ else:
+ protocol = values.get("protocol")
+ host = values.get("host")
+ port = values.get("port")
+ return uuid.uuid5(uuid.NAMESPACE_URL, f"{protocol}://{host}:{port}")
+
+ @validator("name", pre=True, always=True)
def generate_name(cls, name, values):
if name is not None:
@@ -119,7 +131,7 @@ class Compute(DateTimeModelMixin, ComputeBase):
Data returned for a compute.
"""
- compute_id: Union[str, UUID]
+ compute_id: Union[str, uuid.UUID]
name: str
connected: Optional[bool] = Field(None, description="Whether the controller is connected to the compute or not")
cpu_usage_percent: Optional[float] = Field(None, description="CPU usage of the compute", ge=0, le=100)
diff --git a/gns3server/schemas/controller/users.py b/gns3server/schemas/controller/users.py
index 3fa0a551..28d40688 100644
--- a/gns3server/schemas/controller/users.py
+++ b/gns3server/schemas/controller/users.py
@@ -58,6 +58,39 @@ class User(DateTimeModelMixin, UserBase):
orm_mode = True
+class UserGroupBase(BaseModel):
+ """
+ Common user group properties.
+ """
+
+ name: Optional[str] = Field(None, min_length=3, regex="[a-zA-Z0-9_-]+$")
+
+
+class UserGroupCreate(UserGroupBase):
+ """
+ Properties to create an user group.
+ """
+
+ name: Optional[str] = Field(..., min_length=3, regex="[a-zA-Z0-9_-]+$")
+
+
+class UserGroupUpdate(UserGroupBase):
+ """
+ Properties to update an user group.
+ """
+
+ pass
+
+
+class UserGroup(DateTimeModelMixin, UserGroupBase):
+
+ user_group_id: UUID
+ is_updatable: bool
+
+ class Config:
+ orm_mode = True
+
+
class Credentials(BaseModel):
username: str
diff --git a/gns3server/server.py b/gns3server/server.py
index 3c7dedb1..e52f0834 100644
--- a/gns3server/server.py
+++ b/gns3server/server.py
@@ -319,6 +319,7 @@ class Server:
access_log=access_log,
ssl_certfile=config.Server.certfile,
ssl_keyfile=config.Server.certkey,
+ lifespan="on"
)
# overwrite uvicorn loggers with our own logger
diff --git a/gns3server/static/web-ui/index.html b/gns3server/static/web-ui/index.html
index 8a91932b..6f9c282d 100644
--- a/gns3server/static/web-ui/index.html
+++ b/gns3server/static/web-ui/index.html
@@ -25,7 +25,7 @@
-
+
@@ -41,12 +41,12 @@
-
+