diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..21e1bf803 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +name: Bug report +description: Report a bug so we can fix it. +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: How can we reproduce this? Numbered steps if possible. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen instead? + - type: input + id: version + attributes: + label: Version / commit + description: Which version or commit hash are you on? + - type: textarea + id: environment + attributes: + label: Environment + description: OS, runtime version, anything else that might be relevant. + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Paste any relevant log output. This is automatically rendered as code. + render: shell diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 834bad959..3978699ee 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -28,6 +28,13 @@ jobs: echo "stable=false" >> $GITHUB_OUTPUT fi + - name: Set lowercase image name vars + if: steps.ver.outputs.stable == 'true' + id: names + run: | + echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + echo "repo=$(echo '${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx if: steps.ver.outputs.stable == 'true' uses: docker/setup-buildx-action@v3 @@ -57,5 +64,5 @@ jobs: tags: | ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }} ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest - ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }} - ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest + ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:${{ steps.ver.outputs.tag }} + ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:latest diff --git a/CHANGELOG b/CHANGELOG index e6b4899b7..0f152d278 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,14 @@ # Change Log +## 2.2.61 30/07/2026 + +* Sync appliances +* fix(import): unvalidated symlink creation in import_project +* fix(qemu): fix addition of QEMU RNG device causes interface names to change +* fix(qemu): remove trailing space from RNG object argument +* fix: do not start nodes when deleting a project +* fix: correct always-true state check in DockerVM.stop() + ## 2.2.60 15/07/2026 * Sync appliances diff --git a/gns3server/appliances/armbian.gns3a b/gns3server/appliances/armbian.gns3a new file mode 100644 index 000000000..094f2bd35 --- /dev/null +++ b/gns3server/appliances/armbian.gns3a @@ -0,0 +1,59 @@ +{ + "appliance_id": "b3b90fde-143a-4129-8031-ccbba73c5e02", + "name": "armbian", + "category": "guest", + "description": "A highly optimized base operating system specialized for single board computers (SBCs) and its extensive build framework.", + "vendor_name": "The Armbian team", + "vendor_url": "https://armbian.com/", + "documentation_url": "https://docs.armbian.com/", + "product_name": "Armbian UEFI x86", + "product_url": "https://armbian.com/boards/uefi-x86", + "registry_version": 4, + "status": "stable", + "maintainer": "GNS3 Team", + "maintainer_email": "developers@gns3.net", + "usage": "By first login you create root password and new sudo user.\n\nBoot disk from UEFI shell, type: FS0:EFI\\BOOT\\BOOTX64 and press ", + "port_name_format": "Ethernet{0}", + "qemu": { + "adapter_type": "virtio-net-pci", + "adapters": 2, + "ram": 256, + "hda_disk_interface": "virtio", + "arch": "x86_64", + "console_type": "spice+agent", + "uefi": false, + "boot_priority": "c", + "kvm": "require", + "options": "-nographic" + }, + "images": [ + { + "filename": "OVMF-edk2-stable202305.fd", + "version": "stable202305", + "md5sum": "6c4cf1519fec4a4b95525d9ae562963a", + "filesize": 4194304, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/OVMF-edk2-stable202305.fd.zip/download", + "compression": "zip" + }, + { + "filename": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2", + "version": "Armbian 26.5.1 Minimal (CLI)", + "md5sum": "7f4c915668718d6135406de5a6c4fc30", + "filesize": 877920512, + "download_url": "https://armbian.com/boards/uefi-x86", + "direct_download_url": "https://armbian.atomonetworks.com/dl/uefi-x86/archive/Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2.xz", + "compression": "xz" + } + + ], + "versions": [ + { + "name": "Armbian 26.5.1 Minimal (CLI)", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "Armbian_26.5.1_Uefi-x86_trixie_cloud_6.18.32_minimal.img.qcow2" + } + } + ] +} diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 7315dad0d..dafe3e8ee 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1048,7 +1048,7 @@ class DockerVM(BaseNode): await self._fix_permissions() state = await self._get_container_state() - if state != "stopped" or state != "exited": + if state != "stopped" and state != "exited": # t=5 number of seconds to wait before killing the container try: await self.manager.query("POST", f"containers/{self._cid}/stop", params={"t": 5}) diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index c4866fbb0..b099becb6 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -2341,7 +2341,7 @@ class QemuVM(BaseNode): options.extend(["-drive", "if=pflash,format=raw,file={}".format(ovmf_vars_node_path)]) # edk2 firmware requires a Random Number Generator (RNG) device in order to turn network adapters on - options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0 "]) + options.extend(["-object", "rng-random,filename=/dev/urandom,id=rng0"]) options.extend(["-device", "virtio-rng-pci,rng=rng0"]) return options @@ -2665,7 +2665,6 @@ class QemuVM(BaseNode): elif sys.platform.startswith("darwin"): command.extend(["-enable-hax"]) command.extend(["-boot", f"order={self._boot_priority}"]) - command.extend(self._bios_option()) command.extend(self._cdrom_option()) command.extend(await self._disk_options()) command.extend(self._linux_boot_options()) @@ -2675,6 +2674,9 @@ class QemuVM(BaseNode): command.extend(self._aux_options()) command.extend(self._monitor_options()) command.extend(await self._network_options()) + command.extend((await self._network_options())) + # bios options must be last to have predictable NIC numbering, see https://github.com/GNS3/gns3-server/issues/2838 + command.extend(self._bios_option()) if self.on_close != "save_vm_state": await self._clear_save_vm_stated() else: diff --git a/gns3server/controller/import_project.py b/gns3server/controller/import_project.py index 3aaec54e6..e5da5460c 100644 --- a/gns3server/controller/import_project.py +++ b/gns3server/controller/import_project.py @@ -182,24 +182,46 @@ async def import_project( project = await controller.load_project(dot_gns3_path, load=False) return project + def _create_symbolic_links(zip_file, path): """ Manually create symbolic links (if any) because ZipFile does not support it. + Refuse any target that escapes `path`. :param zip_file: ZipFile instance :param path: project location """ + path_root = os.path.realpath(path) + os.sep for zip_info in zip_file.infolist(): - if stat.S_ISLNK(zip_info.external_attr >> 16): - symlink_target = zip_file.read(zip_info.filename).decode() - symlink_path = os.path.join(path, zip_info.filename) - try: - # remove the regular file and replace it by a symbolic link - os.remove(symlink_path) - os.symlink(symlink_target, symlink_path) - except OSError as e: - raise ControllerError(f"Cannot create symbolic link: {e}") + if not stat.S_ISLNK(zip_info.external_attr >> 16): + continue + symlink_target = zip_file.read(zip_info.filename).decode() + symlink_path = os.path.join(path, zip_info.filename) + + # 1. Reject absolute targets outright. + if os.path.isabs(symlink_target): + raise ControllerError(f"Symlink {zip_info.filename!r} has absolute target {symlink_target!r}, refusing") + + # 2. Reject paths where the entry name itself escapes (defence in depth; + # extractall normally would already have caught this). + member_abs = os.path.realpath(symlink_path) + if not (member_abs + os.sep).startswith(path_root) and member_abs + os.sep != path_root: + raise ControllerError(f"Symlink entry {zip_info.filename!r} escapes project dir, refusing") + + # 3. Resolve the symlink target relative to the entry's own parent + # directory and verify the resolved real path stays inside `path`. + link_dir = os.path.realpath(os.path.dirname(symlink_path)) + resolved_target = os.path.realpath(os.path.join(link_dir, symlink_target)) + if not (resolved_target + os.sep).startswith(path_root) and resolved_target + os.sep != path_root: + raise ControllerError("Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing") + + try: + os.remove(symlink_path) + os.symlink(symlink_target, symlink_path) + except OSError as e: + raise ControllerError(f"Cannot create symbolic link: {e}") + def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False): """ diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index e6a0ffd6a..a6bcc159c 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -1434,7 +1434,7 @@ class Project: if self._status != "opened": try: - await self.open() + await self.open(auto_start=False) except ControllerError as e: # ignore missing images or other conflicts when deleting a project log.warning(f"Conflict while deleting project: {e}") @@ -1524,9 +1524,12 @@ class Project: return os.path.join(self.path, self._filename) @locking - async def open(self): + async def open(self, auto_start=True): """ Load topology elements + + :param auto_start: whether the nodes may be started when the project + has auto start enabled """ if self._closing is True: @@ -1692,7 +1695,7 @@ class Project: self._loading = False self.emit_controller_notification("project.opened", self.asdict()) # Should we start the nodes when project is open - if self._auto_start: + if self._auto_start and auto_start: # Start all in the background without waiting for completion # we ignore errors because we want to let the user open # their project and fix it diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index a73d1faf7..92aa661ab 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -1882,3 +1882,19 @@ async def test_memory(compute_project, manager): "Cmd": ["/bin/sh"] }) assert vm._cid == "e90e34656806" + + +async def test_stop_exited_container_no_stop_query(vm): + + vm._ubridge_hypervisor = None + vm._fix_permissions = MagicMock() + + with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="exited"): + with asyncio_patch("gns3server.compute.docker.Docker.query") as mock_query: + vm._permissions_fixed = False + await vm.stop() + assert not any( + call.args[:2] == ("POST", "containers/e90e34656842/stop") + for call in mock_query.mock_calls + ) + assert vm.status == "stopped" diff --git a/tests/controller/test_import_project.py b/tests/controller/test_import_project.py index c1e976aef..1cd77dbf0 100644 --- a/tests/controller/test_import_project.py +++ b/tests/controller/test_import_project.py @@ -20,6 +20,8 @@ import os import uuid import json import zipfile +import pytest +import aiohttp from pathlib import Path from tests.utils import asyncio_patch, AsyncioMagicMock @@ -122,24 +124,49 @@ async def write_file(path, z): f.write(chunk) -@pytest.mark.asyncio -async def test_import_project_containing_symlink(tmpdir, controller): +@pytest.fixture +def export_project_with_symlink(tmpdir, controller): + async def _export(symlink_target): + project = Project(controller=controller, name="test") + project.dump = MagicMock() - project = Project(controller=controller, name="test") - project.dump = MagicMock() - path = project.path + topology = { + "project_id": str(uuid.uuid4()), + "name": "test", + "auto_open": True, + "auto_start": True, + "topology": { + }, + "version": "2.0.0" + } + + with open(os.path.join(project.path, "project.gns3"), 'w+') as f: + json.dump(topology, f) + + os.makedirs(os.path.join(project.path, "vm1", "dynamips")) + symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") + os.symlink(symlink_target, symlink_path) + + zip_path = str(tmpdir / "project.zip") + with aiozipstream.ZipFile() as z: + with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): + await export_project(z, project, str(tmpdir), include_images=False) + await write_file(zip_path, z) + + return zip_path + + return _export + + +@pytest.mark.asyncio +async def test_import_project_containing_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing a valid symlink (target inside the project directory). + """ project_id = str(uuid.uuid4()) - os.makedirs(os.path.join(path, "vm1", "dynamips")) - symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") - symlink_target = "/tmp/anywhere" - os.symlink(symlink_target, symlink_path) - - zip_path = str(tmpdir / "project.zip") - with aiozipstream.ZipFile() as z: - with patch("gns3server.compute.Dynamips.get_images_directory", return_value=str(tmpdir / "IOS"),): - await export_project(z, project, str(tmpdir), include_images=False) - await write_file(zip_path, z) + symlink_target = "../symlink_target" + zip_path = await export_project_with_symlink(symlink_target) with open(zip_path, "rb") as f: project = await import_project(controller, project_id, f) @@ -152,6 +179,36 @@ async def test_import_project_containing_symlink(tmpdir, controller): @pytest.mark.asyncio +async def test_import_project_containing_absolute_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing an absolute symlink. + This should fail because absolute symlinks are not allowed for security reasons. + """ + + project_id = str(uuid.uuid4()) + symlink_target = "/tmp/anywhere" + zip_path = await export_project_with_symlink(symlink_target) + + with pytest.raises(aiohttp.web.HTTPConflict): + with open(zip_path, "rb") as f: + await import_project(controller, project_id, f) + + +async def test_import_project_containing_escaping_symlink(controller, export_project_with_symlink): + """ + Test importing a project containing a symlink that escapes the project directory. + This should fail because symlinks that escape the project directory are not allowed for security reasons. + """ + + project_id = str(uuid.uuid4()) + symlink_target = "../../../../symlink_target" + zip_path = await export_project_with_symlink(symlink_target) + + with pytest.raises(aiohttp.web.HTTPConflict): + with open(zip_path, "rb") as f: + await import_project(controller, project_id, f) + + async def test_import_upgrade(tmpdir, controller): """ Topology made for previous GNS3 version are upgraded during the process diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index b0202c01f..c4a919b4a 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -700,6 +700,19 @@ async def test_delete(project): @pytest.mark.asyncio +async def test_delete_does_not_start_nodes(project): + """ + Deleting a project must not start its nodes, even when auto_start is enabled. + """ + + project.auto_start = True + project.dump() + await project.close() + project.start_all = AsyncioMagicMock() + await project.delete() + assert not project.start_all.called + + async def test_dump(projects_dir): directory = projects_dir