diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index 3476159a7..119192121 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -18,7 +18,7 @@ jobs:
strategy:
matrix:
os: ["ubuntu-latest"]
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
diff --git a/gns3server/api/routes/compute/dependencies/authentication.py b/gns3server/api/routes/compute/dependencies/authentication.py
index 377a89dc6..5e725b3d8 100644
--- a/gns3server/api/routes/compute/dependencies/authentication.py
+++ b/gns3server/api/routes/compute/dependencies/authentication.py
@@ -26,12 +26,29 @@ from gns3server.config import Config
from typing import Optional, Union
log = logging.getLogger(__name__)
-security = HTTPBasic()
+security = HTTPBasic(auto_error=False)
def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None:
+ """
+ Authenticate compute requests.
+
+ Returns None if authentication is disabled or if authentication succeeds
+ Raises HTTPException if authentication is required but credentials are invalid
+ """
server_settings = Config.instance().settings.Server
+
+ if not server_settings.enable_http_auth:
+ return None
+
+ if credentials is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid compute username or password",
+ headers={"WWW-Authenticate": "Basic"},
+ )
+
username = secrets.compare_digest(credentials.username, server_settings.compute_username)
password = secrets.compare_digest(credentials.password, server_settings.compute_password.get_secret_value())
if not (username and password):
@@ -44,6 +61,12 @@ def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
"""
"""
+
+ server_settings = Config.instance().settings.Server
+
+ if not server_settings.enable_http_auth:
+ await websocket.accept()
+ return websocket
await websocket.accept()
@@ -68,7 +91,6 @@ async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSock
if not separator:
raise invalid_user_credentials_exc
- server_settings = Config.instance().settings.Server
username = secrets.compare_digest(username, server_settings.compute_username)
password = secrets.compare_digest(password, server_settings.compute_password.get_secret_value())
if not (username and password):
diff --git a/gns3server/api/routes/index.py b/gns3server/api/routes/index.py
index 8a12a9ec1..ebd22c096 100644
--- a/gns3server/api/routes/index.py
+++ b/gns3server/api/routes/index.py
@@ -46,8 +46,8 @@ async def web_ui(file_path: str):
file_path = os.path.normpath(file_path).strip("/")
file_path = os.path.join("static", "web-ui", file_path)
- # Raise error if user try to escape
- if file_path[0] == ".":
+ # Raise error if user tries to escape the web-ui directory
+ if not os.path.normpath(file_path).startswith(os.path.join("static", "web-ui")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
static = get_resource(file_path)
diff --git a/gns3server/api/server.py b/gns3server/api/server.py
index acdda6409..145394681 100644
--- a/gns3server/api/server.py
+++ b/gns3server/api/server.py
@@ -195,7 +195,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
@app.exception_handler(SQLAlchemyError)
-async def sqlalchemry_error_handler(request: Request, exc: SQLAlchemyError):
+async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
log.error(f"Controller database error in {request.url.path} ({request.method}): {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py
index c7a3821c1..4d56624a8 100644
--- a/gns3server/compute/base_node.py
+++ b/gns3server/compute/base_node.py
@@ -349,8 +349,10 @@ class BaseNode:
Stop the node process.
"""
- await self.stop_wrap_console()
- self.status = "stopped"
+ try:
+ await self.stop_wrap_console()
+ finally:
+ self.status = "stopped"
def suspend(self):
"""
diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py
index 3e49d6749..e712ddd49 100644
--- a/gns3server/compute/qemu/qemu_vm.py
+++ b/gns3server/compute/qemu/qemu_vm.py
@@ -1131,7 +1131,6 @@ class QemuVM(BaseNode):
await cancellable_wait_run_in_executor(md5sum, self._hdb_disk_image, self.working_dir)
await cancellable_wait_run_in_executor(md5sum, self._hdc_disk_image, self.working_dir)
await cancellable_wait_run_in_executor(md5sum, self._hdd_disk_image, self.working_dir)
-
super().create()
async def start(self):
@@ -2700,18 +2699,19 @@ class QemuVM(BaseNode):
if not disk_image:
continue
answer[f"hd{drive}_disk_image"] = self.manager.get_relative_image_path(disk_image, self.working_dir)
- answer[f"hd{drive}_disk_image_md5sum"] = md5sum(disk_image, self.working_dir)
local_disk = os.path.join(self.working_dir, f"hd{drive}_disk.qcow2")
if os.path.exists(local_disk):
try:
qcow2 = Qcow2(local_disk)
if qcow2.backing_file:
- answer[f"hd{drive}_disk_image"] = os.path.basename(local_disk)
- answer[f"hd{drive}_disk_image_md5sum"] = md5sum(local_disk, self.working_dir)
+ # update disk image path to the local disk image and add the backing file name in the answer
answer[f"hd{drive}_disk_image_backing_file"] = os.path.basename(qcow2.backing_file)
+ answer[f"hd{drive}_disk_image"] = os.path.basename(local_disk)
except (Qcow2Error, OSError) as e:
log.error(f"Could not read qcow2 disk image '{local_disk}': {e}")
continue
+ # only compute the md5sum if the disk exists to avoid computing one for a backing file
+ answer[f"hd{drive}_disk_image_md5sum"] = md5sum(local_disk, self.working_dir)
answer["cdrom_image"] = self.manager.get_relative_image_path(self._cdrom_image, self.working_dir)
answer["cdrom_image_md5sum"] = md5sum(self._cdrom_image, self.working_dir)
diff --git a/gns3server/config.py b/gns3server/config.py
index 0aee21172..eb0ac1b9f 100644
--- a/gns3server/config.py
+++ b/gns3server/config.py
@@ -97,8 +97,20 @@ class Config:
if self._main_config_file is None:
- # TODO: migrate versioned config file from a previous version of GNS3 (for instance 2.2 -> 3.0) + support profiles
- # migrate post version 2.2.0 config files if they exist
+ if not os.path.exists(versioned_user_dir):
+ # Try to migrate the configuration files and database from the previous version if it exists
+ previous_version = f"{__version_info__[0]}.{int(__version_info__[1]) - 1}"
+ if self._profile:
+ previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version, "profiles", self._profile)
+ else:
+ previous_versioned_user_dir = os.path.join(home, ".config", appname, previous_version)
+ if os.path.exists(previous_versioned_user_dir):
+ try:
+ shutil.copytree(previous_versioned_user_dir, versioned_user_dir, symlinks=True, ignore_dangling_symlinks=True)
+ log.info(f"Migrated configuration files and database from '{previous_versioned_user_dir}' to '{versioned_user_dir}'")
+ except OSError as e:
+ log.error(f"Cannot migrate old config files and database from '{previous_versioned_user_dir}: {e}")
+
os.makedirs(versioned_user_dir, exist_ok=True)
try:
# migrate the server config file
diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf
index 0a99ea338..92142bab5 100644
--- a/gns3server/config_samples/gns3_server.conf
+++ b/gns3server/config_samples/gns3_server.conf
@@ -91,6 +91,9 @@ udp_end_port_range = 30000
; uBridge executable location, default: search in PATH
;ubridge_path = ubridge
+; Option to enable or disable compute HTTP authentication
+enable_http_auth = True
+
; Username for compute HTTP authentication, "gns3" is the default if not specified
compute_username = gns3
; Password for compute HTTP authentication, a randomly generated password is used if not specified
diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py
index 75d576747..ad824e51c 100644
--- a/gns3server/controller/appliance_manager.py
+++ b/gns3server/controller/appliance_manager.py
@@ -15,12 +15,9 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see