Compare commits

..

No commits in common. "master" and "v2.2.60" have entirely different histories.

15 changed files with 50 additions and 270 deletions

View File

@ -1,40 +0,0 @@
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

View File

@ -28,13 +28,6 @@ jobs:
echo "stable=false" >> $GITHUB_OUTPUT echo "stable=false" >> $GITHUB_OUTPUT
fi 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 - name: Set up Docker Buildx
if: steps.ver.outputs.stable == 'true' if: steps.ver.outputs.stable == 'true'
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -64,5 +57,5 @@ jobs:
tags: | tags: |
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }} ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest ${{ env.DOCKERHUB_ORG }}/${{ github.event.repository.name }}:latest
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:${{ steps.ver.outputs.tag }} ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ steps.ver.outputs.tag }}
ghcr.io/${{ steps.names.outputs.owner }}/${{ steps.names.outputs.repo }}:latest ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest

View File

@ -1,14 +1,5 @@
# Change Log # 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 ## 2.2.60 15/07/2026
* Sync appliances * Sync appliances

View File

@ -1,59 +0,0 @@
{
"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 <Enter>",
"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"
}
}
]
}

View File

@ -311,7 +311,7 @@ class Cloud(BaseNode):
if not port_info["interface"] in network_interfaces: if not port_info["interface"] in network_interfaces:
raise NodeError("Interface '{}' could not be found on this system, please update '{}'".format(port_info["interface"], self.name)) raise NodeError("Interface '{}' could not be found on this system, please update '{}'".format(port_info["interface"], self.name))
if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"): if sys.platform.startswith("linux"):
await self._add_linux_ethernet(port_info, bridge_name) await self._add_linux_ethernet(port_info, bridge_name)
elif sys.platform.startswith("darwin"): elif sys.platform.startswith("darwin"):
await self._add_osx_ethernet(port_info, bridge_name) await self._add_osx_ethernet(port_info, bridge_name)

View File

@ -869,7 +869,7 @@ class DockerVM(BaseNode):
await self._fix_permissions() await self._fix_permissions()
state = await self._get_container_state() state = await self._get_container_state()
if state != "stopped" and state != "exited": if state != "stopped" or state != "exited":
# t=5 number of seconds to wait before killing the container # t=5 number of seconds to wait before killing the container
try: try:
await self.manager.query("POST", "containers/{}/stop".format(self._cid), params={"t": 5}) await self.manager.query("POST", "containers/{}/stop".format(self._cid), params={"t": 5})

View File

@ -2480,6 +2480,7 @@ class QemuVM(BaseNode):
elif sys.platform.startswith("win") or sys.platform.startswith("darwin"): elif sys.platform.startswith("win") or sys.platform.startswith("darwin"):
command.extend(["-enable-hax"]) command.extend(["-enable-hax"])
command.extend(["-boot", "order={}".format(self._boot_priority)]) command.extend(["-boot", "order={}".format(self._boot_priority)])
command.extend(self._bios_option())
command.extend(self._cdrom_option()) command.extend(self._cdrom_option())
command.extend((await self._disk_options())) command.extend((await self._disk_options()))
command.extend(self._linux_boot_options()) command.extend(self._linux_boot_options())
@ -2497,8 +2498,6 @@ class QemuVM(BaseNode):
raise QemuError("Console type {} is unknown".format(self._console_type)) raise QemuError("Console type {} is unknown".format(self._console_type))
command.extend(self._monitor_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": if self.on_close != "save_vm_state":
await self._clear_save_vm_stated() await self._clear_save_vm_stated()
else: else:

View File

@ -166,53 +166,25 @@ async def import_project(
project = await controller.load_project(dot_gns3_path, load=False) project = await controller.load_project(dot_gns3_path, load=False)
return project return project
def _create_symbolic_links(zip_file, path): def _create_symbolic_links(zip_file, path):
""" """
Manually create symbolic links (if any) because ZipFile does not support it. Manually create symbolic links (if any) because ZipFile does not support it.
Refuse any target that escapes `path`.
:param zip_file: ZipFile instance :param zip_file: ZipFile instance
:param path: project location :param path: project location
""" """
path_root = os.path.realpath(path) + os.sep
for zip_info in zip_file.infolist(): for zip_info in zip_file.infolist():
if not stat.S_ISLNK(zip_info.external_attr >> 16): if stat.S_ISLNK(zip_info.external_attr >> 16):
continue
symlink_target = zip_file.read(zip_info.filename).decode() symlink_target = zip_file.read(zip_info.filename).decode()
symlink_path = os.path.join(path, zip_info.filename) symlink_path = os.path.join(path, zip_info.filename)
# 1. Reject absolute targets outright.
if os.path.isabs(symlink_target):
raise aiohttp.web.HTTPConflict(
text=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 aiohttp.web.HTTPConflict(
text=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 aiohttp.web.HTTPConflict(
text=f"Symlink {zip_info.filename!r} -> {symlink_target!r} escapes project dir, refusing"
)
try: try:
# remove the regular file and replace it by a symbolic link
os.remove(symlink_path) os.remove(symlink_path)
os.symlink(symlink_target, symlink_path) os.symlink(symlink_target, symlink_path)
except OSError as e: except OSError as e:
raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}") raise aiohttp.web.HTTPConflict(text=f"Cannot create symbolic link: {e}")
def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False): def regenerate_topology_ids(topology, new_project_path, reset_mac_addresses=False):
""" """
Regenerate IDs in the topology and move the files of the nodes to match the new IDs. Regenerate IDs in the topology and move the files of the nodes to match the new IDs.

View File

@ -917,7 +917,7 @@ class Project:
if self._status != "opened": if self._status != "opened":
try: try:
await self.open(auto_start=False) await self.open()
except aiohttp.web.HTTPConflict as e: except aiohttp.web.HTTPConflict as e:
# ignore missing images or other conflicts when deleting a project # ignore missing images or other conflicts when deleting a project
log.warning(f"Conflict while deleting project: {e}") log.warning(f"Conflict while deleting project: {e}")
@ -998,12 +998,9 @@ class Project:
return os.path.join(self.path, self._filename) return os.path.join(self.path, self._filename)
@locking @locking
async def open(self, auto_start=True): async def open(self):
""" """
Load topology elements Load topology elements
:param auto_start: whether the nodes may be started when the project
has auto start enabled
""" """
if self._closing: if self._closing:
@ -1117,7 +1114,7 @@ class Project:
self._loading = False self._loading = False
self.emit_controller_notification("project.opened", self.__json__()) self.emit_controller_notification("project.opened", self.__json__())
# Should we start the nodes when project is open # Should we start the nodes when project is open
if self._auto_start and auto_start: if self._auto_start:
# Start all in the background without waiting for completion # Start all in the background without waiting for completion
# we ignore errors because we want to let the user open # we ignore errors because we want to let the user open
# their project and fix it # their project and fix it

View File

@ -57,7 +57,7 @@ class CrashReport:
Report crash to a third party service Report crash to a third party service
""" """
DSN = "https://89ba0bf8d773c2c95518bfb696d6d86f@o19455.ingest.us.sentry.io/38482" DSN = "https://deee946d000cb5548acebb1cd7c1a1d3@o19455.ingest.us.sentry.io/38482"
_instance = None _instance = None
def __init__(self): def __init__(self):

View File

@ -147,7 +147,7 @@ def is_interface_up(interface):
:returns: boolean :returns: boolean
""" """
if sys.platform.startswith("linux") or sys.platform.startswith("openbsd"): if sys.platform.startswith("linux"):
if interface not in psutil.net_if_addrs(): if interface not in psutil.net_if_addrs():
return False return False
@ -234,7 +234,7 @@ def interfaces():
result["special"] = False result["special"] = False
for special_interface in ("lo", "vmnet", "vboxnet", "docker", "lxcbr", for special_interface in ("lo", "vmnet", "vboxnet", "docker", "lxcbr",
"virbr", "ovs-system", "veth", "fw", "p2p", "virbr", "ovs-system", "veth", "fw", "p2p",
"bridge", "vmware", "virtualbox", "gns3","veb"): "bridge", "vmware", "virtualbox", "gns3"):
if result["name"].lower().startswith(special_interface): if result["name"].lower().startswith(special_interface):
result["special"] = True result["special"] = True
for special_interface in ("-nic"): for special_interface in ("-nic"):

View File

@ -23,8 +23,8 @@
# or negative for a release candidate or beta (after the base version # or negative for a release candidate or beta (after the base version
# number has been incremented) # number has been incremented)
__version__ = "2.2.61" __version__ = "2.2.60"
__version_info__ = (2, 2, 61, 0) __version_info__ = (2, 2, 60, 0)
if "dev" in __version__: if "dev" in __version__:
try: try:

View File

@ -1503,19 +1503,3 @@ async def test_read_console_output_with_binary_mode(vm):
with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'): with asyncio_patch('gns3server.compute.docker.docker_vm.DockerVM.stop'):
await vm._read_console_output(input_stream, output_stream) await vm._read_console_output(input_stream, output_stream)
output_stream.feed_data.assert_called_once_with(b"test") output_stream.feed_data.assert_called_once_with(b"test")
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"

View File

@ -19,8 +19,6 @@ import os
import uuid import uuid
import json import json
import zipfile import zipfile
import pytest
import aiohttp
from tests.utils import asyncio_patch, AsyncioMagicMock from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -119,12 +117,13 @@ async def write_file(path, z):
f.write(chunk) f.write(chunk)
@pytest.fixture async def test_import_project_containing_symlink(tmpdir, controller):
def export_project_with_symlink(tmpdir, controller):
async def _export(symlink_target):
project = Project(controller=controller, name="test") project = Project(controller=controller, name="test")
project.dump = MagicMock() project.dump = MagicMock()
path = project.path
project_id = str(uuid.uuid4())
topology = { topology = {
"project_id": str(uuid.uuid4()), "project_id": str(uuid.uuid4()),
"name": "test", "name": "test",
@ -135,11 +134,12 @@ def export_project_with_symlink(tmpdir, controller):
"version": "2.0.0" "version": "2.0.0"
} }
with open(os.path.join(project.path, "project.gns3"), 'w+') as f: with open(os.path.join(path, "project.gns3"), 'w+') as f:
json.dump(topology, f) json.dump(topology, f)
os.makedirs(os.path.join(project.path, "vm1", "dynamips")) os.makedirs(os.path.join(path, "vm1", "dynamips"))
symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink") symlink_path = os.path.join(project.path, "vm1", "dynamips", "symlink")
symlink_target = "/tmp/anywhere"
os.symlink(symlink_target, symlink_path) os.symlink(symlink_target, symlink_path)
zip_path = str(tmpdir / "project.zip") zip_path = str(tmpdir / "project.zip")
@ -148,20 +148,6 @@ def export_project_with_symlink(tmpdir, controller):
await export_project(z, project, str(tmpdir), include_images=False) await export_project(z, project, str(tmpdir), include_images=False)
await write_file(zip_path, z) await write_file(zip_path, z)
return zip_path
return _export
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())
symlink_target = "../symlink_target"
zip_path = await export_project_with_symlink(symlink_target)
with open(zip_path, "rb") as f: with open(zip_path, "rb") as f:
project = await import_project(controller, project_id, f) project = await import_project(controller, project_id, f)
@ -172,36 +158,6 @@ async def test_import_project_containing_symlink(controller, export_project_with
assert os.readlink(symlink_path) == symlink_target assert os.readlink(symlink_path) == symlink_target
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): async def test_import_upgrade(tmpdir, controller):
""" """
Topology made for previous GNS3 version are upgraded during the process Topology made for previous GNS3 version are upgraded during the process

View File

@ -655,19 +655,6 @@ async def test_delete(project):
assert not os.path.exists(project.path) assert not os.path.exists(project.path)
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): async def test_dump(projects_dir):
directory = projects_dir directory = projects_dir